From da07e15a8786ef681797def08cfe3229112de38a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 10 Mar 2012 19:41:37 +0900 Subject: [PATCH] generalize into 'dynamic decks' - search and limits are embedded in the deck - decks can be refreshed - they have the option to treat due reviews normally rather than cram them - some options are inherited from the original deck, others taken from the dynamic deck --- anki/cards.py | 6 +- anki/decks.py | 49 ++++++++++---- anki/sched.py | 155 +++++++++++++++++++++++++++----------------- tests/test_sched.py | 6 +- 4 files changed, 140 insertions(+), 76 deletions(-) diff --git a/anki/cards.py b/anki/cards.py index 96e2b2109..d4abf8fee 100644 --- a/anki/cards.py +++ b/anki/cards.py @@ -128,9 +128,6 @@ lapses=?, left=?, odue=?, did=? where id = ?""", def model(self): return self.col.models.get(self.note().mid) - def deckConf(self): - return self.col.decks.confForDid(self.did) - def template(self): return self.model()['tmpls'][self.ord] @@ -140,4 +137,5 @@ lapses=?, left=?, odue=?, did=? where id = ?""", def timeTaken(self): "Time taken to answer card, in integer MS." total = int((time.time() - self.timerStarted)*1000) - return min(total, self.deckConf()['maxTaken']*1000) + conf = self.col.decks.confForDid(self.odid or self.did) + return min(total, conf['maxTaken']*1000) diff --git a/anki/decks.py b/anki/decks.py index 901bf59c8..918269f0a 100644 --- a/anki/decks.py +++ b/anki/decks.py @@ -19,6 +19,24 @@ defaultDeck = { 'conf': 1, 'usn': 0, 'desc': "", + 'dyn': 0, +} + +defaultDynamicDeck = { + 'newToday': [0, 0], + 'revToday': [0, 0], + 'lrnToday': [0, 0], + 'timeToday': [0, 0], + 'dyn': 1, + 'desc': "", + 'usn': 0, + 'delays': [1, 10], + 'separate': True, + 'fmult': 0, + 'cramRev': True, + 'search': "", + 'limit': 100, + 'order': 'oldestSeen', } defaultConf = { @@ -39,13 +57,6 @@ defaultConf = { # type 0=suspend, 1=tagonly 'leechAction': 0, }, - 'cram': { - 'delays': [1, 5, 10], - 'resched': True, - 'reset': True, - 'mult': 0, - 'minInt': 1, - }, 'rev': { 'perDay': 100, 'ease4': 1.3, @@ -91,7 +102,7 @@ class DeckManager(object): # Deck save/load ############################################################# - def id(self, name, create=True): + def id(self, name, create=True, type=defaultDeck): "Add a deck with NAME. Reuse deck if already exists. Return id as int." name = name.replace("'", "").replace('"', '') for id, g in self.decks.items(): @@ -99,7 +110,7 @@ class DeckManager(object): return int(id) if not create: return None - g = copy.deepcopy(defaultDeck) + g = copy.deepcopy(type) if "::" in name: # not top level; ensure all parents exist self._ensureParents(name) @@ -123,10 +134,11 @@ class DeckManager(object): if not str(did) in self.decks: return deck = self.get(did) - if deck.get('cram'): + print "fixme: add dyn to old decks" + if deck['dyn']: # deleting a cramming deck returns cards to their previous deck # rather than deleting the cards - self.col.sched.remCram(did) + self.col.sched.remDyn(did) else: # delete children first if childrenToo: @@ -233,7 +245,13 @@ class DeckManager(object): return self.dconf.values() def confForDid(self, did): - return self.getConf(self.get(did)['conf']) + deck = self.get(did) + if 'conf' in deck: + conf = self.getConf(deck['conf']) + conf['dyn'] = False + return conf + # dynamic decks have embedded conf + return deck def getConf(self, confId): return self.dconf[str(confId)] @@ -370,3 +388,10 @@ class DeckManager(object): for c in self.allConf(): c['usn'] = 0 self.save() + + # Dynamic decks + ########################################################################## + + def newDyn(self, name): + "Return a new dynamic deck and set it as the current deck." + return self.id(name, type=defaultDynamicDeck) diff --git a/anki/sched.py b/anki/sched.py index 1e63b2d86..65d08719f 100644 --- a/anki/sched.py +++ b/anki/sched.py @@ -28,7 +28,6 @@ class Scheduler(object): self.reportLimit = 1000 # fixme: replace reps with deck based counts self.reps = 0 - self._cramming = False self._updateCutoff() def getCard(self): @@ -41,7 +40,6 @@ class Scheduler(object): def reset(self): deck = self.col.decks.current() - self._cramming = deck.get('cram') self._updateCutoff() self._resetLrn() self._resetRev() @@ -61,10 +59,10 @@ class Scheduler(object): card.type = 1 # init reps to graduation card.left = self._startingLeft(card) - # cramming? - if self._cramming and card.type == 2: + # dynamic? + if card.odid and card.type == 2: # reviews get their ivl boosted on first sight - card.ivl = self._cramIvlBoost(card) + card.ivl = self._dynIvlBoost(card) card.odue = self.today + card.ivl self._updateStats(card, 'new') if card.queue == 1: @@ -306,7 +304,7 @@ select id, due from cards where did = ? and queue = 0 limit ?""", did, lim) (id, due) = self._newQueue.pop() # move any siblings to the end? conf = self.col.decks.confForDid(self._newDids[0]) - if conf['new']['separate']: + if conf['dyn'] or conf['new']['separate']: n = len(self._newQueue) while self._newQueue and self._newQueue[-1][1] == due: self._newQueue.insert(0, self._newQueue.pop()) @@ -332,8 +330,6 @@ select id, due from cards where did = ? and queue = 0 limit ?""", did, lim) "True if it's time to display a new card when distributing." if not self.newCount: return False - if self._cramming: - return True if self.col.conf['newSpread'] == NEW_CARDS_LAST: return False elif self.col.conf['newSpread'] == NEW_CARDS_FIRST: @@ -366,7 +362,7 @@ select count() from def _deckNewLimitSingle(self, g): "Limit for deck without parent limits." - if self._cramming: + if g['dyn']: return self.reportLimit c = self.col.decks.confForDid(g['id']) return max(0, c['new']['perDay'] - g['newToday'][1]) @@ -434,7 +430,7 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) # failed else: card.left = self._startingLeft(card) - if self._cramming: + if card.odid: print "fixme: configurable failure handling" card.ivl = 1 card.odue = self.today + 1 @@ -463,9 +459,9 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) def _lrnConf(self, card): conf = self._cardConf(card) if card.type == 2: - return conf['lapse'] + return self._lapseConf(card) else: - return conf['new'] + return self._newConf(card) def _rescheduleAsRev(self, card, conf, early): if card.type == 2: @@ -474,20 +470,21 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) self._rescheduleNew(card, conf, early) card.queue = 2 card.type = 2 - # if we were cramming, graduating means moving back to the old deck - if self._cramming: + # if we were dynamic, graduating means moving back to the old deck + if card.odid: card.did = card.odid card.odue = 0 card.odid = 0 def _startingLeft(self, card): - return len(self._cardConf(card)['new']['delays']) + conf = self._newConf(card) + return len(conf['delays']) def _graduatingIvl(self, card, conf, early, adj=True): if card.type == 2: # lapsed card being relearnt - if self._cramming: - return self._cramIvlBoost(card) + if card.odid: + return self._dynIvlBoost(card) return card.ivl if not early: # graduate @@ -547,6 +544,8 @@ select sum(left) from return self._deckNewLimit(did, self._deckRevLimitSingle) def _deckRevLimitSingle(self, d): + if d['dyn']: + return self.reportLimit c = self.col.decks.confForDid(d['id']) return max(0, c['rev']['perDay'] - d['revToday'][1]) @@ -606,6 +605,8 @@ did = ? and queue = 2 and due <= ? %s limit ?""" % order, def _revOrder(self, did): d = self.col.decks.confForDid(did) + if d['dyn']: + return "" o = d['rev']['order'] if o: return "order by %s" % ("ivl desc", "ivl")[o-1] @@ -622,7 +623,7 @@ did = ? and queue = 2 and due <= ? %s limit ?""" % order, self._logRev(card, ease) def _rescheduleLapse(self, card): - conf = self._cardConf(card)['lapse'] + conf = self._lapseConf(card) card.lapses += 1 card.lastIvl = card.ivl card.ivl = self._nextLapseIvl(card, conf) @@ -722,64 +723,66 @@ did = ? and queue = 2 and due <= ? %s limit ?""" % order, break return idealIvl + fudge - # Creation of cramming decks + # Dynamic deck handling ########################################################################## - # type is one of all, due, rev - # order is one of due, added, random, relative, lapses - def cram(self, search, type="all", order="due", - limit=100, sched=[1, 10]): + def rebuildDyn(self, did=None): + "Rebuild a dynamic deck." + did = did or self.col.decks.selected() + deck = self.col.decks.get(did) + assert deck['dyn'] # gather card ids and sort - order = self._cramOrder(order) - limit = " limit %d" % limit - ids = self.col.findCards(search, order=order+limit) - if not order: + order = self._dynOrder(deck) + limit = " limit %d" % deck['limit'] + ids = self.col.findCards(deck['search'], order=order+limit) + if deck['order'] == "random": random.shuffle(ids) - # get a cram deck - did = self._cramDeck() # move the cards over - self._moveToCram(did, ids) + self._moveToDyn(did, ids) # and change to our new deck self.col.decks.select(did) - def remCram(self, did): + def remDyn(self, did): self.col.db.execute(""" update cards set did = odid, queue = type, due = odue, odue = 0, odid = 0, usn = ?, mod = ? where did = ?""", self.col.usn(), intTime(), did) - def _cramOrder(self, order): - if order == "due": - return "order by c.due" - elif order == "added": - return "order by n.id" - elif order == "random": - return "" - elif order == "relative": - pass - elif order == "lapses": - return "order by lapses desc" - elif order == "failed": - pass + def _dynOrder(self, deck): + o = deck['order'] + if o == "oldestSeen": + return "order by c.mod" + # elif o == "added": + # return "order by n.id" + # elif o == "random": + # return "" + # elif o == "relative": + # pass + # elif o == "lapses": + # return "order by lapses desc" + # elif o == "failed": + # pass - def _cramDeck(self): - did = self.col.decks.id(_("Cram")) + def _moveToDyn(self, did, ids): deck = self.col.decks.get(did) - # mark it as a cram deck - deck['cram'] = True - self.col.decks.save(deck) - return did - - def _moveToCram(self, did, ids): data = [] t = intTime(); u = self.col.usn() for c, id in enumerate(ids): data.append((did, c, t, u, id)) + if deck['cramRev']: + # everything in the new queue + queue = "0" + else: + # due reviews stay in the review queue + queue = "(case when queue=2 and due <= %d then 2 else 0 end)" + queue %= self.today self.col.db.executemany(""" -update cards set odid = did, odue = due, did = ?, queue = 0, due = ?, -mod = ?, usn = ? where id = ?""", data) +update cards set +odid = (case when odid then odid else did end), +odue = (case when odue then odue else due end), +did = ?, queue = %s, due = ?, mod = ?, usn = ? where id = ?""" % queue, data) - def _cramIvlBoost(self, card): - assert self._cramming and card.type == 2 + def _dynIvlBoost(self, card): + assert card.odid and card.type == 2 assert card.factor elapsed = card.ivl - card.odue - self.today factor = ((card.factor/1000.0)+1.2)/2.0 @@ -815,6 +818,42 @@ mod = ?, usn = ? where id = ?""", data) def _cardConf(self, card): return self.col.decks.confForDid(card.did) + def _newConf(self, card): + conf = self._cardConf(card) + # normal deck + if not card.odid: + return conf['new'] + # dynamic deck; override some attributes, use original deck for others + oconf = self.col.decks.confForDid(card.odid) + return dict( + # original deck + ints=oconf['new']['ints'], + initialFactor=oconf['new']['initialFactor'], + # overrides + delays=conf['delays'], + separate=conf['separate'], + order=NEW_CARDS_DUE, + perDay=self.reportLimit + ) + + + def _lapseConf(self, card): + conf = self._cardConf(card) + # normal deck + if not card.odid: + return conf['lapse'] + # dynamic deck; override some attributes, use original deck for others + oconf = self.col.decks.confForDid(card.odid) + return dict( + # original deck + minInt=oconf['lapse']['minInt'], + leechFails=oconf['lapse']['leechFails'], + leechAction=oconf['lapse']['leechAction'], + # overrides + delays=conf['delays'], + mult=conf['fmult'], + ) + def _deckLimit(self): return ids2str(self.col.decks.active()) @@ -897,7 +936,7 @@ your short-term review workload will become.""")) return self._nextLrnIvl(card, ease) elif ease == 1: # lapsed - conf = self._cardConf(card)['lapse'] + conf = self._lapseConf(card) if conf['delays']: return conf['delays'][0]*60 return self._nextLapseIvl(card, conf)*86400 @@ -953,6 +992,7 @@ your short-term review workload will become.""")) def forgetCards(self, ids): "Put cards at the end of the new queue." + print "fixme: make sure this works with dynamic decks, and mv too" self.col.db.execute( "update cards set type=0,queue=0,ivl=0 where id in "+ids2str(ids)) pmax = self.col.db.scalar( @@ -1024,4 +1064,3 @@ and due >= ?""" % scids, now, self.col.usn(), shiftby, low) self.randomizeCards(did) else: self.orderCards(did) - diff --git a/tests/test_sched.py b/tests/test_sched.py index f25656eb4..9b291eff0 100644 --- a/tests/test_sched.py +++ b/tests/test_sched.py @@ -447,7 +447,9 @@ def test_cram(): d.reset() assert d.sched.counts() == (0,0,0) cardcopy = copy.copy(c) - d.sched.cram("") + # create a dynamic deck and refresh it + did = d.decks.newDyn("Cram") + d.sched.rebuildDyn(did) d.reset() # should appear as new in the deck list assert sorted(d.sched.deckDueList())[0][3] == 1 @@ -478,7 +480,7 @@ def test_cram(): # and it will have moved back to the previous deck assert c.did == 1 # cram the deck again - d.sched.cram("") + d.sched.rebuildDyn(did) d.reset() c = d.sched.getCard() # check ivls again - passing should be idempotent