1# Copyright: Ankitects Pty Ltd and contributors 2# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 4# Profile handling 5########################################################################## 6# - Saves in pickles rather than json to easily store Qt window state. 7# - Saves in sqlite rather than a flat file so the config can't be corrupted 8 9import random 10import pickle 11import shutil 12import io 13import locale 14 15from aqt.qt import * 16from anki.db import DB 17from anki.utils import isMac, isWin, intTime 18import anki.lang 19from aqt.utils import showWarning 20from aqt import appHelpSite 21import aqt.forms 22from send2trash import send2trash 23import anki.sound 24from anki.lang import _ 25 26metaConf = dict( 27 ver=0, 28 updates=True, 29 created=intTime(), 30 id=random.randrange(0, 2**63), 31 lastMsg=-1, 32 suppressUpdate=False, 33 firstRun=True, 34 defaultLang=None, 35 disabledAddons=[], 36) 37 38profileConf = dict( 39 # profile 40 mainWindowGeom=None, 41 mainWindowState=None, 42 numBackups=50, 43 lastOptimize=intTime(), 44 # editing 45 fullSearch=False, 46 searchHistory=[], 47 lastColour="#00f", 48 stripHTML=True, 49 pastePNG=False, 50 # not exposed in gui 51 deleteMedia=False, 52 preserveKeyboard=True, 53 # syncing 54 syncKey=None, 55 syncMedia=True, 56 autoSync=True, 57 # importing 58 allowHTML=False, 59 importMode=1, 60) 61 62class ProfileManager: 63 64 def __init__(self, base=None): 65 self.name = None 66 self.db = None 67 # instantiate base folder 68 self._setBaseFolder(base) 69 70 anki.sound.setMpvConfigBase(self.base) 71 72 def setupMeta(self): 73 # load metadata 74 self.firstRun = self._loadMeta() 75 76 # profile load on startup 77 def openProfile(self, profile): 78 if profile: 79 if profile not in self.profiles(): 80 QMessageBox.critical(None, "Error", "Requested profile does not exist.") 81 sys.exit(1) 82 try: 83 self.load(profile) 84 except TypeError: 85 raise Exception("Provided profile does not exist.") 86 87 # Base creation 88 ###################################################################### 89 90 def ensureBaseExists(self): 91 try: 92 self._ensureExists(self.base) 93 except: 94 # can't translate, as lang not initialized, and qt may not be 95 print("unable to create base folder") 96 QMessageBox.critical( 97 None, "Error", """\ 98Anki could not create the folder %s. Please ensure that location is not \ 99read-only and you have permission to write to it. If you cannot fix this \ 100issue, please see the documentation for information on running Anki from \ 101a flash drive.""" % self.base) 102 raise 103 104 # Folder migration 105 ###################################################################### 106 107 def _oldFolderLocation(self): 108 if isMac: 109 return os.path.expanduser("~/Documents/Anki") 110 elif isWin: 111 from aqt.winpaths import get_personal 112 return os.path.join(get_personal(), "Anki") 113 else: 114 p = os.path.expanduser("~/Anki") 115 if os.path.isdir(p): 116 return p 117 return os.path.expanduser("~/Documents/Anki") 118 119 def maybeMigrateFolder(self): 120 oldBase = self._oldFolderLocation() 121 122 if oldBase and not os.path.exists(self.base) and os.path.isdir(oldBase): 123 shutil.move(oldBase, self.base) 124 125 # Profile load/save 126 ###################################################################### 127 128 def profiles(self): 129 def names(): 130 return self.db.list("select name from profiles where name != '_global'") 131 132 n = names() 133 if not n: 134 self._ensureProfile() 135 n = names() 136 137 return n 138 139 def _unpickle(self, data): 140 class Unpickler(pickle.Unpickler): 141 def find_class(self, module, name): 142 if module == "PyQt5.sip": 143 try: 144 import PyQt5.sip # pylint: disable=unused-import 145 except: 146 # use old sip location 147 module = "sip" 148 fn = super().find_class(module, name) 149 if module == "sip" and name == "_unpickle_type": 150 def wrapper(mod, obj, args): 151 if mod.startswith("PyQt4") and obj == "QByteArray": 152 # can't trust str objects from python 2 153 return QByteArray() 154 return fn(mod, obj, args) 155 return wrapper 156 else: 157 return fn 158 up = Unpickler(io.BytesIO(data), errors="ignore") 159 return up.load() 160 161 def _pickle(self, obj): 162 return pickle.dumps(obj, protocol=0) 163 164 def load(self, name): 165 assert name != "_global" 166 data = self.db.scalar("select cast(data as blob) from profiles where name = ?", name) 167 self.name = name 168 try: 169 self.profile = self._unpickle(data) 170 except: 171 QMessageBox.warning( 172 None, _("Profile Corrupt"), _("""\ 173Anki could not read your profile data. Window sizes and your sync login \ 174details have been forgotten.""")) 175 176 print("resetting corrupt profile") 177 self.profile = profileConf.copy() 178 self.save() 179 return True 180 181 def save(self): 182 sql = "update profiles set data = ? where name = ?" 183 self.db.execute(sql, self._pickle(self.profile), self.name) 184 self.db.execute(sql, self._pickle(self.meta), "_global") 185 self.db.commit() 186 187 def create(self, name): 188 prof = profileConf.copy() 189 self.db.execute("insert or ignore into profiles values (?, ?)", 190 name, self._pickle(prof)) 191 self.db.commit() 192 193 def remove(self, name): 194 p = self.profileFolder() 195 if os.path.exists(p): 196 send2trash(p) 197 self.db.execute("delete from profiles where name = ?", name) 198 self.db.commit() 199 200 def trashCollection(self): 201 p = self.collectionPath() 202 if os.path.exists(p): 203 send2trash(p) 204 205 def rename(self, name): 206 oldName = self.name 207 oldFolder = self.profileFolder() 208 self.name = name 209 newFolder = self.profileFolder(create=False) 210 if os.path.exists(newFolder): 211 if (oldFolder != newFolder) and ( 212 oldFolder.lower() == newFolder.lower()): 213 # OS is telling us the folder exists because it does not take 214 # case into account; use a temporary folder location 215 midFolder = ''.join([oldFolder, '-temp']) 216 if not os.path.exists(midFolder): 217 os.rename(oldFolder, midFolder) 218 oldFolder = midFolder 219 else: 220 showWarning(_("Please remove the folder %s and try again.") 221 % midFolder) 222 self.name = oldName 223 return 224 else: 225 showWarning(_("Folder already exists.")) 226 self.name = oldName 227 return 228 229 # update name 230 self.db.execute("update profiles set name = ? where name = ?", 231 name, oldName) 232 # rename folder 233 try: 234 os.rename(oldFolder, newFolder) 235 except Exception as e: 236 self.db.rollback() 237 if "WinError 5" in str(e): 238 showWarning(_("""\ 239Anki could not rename your profile because it could not rename the profile \ 240folder on disk. Please ensure you have permission to write to Documents/Anki \ 241and no other programs are accessing your profile folders, then try again.""")) 242 else: 243 raise 244 except: 245 self.db.rollback() 246 raise 247 else: 248 self.db.commit() 249 250 # Folder handling 251 ###################################################################### 252 253 def profileFolder(self, create=True): 254 path = os.path.join(self.base, self.name) 255 if create: 256 self._ensureExists(path) 257 return path 258 259 def addonFolder(self): 260 return self._ensureExists(os.path.join(self.base, "addons21")) 261 262 def backupFolder(self): 263 return self._ensureExists( 264 os.path.join(self.profileFolder(), "backups")) 265 266 def collectionPath(self): 267 return os.path.join(self.profileFolder(), "collection.anki2") 268 269 # Helpers 270 ###################################################################### 271 272 def _ensureExists(self, path): 273 if not os.path.exists(path): 274 os.makedirs(path) 275 return path 276 277 def _setBaseFolder(self, cmdlineBase): 278 if cmdlineBase: 279 self.base = os.path.abspath(cmdlineBase) 280 elif os.environ.get("ANKI_BASE"): 281 self.base = os.path.abspath(os.environ["ANKI_BASE"]) 282 else: 283 self.base = self._defaultBase() 284 self.maybeMigrateFolder() 285 self.ensureBaseExists() 286 287 def _defaultBase(self): 288 if isWin: 289 from aqt.winpaths import get_appdata 290 return os.path.join(get_appdata(), "Anki2") 291 elif isMac: 292 return os.path.expanduser("~/Library/Application Support/Anki2") 293 else: 294 dataDir = os.environ.get( 295 "XDG_DATA_HOME", os.path.expanduser("~/.local/share")) 296 if not os.path.exists(dataDir): 297 os.makedirs(dataDir) 298 return os.path.join(dataDir, "Anki2") 299 300 def _loadMeta(self): 301 opath = os.path.join(self.base, "prefs.db") 302 path = os.path.join(self.base, "prefs21.db") 303 if os.path.exists(opath) and not os.path.exists(path): 304 shutil.copy(opath, path) 305 306 new = not os.path.exists(path) 307 def recover(): 308 # if we can't load profile, start with a new one 309 if self.db: 310 try: 311 self.db.close() 312 except: 313 pass 314 for suffix in ("", "-journal"): 315 fpath = path + suffix 316 if os.path.exists(fpath): 317 os.unlink(fpath) 318 QMessageBox.warning( 319 None, "Preferences Corrupt", """\ 320Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple \ 321profiles, please add them back using the same names to recover your cards.""") 322 try: 323 self.db = DB(path) 324 assert self.db.scalar("pragma integrity_check") == "ok" 325 self.db.execute(""" 326create table if not exists profiles 327(name text primary key, data text not null);""") 328 data = self.db.scalar( 329 "select cast(data as blob) from profiles where name = '_global'") 330 except: 331 recover() 332 return self._loadMeta() 333 if not new: 334 # load previously created data 335 try: 336 self.meta = self._unpickle(data) 337 return 338 except: 339 print("resetting corrupt _global") 340 # create a default global profile 341 self.meta = metaConf.copy() 342 self.db.execute("insert or replace into profiles values ('_global', ?)", 343 self._pickle(metaConf)) 344 self._setDefaultLang() 345 return True 346 347 def _ensureProfile(self): 348 "Create a new profile if none exists." 349 self.create(_("User 1")) 350 p = os.path.join(self.base, "README.txt") 351 open(p, "w", encoding="utf8").write(_("""\ 352This folder stores all of your Anki data in a single location, 353to make backups easy. To tell Anki to use a different location, 354please see: 355 356%s 357""") % (appHelpSite + "#startupopts")) 358 359 # Default language 360 ###################################################################### 361 # On first run, allow the user to choose the default language 362 363 def _setDefaultLang(self): 364 # create dialog 365 class NoCloseDiag(QDialog): 366 def reject(self): 367 pass 368 d = self.langDiag = NoCloseDiag() 369 f = self.langForm = aqt.forms.setlang.Ui_Dialog() 370 f.setupUi(d) 371 d.accepted.connect(self._onLangSelected) 372 d.rejected.connect(lambda: True) 373 # default to the system language 374 try: 375 (lang, enc) = locale.getdefaultlocale() 376 except: 377 # fails on osx 378 lang = "en_US" 379 # find index 380 idx = None 381 en = None 382 for c, (name, code) in enumerate(anki.lang.langs): 383 if code == "en_US": 384 en = c 385 if code == lang: 386 idx = c 387 # if the system language isn't available, revert to english 388 if idx is None: 389 idx = en 390 # update list 391 f.lang.addItems([x[0] for x in anki.lang.langs]) 392 f.lang.setCurrentRow(idx) 393 d.exec_() 394 395 def _onLangSelected(self): 396 f = self.langForm 397 obj = anki.lang.langs[f.lang.currentRow()] 398 code = obj[1] 399 name = obj[0] 400 en = "Are you sure you wish to display Anki's interface in %s?" 401 r = QMessageBox.question( 402 None, "Anki", en%name, QMessageBox.Yes | QMessageBox.No, 403 QMessageBox.No) 404 if r != QMessageBox.Yes: 405 return self._setDefaultLang() 406 self.setLang(code) 407 408 def setLang(self, code): 409 self.meta['defaultLang'] = code 410 sql = "update profiles set data = ? where name = ?" 411 self.db.execute(sql, self._pickle(self.meta), "_global") 412 self.db.commit() 413 anki.lang.setLang(code, local=False) 414 415 # OpenGL 416 ###################################################################### 417 418 def _glPath(self): 419 return os.path.join(self.base, "gldriver") 420 421 def glMode(self): 422 if isMac: 423 return "auto" 424 425 path = self._glPath() 426 if not os.path.exists(path): 427 return "software" 428 429 mode = open(path, "r").read().strip() 430 431 if mode == "angle" and isWin: 432 return mode 433 elif mode == "software": 434 return mode 435 return "auto" 436 437 def setGlMode(self, mode): 438 open(self._glPath(), "w").write(mode) 439 440 def nextGlMode(self): 441 mode = self.glMode() 442 if mode == "software": 443 self.setGlMode("auto") 444 elif mode == "auto": 445 if isWin: 446 self.setGlMode("angle") 447 else: 448 self.setGlMode("software") 449 elif mode == "angle": 450 self.setGlMode("software") 451