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