implement new review code, add unit tests

Instead of the old approach to sibling spacing, we instead try to pick a due
date that doesn't have any siblings.
This commit is contained in:
Damien Elmes 2011-03-18 05:27:51 +09:00
parent ca0748b664
commit 908dccc2c0
7 changed files with 261 additions and 282 deletions

View file

@ -58,9 +58,6 @@ defaultConf = {
# this is initialized by storage.Deck # this is initialized by storage.Deck
class _Deck(object): class _Deck(object):
# fixme: make configurable?
factorFour = 1.3
def __init__(self, db): def __init__(self, db):
self.db = db self.db = db
self.path = db._path self.path = db._path

View file

@ -5,7 +5,7 @@
import time import time
from anki.errors import AnkiError from anki.errors import AnkiError
from anki.utils import stripHTMLMedia, fieldChecksum, intTime, \ from anki.utils import stripHTMLMedia, fieldChecksum, intTime, \
joinFields, splitFields, ids2str, parseTags, joinTags, hasTag joinFields, splitFields, ids2str, parseTags, canonifyTags, hasTag
class Fact(object): class Fact(object):
@ -44,7 +44,7 @@ select mid, gid, crt, mod, tags, flds, data from facts where id = ?""", self.id)
self.mod = intTime() self.mod = intTime()
# facts table # facts table
sfld = self._fields[self._model.sortIdx()] sfld = self._fields[self._model.sortIdx()]
tags = joinTags(self.tags) tags = canonifyTags(self.tags)
res = self.deck.db.execute(""" res = self.deck.db.execute("""
insert or replace into facts values (?, ?, ?, ?, ?, ?, ?, ?, ?)""", insert or replace into facts values (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
self.id, self.mid, self.gid, self.crt, self.id, self.mid, self.gid, self.crt,

View file

@ -17,10 +17,17 @@ defaultConf = {
}, },
'lapse': { 'lapse': {
'delays': [0.5, 3, 10], 'delays': [0.5, 3, 10],
'mult': 0 'mult': 0,
'relearn': True,
'leechFails': 16,
# one of [suspend], [tagonly]
'leechAction': ["suspend"],
}, },
'suspendLeeches': True, 'rev': {
'leechFails': 16, 'ease4': 1.3,
'fuzz': 0.05,
'minSpace': 1,
}
} }
class GroupConfig(object): class GroupConfig(object):

View file

@ -18,43 +18,43 @@ class Scheduler(object):
self.name = "main" self.name = "main"
self.queueLimit = 200 self.queueLimit = 200
self.reportLimit = 1000 self.reportLimit = 1000
self.updateCutoff() self._updateCutoff()
def getCard(self): def getCard(self):
"Pop the next card from the queue. None if finished." "Pop the next card from the queue. None if finished."
self.checkDay() self._checkDay()
id = self.getCardId() id = self._getCardId()
if id: if id:
c = self.deck.getCard(id) c = self.deck.getCard(id)
c.startTimer() c.startTimer()
return c return c
def reset(self): def reset(self):
self.resetConf() self._resetConf()
t = time.time() t = time.time()
self.resetLearn() self._resetLearn()
#print "lrn %0.2fms" % ((time.time() - t)*1000); t = time.time() #print "lrn %0.2fms" % ((time.time() - t)*1000); t = time.time()
self.resetReview() self._resetReview()
#print "rev %0.2fms" % ((time.time() - t)*1000); t = time.time() #print "rev %0.2fms" % ((time.time() - t)*1000); t = time.time()
self.resetNew() self._resetNew()
#print "new %0.2fms" % ((time.time() - t)*1000); t = time.time() #print "new %0.2fms" % ((time.time() - t)*1000); t = time.time()
def answerCard(self, card, ease): def answerCard(self, card, ease):
if card.queue == 0: if card.queue == 0:
self.answerLearnCard(card, ease) self._answerLearnCard(card, ease)
elif card.queue == 1: elif card.queue == 1:
self.revCount -= 1 self._answerRevCard(card, ease)
self.answerRevCard(card, ease)
elif card.queue == 2: elif card.queue == 2:
# put it in the learn queue # put it in the learn queue
card.queue = 0 card.queue = 0
self.newCount -= 1 self._answerLearnCard(card, ease)
self.answerLearnCard(card, ease)
else: else:
raise Exception("Invalid queue") raise Exception("Invalid queue")
card.mod = intTime()
card.flushSched() card.flushSched()
def counts(self): def counts(self):
"Does not include fetched but unanswered."
return (self.learnCount, self.revCount, self.newCount) return (self.learnCount, self.revCount, self.newCount)
def timeToday(self): def timeToday(self):
@ -75,32 +75,32 @@ class Scheduler(object):
# Getting the next card # Getting the next card
########################################################################## ##########################################################################
def getCardId(self): def _getCardId(self):
"Return the next due card id, or None." "Return the next due card id, or None."
# learning card due? # learning card due?
id = self.getLearnCard() id = self._getLearnCard()
if id: if id:
return id return id
# new first, or time for one? # new first, or time for one?
if self.timeForNewCard(): if self._timeForNewCard():
return self.getNewCard() return self._getNewCard()
# card due for review? # card due for review?
id = self.getReviewCard() id = self._getReviewCard()
if id: if id:
return id return id
# new cards left? # new cards left?
id = self.getNewCard() id = self._getNewCard()
if id: if id:
return id return id
# collapse or finish # collapse or finish
return self.getLearnCard(collapse=True) return self._getLearnCard(collapse=True)
# New cards # New cards
########################################################################## ##########################################################################
# need to keep track of reps for timebox and new card introduction # need to keep track of reps for timebox and new card introduction
def resetNewCount(self): def _resetNewCount(self):
l = self.deck.qconf l = self.deck.qconf
if l['newToday'][0] != self.today: if l['newToday'][0] != self.today:
# it's a new day; reset counts # it's a new day; reset counts
@ -111,28 +111,29 @@ class Scheduler(object):
else: else:
self.newCount = self.db.scalar(""" self.newCount = self.db.scalar("""
select count() from (select id from cards where select count() from (select id from cards where
queue = 2 %s limit %d)""" % (self.groupLimit('new'), lim)) queue = 2 %s limit %d)""" % (self._groupLimit('new'), lim))
def resetNew(self): def _resetNew(self):
self.resetNewCount() self._resetNewCount()
lim = min(self.queueLimit, self.newCount) lim = min(self.queueLimit, self.newCount)
self.newQueue = self.db.all(""" self.newQueue = self.db.all("""
select id, due from cards where select id, due from cards where
queue = 2 %s order by due limit %d""" % (self.groupLimit('new'), queue = 2 %s order by due limit %d""" % (self._groupLimit('new'),
lim)) lim))
self.newQueue.reverse() self.newQueue.reverse()
self.updateNewCardRatio() self._updateNewCardRatio()
def getNewCard(self): def _getNewCard(self):
if self.newQueue: if self.newQueue:
(id, due) = self.newQueue.pop() (id, due) = self.newQueue.pop()
# move any siblings to the end? # move any siblings to the end?
if self.deck.qconf['newTodayOrder'] == NEW_TODAY_ORD: if self.deck.qconf['newTodayOrder'] == NEW_TODAY_ORD:
while self.newQueue and self.newQueue[-1][1] == due: while self.newQueue and self.newQueue[-1][1] == due:
self.newQueue.insert(0, self.newQueue.pop()) self.newQueue.insert(0, self.newQueue.pop())
self.newCount -= 1
return id return id
def updateNewCardRatio(self): def _updateNewCardRatio(self):
if self.deck.qconf['newCardSpacing'] == NEW_CARDS_DISTRIBUTE: if self.deck.qconf['newCardSpacing'] == NEW_CARDS_DISTRIBUTE:
if self.newCount: if self.newCount:
self.newCardModulus = ( self.newCardModulus = (
@ -143,7 +144,7 @@ queue = 2 %s order by due limit %d""" % (self.groupLimit('new'),
return return
self.newCardModulus = 0 self.newCardModulus = 0
def timeForNewCard(self): def _timeForNewCard(self):
"True if it's time to display a new card when distributing." "True if it's time to display a new card when distributing."
if not self.newCount: if not self.newCount:
return False return False
@ -157,35 +158,37 @@ queue = 2 %s order by due limit %d""" % (self.groupLimit('new'),
# Learning queue # Learning queue
########################################################################## ##########################################################################
def resetLearnCount(self): def _resetLearnCount(self):
self.learnCount = self.db.scalar( self.learnCount = self.db.scalar(
"select count() from cards where queue = 0 and due < ?", "select count() from cards where queue = 0 and due < ?",
intTime() + self.deck.qconf['collapseTime']) intTime() + self.deck.qconf['collapseTime'])
def resetLearn(self): def _resetLearn(self):
self.resetLearnCount() self._resetLearnCount()
self.learnQueue = self.db.all(""" self.learnQueue = self.db.all("""
select due, id from cards where select due, id from cards where
queue = 0 and due < :lim order by due queue = 0 and due < :lim order by due
limit %d""" % self.reportLimit, lim=self.dayCutoff) limit %d""" % self.reportLimit, lim=self.dayCutoff)
def getLearnCard(self, collapse=False): def _getLearnCard(self, collapse=False):
if self.learnQueue: if self.learnQueue:
cutoff = time.time() cutoff = time.time()
if collapse: if collapse:
cutoff -= self.deck.collapseTime cutoff -= self.deck.collapseTime
if self.learnQueue[0][0] < cutoff: if self.learnQueue[0][0] < cutoff:
return heappop(self.learnQueue)[1] id = heappop(self.learnQueue)[1]
self.learnCount -= 1
return id
def answerLearnCard(self, card, ease): def _answerLearnCard(self, card, ease):
# ease 1=no, 2=yes, 3=remove # ease 1=no, 2=yes, 3=remove
conf = self.learnConf(card) conf = self._learnConf(card)
leaving = False leaving = False
if ease == 3: if ease == 3:
self.rescheduleAsReview(card, conf, True) self._rescheduleAsReview(card, conf, True)
leaving = True leaving = True
elif ease == 2 and card.grade+1 >= len(conf['delays']): elif ease == 2 and card.grade+1 >= len(conf['delays']):
self.rescheduleAsReview(card, conf, False) self._rescheduleAsReview(card, conf, False)
leaving = True leaving = True
else: else:
card.cycles += 1 card.cycles += 1
@ -193,33 +196,33 @@ limit %d""" % self.reportLimit, lim=self.dayCutoff)
card.grade += 1 card.grade += 1
else: else:
card.grade = 0 card.grade = 0
card.due = time.time() + self.delayForGrade(conf, card.grade) card.due = time.time() + self._delayForGrade(conf, card.grade)
try: try:
self.logLearn(card, ease, conf, leaving) self._logLearn(card, ease, conf, leaving)
except: except:
time.sleep(0.01) time.sleep(0.01)
self.logLearn(card, ease, conf, leaving) self._logLearn(card, ease, conf, leaving)
def delayForGrade(self, conf, grade): def _delayForGrade(self, conf, grade):
return conf['delays'][grade]*60 return conf['delays'][grade]*60
def learnConf(self, card): def _learnConf(self, card):
conf = self.confForCard(card) conf = self._cardConf(card)
if card.type == 2: if card.type == 2:
return conf['new'] return conf['new']
else: else:
return conf['lapse'] return conf['lapse']
def rescheduleAsReview(self, card, conf, early): def _rescheduleAsReview(self, card, conf, early):
if card.type == 1: if card.type == 1:
# failed; put back entry due # failed; put back entry due
card.due = card.edue card.due = card.edue
else: else:
self.rescheduleNew(card, conf, early) self._rescheduleNew(card, conf, early)
card.queue = 1 card.queue = 1
card.type = 1 card.type = 1
def rescheduleNew(self, card, conf, early): def _rescheduleNew(self, card, conf, early):
if not early: if not early:
# graduate # graduate
int_ = conf['ints'][0] int_ = conf['ints'][0]
@ -233,13 +236,22 @@ limit %d""" % self.reportLimit, lim=self.dayCutoff)
card.due = self.today+int_ card.due = self.today+int_
card.factor = conf['initialFactor'] card.factor = conf['initialFactor']
def logLearn(self, card, ease, conf, leaving): def _logLearn(self, card, ease, conf, leaving):
self.deck.db.execute( for i in range(2):
"insert into revlog values (?,?,?,?,?,?,?,?,?)", try:
int(time.time()*1000), card.id, ease, card.cycles, self.deck.db.execute(
self.delayForGrade(conf, card.grade), "insert into revlog values (?,?,?,?,?,?,?,?,?)",
self.delayForGrade(conf, max(0, card.grade-1)), int(time.time()*1000), card.id, ease, card.cycles,
leaving, card.timeTaken(), 0) self._delayForGrade(conf, card.grade),
self._delayForGrade(conf, max(0, card.grade-1)),
leaving, card.timeTaken(), 0)
return
except:
if i == 0:
# last answer was less than 1ms ago; retry
time.sleep(0.01)
else:
raise
def removeFailed(self): def removeFailed(self):
"Remove failed cards from the learning queue." "Remove failed cards from the learning queue."
@ -252,33 +264,33 @@ where queue = 0 and type = 1
# Reviews # Reviews
########################################################################## ##########################################################################
def resetReviewCount(self): def _resetReviewCount(self):
self.revCount = self.db.scalar(""" self.revCount = self.db.scalar("""
select count() from (select id from cards where select count() from (select id from cards where
queue = 1 %s and due <= :lim limit %d)""" % ( queue = 1 %s and due <= :lim limit %d)""" % (
self.groupLimit("rev"), self.reportLimit), self._groupLimit("rev"), self.reportLimit),
lim=self.today) lim=self.today)
def resetReview(self): def _resetReview(self):
self.resetReviewCount() self._resetReviewCount()
self.revQueue = self.db.all(""" self.revQueue = self.db.all("""
select id from cards where select id from cards where
queue = 1 %s and due <= :lim order by %s limit %d""" % ( queue = 1 %s and due <= :lim order by %s limit %d""" % (
self.groupLimit("rev"), self.revOrder(), self.queueLimit), self._groupLimit("rev"), self.revOrder(), self.queueLimit),
lim=self.today) lim=self.today)
if self.deck.qconf['revCardOrder'] == REV_CARDS_RANDOM: if self.deck.qconf['revCardOrder'] == REV_CARDS_RANDOM:
random.shuffle(self.revQueue) random.shuffle(self.revQueue)
else: else:
self.revQueue.reverse() self.revQueue.reverse()
def getReviewCard(self): def _getReviewCard(self):
if self.haveRevCards(): if self._haveRevCards():
return self.revQueue.pop() return self.revQueue.pop()
def haveRevCards(self): def _haveRevCards(self):
if self.revCount: if self.revCount:
if not self.revQueue: if not self.revQueue:
self.fillRevQueue() self._resetReview()
return self.revQueue return self.revQueue
def revOrder(self): def revOrder(self):
@ -290,257 +302,152 @@ queue = 1 %s and due <= :lim order by %s limit %d""" % (
def showFailedLast(self): def showFailedLast(self):
return self.collapseTime or not self.delay0 return self.collapseTime or not self.delay0
# Answering a card # Answering a review card
########################################################################## ##########################################################################
def _answerCard(self, card, ease): def _answerRevCard(self, card, ease):
undoName = _("Answer Card") self.revCount -= 1
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.ivl = self.nextInterval(card, ease)
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.saveSched()
# 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 card.reps += 1
if ease == 1: if ease == 1:
card.successive = 0 self._rescheduleLapse(card)
card.lapses += 1
else: else:
card.successive += 1 self._rescheduleReview(card, ease)
# if not card.firstAnswered: self._logReview(card, ease)
# card.firstAnswered = time.time()
def spaceCards(self, card): def _rescheduleLapse(self, card):
new = time.time() + self.newSpacing conf = self._cardConf(card)['lapse']
self.db.execute(""" card.streak = 0
update cards set card.lapses += 1
due = (case card.lastIvl = card.ivl
when queue = 1 then due + 86400 * (case card.ivl = int(card.ivl*conf['mult']) + 1
when interval*:rev < 1 then 0 card.factor = max(1300, card.factor-200)
else interval*:rev card.due = card.edue = self.today + card.ivl
end) # put back in the learn queue?
when queue = 2 then :new if conf['relearn']:
end), card.queue = 0
modified = :now self.learnCount += 1
where id != :id and fid = :fid # leech?
and due < :cut self._checkLeech(card, conf)
and queue between 1 and 2""",
id=card.id, now=time.time(), fid=card.fid,
cut=self.dayCutoff, new=new, rev=self.revSpacing)
# update local cache of seen facts
self.spacedFacts[card.fid] = new
# Flags: 0=standard review, 1=reschedule due to cram, drill, etc def _rescheduleReview(self, card, ease):
# Rep: Repetition number. The same number may appear twice if a card has been card.streak += 1
# manually rescheduled or answered on multiple sites before a sync. # update interval
# card.lastIvl = card.ivl
# We store the times in integer milliseconds to avoid an extra index on the self._updateInterval(card, ease)
# primary key. # then the rest
card.factor = max(1300, card.factor+[-150, 0, 150][ease-2])
card.due = self.today + card.ivl
def logReview(db, card, ease, flags=0): def _logReview(self, card, ease):
db.execute(""" for i in range(2):
insert into revlog values ( try:
:created, :cardId, :ease, :rep, :lastInterval, :interval, :factor, self.deck.db.execute(
:userTime, :flags)""", "insert into revlog values (?,?,?,?,?,?,?,?,?)",
created=int(time.time()*1000), cardId=card.id, ease=ease, rep=card.reps, int(time.time()*1000), card.id, ease, card.reps,
lastInterval=card.lastInterval, interval=card.interval, card.ivl, card.lastIvl, card.factor, card.timeTaken(),
factor=card.factor, userTime=int(card.userTime()*1000), 1)
flags=flags) return
except:
if i == 0:
# last answer was less than 1ms ago; retry
time.sleep(0.01)
else:
raise
# Interval management # Interval management
########################################################################## ##########################################################################
def nextInterval(self, card, ease): def nextInterval(self, card, ease):
"Return the next interval for CARD given EASE." "Ideal next interval for CARD, given EASE."
delay = self.adjustedDelay(card, ease) delay = self._daysLate(card)
return self._nextInterval(card, delay, ease) conf = self._cardConf(card)
fct = card.factor / 1000.0
def _nextInterval(self, card, delay, ease): if ease == 2:
interval = card.interval interval = (card.ivl + delay/4) * 1.2
factor = card.factor elif ease == 3:
# if cramming / reviewing early interval = (card.ivl + delay/2) * fct
if delay < 0: elif ease == 4:
interval = max(card.lastInterval, card.interval + delay) interval = (card.ivl + delay) * fct * conf['rev']['ease4']
if interval < self.midIntervalMin: # must be at least one day greater than previous interval
interval = 0 return max(card.ivl+1, int(interval))
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): def nextIntervalStr(self, card, ease, short=False):
"Return the next interval for CARD given EASE as a string." "Return the next interval for CARD given EASE as a string."
int = self.nextInterval(card, ease) int = self.nextInterval(card, ease)
return anki.utils.fmtTimeSpan(int*86400, short=short) return anki.utils.fmtTimeSpan(int*86400, short=short)
def nextDue(self, card, ease, oldState): def _daysLate(self, card):
"Return time when CARD will expire given EASE." "Number of days later than scheduled."
if ease == 1: return max(0, self.today - card.due)
# 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): def _updateInterval(self, card, ease):
"Update CARD's factor based on EASE." "Update CARD's interval, trying to avoid siblings."
print "update cardIsBeingLearnt()" idealIvl = self.nextInterval(card, ease)
if not card.reps: idealDue = self.today + idealIvl
# card is new, inherit beginning factor conf = self._cardConf(card)['rev']
card.factor = self.averageFactor # find sibling positions
if card.successive and not self.cardIsBeingLearnt(card): dues = self.db.list(
if ease == 1: "select due from cards where fid = ? and queue = 1"
card.factor -= 0.20 " and id != ?", card.fid, card.id)
elif ease == 2: if not dues or idealDue not in dues:
card.factor -= 0.15 card.ivl = idealIvl
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: else:
return (self.dayCutoff - card.due) / 86400.0 leeway = max(conf['minSpace'], int(idealIvl * conf['fuzz']))
# do we have any room to adjust the interval?
if leeway:
fudge = 0
# loop through possible due dates for an empty one
for diff in range(1, leeway+1):
# ensure we're due at least tomorrow
if idealDue - diff >= 1 and (idealDue - diff) not in dues:
fudge = -diff
break
elif (idealDue + diff) not in dues:
fudge = diff
break
card.ivl = idealIvl + fudge
# Leeches # Leeches
########################################################################## ##########################################################################
def isLeech(self, card): def _checkLeech(self, card, conf):
no = card.lapses "Leech handler. True if card was a leech."
fmax = self.getInt('leechFails') lf = conf['leechFails']
if not fmax: if not lf:
return return
return ( # if over threshold or every half threshold reps after that
# failed if (lf >= card.lapses and
not card.successive and (card.lapses-lf) % (max(lf/2, 1)) == 0):
# greater than fail threshold # add a leech tag
no >= fmax and f = card.fact()
# at least threshold/2 reps since last time f.tags.append("leech")
(fmax - no) % (max(fmax/2, 1)) == 0) f.flush()
# handle
def handleLeech(self, card): if conf['leechAction'][0] == "suspend":
scard = self.cardFromId(card.id, True) self.deck.suspendCard(card)
tags = scard.fact.tags # notify UI
tags = addTags("Leech", tags) runHook("leech", card)
scard.fact.tags = canonifyTags(tags)
scard.fact.setModified(textChanged=True, deck=self)
self.updateFactTags([scard.fact.id])
self.db.expunge(scard)
if self.getBool('suspendLeeches'):
self.suspendCards([card.id])
self.reset()
# Tools # Tools
########################################################################## ##########################################################################
def resetConf(self): def _resetConf(self):
"Update group conf cache." "Update group conf cache."
self.groupConfs = dict(self.db.all("select id, gcid from groups")) self.groupConfs = dict(self.db.all("select id, gcid from groups"))
self.confCache = {} self.confCache = {}
def confForCard(self, card): def _cardConf(self, card):
id = self.groupConfs[card.gid] id = self.groupConfs[card.gid]
if id not in self.confCache: if id not in self.confCache:
self.confCache[id] = self.deck.groupConf(id) self.confCache[id] = self.deck.groupConf(id)
return self.confCache[id] return self.confCache[id]
def resetSchedBuried(self): def _resetSchedBuried(self):
"Put temporarily suspended cards back into play." "Put temporarily suspended cards back into play."
self.db.execute( self.db.execute(
"update cards set queue = type where queue = -3") "update cards set queue = type where queue = -3")
def groupLimit(self, type): def _groupLimit(self, type):
l = self.deck.activeGroups(type) l = self.deck.activeGroups(type)
if not l: if not l:
# everything # everything
@ -550,7 +457,7 @@ insert into revlog values (
# Daily cutoff # Daily cutoff
########################################################################## ##########################################################################
def updateCutoff(self): def _updateCutoff(self):
d = datetime.datetime.utcfromtimestamp( d = datetime.datetime.utcfromtimestamp(
time.time() - self.deck.utcOffset) + datetime.timedelta(days=1) time.time() - self.deck.utcOffset) + datetime.timedelta(days=1)
d = datetime.datetime(d.year, d.month, d.day) d = datetime.datetime(d.year, d.month, d.day)
@ -565,7 +472,7 @@ insert into revlog values (
self.dayCutoff = cutoff self.dayCutoff = cutoff
self.today = int(cutoff/86400 - self.deck.crt/86400) self.today = int(cutoff/86400 - self.deck.crt/86400)
def checkDay(self): def _checkDay(self):
# check if the day has rolled over # check if the day has rolled over
if time.time() > self.dayCutoff: if time.time() > self.dayCutoff:
self.updateCutoff() self.updateCutoff()

View file

@ -5,7 +5,6 @@
import time, sys, os, datetime import time, sys, os, datetime
import anki, anki.utils import anki, anki.utils
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.utils import canonifyTags, ids2str
from anki.hooks import runFilter from anki.hooks import runFilter
# Card stats # Card stats

View file

@ -227,7 +227,7 @@ def joinTags(tags):
def canonifyTags(tags): def canonifyTags(tags):
"Strip leading/trailing/superfluous commas and duplicates." "Strip leading/trailing/superfluous commas and duplicates."
tags = [t.lstrip(":") for t in set(parseTags(tags))] tags = [t.lstrip(":") for t in set(tags)]
return joinTags(sorted(tags)) return joinTags(sorted(tags))
def hasTag(tag, tags): def hasTag(tag, tags):

View file

@ -130,3 +130,72 @@ def test_learn():
c.load() c.load()
assert c.queue == 1 assert c.queue == 1
assert c.due == 321 assert c.due == 321
def test_reviews():
d = getEmptyDeck()
# add a fact
f = d.newFact()
f['Front'] = u"one"; f['Back'] = u"two"
d.addFact(f)
# set the card up as a review card, due yesterday
c = f.cards()[0]
c.type = 1
c.queue = 1
c.due = d.sched.today - 8
c.factor = 2500
c.reps = 3
c.streak = 2
c.lapses = 1
c.ivl = 100
c.startTimer()
c.flush()
# save it for later use as well
import copy
cardcopy = copy.copy(c)
# failing it should put it in the learn queue with the default options
##################################################
d.sched.answerCard(c, 1)
assert c.queue == 0
# it should be due tomorrow, with an interval of 1
assert c.due == d.sched.today + 1
assert c.ivl == 1
# factor should have been decremented
assert c.factor == 2300
# check counters
assert c.streak == 0
assert c.lapses == 2
assert c.reps == 4
# try again with an ease of 2 instead
##################################################
c = copy.copy(cardcopy)
c.flush()
d.sched.answerCard(c, 2)
# the new interval should be (100 + 8/4) * 1.2 = 122
assert c.ivl == 122
assert c.due == d.sched.today + 122
# factor should have been decremented
assert c.factor == 2350
# check counters
assert c.streak == 3
assert c.lapses == 1
assert c.reps == 4
# ease 3
##################################################
c = copy.copy(cardcopy)
c.flush()
d.sched.answerCard(c, 3)
# the new interval should be (100 + 8/2) * 2.5 = 260
assert c.ivl == 260
assert c.due == d.sched.today + 260
# factor should have been left alone
assert c.factor == 2500
# ease 4
##################################################
c = copy.copy(cardcopy)
c.flush()
d.sched.answerCard(c, 4)
# the new interval should be (100 + 8) * 2.5 * 1.3 = 351
assert c.ivl == 351
assert c.due == d.sched.today + 351
# factor should have been increased
assert c.factor == 2650