Anki/anki/cards.py
Damien Elmes 0c9672e7b8 rewrite media support
- media is no longer hashed, and instead stored in the db using its original
  name
- when adding media, its checksum is calculated and used to look for
  duplicates
- duplicate filenames will result in a number tacked on the file
- the size column is used to count card references to media. If media is
  referenced in a fact but not the question or answer, the count will be zero.
- there is no guarantee media will be listed in the media db if it is unused
  on the question & answer
- if rebuildMediaDir(delete=True), then entries with zero references are
  deleted, along with any unused files in the media dir.
- rebuildMediaDir() will update the internal checksums, and set the checksum
  to "" if a file can't be found
- rebuildMediaDir() is a lot less destructive now, and will leave alone
  directories it finds in the media folder (but not look in them either)
- rebuildMediaDir() returns more information about the state of media now
- the online and mobile clients will need to to make sure that when
  downloading media, entries with no checksum are non-fatal and should not
  abort the download process.
- the ref count is updated every time the q/a is updated - so the db should be
  up to date after every add/edit/import
- since we look for media on the q/a now, card templates like '<img
  src="{{{field}}}">' will work now
- export original files as gone as it is not needed anymore
- move from per-model media URL to deckVar. downloadMissingMedia() uses this
  now. Deck subscriptions will have to be updated to share media another way.
- pass deck in formatQA, as latex support is going to change
2010-12-11 01:19:31 +09:00

311 lines
10 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
from anki.media import updateMediaCount, mediaFiles
# 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
def rebuildQA(self, deck, media=True):
# format qa
d = {}
for f in self.fact.model.fieldModels:
d[f.name] = (f.id, self.fact[f.name])
qa = formatQA(None, self.fact.modelId, d, self.splitTags(),
self.cardModel, deck)
# find old media references
files = {}
for type in ("question", "answer"):
for f in mediaFiles(getattr(self, type) or ""):
if f in files:
files[f] -= 1
else:
files[f] = -1
# update q/a
self.question = qa['question']
self.answer = qa['answer']
# determine media delta
for type in ("question", "answer"):
for f in mediaFiles(getattr(self, type)):
if f in files:
files[f] += 1
else:
files[f] = 1
# update media counts if we're attached to deck
if media:
for (f, cnt) in files.items():
updateMediaCount(deck, f, cnt)
self.setModified()
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))