mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
start work on learn mode, change models, more
- model config is now stored as a json-serialized dict, which allows us to quickly gather the info and allows for adding extra options more easily in the future - denormalize modelId into the cards table, so we can get the model scheduling information without having to hit the facts table - remove position - since we will handle spacing differently we don't need a separate variable to due to define sort order - remove lastInterval from cards; the new cram mode and review early shouldn't need it - successive->streak - add new columns for learn mode - move cram mode into new file; learn more and review early need more thought - initial work on learn mode - initial unit tests
This commit is contained in:
parent
4e7e8b03bc
commit
b0b4074cbd
9 changed files with 479 additions and 381 deletions
|
@ -14,46 +14,39 @@ MAX_TIMER = 60
|
|||
# Cards
|
||||
##########################################################################
|
||||
|
||||
# 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)
|
||||
# Type: 0=learning, 1=due, 2=new
|
||||
# Queue: 0=learning, 1=due, 2=new
|
||||
# -1=suspended, -2=user buried, -3=sched buried
|
||||
# Ordinal: card template # for fact
|
||||
# Position: sorting position, only for new cards
|
||||
# Flags: unused; reserved for future use
|
||||
|
||||
cardsTable = Table(
|
||||
'cards', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('factId', Integer, ForeignKey("facts.id"), nullable=False),
|
||||
Column('modelId', Integer, ForeignKey("models.id"), nullable=False),
|
||||
Column('cardModelId', Integer, ForeignKey("cardModels.id"), nullable=False),
|
||||
# general
|
||||
Column('created', Float, nullable=False, default=time.time),
|
||||
Column('modified', Float, nullable=False, default=time.time),
|
||||
Column('question', UnicodeText, nullable=False, default=u""),
|
||||
Column('answer', UnicodeText, nullable=False, default=u""),
|
||||
Column('flags', Integer, nullable=False, default=0),
|
||||
# ordering
|
||||
Column('ordinal', Integer, nullable=False),
|
||||
Column('position', Integer, nullable=False),
|
||||
# scheduling data
|
||||
Column('flags', Integer, nullable=False, default=0),
|
||||
# shared scheduling
|
||||
Column('type', Integer, nullable=False, default=2),
|
||||
Column('queue', Integer, nullable=False, default=2),
|
||||
Column('lastInterval', Float, nullable=False, default=0),
|
||||
Column('interval', Float, nullable=False, default=0),
|
||||
Column('due', Float, nullable=False),
|
||||
# sm2
|
||||
Column('interval', Float, nullable=False, default=0),
|
||||
Column('factor', Float, nullable=False, default=2.5),
|
||||
# counters
|
||||
Column('reps', Integer, nullable=False, default=0),
|
||||
Column('successive', Integer, nullable=False, default=0),
|
||||
Column('lapses', Integer, nullable=False, default=0))
|
||||
Column('streak', Integer, nullable=False, default=0),
|
||||
Column('lapses', Integer, nullable=False, default=0),
|
||||
# learn
|
||||
Column('grade', Integer, nullable=False, default=0),
|
||||
Column('cycles', Integer, nullable=False, default=0)
|
||||
)
|
||||
|
||||
class Card(object):
|
||||
|
||||
|
@ -68,6 +61,7 @@ class Card(object):
|
|||
self.position = self.due
|
||||
if fact:
|
||||
self.fact = fact
|
||||
self.modelId = fact.modelId
|
||||
if cardModel:
|
||||
self.cardModel = cardModel
|
||||
# for non-orm use
|
||||
|
@ -151,45 +145,47 @@ class Card(object):
|
|||
return
|
||||
(self.id,
|
||||
self.factId,
|
||||
self.modelId,
|
||||
self.cardModelId,
|
||||
self.created,
|
||||
self.modified,
|
||||
self.question,
|
||||
self.answer,
|
||||
self.flags,
|
||||
self.ordinal,
|
||||
self.position,
|
||||
self.flags,
|
||||
self.type,
|
||||
self.queue,
|
||||
self.lastInterval,
|
||||
self.interval,
|
||||
self.due,
|
||||
self.interval,
|
||||
self.factor,
|
||||
self.reps,
|
||||
self.successive,
|
||||
self.lapses) = r
|
||||
self.streak,
|
||||
self.lapses,
|
||||
self.grade,
|
||||
self.cycles) = r
|
||||
return True
|
||||
|
||||
def toDB(self, s):
|
||||
s.execute("""update cards set
|
||||
factId=:factId,
|
||||
modelId=:modelId,
|
||||
cardModelId=:cardModelId,
|
||||
created=:created,
|
||||
modified=:modified,
|
||||
question=:question,
|
||||
answer=:answer,
|
||||
flags=:flags,
|
||||
ordinal=:ordinal,
|
||||
position=:position,
|
||||
flags=:flags,
|
||||
type=:type,
|
||||
queue=:queue,
|
||||
lastInterval=:lastInterval,
|
||||
interval=:interval,
|
||||
due=:due,
|
||||
interval=:interval,
|
||||
factor=:factor,
|
||||
reps=:reps,
|
||||
successive=:successive,
|
||||
lapses=:lapses
|
||||
streak=:streak,
|
||||
lapses=:lapses,
|
||||
grade=:grade,
|
||||
cycles=:cycles
|
||||
where id=:id""", self.__dict__)
|
||||
|
||||
mapper(Card, cardsTable, properties={
|
||||
|
|
118
anki/cram.py
Normal file
118
anki/cram.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright: Damien Elmes <anki@ichi2.net>
|
||||
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
|
||||
|
||||
from anki.sched import Scheduler
|
||||
|
||||
class CramScheduler(Scheduler):
|
||||
|
||||
# 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.learnCount >= 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.learnCount = len(self.lrnCramQueue)
|
||||
|
||||
def _fillLrnCramQueue(self):
|
||||
self.lrnQueue = self.lrnCramQueue
|
|
@ -133,6 +133,9 @@ class Deck(object):
|
|||
|
||||
def getCard(self):
|
||||
return self.sched.getCard()
|
||||
|
||||
def answerCard(self, card, ease):
|
||||
self.sched.answerCard(card, ease)
|
||||
# if card:
|
||||
# return card
|
||||
# if sched.name == "main":
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# Copyright: Damien Elmes <anki@ichi2.net>
|
||||
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
|
||||
|
||||
import time, re
|
||||
import time, re, simplejson
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
from anki.db import *
|
||||
from anki.utils import genID, canonifyTags
|
||||
|
@ -153,9 +153,6 @@ def formatQA(cid, mid, fact, tags, cm, deck):
|
|||
# Model table
|
||||
##########################################################################
|
||||
|
||||
# ...Schedule: fail, pass1, pass2, etc in minutes
|
||||
# ...Intervals: graduation, first remove, later remove
|
||||
|
||||
# maybe define a random cutoff at say +/-30% which controls exit interval
|
||||
# variation - 30% of 1 day is 0.7 or 1.3 so always 1 day; 30% of 4 days is
|
||||
# 2.8-5.2, so any time from 3-5 days is acceptable
|
||||
|
@ -166,27 +163,29 @@ def formatQA(cid, mid, fact, tags, cm, deck):
|
|||
# optional, what intervals should the default be? 3 days or more if cards are
|
||||
# over that interval range? and what about failed mature bonus?
|
||||
|
||||
defaultConf = {
|
||||
'new': {
|
||||
'delays': [0.5, 3, 10],
|
||||
'ints': [1, 7, 4],
|
||||
},
|
||||
'lapse': {
|
||||
'delays': [0.5, 3, 10],
|
||||
'ints': [1, 7, 4],
|
||||
'mult': 0
|
||||
},
|
||||
'initialFactor': 2.5,
|
||||
}
|
||||
|
||||
modelsTable = Table(
|
||||
'models', metadata,
|
||||
Column('id', Integer, primary_key=True),
|
||||
Column('created', Float, nullable=False, default=time.time),
|
||||
Column('modified', Float, nullable=False, default=time.time),
|
||||
Column('name', UnicodeText, nullable=False),
|
||||
# new cards
|
||||
Column('newSched', UnicodeText, nullable=False, default=u"[0.5, 3, 10]"),
|
||||
Column('newInts', UnicodeText, nullable=False, default=u"[1, 7, 4]"),
|
||||
# failed cards
|
||||
Column('failSched', UnicodeText, nullable=False, default=u"[0.5, 3, 10]"),
|
||||
Column('failInts', UnicodeText, nullable=False, default=u"[1, 7, 4]"),
|
||||
Column('failMult', Float, nullable=False, default=0),
|
||||
# other scheduling
|
||||
Column('initialFactor', Float, nullable=False, default=2.5),
|
||||
Column('config', UnicodeText, nullable=False,
|
||||
default=unicode(simplejson.dumps(defaultConf))),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Model(object):
|
||||
"Defines the way a fact behaves, what fields it can contain, etc."
|
||||
def __init__(self, name=u""):
|
||||
|
|
558
anki/sched.py
558
anki/sched.py
|
@ -2,11 +2,12 @@
|
|||
# Copyright: Damien Elmes <anki@ichi2.net>
|
||||
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
|
||||
|
||||
import time, datetime
|
||||
import time, datetime, simplejson
|
||||
from heapq import *
|
||||
from anki.db import *
|
||||
from anki.cards import Card
|
||||
from anki.utils import parseTags
|
||||
from anki.lang import _
|
||||
|
||||
# the standard Anki scheduler
|
||||
class Scheduler(object):
|
||||
|
@ -26,106 +27,57 @@ class Scheduler(object):
|
|||
|
||||
def getCard(self, orm=True):
|
||||
"Pop the next card from the queue. None if finished."
|
||||
id = self._getCard()
|
||||
self.checkDay()
|
||||
id = self.getCardId()
|
||||
if id:
|
||||
card = Card()
|
||||
assert card.fromDB(self.db, id)
|
||||
return card
|
||||
|
||||
def reset(self):
|
||||
self.modelConfigs = {}
|
||||
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""")
|
||||
def answerCard(self, card, ease):
|
||||
if card.queue == 0:
|
||||
self.answerLearnCard(card, ease)
|
||||
elif card.queue == 1:
|
||||
self.answerRevCard(card, ease)
|
||||
else:
|
||||
raise Exception("Invalid queue")
|
||||
card.toDB(self.db)
|
||||
|
||||
def counts(self):
|
||||
# FIXME: should learn count include new cards due today, or be separate?
|
||||
return (self.learnCount, self.revCount)
|
||||
|
||||
# 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)
|
||||
return card.queue
|
||||
|
||||
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
|
||||
# Getting the next card
|
||||
##########################################################################
|
||||
|
||||
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()
|
||||
def getCardId(self):
|
||||
"Return the next due card id, or None."
|
||||
# learning card due?
|
||||
id = self.getLearnCard()
|
||||
if id:
|
||||
return id
|
||||
# new first, or time for one?
|
||||
if self.timeForNewCard():
|
||||
return self.getNewCard()
|
||||
# card due for review?
|
||||
id = self.getReviewCard()
|
||||
if id:
|
||||
return id
|
||||
# new cards left?
|
||||
id = self.getNewCard()
|
||||
if id:
|
||||
return id
|
||||
# collapse or finish
|
||||
return self.getLearnCard(collapse=True)
|
||||
|
||||
# Learning queue
|
||||
##########################################################################
|
||||
|
@ -135,12 +87,64 @@ when queue != -1""")
|
|||
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)
|
||||
def getLearnCard(self, collapse=False):
|
||||
if self.learnQueue:
|
||||
cutoff = time.time()
|
||||
if collapse:
|
||||
cutoff -= self.deck.collapseTime
|
||||
if self.learnQueue[0][0] < cutoff:
|
||||
return heappop(self.learnQueue)[1]
|
||||
|
||||
def answerLearnCard(self, card, ease):
|
||||
# ease 1=no, 2=yes, 3=remove
|
||||
conf = self.learnConf(card)
|
||||
if ease == 3:
|
||||
self.removeLearnCard(card, conf)
|
||||
return
|
||||
card.cycles += 1
|
||||
if ease == 2:
|
||||
card.grade += 1
|
||||
else:
|
||||
card.grade = 0
|
||||
if card.grade >= len(conf['delays']):
|
||||
self.graduateLearnCard(card, conf)
|
||||
else:
|
||||
card.due = time.time() + conf['delays'][card.grade]*60
|
||||
|
||||
def learnConf(self, card):
|
||||
conf = self.configForCard(card)
|
||||
if card.type == 2:
|
||||
return conf['new']
|
||||
else:
|
||||
return conf['lapse']
|
||||
|
||||
def removeLearnCard(self, card, conf):
|
||||
if card.type == 1:
|
||||
int_ = None
|
||||
elif not card.cycles:
|
||||
# first time bonus
|
||||
int_ = conf['ints'][1]
|
||||
else:
|
||||
# normal remove
|
||||
int_ = conf['ints'][2]
|
||||
self.rescheduleAsReview(card, int_)
|
||||
|
||||
def graduateLearnCard(self, card, conf):
|
||||
if card.type == 1:
|
||||
int_ = None
|
||||
else:
|
||||
int_ = conf['ints'][0]
|
||||
self.rescheduleAsReview(card, int_)
|
||||
|
||||
def rescheduleAsReview(self, card, int_):
|
||||
card.queue = 1
|
||||
if int_:
|
||||
# new card
|
||||
card.type = 1
|
||||
card.interval = int_
|
||||
print "handle log, etc"
|
||||
|
||||
# Reviews
|
||||
##########################################################################
|
||||
|
@ -183,128 +187,10 @@ limit %d""" % (self.revOrder(), self.queueLimit)), lim=self.dayCutoff)
|
|||
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):
|
||||
def _answerCard(self, card, ease):
|
||||
undoName = _("Answer Card")
|
||||
self.setUndoStart(undoName)
|
||||
now = time.time()
|
||||
|
@ -520,6 +406,148 @@ and queue between 1 and 2""",
|
|||
self.reset()
|
||||
self.refreshSession()
|
||||
|
||||
# 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."
|
||||
|
||||
# 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()
|
||||
|
||||
def updateNewCountToday(self):
|
||||
self.newCount = max(min(
|
||||
self.newAvail, self.newCardsPerDay -
|
||||
self.newSeenToday), 0)
|
||||
|
||||
def fillNewQueue(self):
|
||||
if self.newCount and not self.newQueue:
|
||||
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."
|
||||
# FIXME
|
||||
return False
|
||||
|
||||
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):
|
||||
# FIXME
|
||||
return None
|
||||
#return self.newQueue[-1][0]
|
||||
|
||||
def newOrder(self):
|
||||
return ("due",
|
||||
"due",
|
||||
"due desc")[self.newCardOrder]
|
||||
|
||||
# Tools
|
||||
##########################################################################
|
||||
|
||||
def configForCard(self, card):
|
||||
mid = card.modelId
|
||||
if not mid in self.modelConfigs:
|
||||
self.modelConfigs[mid] = simplejson.loads(
|
||||
self.db.scalar("select config from models where id = :id",
|
||||
id=mid))
|
||||
return self.modelConfigs[mid]
|
||||
|
||||
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)
|
||||
|
||||
def checkDay(self):
|
||||
# check if the day has rolled over
|
||||
if time.time() > self.dayCutoff:
|
||||
self.updateCutoff()
|
||||
self.reset()
|
||||
|
||||
# Review early
|
||||
##########################################################################
|
||||
|
||||
|
@ -571,115 +599,3 @@ order by due limit %d""" % self.queueLimit), lim=self.dayCutoff)
|
|||
|
||||
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
|
||||
|
||||
|
|
|
@ -37,9 +37,11 @@ def upgradeSchema(engine, s):
|
|||
import cards
|
||||
metadata.create_all(engine, tables=[cards.cardsTable])
|
||||
s.execute("""
|
||||
insert into cards select id, factId, cardModelId, created, modified,
|
||||
question, answer, 0, ordinal, 0, relativeDelay, type, lastInterval, interval,
|
||||
due, factor, reps, successive, noCount from cards2""")
|
||||
insert into cards select id, factId,
|
||||
(select modelId from facts where facts.id = cards.factId),
|
||||
cardModelId, created, modified,
|
||||
question, answer, ordinal, 0, relativeDelay, type, due, interval,
|
||||
factor, reps, successive, noCount, 0, 0 from cards2""")
|
||||
s.execute("drop table cards2")
|
||||
# tags
|
||||
###########
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import tempfile, os
|
||||
from anki import Deck
|
||||
|
||||
def assertException(exception, func):
|
||||
found = False
|
||||
try:
|
||||
|
@ -5,3 +8,8 @@ def assertException(exception, func):
|
|||
except exception:
|
||||
found = True
|
||||
assert found
|
||||
|
||||
def getDeck():
|
||||
(fd, nam) = tempfile.mkstemp(suffix=".anki")
|
||||
os.unlink(nam)
|
||||
return Deck(nam)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# coding: utf-8
|
||||
|
||||
import nose, os, re
|
||||
from tests.shared import assertException
|
||||
from tests.shared import assertException, getDeck
|
||||
|
||||
from anki.errors import *
|
||||
from anki import Deck
|
||||
|
@ -15,12 +15,6 @@ newModified = None
|
|||
|
||||
testDir = os.path.dirname(__file__)
|
||||
|
||||
def getDeck():
|
||||
import tempfile
|
||||
(fd, nam) = tempfile.mkstemp(suffix=".anki")
|
||||
os.unlink(nam)
|
||||
return Deck(nam)
|
||||
|
||||
## opening/closing
|
||||
|
||||
def test_attachNew():
|
||||
|
|
62
tests/test_sched.py
Normal file
62
tests/test_sched.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# coding: utf-8
|
||||
|
||||
import time
|
||||
from tests.shared import assertException, getDeck
|
||||
from anki.stdmodels import BasicModel
|
||||
#from anki.db import *
|
||||
|
||||
def getEmptyDeck():
|
||||
d = getDeck()
|
||||
d.addModel(BasicModel())
|
||||
d.db.commit()
|
||||
return d
|
||||
|
||||
def test_basics():
|
||||
d = getEmptyDeck()
|
||||
assert not d.getCard()
|
||||
|
||||
def test_learn():
|
||||
d = getEmptyDeck()
|
||||
# add a fact
|
||||
f = d.newFact()
|
||||
f['Front'] = u"one"; f['Back'] = u"two"
|
||||
f = d.addFact(f)
|
||||
d.db.flush()
|
||||
# set as a learn card and rebuild queues
|
||||
d.db.statement("update cards set queue=0, type=2")
|
||||
d.reset()
|
||||
# getCard should return it, since it's due in the past
|
||||
c = d.getCard()
|
||||
assert c
|
||||
# it should have no cycles and a grade of 0
|
||||
assert c.grade == c.cycles == 0
|
||||
# fail it
|
||||
d.answerCard(c, 1)
|
||||
# it should by due in 30 seconds
|
||||
assert round(c.due - time.time()) == 30
|
||||
# and have 1 cycle, but still a zero grade
|
||||
assert c.grade == 0
|
||||
assert c.cycles == 1
|
||||
# pass it once
|
||||
d.answerCard(c, 2)
|
||||
# it should by due in 3 minutes
|
||||
assert round(c.due - time.time()) == 180
|
||||
# and it should be grade 1 now
|
||||
assert c.grade == 1
|
||||
assert c.cycles == 2
|
||||
# pass again
|
||||
d.answerCard(c, 2)
|
||||
# it should by due in 10 minutes
|
||||
assert round(c.due - time.time()) == 600
|
||||
# and it should be grade 1 now
|
||||
assert c.grade == 2
|
||||
assert c.cycles == 3
|
||||
# the next pass should graduate the card
|
||||
assert c.queue == 0
|
||||
assert c.type == 2
|
||||
d.answerCard(c, 2)
|
||||
assert c.queue == 1
|
||||
assert c.type == 1
|
||||
print "test intervals, check early removal, etc"
|
||||
|
||||
|
Loading…
Reference in a new issue