From 4e7e8b03bcc7118e38d02967831ee202503767c3 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 26 Feb 2011 14:37:49 +0900 Subject: [PATCH] moving scheduling code into separate file, some preliminary refactoring --- anki/cards.py | 12 +- anki/deck.py | 871 +++--------------------------------- anki/exporting.py | 3 +- anki/importing/__init__.py | 2 - anki/sched.py | 685 ++++++++++++++++++++++++++++ anki/stats.py | 8 +- tests/off/test_exporting.py | 4 +- tests/off/test_importing.py | 10 +- tests/off/test_sync.py | 24 +- tests/test_deck.py | 20 +- 10 files changed, 784 insertions(+), 855 deletions(-) create mode 100644 anki/sched.py diff --git a/anki/cards.py b/anki/cards.py index 18fe77c1a..9a5ea4825 100644 --- a/anki/cards.py +++ b/anki/cards.py @@ -14,8 +14,16 @@ MAX_TIMER = 60 # Cards ########################################################################## -# Type: 0=lapsed, 1=due, 2=new, 3=drilled -# Queue: under normal circumstances, same as type. +# tasks: +# - remove all failed cards from learning queue - set queue=1; type=1 and +# leave scheduling parameters alone (need separate due for learn queue and +# reviews) +# +# - cram cards. gather and introduce to queue=0. +# - remove all cram cards from learning queue. if type h + +# Type: 0=new+learning, 1=due, 2=new, 3=failed+learning, 4=cram+learning +# Queue: 0=learning, 1=due, 2=new, 3=new today, # -1=suspended, -2=user buried, -3=sched buried (rev early, etc) # Ordinal: card template # for fact # Position: sorting position, only for new cards diff --git a/anki/deck.py b/anki/deck.py index 98be79542..4d37c9c04 100644 --- a/anki/deck.py +++ b/anki/deck.py @@ -22,6 +22,7 @@ from anki.template import render from anki.media import updateMediaCount, mediaFiles, \ rebuildMediaDir from anki.upgrade import upgradeSchema, updateIndices, upgradeDeck, DECK_VERSION +from anki.sched import Scheduler import anki.latex # sets up hook # ensure all the DB metadata in other files is loaded before proceeding @@ -107,7 +108,6 @@ class Deck(object): self.sessionStartReps = 0 self.sessionStartTime = 0 self.lastSessionStart = 0 - self.queueLimit = 200 # if most recent deck var not defined, make sure defaults are set if not self.db.scalar("select 1 from deckVars where key = 'latexPost'"): self.setVarDefault("mediaURL", "") @@ -121,821 +121,28 @@ class Deck(object): \\begin{document} """) self.setVarDefault("latexPost", "\\end{document}") - self.updateCutoff() - self.setupStandardScheduler() + self.sched = Scheduler(self) def modifiedSinceSave(self): return self.modified > self.lastLoaded - # Queue management - ########################################################################## - - def setupStandardScheduler(self): - self.getCardId = self._getCardId - self.fillFailedQueue = self._fillFailedQueue - self.fillRevQueue = self._fillRevQueue - self.fillNewQueue = self._fillNewQueue - self.rebuildFailedCount = self._rebuildFailedCount - self.rebuildRevCount = self._rebuildRevCount - self.rebuildNewCount = self._rebuildNewCount - self.requeueCard = self._requeueCard - self.timeForNewCard = self._timeForNewCard - self.updateNewCountToday = self._updateNewCountToday - self.cardQueue = self._cardQueue - self.finishScheduler = None - self.answerCard = self._answerCard - self.cardLimit = self._cardLimit - self.answerPreSave = None - self.spaceCards = self._spaceCards - self.scheduler = "standard" - # restore any cards temporarily suspended by alternate schedulers - try: - self.resetAfterReviewEarly() - except OperationalError, e: - # will fail if deck hasn't been upgraded yet - pass - - def fillQueues(self): - self.fillFailedQueue() - self.fillRevQueue() - self.fillNewQueue() - - def rebuildCounts(self): - # global counts - self.cardCount = self.db.scalar("select count(*) from cards") - self.factCount = self.db.scalar("select count(*) from facts") - # day counts - (self.repsToday, self.newSeenToday) = self.db.first(""" -select count(), sum(case when rep = 1 then 1 else 0 end) from revlog -where time > :t""", t=self.failedCutoff-86400) - self.newSeenToday = self.newSeenToday or 0 - print "newSeenToday in answer(), reset called twice" - print "newSeenToday needs to account for drill mode too." - # due counts - self.rebuildFailedCount() - self.rebuildRevCount() - self.rebuildNewCount() - - def _cardLimit(self, active, inactive, sql): - yes = parseTags(getattr(self, active)) - no = parseTags(getattr(self, inactive)) - if yes: - yids = tagIds(self.db, yes).values() - nids = tagIds(self.db, no).values() - return sql.replace( - "where", - "where +c.id in (select cardId from cardTags where " - "tagId in %s) and +c.id not in (select cardId from " - "cardTags where tagId in %s) and" % ( - ids2str(yids), - ids2str(nids))) - elif no: - nids = tagIds(self.db, no).values() - return sql.replace( - "where", - "where +c.id not in (select cardId from cardTags where " - "tagId in %s) and" % ids2str(nids)) - else: - return sql - - def _rebuildFailedCount(self): - # This is a count of all failed cards within the current day cutoff. - # The cards may not be ready for review yet, but can still be - # displayed if failedCardsMax is reached. - self.failedSoonCount = self.db.scalar( - self.cardLimit( - "revActive", "revInactive", - "select count(*) from cards c where queue = 0 " - "and due < :lim"), lim=self.failedCutoff) - - def _rebuildRevCount(self): - self.revCount = self.db.scalar( - self.cardLimit( - "revActive", "revInactive", - "select count(*) from cards c where queue = 1 " - "and due < :lim"), lim=self.dueCutoff) - - def _rebuildNewCount(self): - self.newAvail = self.db.scalar( - self.cardLimit( - "newActive", "newInactive", - "select count(*) from cards c where queue = 2 " - "and due < :lim"), lim=self.dueCutoff) - self.updateNewCountToday() - self.spacedCards = [] - - def _updateNewCountToday(self): - self.newCount = max(min( - self.newAvail, self.newCardsPerDay - - self.newSeenToday), 0) - - def _fillFailedQueue(self): - if self.failedSoonCount and not self.failedQueue: - self.failedQueue = self.db.all( - self.cardLimit( - "revActive", "revInactive", """ -select c.id, factId, due from cards c where -queue = 0 and due < :lim order by due -limit %d""" % self.queueLimit), lim=self.failedCutoff) - self.failedQueue.reverse() - - def _fillRevQueue(self): - if self.revCount and not self.revQueue: - self.revQueue = self.db.all( - self.cardLimit( - "revActive", "revInactive", """ -select c.id, factId from cards c where -queue = 1 and due < :lim order by %s -limit %d""" % (self.revOrder(), self.queueLimit)), lim=self.dueCutoff) - self.revQueue.reverse() - - def _fillNewQueue(self): - if self.newCount and not self.newQueue and not self.spacedCards: - self.newQueue = self.db.all( - self.cardLimit( - "newActive", "newInactive", """ -select c.id, factId from cards c where -queue = 2 and due < :lim order by %s -limit %d""" % (self.newOrder(), self.queueLimit)), lim=self.dueCutoff) - self.newQueue.reverse() - - def queueNotEmpty(self, queue, fillFunc, new=False): - while True: - self.removeSpaced(queue, new) - if queue: - return True - fillFunc() - if not queue: - return False - - def removeSpaced(self, queue, new=False): - popped = [] - delay = None - while queue: - fid = queue[-1][1] - if fid in self.spacedFacts: - # still spaced - id = queue.pop()[0] - # assuming 10 cards/minute, track id if likely to expire - # before queue refilled - if new and self.newSpacing < self.queueLimit * 6: - popped.append(id) - delay = self.spacedFacts[fid] - else: - if popped: - self.spacedCards.append((delay, popped)) - return - - def revNoSpaced(self): - return self.queueNotEmpty(self.revQueue, self.fillRevQueue) - - def newNoSpaced(self): - return self.queueNotEmpty(self.newQueue, self.fillNewQueue, True) - - def _requeueCard(self, card, oldSuc): - newType = None - try: - if card.reps == 1: - if self.newFromCache: - # fetched from spaced cache - newType = 2 - cards = self.spacedCards.pop(0)[1] - # reschedule the siblings - if len(cards) > 1: - self.spacedCards.append( - (time.time() + self.newSpacing, cards[1:])) - else: - # fetched from normal queue - newType = 1 - self.newQueue.pop() - elif oldSuc == 0: - self.failedQueue.pop() - else: - self.revQueue.pop() - except: - raise Exception("""\ -requeueCard() failed. Please report this along with the steps you take to -produce the problem. - -Counts %d %d %d -Queue %d %d %d -Card info: %d %d %d -New type: %s""" % (self.failedSoonCount, self.revCount, self.newCount, - len(self.failedQueue), len(self.revQueue), - len(self.newQueue), - card.reps, card.successive, oldSuc, `newType`)) - - def revOrder(self): - return ("interval desc", - "interval", - "due", - "factId, ordinal")[self.revCardOrder] - - def newOrder(self): - return ("due", - "due", - "due desc")[self.newCardOrder] - - def rebuildTypes(self): - "Rebuild the type cache. Only necessary on upgrade." - # set type first - self.db.statement(""" -update cards set type = (case -when successive then 1 when reps then 0 else 2 end) -""") - # then queue - self.db.statement(""" -update cards set queue = type -when queue != -1""") - - def updateAllFieldChecksums(self): - # zero out - self.db.statement("update fields set chksum = ''") - # add back for unique fields - for m in self.models: - for fm in m.fieldModels: - self.updateFieldChecksums(fm.id) - - def updateFieldChecksums(self, fmid): - self.db.flush() - self.setSchemaModified() - unique = self.db.scalar( - "select \"unique\" from fieldModels where id = :id", id=fmid) - if unique: - l = [] - for (id, value) in self.db.all( - "select id, value from fields where fieldModelId = :id", - id=fmid): - l.append({'id':id, 'chk':fieldChecksum(value)}) - self.db.statements( - "update fields set chksum = :chk where id = :id", l) - else: - self.db.statement( - "update fields set chksum = '' where fieldModelId=:id", - id=fmid) - - def _cardQueue(self, card): - return self.cardType(card) - - def cardType(self, card): - "Return the type of the current card (what queue it's in)" - if card.successive: - return 1 - elif card.reps: - return 0 - else: - return 2 - - def updateCutoff(self): - d = datetime.datetime.utcfromtimestamp( - time.time() - self.utcOffset) + datetime.timedelta(days=1) - d = datetime.datetime(d.year, d.month, d.day) - newday = self.utcOffset - time.timezone - d += datetime.timedelta(seconds=newday) - cutoff = time.mktime(d.timetuple()) - # cutoff must not be in the past - while cutoff < time.time(): - cutoff += 86400 - # cutoff must not be more than 24 hours in the future - cutoff = min(time.time() + 86400, cutoff) - self.failedCutoff = cutoff - if self.getBool("perDay"): - self.dueCutoff = cutoff - else: - self.dueCutoff = time.time() - def reset(self): - # recheck counts - self.rebuildCounts() - # empty queues; will be refilled by getCard() - self.failedQueue = [] - self.revQueue = [] - self.newQueue = [] - self.spacedFacts = {} - # determine new card distribution - if self.newCardSpacing == NEW_CARDS_DISTRIBUTE: - if self.newCount: - self.newCardModulus = ( - (self.newCount + self.revCount) / self.newCount) - # if there are cards to review, ensure modulo >= 2 - if self.revCount: - self.newCardModulus = max(2, self.newCardModulus) - else: - self.newCardModulus = 0 - else: - self.newCardModulus = 0 + self.sched.reset() # recache css self.rebuildCSS() - # spacing for delayed cards - not to be confused with newCardSpacing - # above - print "newSpacing/revSpacing" - self.newSpacing = 0 - self.revSpacing = 0 - def checkDay(self): - # check if the day has rolled over - if time.time() > self.failedCutoff: - self.updateCutoff() - self.reset() - - # Review early - ########################################################################## - - def setupReviewEarlyScheduler(self): - self.fillRevQueue = self._fillRevEarlyQueue - self.rebuildRevCount = self._rebuildRevEarlyCount - self.finishScheduler = self._onReviewEarlyFinished - self.answerPreSave = self._reviewEarlyPreSave - self.scheduler = "reviewEarly" - - def _reviewEarlyPreSave(self, card, ease): - if ease > 1: - # prevent it from appearing in next queue fill - card.queue = -3 - - def resetAfterReviewEarly(self): - "Put temporarily suspended cards back into play. Caller must .reset()" - self.db.statement( - "update cards set queue = type where queue = -3") - - def _onReviewEarlyFinished(self): - # clean up buried cards - self.resetAfterReviewEarly() - # and go back to regular scheduler - self.setupStandardScheduler() - - def _rebuildRevEarlyCount(self): - # in the future it would be nice to skip the first x days of due cards - self.revCount = self.db.scalar( - self.cardLimit( - "revActive", "revInactive", """ -select count() from cards c where queue = 1 and due > :now -"""), now=self.dueCutoff) - - def _fillRevEarlyQueue(self): - if self.revCount and not self.revQueue: - self.revQueue = self.db.all( - self.cardLimit( - "revActive", "revInactive", """ -select id, factId from cards c where queue = 1 and due > :lim -order by due limit %d""" % self.queueLimit), lim=self.dueCutoff) - self.revQueue.reverse() - - # Learn more - ########################################################################## - - def setupLearnMoreScheduler(self): - self.rebuildNewCount = self._rebuildLearnMoreCount - self.updateNewCountToday = self._updateLearnMoreCountToday - self.finishScheduler = self.setupStandardScheduler - self.scheduler = "learnMore" - - def _rebuildLearnMoreCount(self): - self.newAvail = self.db.scalar( - self.cardLimit( - "newActive", "newInactive", - "select count(*) from cards c where queue = 2 " - "and due < :lim"), lim=self.dueCutoff) - self.spacedCards = [] - - def _updateLearnMoreCountToday(self): - self.newCount = self.newAvail - - # Cramming - ########################################################################## - - def setupCramScheduler(self, active, order): - self.getCardId = self._getCramCardId - self.activeCramTags = active - self.cramOrder = order - self.rebuildNewCount = self._rebuildCramNewCount - self.rebuildRevCount = self._rebuildCramCount - self.rebuildFailedCount = self._rebuildFailedCramCount - self.fillRevQueue = self._fillCramQueue - self.fillFailedQueue = self._fillFailedCramQueue - self.finishScheduler = self.setupStandardScheduler - self.failedCramQueue = [] - self.requeueCard = self._requeueCramCard - self.cardQueue = self._cramCardQueue - self.answerCard = self._answerCramCard - self.spaceCards = self._spaceCramCards - # reuse review early's code - self.answerPreSave = self._cramPreSave - self.cardLimit = self._cramCardLimit - self.scheduler = "cram" - - def _cramPreSave(self, card, ease): - # prevent it from appearing in next queue fill - card.lastInterval = self.cramLastInterval - card.type = -3 - - def _spaceCramCards(self, card): - self.spacedFacts[card.factId] = time.time() + self.newSpacing - - def _answerCramCard(self, card, ease): - self.cramLastInterval = card.lastInterval - self._answerCard(card, ease) - if ease == 1: - self.failedCramQueue.insert(0, [card.id, card.factId]) - - def _getCramCardId(self, check=True): - self.checkDay() - self.fillQueues() - if self.failedCardMax and self.failedSoonCount >= self.failedCardMax: - return self.failedQueue[-1][0] - # card due for review? - if self.revNoSpaced(): - return self.revQueue[-1][0] - if self.failedQueue: - return self.failedQueue[-1][0] - if check: - # collapse spaced cards before reverting back to old scheduler - self.reset() - return self.getCardId(False) - # if we're in a custom scheduler, we may need to switch back - if self.finishScheduler: - self.finishScheduler() - self.reset() - return self.getCardId() - - def _cramCardQueue(self, card): - if self.revQueue and self.revQueue[-1][0] == card.id: - return 1 - else: - return 0 - - def _requeueCramCard(self, card, oldSuc): - if self.cardQueue(card) == 1: - self.revQueue.pop() - else: - self.failedCramQueue.pop() - - def _rebuildCramNewCount(self): - self.newAvail = 0 - self.newCount = 0 - - def _cramCardLimit(self, active, inactive, sql): - # inactive is (currently) ignored - if isinstance(active, list): - return sql.replace( - "where", "where +c.id in " + ids2str(active) + " and") - else: - yes = parseTags(active) - if yes: - yids = tagIds(self.db, yes).values() - return sql.replace( - "where ", - "where +c.id in (select cardId from cardTags where " - "tagId in %s) and " % ids2str(yids)) - else: - return sql - - def _fillCramQueue(self): - if self.revCount and not self.revQueue: - self.revQueue = self.db.all(self.cardLimit( - self.activeCramTags, "", """ -select id, factId from cards c -where queue between 0 and 2 -order by %s -limit %s""" % (self.cramOrder, self.queueLimit))) - self.revQueue.reverse() - - def _rebuildCramCount(self): - self.revCount = self.db.scalar(self.cardLimit( - self.activeCramTags, "", - "select count(*) from cards c where queue between 0 and 2")) - - def _rebuildFailedCramCount(self): - self.failedSoonCount = len(self.failedCramQueue) - - def _fillFailedCramQueue(self): - self.failedQueue = self.failedCramQueue - - # Getting the next card - ########################################################################## - - def getCard(self, orm=True): - "Return the next card object, or None." - id = self.getCardId() - if id: - return self.cardFromId(id, orm) - else: - self.stopSession() - - def _getCardId(self, check=True): - "Return the next due card id, or None." - self.checkDay() - self.fillQueues() - self.updateNewCountToday() - if self.failedQueue: - # failed card due? - if self.delay0: - if self.failedQueue[-1][2] + self.delay0 < time.time(): - return self.failedQueue[-1][0] - # failed card queue too big? - if (self.failedCardMax and - self.failedSoonCount >= self.failedCardMax): - return self.failedQueue[-1][0] - # distribute new cards? - if self.newNoSpaced() and self.timeForNewCard(): - return self.getNewCard() - # card due for review? - if self.revNoSpaced(): - return self.revQueue[-1][0] - # new cards left? - if self.newCount: - id = self.getNewCard() - if id: - return id - if check: - # check for expired cards, or new day rollover - self.updateCutoff() - self.reset() - return self.getCardId(check=False) - # display failed cards early/last - if not check and self.showFailedLast() and self.failedQueue: - return self.failedQueue[-1][0] - # if we're in a custom scheduler, we may need to switch back - if self.finishScheduler: - self.finishScheduler() - self.reset() - return self.getCardId() - - # Get card: helper functions - ########################################################################## - - def _timeForNewCard(self): - "True if it's time to display a new card when distributing." - if not self.newCount: - return False - if self.newCardSpacing == NEW_CARDS_LAST: - return False - if self.newCardSpacing == NEW_CARDS_FIRST: - return True - if self.newCardModulus: - return self.repsToday % self.newCardModulus == 0 - else: - return False - - def getNewCard(self): - src = None - if (self.spacedCards and - self.spacedCards[0][0] < time.time()): - # spaced card has expired - src = 0 - elif self.newQueue: - # card left in new queue - src = 1 - elif self.spacedCards: - # card left in spaced queue - src = 0 - else: - # only cards spaced to another day left - return - if src == 0: - cards = self.spacedCards[0][1] - self.newFromCache = True - return cards[0] - else: - self.newFromCache = False - return self.newQueue[-1][0] - - def showFailedLast(self): - return self.collapseTime or not self.delay0 - - def cardFromId(self, id, orm=False): - "Given a card ID, return a card, and start the card timer." - if orm: - card = self.db.query(anki.cards.Card).get(id) - if not card: - return - card.timerStopped = False - else: - card = anki.cards.Card() - if not card.fromDB(self.db, id): - return - card.deck = self - #card.genFuzz() - card.startTimer() - return card - - # Answering a card - ########################################################################## - - def _answerCard(self, card, ease): - undoName = _("Answer Card") - self.setUndoStart(undoName) - now = time.time() - # old state - oldState = self.cardState(card) - oldQueue = self.cardQueue(card) - lastDelaySecs = time.time() - card.due - lastDelay = lastDelaySecs / 86400.0 - oldSuc = card.successive - # update card details - last = card.interval - card.interval = self.nextInterval(card, ease) - card.lastInterval = last - if card.reps: - # only update if card was not new - card.lastDue = card.due - card.due = self.nextDue(card, ease, oldState) - if not self.finishScheduler: - # don't update factor in custom schedulers - self.updateFactor(card, ease) - # spacing - self.spaceCards(card) - # adjust counts for current card - if ease == 1: - if card.due < self.failedCutoff: - self.failedSoonCount += 1 - if oldQueue == 0: - self.failedSoonCount -= 1 - elif oldQueue == 1: - self.revCount -= 1 - else: - self.newAvail -= 1 - # card stats - self.updateCardStats(card, ease, oldState) - # update type & ensure past cutoff - card.type = self.cardType(card) - card.queue = card.type - if ease != 1: - card.due = max(card.due, self.dueCutoff+1) - # allow custom schedulers to munge the card - if self.answerPreSave: - self.answerPreSave(card, ease) - # save - card.due = card.due - card.toDB(self.db) - # review history - print "make sure flags is set correctly when reviewing early" - logReview(self.db, card, ease, 0) - self.modified = now - # remove from queue - self.requeueCard(card, oldSuc) - # leech handling - we need to do this after the queue, as it may cause - # a reset() - isLeech = self.isLeech(card) - if isLeech: - self.handleLeech(card) - runHook("cardAnswered", card.id, isLeech) - self.setUndoEnd(undoName) - - def updateCardStats(self, card, ease, state): - card.reps += 1 - if ease == 1: - card.successive = 0 - card.lapses += 1 - else: - card.successive += 1 - # if not card.firstAnswered: - # card.firstAnswered = time.time() - card.setModified() - - def _spaceCards(self, card): - new = time.time() + self.newSpacing - self.db.statement(""" -update cards set -due = (case -when queue = 1 then due + 86400 * (case - when interval*:rev < 1 then 0 - else interval*:rev - end) -when queue = 2 then :new -end), -modified = :now -where id != :id and factId = :factId -and due < :cut -and queue between 1 and 2""", - id=card.id, now=time.time(), factId=card.factId, - cut=self.dueCutoff, new=new, rev=self.revSpacing) - # update local cache of seen facts - self.spacedFacts[card.factId] = new - - def isLeech(self, card): - no = card.lapses - fmax = self.getInt('leechFails') - if not fmax: - return - return ( - # failed - not card.successive and - # greater than fail threshold - no >= fmax and - # at least threshold/2 reps since last time - (fmax - no) % (max(fmax/2, 1)) == 0) - - def handleLeech(self, card): - self.refreshSession() - scard = self.cardFromId(card.id, True) - tags = scard.fact.tags - tags = addTags("Leech", tags) - scard.fact.tags = canonifyTags(tags) - scard.fact.setModified(textChanged=True, deck=self) - self.updateFactTags([scard.fact.id]) - self.db.flush() - self.db.expunge(scard) - if self.getBool('suspendLeeches'): - self.suspendCards([card.id]) - self.reset() - self.refreshSession() - - # Interval management - ########################################################################## - - def nextInterval(self, card, ease): - "Return the next interval for CARD given EASE." - delay = self._adjustedDelay(card, ease) - return self._nextInterval(card, delay, ease) - - def _nextInterval(self, card, delay, ease): - interval = card.interval - factor = card.factor - # if cramming / reviewing early - if delay < 0: - interval = max(card.lastInterval, card.interval + delay) - if interval < self.midIntervalMin: - interval = 0 - delay = 0 - # if interval is less than mid interval, use presets - if ease == 1: - interval *= self.delay2 - if interval < self.hardIntervalMin: - interval = 0 - elif interval == 0: - if ease == 2: - interval = random.uniform(self.hardIntervalMin, - self.hardIntervalMax) - elif ease == 3: - interval = random.uniform(self.midIntervalMin, - self.midIntervalMax) - elif ease == 4: - interval = random.uniform(self.easyIntervalMin, - self.easyIntervalMax) - else: - # if not cramming, boost initial 2 - if (interval < self.hardIntervalMax and - interval > 0.166): - mid = (self.midIntervalMin + self.midIntervalMax) / 2.0 - interval = mid / factor - # multiply last interval by factor - if ease == 2: - interval = (interval + delay/4) * 1.2 - elif ease == 3: - interval = (interval + delay/2) * factor - elif ease == 4: - interval = (interval + delay) * factor * self.factorFour - fuzz = random.uniform(0.95, 1.05) - interval *= fuzz - return interval - - def nextIntervalStr(self, card, ease, short=False): - "Return the next interval for CARD given EASE as a string." - int = self.nextInterval(card, ease) - return anki.utils.fmtTimeSpan(int*86400, short=short) - - def nextDue(self, card, ease, oldState): - "Return time when CARD will expire given EASE." - if ease == 1: - # 600 is a magic value which means no bonus, and is used to ease - # upgrades - cram = self.scheduler == "cram" - if (not cram and oldState == "mature" - and self.delay1 and self.delay1 != 600): - # user wants a bonus of 1+ days. put the failed cards at the - # start of the future day, so that failures that day will come - # after the waiting cards - return self.failedCutoff + (self.delay1 - 1)*86400 - else: - due = 0 - else: - due = card.interval * 86400.0 - return due + time.time() - - def updateFactor(self, card, ease): - "Update CARD's factor based on EASE." - print "update cardIsBeingLearnt()" - if not card.reps: - # card is new, inherit beginning factor - card.factor = self.averageFactor - if card.successive and not self.cardIsBeingLearnt(card): - if ease == 1: - card.factor -= 0.20 - elif ease == 2: - card.factor -= 0.15 - if ease == 4: - card.factor += 0.10 - card.factor = max(1.3, card.factor) - - def _adjustedDelay(self, card, ease): - "Return an adjusted delay value for CARD based on EASE." - if self.cardIsNew(card): - return 0 - if card.due <= self.dueCutoff: - return (self.dueCutoff - card.due) / 86400.0 - else: - return (self.dueCutoff - card.due) / 86400.0 + def getCard(self): + return self.sched.getCard() + # if card: + # return card + # if sched.name == "main": + # self.stopSession() + # else: + # # in a custom scheduler; return to normal + # print "fixme: this should be done in gui code" + # self.sched.cleanup() + # self.sched = AnkiScheduler(self) + # return self.getCard() def resetCards(self, ids=None): "Reset progress on cards in IDS." @@ -1015,9 +222,9 @@ where id = :id""", vals) next = self.earliestTime() if next: # all new cards except suspended - newCount = self.newCardsDueBy(self.dueCutoff + 86400) + newCount = self.newCardsDueBy(self.dayCutoff + 86400) newCardsTomorrow = min(newCount, self.newCardsPerDay) - cards = self.cardsDueBy(self.dueCutoff + 86400) + cards = self.cardsDueBy(self.dayCutoff + 86400) msg = _('''\ At this time tomorrow:
@@ -1029,7 +236,7 @@ At this time tomorrow:
'wait': ngettext("There will be %s review.", "There will be %s reviews.", cards) % cards, } - if next > (self.dueCutoff+86400) and not newCardsTomorrow: + if next > (self.dayCutoff+86400) and not newCardsTomorrow: msg = (_("The next review is in %s.") % self.earliestTimeStr()) else: @@ -1143,7 +350,7 @@ where queue = -1 and id in %s""" % "Assumes queue finished. True if some due cards have not been shown." return self.db.scalar(""" select 1 from cards where due < :now -and queue between 0 and 1 limit 1""", now=self.dueCutoff) +and queue between 0 and 1 limit 1""", now=self.dayCutoff) def spacedCardCount(self): "Number of spaced cards." @@ -1218,6 +425,9 @@ due > :now and due < :now""", now=time.time()) # Facts ########################################################################## + def factCount(self): + return self.db.scalar("select count() from facts") + def newFact(self, model=None): "Return a new fact with the current model." if model is None: @@ -1239,7 +449,6 @@ due > :now and due < :now""", now=time.time()) cards = [] self.db.save(fact) # update field cache - self.factCount += 1 self.flushMod() isRandom = self.newCardOrder == NEW_CARDS_RANDOM if isRandom: @@ -1309,7 +518,6 @@ where factId = :fid and cardModelId = :cmid""", fact, cardModel, fact.created+0.0001*cardModel.ordinal) self.updateCardTags([card.id]) - self.cardCount += 1 raise Exception("incorrect; not checking selective study") self.newAvail += 1 ids.append(card.id) @@ -1394,6 +602,9 @@ where facts.id not in (select distinct factId from cards)""") # Cards ########################################################################## + def cardCount(self): + return self.db.scalar("select count() from cards") + def deleteCard(self, id): "Delete a card given its id. Delete any unused facts. Don't flush." self.deleteCards([id]) @@ -1719,6 +930,32 @@ set modified = strftime("%s", "now") where modelId = :id""", id=modelId) self.flushMod() + def updateAllFieldChecksums(self): + # zero out + self.db.statement("update fields set chksum = ''") + # add back for unique fields + for m in self.models: + for fm in m.fieldModels: + self.updateFieldChecksums(fm.id) + + def updateFieldChecksums(self, fmid): + self.db.flush() + self.setSchemaModified() + unique = self.db.scalar( + "select \"unique\" from fieldModels where id = :id", id=fmid) + if unique: + l = [] + for (id, value) in self.db.all( + "select id, value from fields where fieldModelId = :id", + id=fmid): + l.append({'id':id, 'chk':fieldChecksum(value)}) + self.db.statements( + "update fields set chksum = :chk where id = :id", l) + else: + self.db.statement( + "update fields set chksum = '' where fieldModelId=:id", + id=fmid) + # Card models ########################################################################## @@ -2504,7 +1741,7 @@ select cardId from cardTags where cardTags.tagId in %s""" % ids2str(ids) qquery += " intersect " elif isNeg: qquery += "select id from cards except " - if token in ("rev", "new", "failed"): + if token in ("rev", "new", "lrn"): if token == "rev": n = 1 elif token == "new": @@ -2517,7 +1754,7 @@ select cardId from cardTags where cardTags.tagId in %s""" % ids2str(ids) qquery += ("select id from cards where " "due < %d and due > %d and " "type in (0,1,2)") % ( - self.dueCutoff, self.dueCutoff) + self.dayCutoff, self.dayCutoff) elif token == "suspended": qquery += ("select id from cards where " "queue = -1") @@ -2527,7 +1764,7 @@ select cardId from cardTags where cardTags.tagId in %s""" % ids2str(ids) "from deckvars where key = 'leechFails')") else: # due qquery += ("select id from cards where " - "queue between 0 and 1 and due < %d") % self.dueCutoff + "queue between 0 and 1 and due < %d") % self.dayCutoff elif type == SEARCH_FID: if fidquery: if isNeg: diff --git a/anki/exporting.py b/anki/exporting.py index 8a9b6a516..0a9b87a36 100644 --- a/anki/exporting.py +++ b/anki/exporting.py @@ -101,7 +101,8 @@ class AnkiExporter(Exporter): copyLocalMedia(client.deck, server.deck) # need to save manually self.newDeck.rebuildCounts() - self.exportedCards = self.newDeck.cardCount + # FIXME + #self.exportedCards = self.newDeck.cardCount self.newDeck.utcOffset = -1 self.newDeck.db.commit() self.newDeck.close() diff --git a/anki/importing/__init__.py b/anki/importing/__init__.py index 5003d0c20..d78f50f47 100644 --- a/anki/importing/__init__.py +++ b/anki/importing/__init__.py @@ -242,7 +242,6 @@ The current importer only supports a single active card template. Please disable [fudgeCreated({'modelId': self.model.id, 'tags': canonifyTags(self.tagsToAdd + " " + cards[n].tags), 'id': factIds[n]}) for n in range(len(cards))]) - self.deck.factCount += len(factIds) self.deck.db.execute(""" delete from factsDeleted where factId in (%s)""" % ",".join([str(s) for s in factIds])) @@ -285,7 +284,6 @@ where factId in (%s)""" % ",".join([str(s) for s in factIds])) data) self.deck.updateProgress() self.deck.updateCardsFromFactIds(factIds) - self.deck.cardCount += len(cards) * active self.total = len(factIds) def addMeta(self, data, card): diff --git a/anki/sched.py b/anki/sched.py new file mode 100644 index 000000000..fa7c0843a --- /dev/null +++ b/anki/sched.py @@ -0,0 +1,685 @@ +# -*- coding: utf-8 -*- +# Copyright: Damien Elmes +# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html + +import time, datetime +from heapq import * +from anki.db import * +from anki.cards import Card +from anki.utils import parseTags + +# the standard Anki scheduler +class Scheduler(object): + def __init__(self, deck): + self.deck = deck + self.db = deck.db + self.name = "main" + self.queueLimit = 200 + self.learnLimit = 1000 + self.updateCutoff() + # restore any cards temporarily suspended by alternate schedulers + try: + self.resetSchedBuried() + except OperationalError, e: + # will fail if deck hasn't been upgraded yet + print "resetSched() failed" + + def getCard(self, orm=True): + "Pop the next card from the queue. None if finished." + id = self._getCard() + if id: + card = Card() + assert card.fromDB(self.db, id) + return card + + def reset(self): + self.resetLearn() + self.resetReview() + self.resetNew() + print "reset(); need to handle new cards" +# # day counts +# (self.repsToday, self.newSeenToday) = self.db.first(""" +# select count(), sum(case when rep = 1 then 1 else 0 end) from revlog +# where time > :t""", t=self.dayCutoff-86400) +# self.newSeenToday = self.newSeenToday or 0 +# print "newSeenToday in answer(), reset called twice" +# print "newSeenToday needs to account for drill mode too." + + # FIXME: can we do this now with the learn queue? + def rebuildTypes(self): + "Rebuild the type cache. Only necessary on upgrade." + # set type first + self.db.statement(""" +update cards set type = (case +when successive then 1 when reps then 0 else 2 end) +""") + # then queue + self.db.statement(""" +update cards set queue = type +when queue != -1""") + + # FIXME: merge these into the fetching code? rely on the type/queue + # properties? have to think about implications for cramming + def cardQueue(self, card): + return self.cardType(card) + + def cardType(self, card): + "Return the type of the current card (what queue it's in)" + if card.successive: + return 1 + elif card.reps: + return 0 + else: + return 2 + + # Tools + ########################################################################## + + def resetSchedBuried(self): + "Put temporarily suspended cards back into play." + self.db.statement( + "update cards set queue = type where queue = -3") + + def cardLimit(self, active, inactive, sql): + yes = parseTags(getattr(self.deck, active)) + no = parseTags(getattr(self.deck, inactive)) + if yes: + yids = tagIds(self.db, yes).values() + nids = tagIds(self.db, no).values() + return sql.replace( + "where", + "where +c.id in (select cardId from cardTags where " + "tagId in %s) and +c.id not in (select cardId from " + "cardTags where tagId in %s) and" % ( + ids2str(yids), + ids2str(nids))) + elif no: + nids = tagIds(self.db, no).values() + return sql.replace( + "where", + "where +c.id not in (select cardId from cardTags where " + "tagId in %s) and" % ids2str(nids)) + else: + return sql + + # Daily cutoff + ########################################################################## + + def updateCutoff(self): + d = datetime.datetime.utcfromtimestamp( + time.time() - self.deck.utcOffset) + datetime.timedelta(days=1) + d = datetime.datetime(d.year, d.month, d.day) + newday = self.deck.utcOffset - time.timezone + d += datetime.timedelta(seconds=newday) + cutoff = time.mktime(d.timetuple()) + # cutoff must not be in the past + while cutoff < time.time(): + cutoff += 86400 + # cutoff must not be more than 24 hours in the future + cutoff = min(time.time() + 86400, cutoff) + self.dayCutoff = cutoff + self.dayCount = int(cutoff/86400 - self.deck.created/86400) + print "dayCount", self.dayCount + + def checkDay(self): + # check if the day has rolled over + if time.time() > self.dayCutoff: + self.updateCutoff() + self.reset() + + # Learning queue + ########################################################################## + + def resetLearn(self): + self.learnQueue = self.db.all(""" +select due, id from cards where +queue = 0 and due < :lim order by due +limit %d""" % self.learnLimit, lim=self.dayCutoff) + self.learnQueue.reverse() + self.learnCount = len(self.learnQueue) + + def getLearnCard(self): + if self.learnQueue and self.learnQueue[0] < time.time(): + return heappop(self.learnQueue) + + # Reviews + ########################################################################## + + def resetReview(self): + self.revCount = self.db.scalar( + self.cardLimit( + "revActive", "revInactive", + "select count(*) from cards c where queue = 1 " + "and due < :lim"), lim=self.dayCutoff) + self.revQueue = [] + + def getReviewCard(self): + if self.haveRevCards(): + return self.revQueue.pop() + + def haveRevCards(self): + if self.revCount: + if not self.revQueue: + self.fillRevQueue() + return self.revQueue + + def fillRevQueue(self): + self.revQueue = self.db.all( + self.cardLimit( + "revActive", "revInactive", """ +select c.id, factId from cards c where +queue = 1 and due < :lim order by %s +limit %d""" % (self.revOrder(), self.queueLimit)), lim=self.dayCutoff) + self.revQueue.reverse() + + # FIXME: current random order won't work with new spacing + def revOrder(self): + return ("interval desc", + "interval", + "due", + "factId, ordinal")[self.revCardOrder] + + # FIXME: rewrite + def showFailedLast(self): + return self.collapseTime or not self.delay0 + + # New cards + ########################################################################## + + # when do we do this? + #self.updateNewCountToday() + + def resetNew(self): +# self.updateNewCardRatio() + pass + + def rebuildNewCount(self): + self.newAvail = self.db.scalar( + self.cardLimit( + "newActive", "newInactive", + "select count(*) from cards c where queue = 2 " + "and due < :lim"), lim=self.dayCutoff) + self.updateNewCountToday() + self.spacedCards = [] + + def updateNewCountToday(self): + self.newCount = max(min( + self.newAvail, self.newCardsPerDay - + self.newSeenToday), 0) + + def fillNewQueue(self): + if self.newCount and not self.newQueue and not self.spacedCards: + self.newQueue = self.db.all( + self.cardLimit( + "newActive", "newInactive", """ +select c.id, factId from cards c where +queue = 2 and due < :lim order by %s +limit %d""" % (self.newOrder(), self.queueLimit)), lim=self.dayCutoff) + self.newQueue.reverse() + + def updateNewCardRatio(self): + if self.newCardSpacing == NEW_CARDS_DISTRIBUTE: + if self.newCount: + self.newCardModulus = ( + (self.newCount + self.revCount) / self.newCount) + # if there are cards to review, ensure modulo >= 2 + if self.revCount: + self.newCardModulus = max(2, self.newCardModulus) + else: + self.newCardModulus = 0 + else: + self.newCardModulus = 0 + + def timeForNewCard(self): + "True if it's time to display a new card when distributing." + if not self.newCount: + return False + if self.newCardSpacing == NEW_CARDS_LAST: + return False + if self.newCardSpacing == NEW_CARDS_FIRST: + return True + if self.newCardModulus: + return self.repsToday % self.newCardModulus == 0 + else: + return False + + def getNewCard(self): + src = None + if (self.spacedCards and + self.spacedCards[0][0] < time.time()): + # spaced card has expired + src = 0 + elif self.newQueue: + # card left in new queue + src = 1 + elif self.spacedCards: + # card left in spaced queue + src = 0 + else: + # only cards spaced to another day left + return + if src == 0: + cards = self.spacedCards[0][1] + self.newFromCache = True + return cards[0] + else: + self.newFromCache = False + return self.newQueue[-1][0] + + def newOrder(self): + return ("due", + "due", + "due desc")[self.newCardOrder] + + # Getting the next card + ########################################################################## + + def getCard(self): + "Return the next due card id, or None." + self.checkDay() + # learning card due? + id = self.getLearnCard() + if id: + return id + # distribute new cards? + if self.newNoSpaced() and self.timeForNewCard(): + return self.getNewCard() + # card due for review? + if self.revNoSpaced(): + return self.revQueue[-1][0] + # new cards left? + if self.newCount: + id = self.getNewCard() + if id: + return id + # display failed cards early/last + if not check and self.showFailedLast() and self.learnQueue: + return self.learnQueue[-1][0] + # if we're in a custom scheduler, we may need to switch back + if self.finishScheduler: + self.finishScheduler() + self.reset() + return self.getCardId() + + # Answering a card + ########################################################################## + + def answerCard(self, card, ease): + undoName = _("Answer Card") + self.setUndoStart(undoName) + now = time.time() + # old state + oldState = self.cardState(card) + oldQueue = self.cardQueue(card) + lastDelaySecs = time.time() - card.due + lastDelay = lastDelaySecs / 86400.0 + oldSuc = card.successive + # update card details + last = card.interval + card.interval = self.nextInterval(card, ease) + card.lastInterval = last + if card.reps: + # only update if card was not new + card.lastDue = card.due + card.due = self.nextDue(card, ease, oldState) + if not self.finishScheduler: + # don't update factor in custom schedulers + self.updateFactor(card, ease) + # spacing + self.spaceCards(card) + # adjust counts for current card + if ease == 1: + if card.due < self.dayCutoff: + self.learnCount += 1 + if oldQueue == 0: + self.learnCount -= 1 + elif oldQueue == 1: + self.revCount -= 1 + else: + self.newAvail -= 1 + # card stats + self.updateCardStats(card, ease, oldState) + # update type & ensure past cutoff + card.type = self.cardType(card) + card.queue = card.type + if ease != 1: + card.due = max(card.due, self.dayCutoff+1) + # allow custom schedulers to munge the card + if self.answerPreSave: + self.answerPreSave(card, ease) + # save + card.due = card.due + card.toDB(self.db) + # review history + print "make sure flags is set correctly when reviewing early" + logReview(self.db, card, ease, 0) + self.modified = now + # leech handling - we need to do this after the queue, as it may cause + # a reset() + isLeech = self.isLeech(card) + if isLeech: + self.handleLeech(card) + runHook("cardAnswered", card.id, isLeech) + self.setUndoEnd(undoName) + + def updateCardStats(self, card, ease, state): + card.reps += 1 + if ease == 1: + card.successive = 0 + card.lapses += 1 + else: + card.successive += 1 + # if not card.firstAnswered: + # card.firstAnswered = time.time() + card.setModified() + + def spaceCards(self, card): + new = time.time() + self.newSpacing + self.db.statement(""" +update cards set +due = (case +when queue = 1 then due + 86400 * (case + when interval*:rev < 1 then 0 + else interval*:rev + end) +when queue = 2 then :new +end), +modified = :now +where id != :id and factId = :factId +and due < :cut +and queue between 1 and 2""", + id=card.id, now=time.time(), factId=card.factId, + cut=self.dayCutoff, new=new, rev=self.revSpacing) + # update local cache of seen facts + self.spacedFacts[card.factId] = new + + # Interval management + ########################################################################## + + def nextInterval(self, card, ease): + "Return the next interval for CARD given EASE." + delay = self.adjustedDelay(card, ease) + return self._nextInterval(card, delay, ease) + + def _nextInterval(self, card, delay, ease): + interval = card.interval + factor = card.factor + # if cramming / reviewing early + if delay < 0: + interval = max(card.lastInterval, card.interval + delay) + if interval < self.midIntervalMin: + interval = 0 + delay = 0 + # if interval is less than mid interval, use presets + if ease == 1: + interval *= self.delay2 + if interval < self.hardIntervalMin: + interval = 0 + elif interval == 0: + if ease == 2: + interval = random.uniform(self.hardIntervalMin, + self.hardIntervalMax) + elif ease == 3: + interval = random.uniform(self.midIntervalMin, + self.midIntervalMax) + elif ease == 4: + interval = random.uniform(self.easyIntervalMin, + self.easyIntervalMax) + else: + # if not cramming, boost initial 2 + if (interval < self.hardIntervalMax and + interval > 0.166): + mid = (self.midIntervalMin + self.midIntervalMax) / 2.0 + interval = mid / factor + # multiply last interval by factor + if ease == 2: + interval = (interval + delay/4) * 1.2 + elif ease == 3: + interval = (interval + delay/2) * factor + elif ease == 4: + interval = (interval + delay) * factor * self.factorFour + fuzz = random.uniform(0.95, 1.05) + interval *= fuzz + return interval + + def nextIntervalStr(self, card, ease, short=False): + "Return the next interval for CARD given EASE as a string." + int = self.nextInterval(card, ease) + return anki.utils.fmtTimeSpan(int*86400, short=short) + + def nextDue(self, card, ease, oldState): + "Return time when CARD will expire given EASE." + if ease == 1: + # 600 is a magic value which means no bonus, and is used to ease + # upgrades + cram = self.scheduler == "cram" + if (not cram and oldState == "mature" + and self.delay1 and self.delay1 != 600): + # user wants a bonus of 1+ days. put the failed cards at the + # start of the future day, so that failures that day will come + # after the waiting cards + return self.dayCutoff + (self.delay1 - 1)*86400 + else: + due = 0 + else: + due = card.interval * 86400.0 + return due + time.time() + + def updateFactor(self, card, ease): + "Update CARD's factor based on EASE." + print "update cardIsBeingLearnt()" + if not card.reps: + # card is new, inherit beginning factor + card.factor = self.averageFactor + if card.successive and not self.cardIsBeingLearnt(card): + if ease == 1: + card.factor -= 0.20 + elif ease == 2: + card.factor -= 0.15 + if ease == 4: + card.factor += 0.10 + card.factor = max(1.3, card.factor) + + def adjustedDelay(self, card, ease): + "Return an adjusted delay value for CARD based on EASE." + if self.cardIsNew(card): + return 0 + if card.due <= self.dayCutoff: + return (self.dayCutoff - card.due) / 86400.0 + else: + return (self.dayCutoff - card.due) / 86400.0 + + # Leeches + ########################################################################## + + def isLeech(self, card): + no = card.lapses + fmax = self.getInt('leechFails') + if not fmax: + return + return ( + # failed + not card.successive and + # greater than fail threshold + no >= fmax and + # at least threshold/2 reps since last time + (fmax - no) % (max(fmax/2, 1)) == 0) + + def handleLeech(self, card): + self.refreshSession() + scard = self.cardFromId(card.id, True) + tags = scard.fact.tags + tags = addTags("Leech", tags) + scard.fact.tags = canonifyTags(tags) + scard.fact.setModified(textChanged=True, deck=self) + self.updateFactTags([scard.fact.id]) + self.db.flush() + self.db.expunge(scard) + if self.getBool('suspendLeeches'): + self.suspendCards([card.id]) + self.reset() + self.refreshSession() + + # Review early + ########################################################################## + + def setupReviewEarlyScheduler(self): + self.fillRevQueue = self._fillRevEarlyQueue + self.rebuildRevCount = self._rebuildRevEarlyCount + self.finishScheduler = self.setupStandardScheduler + self.answerPreSave = self._reviewEarlyPreSave + self.scheduler = "reviewEarly" + + def _reviewEarlyPreSave(self, card, ease): + if ease > 1: + # prevent it from appearing in next queue fill + card.queue = -3 + + def _rebuildRevEarlyCount(self): + # in the future it would be nice to skip the first x days of due cards + self.revCount = self.db.scalar( + self.cardLimit( + "revActive", "revInactive", """ +select count() from cards c where queue = 1 and due > :now +"""), now=self.dayCutoff) + + def _fillRevEarlyQueue(self): + if self.revCount and not self.revQueue: + self.revQueue = self.db.all( + self.cardLimit( + "revActive", "revInactive", """ +select id, factId from cards c where queue = 1 and due > :lim +order by due limit %d""" % self.queueLimit), lim=self.dayCutoff) + self.revQueue.reverse() + + # Learn more + ########################################################################## + + def setupLearnMoreScheduler(self): + self.rebuildNewCount = self._rebuildLearnMoreCount + self.updateNewCountToday = self._updateLearnMoreCountToday + self.finishScheduler = self.setupStandardScheduler + self.scheduler = "learnMore" + + def _rebuildLearnMoreCount(self): + self.newAvail = self.db.scalar( + self.cardLimit( + "newActive", "newInactive", + "select count(*) from cards c where queue = 2 " + "and due < :lim"), lim=self.dayCutoff) + self.spacedCards = [] + + def _updateLearnMoreCountToday(self): + self.newCount = self.newAvail + + # Cramming + ########################################################################## + + def setupCramScheduler(self, active, order): + self.getCardId = self._getCramCardId + self.activeCramTags = active + self.cramOrder = order + self.rebuildNewCount = self._rebuildCramNewCount + self.rebuildRevCount = self._rebuildCramCount + self.rebuildLrnCount = self._rebuildLrnCramCount + self.fillRevQueue = self._fillCramQueue + self.fillLrnQueue = self._fillLrnCramQueue + self.finishScheduler = self.setupStandardScheduler + self.lrnCramQueue = [] + print "requeue cram" + self.requeueCard = self._requeueCramCard + self.cardQueue = self._cramCardQueue + self.answerCard = self._answerCramCard + self.spaceCards = self._spaceCramCards + # reuse review early's code + self.answerPreSave = self._cramPreSave + self.cardLimit = self._cramCardLimit + self.scheduler = "cram" + + def _cramPreSave(self, card, ease): + # prevent it from appearing in next queue fill + card.lastInterval = self.cramLastInterval + card.type = -3 + + def _spaceCramCards(self, card): + self.spacedFacts[card.factId] = time.time() + self.newSpacing + + def _answerCramCard(self, card, ease): + self.cramLastInterval = card.lastInterval + self._answerCard(card, ease) + if ease == 1: + self.lrnCramQueue.insert(0, [card.id, card.factId]) + + def _getCramCardId(self, check=True): + self.checkDay() + self.fillQueues() + if self.lrnCardMax and self.lrnCount >= self.lrnCardMax: + return self.lrnQueue[-1][0] + # card due for review? + if self.revNoSpaced(): + return self.revQueue[-1][0] + if self.lrnQueue: + return self.lrnQueue[-1][0] + if check: + # collapse spaced cards before reverting back to old scheduler + self.reset() + return self.getCardId(False) + # if we're in a custom scheduler, we may need to switch back + if self.finishScheduler: + self.finishScheduler() + self.reset() + return self.getCardId() + + def _cramCardQueue(self, card): + if self.revQueue and self.revQueue[-1][0] == card.id: + return 1 + else: + return 0 + + def _requeueCramCard(self, card, oldSuc): + if self.cardQueue(card) == 1: + self.revQueue.pop() + else: + self.lrnCramQueue.pop() + + def _rebuildCramNewCount(self): + self.newAvail = 0 + self.newCount = 0 + + def _cramCardLimit(self, active, inactive, sql): + # inactive is (currently) ignored + if isinstance(active, list): + return sql.replace( + "where", "where +c.id in " + ids2str(active) + " and") + else: + yes = parseTags(active) + if yes: + yids = tagIds(self.db, yes).values() + return sql.replace( + "where ", + "where +c.id in (select cardId from cardTags where " + "tagId in %s) and " % ids2str(yids)) + else: + return sql + + def _fillCramQueue(self): + if self.revCount and not self.revQueue: + self.revQueue = self.db.all(self.cardLimit( + self.activeCramTags, "", """ +select id, factId from cards c +where queue between 0 and 2 +order by %s +limit %s""" % (self.cramOrder, self.queueLimit))) + self.revQueue.reverse() + + def _rebuildCramCount(self): + self.revCount = self.db.scalar(self.cardLimit( + self.activeCramTags, "", + "select count(*) from cards c where queue between 0 and 2")) + + def _rebuildLrnCramCount(self): + self.lrnCount = len(self.lrnCramQueue) + + def _fillLrnCramQueue(self): + self.lrnQueue = self.lrnCramQueue + diff --git a/anki/stats.py b/anki/stats.py index 0c9e51618..9c4d2eace 100644 --- a/anki/stats.py +++ b/anki/stats.py @@ -75,7 +75,7 @@ class DeckStats(object): d = self.deck html="

" + _("Deck Statistics") + "

" html += _("Deck created: %s ago
") % self.createdTimeStr() - total = d.cardCount + total = d.cardCount() new = d.newCountAll() young = d.youngCardCount() old = d.matureCardCount() @@ -87,7 +87,7 @@ class DeckStats(object): (stats["old"], stats["oldP"]) = (old, oldP) (stats["young"], stats["youngP"]) = (young, youngP) html += _("Total number of cards:") + " %d
" % total - html += _("Total number of facts:") + " %d

" % d.factCount + html += _("Total number of facts:") + " %d

" % d.factCount() html += "" + _("Card Maturity") + "
" html += _("Mature cards: ") + " %(old)d (%(oldP)s)
" % { @@ -118,7 +118,7 @@ class DeckStats(object): 'partOf' : nYes, 'totalSum' : nAll } + "

") # average pending time - existing = d.cardCount - d.newCount + existing = d.cardCount() - d.newCount def tr(a, b): return "%s%s" % (a, b) def repsPerDay(reps,days): @@ -292,7 +292,7 @@ where time >= :x*1000 and time <= :y*1000""",x=x,y=y, off=self.deck.utcOffset) def newAverage(self): "Average number of new cards added each day." - return self.deck.cardCount / max(1, self.ageInDays()) + return self.deck.cardCount() / max(1, self.ageInDays()) def createdTimeStr(self): return anki.utils.fmtTimeSpan(time.time() - self.deck.created) diff --git a/tests/off/test_exporting.py b/tests/off/test_exporting.py index b93afadc7..ab0064e7a 100644 --- a/tests/off/test_exporting.py +++ b/tests/off/test_exporting.py @@ -34,14 +34,14 @@ def test_export_anki(): assert deck.modified == oldTime # connect to new deck d2 = Deck(newname, backup=False) - assert d2.cardCount == 4 + assert d2.cardCount() == 4 # try again, limited to a tag newname = unicode(tempfile.mkstemp(prefix="ankitest")[1]) os.unlink(newname) e.limitTags = ['tag'] e.exportInto(newname) d2 = Deck(newname, backup=False) - assert d2.cardCount == 2 + assert d2.cardCount() == 2 @nose.with_setup(setup1) def test_export_textcard(): diff --git a/tests/off/test_importing.py b/tests/off/test_importing.py index 41bb36e30..7ac0edb2e 100644 --- a/tests/off/test_importing.py +++ b/tests/off/test_importing.py @@ -89,11 +89,11 @@ def test_anki10_modtime(): f = deck1.newFact() f['Front'] = u"foo"; f['Back'] = u"bar" deck1.addFact(f) - assert deck1.cardCount == 1 - assert deck2.cardCount == 0 + assert deck1.cardCount() == 1 + assert deck2.cardCount() == 0 client.sync() - assert deck1.cardCount == 1 - assert deck2.cardCount == 1 + assert deck1.cardCount() == 1 + assert deck2.cardCount() == 1 file_ = unicode(os.path.join(testDir, "importing/test10-3.anki")) file = "/tmp/test10-3.anki" shutil.copy(file_, file) @@ -108,7 +108,7 @@ def test_anki10_modtime(): def test_dingsbums(): deck = Deck() deck.addModel(BasicModel()) - startNumberOfFacts = deck.factCount + startNumberOfFacts = deck.factCount() file = unicode(os.path.join(testDir, "importing/dingsbums.xml")) i = dingsbums.DingsBumsImporter(deck, file) i.doImport() diff --git a/tests/off/test_sync.py b/tests/off/test_sync.py index efa820735..683bf32cb 100644 --- a/tests/off/test_sync.py +++ b/tests/off/test_sync.py @@ -56,8 +56,8 @@ def teardown(): @nose.with_setup(setup_local, teardown) def test_localsync_diffing(): - assert deck1.cardCount == 2 - assert deck2.cardCount == 2 + assert deck1.cardCount() == 2 + assert deck2.cardCount() == 2 lsum = client.summary(deck1.lastSync) rsum = server.summary(deck1.lastSync) result = client.diffSummary(lsum, rsum, 'cards') @@ -157,12 +157,12 @@ def test_localsync_models(): @nose.with_setup(setup_local, teardown) def test_localsync_factsandcards(): - assert deck1.factCount == 1 and deck1.cardCount == 2 - assert deck2.factCount == 1 and deck2.cardCount == 2 + assert deck1.factCount() == 1 and deck1.cardCount() == 2 + assert deck2.factCount() == 1 and deck2.cardCount() == 2 client.sync() deck1.reset(); deck2.reset() - assert deck1.factCount == 2 and deck1.cardCount == 4 - assert deck2.factCount == 2 and deck2.cardCount == 4 + assert deck1.factCount() == 2 and deck1.cardCount() == 4 + assert deck2.factCount() == 2 and deck2.cardCount() == 4 # ensure the fact was copied across f1 = deck1.db.query(Fact).first() f2 = deck1.db.query(Fact).get(f1.id) @@ -193,20 +193,20 @@ def test_localsync_threeway(): f = deck1.addFact(f) card = f.cards[0] client.sync() - assert deck1.cardCount == 6 - assert deck2.cardCount == 6 + assert deck1.cardCount() == 6 + assert deck2.cardCount() == 6 # check it propagates from server to deck3 client2.sync() - assert deck3.cardCount == 6 + assert deck3.cardCount() == 6 # delete a card on deck1 deck1.deleteCard(card.id) client.sync() deck1.reset(); deck2.reset() - assert deck1.cardCount == 5 - assert deck2.cardCount == 5 + assert deck1.cardCount() == 5 + assert deck2.cardCount() == 5 # make sure the delete is now propagated from the server to deck3 client2.sync() - assert deck3.cardCount == 5 + assert deck3.cardCount() == 5 def test_localsync_media(): tmpdir = "/tmp/media-tests" diff --git a/tests/test_deck.py b/tests/test_deck.py index 43d8d59aa..43b0bc1fa 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -72,20 +72,20 @@ def test_saveAs(): f = deck.newFact() f['Front'] = u"foo"; f['Back'] = u"bar" deck.addFact(f) - assert deck.cardCount == 1 + assert deck.cardCount() == 1 # save in new deck newDeck = deck.saveAs(path) - assert newDeck.cardCount == 1 + assert newDeck.cardCount() == 1 # delete card id = newDeck.db.scalar("select id from cards") newDeck.deleteCard(id) # save into new deck newDeck2 = newDeck.saveAs(path2) # new deck should have zero cards - assert newDeck2.cardCount == 0 + assert newDeck2.cardCount() == 0 # but old deck should have reverted the unsaved changes newDeck = Deck(path) - assert newDeck.cardCount == 1 + assert newDeck.cardCount() == 1 newDeck.close() def test_factAddDelete(): @@ -171,10 +171,10 @@ def test_modelAddDelete(): f['Front'] = u'1' f['Back'] = u'2' deck.addFact(f) - assert deck.cardCount == 1 + assert deck.cardCount() == 1 deck.deleteModel(deck.currentModel) deck.reset() - assert deck.cardCount == 0 + assert deck.cardCount() == 0 deck.db.refresh(deck) def test_modelCopy(): @@ -249,8 +249,8 @@ def test_modelChange(): # convert to basic assert deck.modelUseCount(m1) == 2 assert deck.modelUseCount(m2) == 0 - assert deck.cardCount == 4 - assert deck.factCount == 2 + assert deck.cardCount() == 4 + assert deck.factCount() == 2 fmap = {m1.fieldModels[0]: m2.fieldModels[0], m1.fieldModels[1]: None, m1.fieldModels[2]: m2.fieldModels[1]} @@ -260,8 +260,8 @@ def test_modelChange(): deck.reset() assert deck.modelUseCount(m1) == 1 assert deck.modelUseCount(m2) == 1 - assert deck.cardCount == 3 - assert deck.factCount == 2 + assert deck.cardCount() == 3 + assert deck.factCount() == 2 (q, a) = deck.db.first(""" select question, answer from cards where factId = :id""", id=f.id)