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

- obsolete spaceUntil - it serves no useful purpose - the old per-model spacing variables are obsolete, as the new approach requires uniform spacing across all models for new cards - introduce a new per-deck variable: newSpacing - don't fill new queue if we've done today's cards - still need to check cramming / review early newSpacing is a time in seconds to delay introduction of sibling new cards. It can be applied as many times as necessary as there is no harm in new cards being delayed repeatedly. Because the default queue length is 200 and it can take quite some time for the spaced cards to be placed in the queue again, we use a separate array to track spaced new cards provided the configured delay is less than 20 minutes. At times under 20 minutes this number is not a guaranteed minimum spacing - if the new card queue is empty the spaced cards will be flushed before checking the new queue again, as otherwise we end up trying to fill on every repetition. The due counts no longer decrease by more than one if the spacing is less than the due cutoff, since that confused some users. Review cards are now placed at the end of the current review queue, and will never be rescheduled to a different day. The old approach had a number of problems: - the more card models you had, the more likely a card would be spaced multiple times, resulting in you forgetting the card before you get a chance to review it - spacing was applied even if the due card was already late - repeatedly failing one card over a period of days or weeks would also stave the other cards of attention
285 lines
9.5 KiB
Python
285 lines
9.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright: Damien Elmes <anki@ichi2.net>
|
|
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
|
|
|
|
"""\
|
|
Cards
|
|
====================
|
|
"""
|
|
__docformat__ = 'restructuredtext'
|
|
|
|
import time, sys, math, random
|
|
from anki.db import *
|
|
from anki.models import CardModel, Model, FieldModel, formatQA
|
|
from anki.facts import Fact, factsTable, Field
|
|
from anki.utils import parseTags, findTag, stripHTML, genID, hexifyID
|
|
|
|
# Cards
|
|
##########################################################################
|
|
|
|
cardsTable = Table(
|
|
'cards', metadata,
|
|
Column('id', Integer, primary_key=True),
|
|
Column('factId', Integer, ForeignKey("facts.id"), nullable=False),
|
|
Column('cardModelId', Integer, ForeignKey("cardModels.id"), nullable=False),
|
|
Column('created', Float, nullable=False, default=time.time),
|
|
Column('modified', Float, nullable=False, default=time.time),
|
|
Column('tags', UnicodeText, nullable=False, default=u""),
|
|
Column('ordinal', Integer, nullable=False),
|
|
# cached - changed on fact update
|
|
Column('question', UnicodeText, nullable=False, default=u""),
|
|
Column('answer', UnicodeText, nullable=False, default=u""),
|
|
# default to 'normal' priority;
|
|
# this is indexed in deck.py as we need to create a reverse index
|
|
Column('priority', Integer, nullable=False, default=2),
|
|
Column('interval', Float, nullable=False, default=0),
|
|
Column('lastInterval', Float, nullable=False, default=0),
|
|
Column('due', Float, nullable=False, default=time.time),
|
|
Column('lastDue', Float, nullable=False, default=0),
|
|
Column('factor', Float, nullable=False, default=2.5),
|
|
Column('lastFactor', Float, nullable=False, default=2.5),
|
|
Column('firstAnswered', Float, nullable=False, default=0),
|
|
# stats
|
|
Column('reps', Integer, nullable=False, default=0),
|
|
Column('successive', Integer, nullable=False, default=0),
|
|
Column('averageTime', Float, nullable=False, default=0),
|
|
Column('reviewTime', Float, nullable=False, default=0),
|
|
Column('youngEase0', Integer, nullable=False, default=0),
|
|
Column('youngEase1', Integer, nullable=False, default=0),
|
|
Column('youngEase2', Integer, nullable=False, default=0),
|
|
Column('youngEase3', Integer, nullable=False, default=0),
|
|
Column('youngEase4', Integer, nullable=False, default=0),
|
|
Column('matureEase0', Integer, nullable=False, default=0),
|
|
Column('matureEase1', Integer, nullable=False, default=0),
|
|
Column('matureEase2', Integer, nullable=False, default=0),
|
|
Column('matureEase3', Integer, nullable=False, default=0),
|
|
Column('matureEase4', Integer, nullable=False, default=0),
|
|
# this duplicates the above data, because there's no way to map imported
|
|
# data to the above
|
|
Column('yesCount', Integer, nullable=False, default=0),
|
|
Column('noCount', Integer, nullable=False, default=0),
|
|
# obsolete
|
|
Column('spaceUntil', Float, nullable=False, default=0),
|
|
# relativeDelay is reused as type without scheduling (ie, it remains 0-2
|
|
# even if card is suspended, etc)
|
|
Column('relativeDelay', Float, nullable=False, default=0),
|
|
Column('isDue', Boolean, nullable=False, default=0), # obsolete
|
|
Column('type', Integer, nullable=False, default=2),
|
|
Column('combinedDue', Integer, nullable=False, default=0))
|
|
|
|
class Card(object):
|
|
"A card."
|
|
|
|
def __init__(self, fact=None, cardModel=None, created=None):
|
|
self.tags = u""
|
|
self.id = genID()
|
|
# new cards start as new & due
|
|
self.type = 2
|
|
self.relativeDelay = self.type
|
|
self.timerStarted = False
|
|
self.timerStopped = False
|
|
self.modified = time.time()
|
|
if created:
|
|
self.created = created
|
|
self.due = created
|
|
else:
|
|
self.due = self.modified
|
|
self.combinedDue = self.due
|
|
if fact:
|
|
self.fact = fact
|
|
if cardModel:
|
|
self.cardModel = cardModel
|
|
# for non-orm use
|
|
self.cardModelId = cardModel.id
|
|
self.ordinal = cardModel.ordinal
|
|
d = {}
|
|
for f in self.fact.model.fieldModels:
|
|
d[f.name] = (f.id, self.fact[f.name])
|
|
qa = formatQA(None, fact.modelId, d, self.splitTags(), cardModel)
|
|
self.question = qa['question']
|
|
self.answer = qa['answer']
|
|
|
|
def setModified(self):
|
|
self.modified = time.time()
|
|
|
|
def startTimer(self):
|
|
self.timerStarted = time.time()
|
|
|
|
def stopTimer(self):
|
|
self.timerStopped = time.time()
|
|
|
|
def thinkingTime(self):
|
|
return (self.timerStopped or time.time()) - self.timerStarted
|
|
|
|
def totalTime(self):
|
|
return time.time() - self.timerStarted
|
|
|
|
def genFuzz(self):
|
|
"Generate a random offset to spread intervals."
|
|
self.fuzz = random.uniform(0.95, 1.05)
|
|
|
|
def htmlQuestion(self, type="question", align=True):
|
|
div = '''<div class="card%s" id="cm%s%s">%s</div>''' % (
|
|
type[0], type[0], hexifyID(self.cardModelId),
|
|
getattr(self, type))
|
|
# add outer div & alignment (with tables due to qt's html handling)
|
|
if not align:
|
|
return div
|
|
attr = type + 'Align'
|
|
if getattr(self.cardModel, attr) == 0:
|
|
align = "center"
|
|
elif getattr(self.cardModel, attr) == 1:
|
|
align = "left"
|
|
else:
|
|
align = "right"
|
|
return (("<center><table width=95%%><tr><td align=%s>" % align) +
|
|
div + "</td></tr></table></center>")
|
|
|
|
def htmlAnswer(self, align=True):
|
|
return self.htmlQuestion(type="answer", align=align)
|
|
|
|
def updateStats(self, ease, state):
|
|
self.reps += 1
|
|
if ease > 1:
|
|
self.successive += 1
|
|
else:
|
|
self.successive = 0
|
|
delay = self.totalTime()
|
|
# ignore any times over 60 seconds
|
|
if delay < 60:
|
|
self.reviewTime += delay
|
|
if self.averageTime:
|
|
self.averageTime = (self.averageTime + delay) / 2.0
|
|
else:
|
|
self.averageTime = delay
|
|
# we don't track first answer for cards
|
|
if state == "new":
|
|
state = "young"
|
|
# update ease and yes/no count
|
|
attr = state + "Ease%d" % ease
|
|
setattr(self, attr, getattr(self, attr) + 1)
|
|
if ease < 2:
|
|
self.noCount += 1
|
|
else:
|
|
self.yesCount += 1
|
|
if not self.firstAnswered:
|
|
self.firstAnswered = time.time()
|
|
self.setModified()
|
|
|
|
def splitTags(self):
|
|
return (self.fact.tags, self.fact.model.tags, self.cardModel.name)
|
|
|
|
def allTags(self):
|
|
"Non-canonified string of all tags."
|
|
return (self.fact.tags + "," +
|
|
self.fact.model.tags)
|
|
|
|
def hasTag(self, tag):
|
|
return findTag(tag, parseTags(self.allTags()))
|
|
|
|
def fromDB(self, s, id):
|
|
r = s.first("""select
|
|
id, factId, cardModelId, created, modified, tags, ordinal, question, answer,
|
|
priority, interval, lastInterval, due, lastDue, factor,
|
|
lastFactor, firstAnswered, reps, successive, averageTime, reviewTime,
|
|
youngEase0, youngEase1, youngEase2, youngEase3, youngEase4,
|
|
matureEase0, matureEase1, matureEase2, matureEase3, matureEase4,
|
|
yesCount, noCount, spaceUntil, isDue, type, combinedDue
|
|
from cards where id = :id""", id=id)
|
|
if not r:
|
|
return
|
|
(self.id,
|
|
self.factId,
|
|
self.cardModelId,
|
|
self.created,
|
|
self.modified,
|
|
self.tags,
|
|
self.ordinal,
|
|
self.question,
|
|
self.answer,
|
|
self.priority,
|
|
self.interval,
|
|
self.lastInterval,
|
|
self.due,
|
|
self.lastDue,
|
|
self.factor,
|
|
self.lastFactor,
|
|
self.firstAnswered,
|
|
self.reps,
|
|
self.successive,
|
|
self.averageTime,
|
|
self.reviewTime,
|
|
self.youngEase0,
|
|
self.youngEase1,
|
|
self.youngEase2,
|
|
self.youngEase3,
|
|
self.youngEase4,
|
|
self.matureEase0,
|
|
self.matureEase1,
|
|
self.matureEase2,
|
|
self.matureEase3,
|
|
self.matureEase4,
|
|
self.yesCount,
|
|
self.noCount,
|
|
self.spaceUntil,
|
|
self.isDue,
|
|
self.type,
|
|
self.combinedDue) = r
|
|
return True
|
|
|
|
def toDB(self, s):
|
|
"Write card to DB."
|
|
s.execute("""update cards set
|
|
modified=:modified,
|
|
tags=:tags,
|
|
interval=:interval,
|
|
lastInterval=:lastInterval,
|
|
due=:due,
|
|
lastDue=:lastDue,
|
|
factor=:factor,
|
|
lastFactor=:lastFactor,
|
|
firstAnswered=:firstAnswered,
|
|
reps=:reps,
|
|
successive=:successive,
|
|
averageTime=:averageTime,
|
|
reviewTime=:reviewTime,
|
|
youngEase0=:youngEase0,
|
|
youngEase1=:youngEase1,
|
|
youngEase2=:youngEase2,
|
|
youngEase3=:youngEase3,
|
|
youngEase4=:youngEase4,
|
|
matureEase0=:matureEase0,
|
|
matureEase1=:matureEase1,
|
|
matureEase2=:matureEase2,
|
|
matureEase3=:matureEase3,
|
|
matureEase4=:matureEase4,
|
|
yesCount=:yesCount,
|
|
noCount=:noCount,
|
|
spaceUntil = :spaceUntil,
|
|
isDue = 0,
|
|
type = :type,
|
|
combinedDue = :combinedDue,
|
|
relativeDelay = 0,
|
|
priority = :priority
|
|
where id=:id""", self.__dict__)
|
|
|
|
mapper(Card, cardsTable, properties={
|
|
'cardModel': relation(CardModel),
|
|
'fact': relation(Fact, backref="cards", primaryjoin=
|
|
cardsTable.c.factId == factsTable.c.id),
|
|
})
|
|
|
|
mapper(Fact, factsTable, properties={
|
|
'model': relation(Model),
|
|
'fields': relation(Field, backref="fact", order_by=Field.ordinal),
|
|
})
|
|
|
|
|
|
# Card deletions
|
|
##########################################################################
|
|
|
|
cardsDeletedTable = Table(
|
|
'cardsDeleted', metadata,
|
|
Column('cardId', Integer, ForeignKey("cards.id"),
|
|
nullable=False),
|
|
Column('deletedTime', Float, nullable=False))
|