mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00

As per the forum thread, the current due counts are really demotivating when there's a backlog of cards. In attempt to solve this, I'm trying out a new behaviour as the default: instead of reporting all the due cards including the backlog, the status bar will show an increasing count of cards studied that day. Theoretically this should allow users to focus on what they've done rather than what they have to do. The old behaviour is still there as an option.
831 lines
28 KiB
Python
831 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright: Damien Elmes <anki@ichi2.net>
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
import time, datetime, simplejson, random, itertools
|
|
from operator import itemgetter
|
|
from heapq import *
|
|
#from anki.cards import Card
|
|
from anki.utils import ids2str, intTime, fmtTimeSpan
|
|
from anki.lang import _, ngettext
|
|
from anki.consts import *
|
|
from anki.hooks import runHook
|
|
|
|
# revlog:
|
|
# types: 0=lrn, 1=rev, 2=relrn, 3=cram
|
|
# positive intervals are in days (rev), negative intervals in seconds (lrn)
|
|
|
|
# the standard Anki scheduler
|
|
class Scheduler(object):
|
|
name = "std"
|
|
def __init__(self, deck):
|
|
self.deck = deck
|
|
self.queueLimit = 200
|
|
self.reportLimit = 1000
|
|
self.reps = 0
|
|
self._updateCutoff()
|
|
|
|
def getCard(self):
|
|
"Pop the next card from the queue. None if finished."
|
|
self._checkDay()
|
|
id = self._getCardId()
|
|
if id:
|
|
c = self.deck.getCard(id)
|
|
c.startTimer()
|
|
return c
|
|
|
|
def reset(self):
|
|
self._resetCounts()
|
|
self._resetLrn()
|
|
self._resetRev()
|
|
self._resetNew()
|
|
|
|
def answerCard(self, card, ease):
|
|
assert ease >= 1 and ease <= 4
|
|
self.deck.markReview(card)
|
|
self.reps += 1
|
|
card.reps += 1
|
|
wasNew = card.queue == 0
|
|
if wasNew:
|
|
# put it in the learn queue
|
|
card.queue = 1
|
|
card.type = 1
|
|
self._updateStats('new')
|
|
if card.queue == 1:
|
|
self._answerLrnCard(card, ease)
|
|
if not wasNew:
|
|
self._updateStats('lrn')
|
|
elif card.queue == 2:
|
|
self._answerRevCard(card, ease)
|
|
self._updateStats('rev')
|
|
else:
|
|
raise Exception("Invalid queue")
|
|
self._updateStats('time', card.timeTaken())
|
|
card.mod = intTime()
|
|
card.flushSched()
|
|
|
|
def counts(self):
|
|
"Does not include fetched but unanswered."
|
|
if self.deck.conf['counts'] == COUNT_REMAINING:
|
|
return (self.newCount, self.lrnCount, self.revCount)
|
|
else:
|
|
return self.answeredCounts()
|
|
|
|
def answeredCounts(self):
|
|
t = self.deck.groups.top()
|
|
return (t['newToday'][1], t['lrnToday'][1], t['revToday'][1])
|
|
|
|
def dueForecast(self, days=7):
|
|
"Return counts over next DAYS. Includes today."
|
|
daysd = dict(self.deck.db.all("""
|
|
select due, count() from cards
|
|
where queue = 2 %s
|
|
and due between ? and ?
|
|
group by due
|
|
order by due""" % self._groupLimit(),
|
|
self.today,
|
|
self.today+days-1))
|
|
for d in range(days):
|
|
d = self.today+d
|
|
if d not in daysd:
|
|
daysd[d] = 0
|
|
# return in sorted order
|
|
ret = [x[1] for x in sorted(daysd.items())]
|
|
return ret
|
|
|
|
def countIdx(self, card):
|
|
return card.queue
|
|
|
|
def answerButtons(self, card):
|
|
if card.queue == 2:
|
|
return 4
|
|
else:
|
|
return 3
|
|
|
|
def onClose(self):
|
|
"Unbury and remove temporary suspends on close."
|
|
self.deck.db.execute(
|
|
"update cards set queue = type where queue between -3 and -2")
|
|
|
|
# Counts
|
|
##########################################################################
|
|
|
|
def _resetCounts(self):
|
|
self._updateCutoff()
|
|
self._resetLrnCount()
|
|
self._resetRevCount()
|
|
self._resetNewCount()
|
|
self._updateStatsDay("time")
|
|
|
|
def _updateStatsDay(self, type):
|
|
l = self.deck.groups.top()
|
|
if l[type+'Today'][0] != self.today:
|
|
# it's a new day; reset counts
|
|
l[type+'Today'] = [self.today, 0]
|
|
|
|
def _updateStats(self, type, cnt=1):
|
|
self.deck.groups.top()[type+'Today'][1] += cnt
|
|
|
|
# Group counts
|
|
##########################################################################
|
|
|
|
def groupCounts(self):
|
|
"Returns [groupname, hasDue, hasNew]"
|
|
# find groups with 1 or more due cards
|
|
gids = {}
|
|
for g in self.deck.groups.all():
|
|
hasDue = self.deck.db.scalar("""
|
|
select 1 from cards where gid = ? and
|
|
((queue = 2 and due <= ?) or (queue = 1 and due < ?)) limit 1""",
|
|
g['id'], self.today, intTime())
|
|
top = self.deck.groups.get(
|
|
self.deck.groups.topFor(g['name']))
|
|
if top['newToday'][0] != self.today:
|
|
# it's a new day; reset counts
|
|
top['newToday'] = [self.today, 0]
|
|
hasNew = max(0, top['newPerDay'] - top['newToday'][1])
|
|
if hasNew:
|
|
# if the limit hasn't run out, check to see if there are
|
|
# actually cards
|
|
hasNew = self.deck.db.scalar(
|
|
"select 1 from cards where queue = 0 and gid = ? limit 1",
|
|
g['id'])
|
|
gids[g['id']] = [hasDue or 0, hasNew or 0]
|
|
return [[grp['name'], int(gid)]+gids.get(int(gid))
|
|
for (gid, grp) in self._orderedGroups()]
|
|
|
|
def _orderedGroups(self):
|
|
grps = self.deck.groups.groups.items()
|
|
def key(grp):
|
|
return grp[1]['name']
|
|
grps.sort(key=key)
|
|
return grps
|
|
|
|
def groupCountTree(self):
|
|
return self._groupChildren(self.groupCounts())
|
|
|
|
def groupTree(self):
|
|
"Like the count tree without the counts. Faster."
|
|
return self._groupChildren([[grp['name'], int(gid), 0, 0, 0]
|
|
for (gid, grp) in self._orderedGroups()])
|
|
|
|
def _groupChildren(self, grps):
|
|
tree = []
|
|
# split strings
|
|
for g in grps:
|
|
g[0] = g[0].split("::", 1)
|
|
# group and recurse
|
|
def key(grp):
|
|
return grp[0][0]
|
|
for (head, tail) in itertools.groupby(grps, key=key):
|
|
tail = list(tail)
|
|
gid = None
|
|
rev = 0
|
|
new = 0
|
|
children = []
|
|
for c in tail:
|
|
if len(c[0]) == 1:
|
|
# current node
|
|
gid = c[1]
|
|
rev += c[2]
|
|
new += c[3]
|
|
else:
|
|
# set new string to tail
|
|
c[0] = c[0][1]
|
|
children.append(c)
|
|
children = self._groupChildren(children)
|
|
# tally up children counts
|
|
for ch in children:
|
|
rev += ch[2]
|
|
new += ch[3]
|
|
tree.append((head, gid, rev, new, children))
|
|
return tuple(tree)
|
|
|
|
# Getting the next card
|
|
##########################################################################
|
|
|
|
def _getCardId(self):
|
|
"Return the next due card id, or None."
|
|
# learning card due?
|
|
id = self._getLrnCard()
|
|
if id:
|
|
return id
|
|
# new first, or time for one?
|
|
if self._timeForNewCard():
|
|
return self._getNewCard()
|
|
# card due for review?
|
|
id = self._getRevCard()
|
|
if id:
|
|
return id
|
|
# new cards left?
|
|
id = self._getNewCard()
|
|
if id:
|
|
return id
|
|
# collapse or finish
|
|
return self._getLrnCard(collapse=True)
|
|
|
|
# New cards
|
|
##########################################################################
|
|
|
|
# FIXME: need to keep track of reps for timebox and new card introduction
|
|
|
|
def _resetNewCount(self):
|
|
self._updateStatsDay("new")
|
|
l = self.deck.groups.top()
|
|
lim = min(self.reportLimit, l['newPerDay'] - l['newToday'][1])
|
|
if lim <= 0:
|
|
self.newCount = 0
|
|
else:
|
|
self.newCount = self.deck.db.scalar("""
|
|
select count() from (select id from cards where
|
|
queue = 0 %s limit %d)""" % (self._groupLimit(), lim))
|
|
|
|
def _resetNew(self):
|
|
lim = min(self.queueLimit, self.newCount)
|
|
self.newQueue = self.deck.db.all("""
|
|
select id, due from cards where
|
|
queue = 0 %s order by due limit %d""" % (self._groupLimit(),
|
|
lim))
|
|
self.newQueue.reverse()
|
|
self._updateNewCardRatio()
|
|
|
|
def _getNewCard(self):
|
|
# We rely on sqlite to return the cards in id order. This may not
|
|
# correspond to the 'ord' order. The alternative would be to do
|
|
# something like due = fid*100+ord, but then we have no efficient way
|
|
# of spacing siblings as we'd need to fetch the fid as well.
|
|
if self.newQueue:
|
|
(id, due) = self.newQueue.pop()
|
|
# move any siblings to the end?
|
|
if self.deck.groups.top()['newTodayOrder'] == NEW_TODAY_ORD:
|
|
n = len(self.newQueue)
|
|
while self.newQueue and self.newQueue[-1][1] == due:
|
|
self.newQueue.insert(0, self.newQueue.pop())
|
|
n -= 1
|
|
if not n:
|
|
# we only have one fact in the queue; stop rotating
|
|
break
|
|
self.newCount -= 1
|
|
return id
|
|
|
|
def _updateNewCardRatio(self):
|
|
if self.deck.groups.top()['newSpread'] == 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)
|
|
return
|
|
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.deck.groups.top()['newSpread'] == NEW_CARDS_LAST:
|
|
return False
|
|
elif self.deck.groups.top()['newSpread'] == NEW_CARDS_FIRST:
|
|
return True
|
|
elif self.newCardModulus:
|
|
return self.reps and self.reps % self.newCardModulus == 0
|
|
|
|
# Learning queue
|
|
##########################################################################
|
|
|
|
def _resetLrnCount(self):
|
|
self._updateStatsDay("lrn")
|
|
self.lrnCount = self.deck.db.scalar("""
|
|
select count() from (select id from cards where
|
|
queue = 1 %s and due < ? limit %d)""" % (
|
|
self._groupLimit(), self.reportLimit),
|
|
intTime() + self.deck.groups.top()['collapseTime'])
|
|
|
|
def _resetLrn(self):
|
|
self.lrnQueue = self.deck.db.all("""
|
|
select due, id from cards where
|
|
queue = 1 %s and due < :lim order by due
|
|
limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
|
|
|
|
def _getLrnCard(self, collapse=False):
|
|
if self.lrnQueue:
|
|
cutoff = time.time()
|
|
if collapse:
|
|
cutoff += self.deck.groups.top()['collapseTime']
|
|
if self.lrnQueue[0][0] < cutoff:
|
|
id = heappop(self.lrnQueue)[1]
|
|
self.lrnCount -= 1
|
|
return id
|
|
|
|
def _answerLrnCard(self, card, ease):
|
|
# ease 1=no, 2=yes, 3=remove
|
|
conf = self._lrnConf(card)
|
|
if card.type == 2:
|
|
type = 2
|
|
else:
|
|
type = 0
|
|
leaving = False
|
|
if ease == 3:
|
|
self._rescheduleAsRev(card, conf, True)
|
|
leaving = True
|
|
elif ease == 2 and card.grade+1 >= len(conf['delays']):
|
|
self._rescheduleAsRev(card, conf, False)
|
|
leaving = True
|
|
else:
|
|
card.cycles += 1
|
|
if ease == 2:
|
|
card.grade += 1
|
|
else:
|
|
card.grade = 0
|
|
delay = self._delayForGrade(conf, card.grade)
|
|
if card.due < time.time():
|
|
# not collapsed; add some randomness
|
|
delay *= random.uniform(1, 1.25)
|
|
card.due = int(time.time() + delay)
|
|
heappush(self.lrnQueue, (card.due, card.id))
|
|
# if it's due within the cutoff, increment count
|
|
if delay <= self.deck.groups.top()['collapseTime']:
|
|
self.lrnCount += 1
|
|
self._logLrn(card, ease, conf, leaving, type)
|
|
|
|
def _delayForGrade(self, conf, grade):
|
|
try:
|
|
delay = conf['delays'][grade]
|
|
except IndexError:
|
|
delay = conf['delays'][-1]
|
|
return delay*60
|
|
|
|
def _lrnConf(self, card):
|
|
conf = self._cardConf(card)
|
|
if card.type == 2:
|
|
return conf['lapse']
|
|
else:
|
|
return conf['new']
|
|
|
|
def _rescheduleAsRev(self, card, conf, early):
|
|
if card.type == 2:
|
|
# failed; put back entry due
|
|
card.due = card.edue
|
|
else:
|
|
self._rescheduleNew(card, conf, early)
|
|
card.queue = 2
|
|
card.type = 2
|
|
|
|
def _graduatingIvl(self, card, conf, early):
|
|
if card.type == 2:
|
|
# lapsed card being relearnt
|
|
return card.ivl
|
|
if not early:
|
|
# graduate
|
|
ideal = conf['ints'][0]
|
|
elif card.cycles:
|
|
# remove
|
|
ideal = conf['ints'][2]
|
|
else:
|
|
# first time bonus
|
|
ideal = conf['ints'][1]
|
|
return self._adjRevIvl(card, ideal)
|
|
|
|
def _rescheduleNew(self, card, conf, early):
|
|
card.ivl = self._graduatingIvl(card, conf, early)
|
|
card.due = self.today+card.ivl
|
|
card.factor = conf['initialFactor']
|
|
|
|
def _logLrn(self, card, ease, conf, leaving, type):
|
|
lastIvl = -(self._delayForGrade(conf, max(0, card.grade-1)))
|
|
ivl = card.ivl if leaving else -(self._delayForGrade(conf, card.grade))
|
|
def log():
|
|
self.deck.db.execute(
|
|
"insert into revlog values (?,?,?,?,?,?,?,?)",
|
|
int(time.time()*1000), card.id, ease,
|
|
ivl, lastIvl,
|
|
card.factor, card.timeTaken(), type)
|
|
try:
|
|
log()
|
|
except:
|
|
# duplicate pk; retry in 10ms
|
|
time.sleep(0.01)
|
|
log()
|
|
|
|
def removeFailed(self, ids=None):
|
|
"Remove failed cards from the learning queue."
|
|
extra = ""
|
|
if ids:
|
|
extra = " and id in "+ids2str(ids)
|
|
self.deck.db.execute("""
|
|
update cards set
|
|
due = edue, queue = 2, mod = %d
|
|
where queue = 1 and type = 2
|
|
%s
|
|
""" % (intTime(), extra))
|
|
|
|
# Reviews
|
|
##########################################################################
|
|
|
|
def _resetRevCount(self):
|
|
self._updateStatsDay("rev")
|
|
self.revCount = self.deck.db.scalar("""
|
|
select count() from (select id from cards where
|
|
queue = 2 %s and due <= :lim limit %d)""" % (
|
|
self._groupLimit(), self.reportLimit),
|
|
lim=self.today)
|
|
|
|
def _resetRev(self):
|
|
self.revQueue = self.deck.db.list("""
|
|
select id from cards where
|
|
queue = 2 %s and due <= :lim order by %s limit %d""" % (
|
|
self._groupLimit(), self._revOrder(), self.queueLimit),
|
|
lim=self.today)
|
|
if self.deck.conf['revOrder'] == REV_CARDS_RANDOM:
|
|
r = random.Random()
|
|
r.seed(self.today)
|
|
r.shuffle(self.revQueue)
|
|
else:
|
|
self.revQueue.reverse()
|
|
|
|
def _getRevCard(self):
|
|
if self._haveRevCards():
|
|
self.revCount -= 1
|
|
return self.revQueue.pop()
|
|
|
|
def _haveRevCards(self):
|
|
if self.revCount:
|
|
if not self.revQueue:
|
|
self._resetRev()
|
|
return self.revQueue
|
|
|
|
def _revOrder(self):
|
|
return ("ivl desc",
|
|
"ivl",
|
|
"due")[self.deck.conf['revOrder']]
|
|
|
|
# Answering a review card
|
|
##########################################################################
|
|
|
|
def _answerRevCard(self, card, ease):
|
|
if ease == 1:
|
|
self._rescheduleLapse(card)
|
|
else:
|
|
self._rescheduleRev(card, ease)
|
|
self._logRev(card, ease)
|
|
|
|
def _rescheduleLapse(self, card):
|
|
conf = self._cardConf(card)['lapse']
|
|
card.lapses += 1
|
|
card.lastIvl = card.ivl
|
|
card.ivl = self._nextLapseIvl(card, conf)
|
|
card.factor = max(1300, card.factor-200)
|
|
card.due = self.today + card.ivl
|
|
# put back in the learn queue?
|
|
if conf['relearn']:
|
|
card.edue = card.due
|
|
card.due = int(self._delayForGrade(conf, 0) + time.time())
|
|
card.queue = 1
|
|
self.lrnCount += 1
|
|
heappush(self.lrnQueue, (card.due, card.id))
|
|
# leech?
|
|
self._checkLeech(card, conf)
|
|
|
|
def _nextLapseIvl(self, card, conf):
|
|
return int(card.ivl*conf['mult']) + 1
|
|
|
|
def _rescheduleRev(self, card, ease):
|
|
# update interval
|
|
card.lastIvl = card.ivl
|
|
self._updateRevIvl(card, ease)
|
|
# then the rest
|
|
card.factor = max(1300, card.factor+[-150, 0, 150][ease-2])
|
|
card.due = self.today + card.ivl
|
|
|
|
def _logRev(self, card, ease):
|
|
def log():
|
|
self.deck.db.execute(
|
|
"insert into revlog values (?,?,?,?,?,?,?,?)",
|
|
int(time.time()*1000), card.id, ease,
|
|
card.ivl, card.lastIvl, card.factor, card.timeTaken(),
|
|
1)
|
|
try:
|
|
log()
|
|
except:
|
|
# duplicate pk; retry in 10ms
|
|
time.sleep(0.01)
|
|
log()
|
|
|
|
# Interval management
|
|
##########################################################################
|
|
|
|
def _nextRevIvl(self, card, ease):
|
|
"Ideal next interval for CARD, given EASE."
|
|
delay = self._daysLate(card)
|
|
conf = self._cardConf(card)
|
|
fct = card.factor / 1000.0
|
|
if ease == 2:
|
|
interval = (card.ivl + delay/4) * 1.2
|
|
elif ease == 3:
|
|
interval = (card.ivl + delay/2) * fct
|
|
elif ease == 4:
|
|
interval = (card.ivl + delay) * fct * conf['rev']['ease4']
|
|
# must be at least one day greater than previous interval; two if easy
|
|
return max(card.ivl + (2 if ease==4 else 1), int(interval))
|
|
|
|
def _daysLate(self, card):
|
|
"Number of days later than scheduled."
|
|
return max(0, self.today - card.due)
|
|
|
|
def _updateRevIvl(self, card, ease):
|
|
"Update CARD's interval, trying to avoid siblings."
|
|
idealIvl = self._nextRevIvl(card, ease)
|
|
card.ivl = self._adjRevIvl(card, idealIvl)
|
|
|
|
def _adjRevIvl(self, card, idealIvl):
|
|
"Given IDEALIVL, return an IVL away from siblings."
|
|
idealDue = self.today + idealIvl
|
|
conf = self._cardConf(card)['rev']
|
|
# find sibling positions
|
|
dues = self.deck.db.list(
|
|
"select due from cards where fid = ? and queue = 2"
|
|
" and id != ?", card.fid, card.id)
|
|
if not dues or idealDue not in dues:
|
|
return idealIvl
|
|
else:
|
|
leeway = max(conf['minSpace'], int(idealIvl * conf['fuzz']))
|
|
fudge = 0
|
|
# do we have any room to adjust the interval?
|
|
if leeway:
|
|
# 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
|
|
return idealIvl + fudge
|
|
|
|
# Leeches
|
|
##########################################################################
|
|
|
|
def _checkLeech(self, card, conf):
|
|
"Leech handler. True if card was a leech."
|
|
lf = conf['leechFails']
|
|
if not lf:
|
|
return
|
|
# if over threshold or every half threshold reps after that
|
|
if (lf >= card.lapses and
|
|
(card.lapses-lf) % (max(lf/2, 1)) == 0):
|
|
# add a leech tag
|
|
f = card.fact()
|
|
f.addTag("leech")
|
|
f.flush()
|
|
# handle
|
|
a = conf['leechAction']
|
|
if a == 0:
|
|
self.suspendCards([card.id])
|
|
card.queue = -1
|
|
# notify UI
|
|
runHook("leech", card)
|
|
|
|
# Tools
|
|
##########################################################################
|
|
|
|
def _cardConf(self, card):
|
|
return self.deck.groups.conf(card.gid)
|
|
|
|
def _groupLimit(self):
|
|
l = self.deck.groups.active()
|
|
if not l:
|
|
# everything
|
|
return ""
|
|
return " and gid in %s" % ids2str(l)
|
|
|
|
# Daily cutoff
|
|
##########################################################################
|
|
|
|
def _updateCutoff(self):
|
|
# days since deck created
|
|
self.today = int((time.time() - self.deck.crt) / 86400)
|
|
# end of day cutoff
|
|
self.dayCutoff = self.deck.crt + (self.today+1)*86400
|
|
|
|
def _checkDay(self):
|
|
# check if the day has rolled over
|
|
if time.time() > self.dayCutoff:
|
|
self.updateCutoff()
|
|
self.reset()
|
|
|
|
# Deck finished state
|
|
##########################################################################
|
|
|
|
def finishedMsg(self):
|
|
return (
|
|
"<h1>"+_("Congratulations!")+"</h1>"+
|
|
_("You have finished the selected groups for now.") +
|
|
"<br><br>"+
|
|
self._nextDueMsg())
|
|
|
|
def _nextDueMsg(self):
|
|
line = []
|
|
rev = self.revTomorrow() + self.lrnTomorrow()
|
|
if rev:
|
|
line.append(
|
|
ngettext("There will be <b>%s review</b>.",
|
|
"There will be <b>%s reviews</b>.", rev) % rev)
|
|
new = self.newTomorrow()
|
|
if new:
|
|
line.append(
|
|
ngettext("There will be <b>%d new</b> card.",
|
|
"There will be <b>%d new</b> cards.", new) % new)
|
|
if line:
|
|
line.insert(0, _("At this time tomorrow:"))
|
|
buf = "<br>".join(line)
|
|
else:
|
|
buf = _("No cards are due tomorrow.")
|
|
buf = '<style>b { color: #00f; }</style>' + buf
|
|
return buf
|
|
|
|
def lrnTomorrow(self):
|
|
"Number of cards in the learning queue due tomorrow."
|
|
return self.deck.db.scalar(
|
|
"select count() from cards where queue = 1 and due < ?",
|
|
self.dayCutoff+86400)
|
|
|
|
def revTomorrow(self):
|
|
"Number of reviews due tomorrow."
|
|
return self.deck.db.scalar(
|
|
"select count() from cards where queue = 2 and due = ?"+
|
|
self._groupLimit(),
|
|
self.today+1)
|
|
|
|
def newTomorrow(self):
|
|
"Number of new cards tomorrow."
|
|
lim = self.deck.groups.top()['newPerDay']
|
|
return self.deck.db.scalar(
|
|
"select count() from (select id from cards where "
|
|
"queue = 0 %s limit %d)" % (self._groupLimit(), lim))
|
|
|
|
# Next time reports
|
|
##########################################################################
|
|
|
|
def nextIvlStr(self, card, ease, short=False):
|
|
"Return the next interval for CARD as a string."
|
|
return fmtTimeSpan(
|
|
self.nextIvl(card, ease), short=short)
|
|
|
|
def nextIvl(self, card, ease):
|
|
"Return the next interval for CARD, in seconds."
|
|
if card.queue in (0,1):
|
|
return self._nextLrnIvl(card, ease)
|
|
elif ease == 1:
|
|
# lapsed
|
|
conf = self._cardConf(card)['lapse']
|
|
if conf['relearn']:
|
|
return conf['delays'][0]*60
|
|
return self._nextLapseIvl(card, conf)*86400
|
|
else:
|
|
# review
|
|
return self._nextRevIvl(card, ease)*86400
|
|
|
|
# this isn't easily extracted from the learn code
|
|
def _nextLrnIvl(self, card, ease):
|
|
conf = self._lrnConf(card)
|
|
if ease == 1:
|
|
# grade 0
|
|
return self._delayForGrade(conf, 0)
|
|
elif ease == 3:
|
|
# early removal
|
|
return self._graduatingIvl(card, conf, True) * 86400
|
|
else:
|
|
grade = card.grade + 1
|
|
if grade >= len(conf['delays']):
|
|
# graduate
|
|
return self._graduatingIvl(card, conf, False) * 86400
|
|
else:
|
|
# next level
|
|
return self._delayForGrade(conf, grade)
|
|
|
|
# Suspending
|
|
##########################################################################
|
|
|
|
def suspendCards(self, ids):
|
|
"Suspend cards."
|
|
self.removeFailed(ids)
|
|
self.deck.db.execute(
|
|
"update cards set queue = -1, mod = ? where id in "+
|
|
ids2str(ids), intTime())
|
|
|
|
def unsuspendCards(self, ids):
|
|
"Unsuspend cards."
|
|
self.deck.db.execute(
|
|
"update cards set queue = type, mod = ? "
|
|
"where queue = -1 and id in "+ ids2str(ids),
|
|
intTime())
|
|
|
|
def buryFact(self, fid):
|
|
"Bury all cards for fact until next session."
|
|
self.deck.setDirty()
|
|
self.removeFailed(
|
|
self.deck.db.list("select id from cards where fid = ?", fid))
|
|
self.deck.db.execute("update cards set queue = -2 where fid = ?", fid)
|
|
|
|
# Counts
|
|
##########################################################################
|
|
|
|
def timeToday(self):
|
|
"Time spent learning today, in seconds."
|
|
t = self.deck.groups.top()
|
|
return t['timeToday'][1] / 1000
|
|
|
|
def repsToday(self):
|
|
"Number of cards answered today."
|
|
return sum(self.answeredCounts())
|
|
|
|
# Dynamic indices
|
|
##########################################################################
|
|
|
|
def updateDynamicIndices(self):
|
|
# determine required columns
|
|
required = []
|
|
if self.deck.conf['revOrder'] in (
|
|
REV_CARDS_OLD_FIRST, REV_CARDS_NEW_FIRST):
|
|
required.append("interval")
|
|
cols = ["queue", "due", "gid"] + required
|
|
# update if changed
|
|
if self.deck.db.scalar(
|
|
"select 1 from sqlite_master where name = 'ix_cards_multi'"):
|
|
rows = self.deck.db.all("pragma index_info('ix_cards_multi')")
|
|
else:
|
|
rows = None
|
|
if not (rows and cols == [r[2] for r in rows]):
|
|
self.deck.db.execute("drop index if exists ix_cards_multi")
|
|
self.deck.db.execute("create index ix_cards_multi on cards (%s)" %
|
|
", ".join(cols))
|
|
self.deck.db.execute("analyze")
|
|
|
|
# Resetting
|
|
##########################################################################
|
|
|
|
def forgetCards(self, ids):
|
|
"Put cards at the end of the new queue."
|
|
self.deck.db.execute(
|
|
"update cards set type=0, queue=0, ivl=0 where id in "+ids2str(ids))
|
|
pmax = self.deck.db.scalar("select max(due) from cards where type=0")
|
|
self.sortCards(ids, start=pmax+1, shuffle=self.deck.models.randomNew())
|
|
|
|
def reschedCards(self, ids, imin, imax):
|
|
"Put cards in review queue with a new interval in days (min, max)."
|
|
d = []
|
|
t = self.today
|
|
mod = intTime()
|
|
for id in ids:
|
|
r = random.randint(imin, imax)
|
|
d.append(dict(id=id, due=r+t, ivl=max(1, r), mod=mod))
|
|
self.deck.db.executemany(
|
|
"update cards set type=2,queue=2,ivl=:ivl,due=:due where id=:id",
|
|
d)
|
|
|
|
# Repositioning new cards
|
|
##########################################################################
|
|
|
|
def sortCards(self, cids, start=1, step=1, shuffle=False, shift=False):
|
|
scids = ids2str(cids)
|
|
now = intTime()
|
|
fids = self.deck.db.list(
|
|
("select distinct fid from cards where type = 0 and id in %s "
|
|
"order by fid") % scids)
|
|
if not fids:
|
|
# no new cards
|
|
return
|
|
# determine fid ordering
|
|
due = {}
|
|
if shuffle:
|
|
random.shuffle(fids)
|
|
for c, fid in enumerate(fids):
|
|
due[fid] = start+c*step
|
|
high = start+c*step
|
|
# shift?
|
|
if shift:
|
|
low = self.deck.db.scalar(
|
|
"select min(due) from cards where due >= ? and type = 0 "
|
|
"and id not in %s" % scids,
|
|
start)
|
|
if low is not None:
|
|
shiftby = high - low + 1
|
|
self.deck.db.execute("""
|
|
update cards set mod=?, due=due+? where id not in %s
|
|
and due >= ?""" % scids, now, shiftby, low)
|
|
# reorder cards
|
|
d = []
|
|
for id, fid in self.deck.db.execute(
|
|
"select id, fid from cards where type = 0 and id in "+scids):
|
|
d.append(dict(now=now, due=due[fid], cid=id))
|
|
self.deck.db.executemany(
|
|
"update cards set due = :due, mod = :now where id = :cid""", d)
|
|
|
|
# fixme: because it's a model property now, these should be done on a
|
|
# per-model basis
|
|
def randomizeCards(self):
|
|
self.sortCards(self.deck.db.list("select id from cards"), shuffle=True)
|
|
|
|
def orderCards(self):
|
|
self.sortCards(self.deck.db.list("select id from cards"))
|