1# -*- coding: utf-8 -*-
2# Copyright: Ankitects Pty Ltd and contributors
3# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4
5import copy, operator
6import unicodedata
7import json
8
9from anki.utils import intTime, ids2str
10from anki.hooks import runHook
11from anki.consts import *
12from anki.lang import _
13from anki.errors import DeckRenameError
14
15# fixmes:
16# - make sure users can't set grad interval < 1
17
18defaultDeck = {
19    'newToday': [0, 0], # currentDay, count
20    'revToday': [0, 0],
21    'lrnToday': [0, 0],
22    'timeToday': [0, 0], # time in ms
23    'conf': 1,
24    'usn': 0,
25    'desc': "",
26    'dyn': 0,  # anki uses int/bool interchangably here
27    'collapsed': False,
28    # added in beta11
29    'extendNew': 10,
30    'extendRev': 50,
31}
32
33defaultDynamicDeck = {
34    'newToday': [0, 0],
35    'revToday': [0, 0],
36    'lrnToday': [0, 0],
37    'timeToday': [0, 0],
38    'collapsed': False,
39    'dyn': 1,
40    'desc': "",
41    'usn': 0,
42    'delays': None,
43    'separate': True,
44     # list of (search, limit, order); we only use first two elements for now
45    'terms': [["", 100, 0]],
46    'resched': True,
47    'return': True, # currently unused
48
49    # v2 scheduler
50    "previewDelay": 10,
51}
52
53defaultConf = {
54    'name': _("Default"),
55    'new': {
56        'delays': [1, 10],
57        'ints': [1, 4, 7], # 7 is not currently used
58        'initialFactor': STARTING_FACTOR,
59        'separate': True,
60        'order': NEW_CARDS_DUE,
61        'perDay': 20,
62        # may not be set on old decks
63        'bury': False,
64    },
65    'lapse': {
66        'delays': [10],
67        'mult': 0,
68        'minInt': 1,
69        'leechFails': 8,
70        # type 0=suspend, 1=tagonly
71        'leechAction': 0,
72    },
73    'rev': {
74        'perDay': 200,
75        'ease4': 1.3,
76        'fuzz': 0.05,
77        'minSpace': 1, # not currently used
78        'ivlFct': 1,
79        'maxIvl': 36500,
80        # may not be set on old decks
81        'bury': False,
82        'hardFactor': 1.2,
83    },
84    'maxTaken': 60,
85    'timer': 0,
86    'autoplay': True,
87    'replayq': True,
88    'mod': 0,
89    'usn': 0,
90}
91
92class DeckManager:
93
94    # Registry save/load
95    #############################################################
96
97    def __init__(self, col):
98        self.col = col
99
100    def load(self, decks, dconf):
101        self.decks = json.loads(decks)
102        self.dconf = json.loads(dconf)
103        # set limits to within bounds
104        found = False
105        for c in list(self.dconf.values()):
106            for t in ('rev', 'new'):
107                pd = 'perDay'
108                if c[t][pd] > 999999:
109                    c[t][pd] = 999999
110                    self.save(c)
111                    found = True
112        if not found:
113            self.changed = False
114
115    def save(self, g=None):
116        "Can be called with either a deck or a deck configuration."
117        if g:
118            g['mod'] = intTime()
119            g['usn'] = self.col.usn()
120        self.changed = True
121
122    def flush(self):
123        if self.changed:
124            self.col.db.execute("update col set decks=?, dconf=?",
125                                 json.dumps(self.decks),
126                                 json.dumps(self.dconf))
127            self.changed = False
128
129    # Deck save/load
130    #############################################################
131
132    def id(self, name, create=True, type=None):
133        "Add a deck with NAME. Reuse deck if already exists. Return id as int."
134        if type is None:
135            type = defaultDeck
136        name = name.replace('"', '')
137        name = unicodedata.normalize("NFC", name)
138        deck = self.byName(name)
139        if deck:
140            return int(deck["id"])
141        if not create:
142            return None
143        g = copy.deepcopy(type)
144        if "::" in name:
145            # not top level; ensure all parents exist
146            name = self._ensureParents(name)
147        g['name'] = name
148        while 1:
149            id = intTime(1000)
150            if str(id) not in self.decks:
151                break
152        g['id'] = id
153        self.decks[str(id)] = g
154        self.save(g)
155        self.maybeAddToActive()
156        runHook("newDeck")
157        return int(id)
158
159    def rem(self, did, cardsToo=False, childrenToo=True):
160        "Remove the deck. If cardsToo, delete any cards inside."
161        if str(did) == '1':
162            # we won't allow the default deck to be deleted, but if it's a
163            # child of an existing deck then it needs to be renamed
164            deck = self.get(did)
165            if '::' in deck['name']:
166                base = deck['name'].split("::")[-1]
167                suffix = ""
168                while True:
169                    # find an unused name
170                    name = base + suffix
171                    if not self.byName(name):
172                        deck['name'] = name
173                        self.save(deck)
174                        break
175                    suffix += "1"
176            return
177        # log the removal regardless of whether we have the deck or not
178        self.col._logRem([did], REM_DECK)
179        # do nothing else if doesn't exist
180        if not str(did) in self.decks:
181            return
182        deck = self.get(did)
183        if deck['dyn']:
184            # deleting a cramming deck returns cards to their previous deck
185            # rather than deleting the cards
186            self.col.sched.emptyDyn(did)
187            if childrenToo:
188                for name, id in self.children(did):
189                    self.rem(id, cardsToo)
190        else:
191            # delete children first
192            if childrenToo:
193                # we don't want to delete children when syncing
194                for name, id in self.children(did):
195                    self.rem(id, cardsToo)
196            # delete cards too?
197            if cardsToo:
198                # don't use cids(), as we want cards in cram decks too
199                cids = self.col.db.list(
200                    "select id from cards where did=? or odid=?", did, did)
201                self.col.remCards(cids)
202        # delete the deck and add a grave
203        del self.decks[str(did)]
204        # ensure we have an active deck
205        if did in self.active():
206            self.select(int(list(self.decks.keys())[0]))
207        self.save()
208
209    def allNames(self, dyn=True, forceDefault=True):
210        "An unsorted list of all deck names."
211        if dyn:
212            return [x['name'] for x in self.all(forceDefault=forceDefault)]
213        else:
214            return [x['name'] for x in self.all(forceDefault=forceDefault) if not x['dyn']]
215
216    def all(self, forceDefault=True):
217        "A list of all decks."
218        decks = list(self.decks.values())
219        if not forceDefault and not self.col.db.scalar("select 1 from cards where did = 1 limit 1") and len(decks)>1:
220            decks = [deck for deck in decks if deck['id'] != 1]
221        return decks
222
223    def allIds(self):
224        return list(self.decks.keys())
225
226    def collapse(self, did):
227        deck = self.get(did)
228        deck['collapsed'] = not deck['collapsed']
229        self.save(deck)
230
231    def collapseBrowser(self, did):
232        deck = self.get(did)
233        collapsed = deck.get('browserCollapsed', False)
234        deck['browserCollapsed'] = not collapsed
235        self.save(deck)
236
237    def count(self):
238        return len(self.decks)
239
240    def get(self, did, default=True):
241        id = str(did)
242        if id in self.decks:
243            return self.decks[id]
244        elif default:
245            return self.decks['1']
246
247    def byName(self, name):
248        """Get deck with NAME, ignoring case."""
249        for m in list(self.decks.values()):
250            if self.equalName(m['name'], name):
251                return m
252
253    def update(self, g):
254        "Add or update an existing deck. Used for syncing and merging."
255        self.decks[str(g['id'])] = g
256        self.maybeAddToActive()
257        # mark registry changed, but don't bump mod time
258        self.save()
259
260    def rename(self, g, newName):
261        "Rename deck prefix to NAME if not exists. Updates children."
262        # make sure target node doesn't already exist
263        if self.byName(newName):
264            raise DeckRenameError(_("That deck already exists."))
265        # make sure we're not nesting under a filtered deck
266        for p in self.parentsByName(newName):
267            if p['dyn']:
268                raise DeckRenameError(_("A filtered deck cannot have subdecks."))
269        # ensure we have parents
270        newName = self._ensureParents(newName)
271        # rename children
272        for grp in self.all():
273            if grp['name'].startswith(g['name'] + "::"):
274                grp['name'] = grp['name'].replace(g['name']+ "::",
275                                                  newName + "::", 1)
276                self.save(grp)
277        # adjust name
278        g['name'] = newName
279        # ensure we have parents again, as we may have renamed parent->child
280        newName = self._ensureParents(newName)
281        self.save(g)
282        # renaming may have altered active did order
283        self.maybeAddToActive()
284
285    def renameForDragAndDrop(self, draggedDeckDid, ontoDeckDid):
286        draggedDeck = self.get(draggedDeckDid)
287        draggedDeckName = draggedDeck['name']
288        ontoDeckName = self.get(ontoDeckDid)['name']
289
290        if ontoDeckDid is None or ontoDeckDid == '':
291            if len(self._path(draggedDeckName)) > 1:
292                self.rename(draggedDeck, self._basename(draggedDeckName))
293        elif self._canDragAndDrop(draggedDeckName, ontoDeckName):
294            draggedDeck = self.get(draggedDeckDid)
295            draggedDeckName = draggedDeck['name']
296            ontoDeckName = self.get(ontoDeckDid)['name']
297            assert ontoDeckName.strip()
298            self.rename(draggedDeck, ontoDeckName + "::" + self._basename(draggedDeckName))
299
300    def _canDragAndDrop(self, draggedDeckName, ontoDeckName):
301        if draggedDeckName == ontoDeckName \
302            or self._isParent(ontoDeckName, draggedDeckName) \
303            or self._isAncestor(draggedDeckName, ontoDeckName):
304            return False
305        else:
306            return True
307
308    def _isParent(self, parentDeckName, childDeckName):
309        return self._path(childDeckName) == self._path(parentDeckName) + [ self._basename(childDeckName) ]
310
311    def _isAncestor(self, ancestorDeckName, descendantDeckName):
312        ancestorPath = self._path(ancestorDeckName)
313        return ancestorPath == self._path(descendantDeckName)[0:len(ancestorPath)]
314
315    def _path(self, name):
316        return name.split("::")
317    def _basename(self, name):
318        return self._path(name)[-1]
319
320    def _ensureParents(self, name):
321        "Ensure parents exist, and return name with case matching parents."
322        s = ""
323        path = self._path(name)
324        if len(path) < 2:
325            return name
326        for p in path[:-1]:
327            if not s:
328                s += p
329            else:
330                s += "::" + p
331            # fetch or create
332            did = self.id(s)
333            # get original case
334            s = self.name(did)
335        name = s + "::" + path[-1]
336        return name
337
338    # Deck configurations
339    #############################################################
340
341    def allConf(self):
342        "A list of all deck config."
343        return list(self.dconf.values())
344
345    def confForDid(self, did):
346        deck = self.get(did, default=False)
347        assert deck
348        if 'conf' in deck:
349            conf = self.getConf(deck['conf'])
350            conf['dyn'] = False
351            return conf
352        # dynamic decks have embedded conf
353        return deck
354
355    def getConf(self, confId):
356        return self.dconf[str(confId)]
357
358    def updateConf(self, g):
359        self.dconf[str(g['id'])] = g
360        self.save()
361
362    def confId(self, name, cloneFrom=None):
363        "Create a new configuration and return id."
364        if cloneFrom is None:
365            cloneFrom = defaultConf
366        c = copy.deepcopy(cloneFrom)
367        while 1:
368            id = intTime(1000)
369            if str(id) not in self.dconf:
370                break
371        c['id'] = id
372        c['name'] = name
373        self.dconf[str(id)] = c
374        self.save(c)
375        return id
376
377    def remConf(self, id):
378        "Remove a configuration and update all decks using it."
379        assert int(id) != 1
380        self.col.modSchema(check=True)
381        del self.dconf[str(id)]
382        for g in self.all():
383            # ignore cram decks
384            if 'conf' not in g:
385                continue
386            if str(g['conf']) == str(id):
387                g['conf'] = 1
388                self.save(g)
389
390    def setConf(self, grp, id):
391        grp['conf'] = id
392        self.save(grp)
393
394    def didsForConf(self, conf):
395        dids = []
396        for deck in list(self.decks.values()):
397            if 'conf' in deck and deck['conf'] == conf['id']:
398                dids.append(deck['id'])
399        return dids
400
401    def restoreToDefault(self, conf):
402        oldOrder = conf['new']['order']
403        new = copy.deepcopy(defaultConf)
404        new['id'] = conf['id']
405        new['name'] = conf['name']
406        self.dconf[str(conf['id'])] = new
407        self.save(new)
408        # if it was previously randomized, resort
409        if not oldOrder:
410            self.col.sched.resortConf(new)
411
412    # Deck utils
413    #############################################################
414
415    def name(self, did, default=False):
416        deck = self.get(did, default=default)
417        if deck:
418            return deck['name']
419        return _("[no deck]")
420
421    def nameOrNone(self, did):
422        deck = self.get(did, default=False)
423        if deck:
424            return deck['name']
425        return None
426
427    def setDeck(self, cids, did):
428        self.col.db.execute(
429            "update cards set did=?,usn=?,mod=? where id in "+
430            ids2str(cids), did, self.col.usn(), intTime())
431
432    def maybeAddToActive(self):
433        # reselect current deck, or default if current has disappeared
434        c = self.current()
435        self.select(c['id'])
436
437    def cids(self, did, children=False):
438        if not children:
439            return self.col.db.list("select id from cards where did=?", did)
440        dids = [did]
441        for name, id in self.children(did):
442            dids.append(id)
443        return self.col.db.list("select id from cards where did in "+
444                                ids2str(dids))
445
446    def _recoverOrphans(self):
447        dids = list(self.decks.keys())
448        mod = self.col.db.mod
449        self.col.db.execute("update cards set did = 1 where did not in "+
450                            ids2str(dids))
451        self.col.db.mod = mod
452
453    def _checkDeckTree(self):
454        decks = self.col.decks.all()
455        decks.sort(key=operator.itemgetter('name'))
456        names = set()
457
458        for deck in decks:
459            # two decks with the same name?
460            if self.normalizeName(deck['name']) in names:
461                self.col.log("fix duplicate deck name", deck['name'])
462                deck['name'] += "%d" % intTime(1000)
463                self.save(deck)
464
465            # ensure no sections are blank
466            if not all(deck['name'].split("::")):
467                self.col.log("fix deck with missing sections", deck['name'])
468                deck['name'] = "recovered%d" % intTime(1000)
469                self.save(deck)
470
471            # immediate parent must exist
472            if "::" in deck['name']:
473                immediateParent = "::".join(deck['name'].split("::")[:-1])
474                if self.normalizeName(immediateParent) not in names:
475                    self.col.log("fix deck with missing parent", deck['name'])
476                    self._ensureParents(deck['name'])
477                    names.add(self.normalizeName(immediateParent))
478
479            names.add(self.normalizeName(deck['name']))
480
481    def checkIntegrity(self):
482        self._recoverOrphans()
483        self._checkDeckTree()
484
485    # Deck selection
486    #############################################################
487
488    def active(self):
489        "The currrently active dids. Make sure to copy before modifying."
490        return self.col.conf['activeDecks']
491
492    def selected(self):
493        "The currently selected did."
494        return self.col.conf['curDeck']
495
496    def current(self):
497        return self.get(self.selected())
498
499    def select(self, did):
500        "Select a new branch."
501        # make sure arg is an int
502        did = int(did)
503        # current deck
504        self.col.conf['curDeck'] = did
505        # and active decks (current + all children)
506        actv = self.children(did)
507        actv.sort()
508        self.col.conf['activeDecks'] = [did] + [a[1] for a in actv]
509        self.changed = True
510
511    def children(self, did):
512        "All children of did, as (name, id)."
513        name = self.get(did)['name']
514        actv = []
515        for g in self.all():
516            if g['name'].startswith(name + "::"):
517                actv.append((g['name'], g['id']))
518        return actv
519
520    def childDids(self, did, childMap):
521        def gather(node, arr):
522            for did, child in node.items():
523                arr.append(did)
524                gather(child, arr)
525
526        arr = []
527        gather(childMap[did], arr)
528        return arr
529
530    def childMap(self):
531        nameMap = self.nameMap()
532        childMap = {}
533
534        # go through all decks, sorted by name
535        for deck in sorted(self.all(), key=operator.itemgetter("name")):
536            node = {}
537            childMap[deck['id']] = node
538
539            # add note to immediate parent
540            parts = deck['name'].split("::")
541            if len(parts) > 1:
542                immediateParent = "::".join(parts[:-1])
543                pid = nameMap[immediateParent]['id']
544                childMap[pid][deck['id']] = node
545
546        return childMap
547
548    def parents(self, did, nameMap=None):
549        "All parents of did."
550        # get parent and grandparent names
551        parents = []
552        for part in self.get(did)['name'].split("::")[:-1]:
553            if not parents:
554                parents.append(part)
555            else:
556                parents.append(parents[-1] + "::" + part)
557        # convert to objects
558        for c, p in enumerate(parents):
559            if nameMap:
560                deck = nameMap[p]
561            else:
562                deck = self.get(self.id(p))
563            parents[c] = deck
564        return parents
565
566    def parentsByName(self, name):
567        "All existing parents of name"
568        if "::" not in name:
569            return []
570        names = name.split("::")[:-1]
571        head = []
572        parents = []
573
574        while names:
575            head.append(names.pop(0))
576            deck = self.byName("::".join(head))
577            if deck:
578                parents.append(deck)
579
580        return parents
581
582    def nameMap(self):
583        return dict((d['name'], d) for d in self.decks.values())
584
585    # Sync handling
586    ##########################################################################
587
588    def beforeUpload(self):
589        for d in self.all():
590            d['usn'] = 0
591        for c in self.allConf():
592            c['usn'] = 0
593        self.save()
594
595    # Dynamic decks
596    ##########################################################################
597
598    def newDyn(self, name):
599        "Return a new dynamic deck and set it as the current deck."
600        did = self.id(name, type=defaultDynamicDeck)
601        self.select(did)
602        return did
603
604    def isDyn(self, did):
605        return self.get(did)['dyn']
606
607    @staticmethod
608    def normalizeName(name):
609        return unicodedata.normalize("NFC", name.lower())
610
611    @staticmethod
612    def equalName(name1, name2):
613        return DeckManager.normalizeName(name1) == DeckManager.normalizeName(name2)
614