top level groups

As discussed on the forums, moving to a single collection requires moving some
deck-level configuration into groups so users can have different settings like
new cards/day for each top level item.

Also:
- store id in groups
- add mod time to gconf updates
- move the limiting code that's not specific to scheduling into groups.py
- store the current model id per top level group
This commit is contained in:
Damien Elmes 2011-09-07 01:31:46 +09:00
parent 5179d82f7f
commit de8a5b69ed
11 changed files with 175 additions and 104 deletions

View file

@ -20,19 +20,12 @@ import anki.cards, anki.facts, anki.template, anki.cram, anki.find
defaultConf = {
# scheduling options
'groups': [],
'newPerDay': 20,
'newToday': [0, 0], # currentDay, count
'newTodayOrder': NEW_TODAY_ORD,
'newOrder': NEW_CARDS_DUE,
'newSpread': NEW_CARDS_DISTRIBUTE,
'activeGroups': [],
'topGroup': 1,
'curGroup': None,
'revOrder': REV_CARDS_RANDOM,
'collapseTime': 1200,
'repLim': 0,
'timeLim': 600,
# other config
'nextPos': 1,
'mediaURL': "",
'fontFamilies': [
[u' 明朝',u'ヒラギノ明朝 Pro W3',u'Kochi Mincho', u'東風明朝']
],
@ -228,7 +221,7 @@ crt=?, mod=?, scm=?, dty=?, syncName=?, lastSync=?, conf=?""",
return 0
fact.flush()
# randomize?
if self.randomNew():
if self.models.randomNew():
due = self._randPos()
else:
due = self.nextID("pos")
@ -284,7 +277,7 @@ crt=?, mod=?, scm=?, dty=?, syncName=?, lastSync=?, conf=?""",
"Generate cards for templates if cards not empty. Return cards."
cards = []
# if random mode, determine insertion point
if self.randomNew():
if self.models.randomNew():
# if this fact has existing new cards, use their due time
due = self.db.scalar(
"select due from cards where fid = ? and queue = 0", fact.id)
@ -331,9 +324,6 @@ crt=?, mod=?, scm=?, dty=?, syncName=?, lastSync=?, conf=?""",
card.flush()
return card
def randomNew(self):
return self.conf['newOrder'] == NEW_CARDS_RANDOM
# Cards
##########################################################################

View file

@ -4,7 +4,27 @@
import simplejson
from anki.utils import intTime
from anki.consts import *
# fixmes:
# - make sure lists like new[delays] are not being shared by multiple groups
# - make sure all children have parents (create as necessary)
# - when renaming a group, top level properties should be added or removed as
# appropriate
# configuration only available to top level groups
defaultTopConf = {
'newPerDay': 20,
'newToday': [0, 0], # currentDay, count
'newTodayOrder': NEW_TODAY_ORD,
'newSpread': NEW_CARDS_DISTRIBUTE,
'collapseTime': 1200,
'repLim': 0,
'timeLim': 600,
'curModel': None,
}
# configuration available to all groups
defaultConf = {
'new': {
'delays': [1, 10],
@ -33,11 +53,7 @@ defaultConf = {
'minSpace': 1,
},
'maxTaken': 60,
}
defaultData = {
'activeTags': None,
'inactiveTags': None,
'mod': 0,
}
class GroupManager(object):
@ -54,6 +70,7 @@ class GroupManager(object):
self.changed = False
def save(self, g):
"Can be called with either a group or a group configuration."
g['mod'] = intTime()
self.changed = True
@ -73,12 +90,20 @@ class GroupManager(object):
return int(id)
if not create:
return None
g = dict(name=name, conf=1, mod=intTime())
if "::" not in name:
# if it's a top level group, it gets the top level config
g = defaultTopConf.copy()
else:
# not top level. calling code should ensure parents already exist?
g = {}
g['name'] = name
g['conf'] = 1
while 1:
id = str(intTime(1000))
if id in self.groups:
id = intTime(1000)
if str(id) in self.groups:
continue
self.groups[id] = g
g['id'] = id
self.groups[str(id)] = g
self.save(g)
return int(id)
@ -89,11 +114,15 @@ class GroupManager(object):
self.deck.db.execute("delete from groups where id = ?", gid)
print "fixme: loop through models and update stale gid references"
def all(self):
def allNames(self):
"An unsorted list of all group names."
return [x['name'] for x in self.groups.values()]
# Utils
def all(self):
"A list of all groups."
return self.groups.values()
# Group utils
#############################################################
def name(self, gid):
@ -102,6 +131,46 @@ class GroupManager(object):
def conf(self, gid):
return self.gconf[str(self.groups[str(gid)]['conf'])]
def get(self, gid):
return self.groups[str(gid)]
def setGroup(self, cids, gid):
self.db.execute(
"update cards set gid = ? where id in "+ids2str(cids), gid)
# Group selection
#############################################################
def top(self):
"The current top level group as an object, and marks as modified."
g = self.get(self.deck.conf['topGroup'])
self.save(g)
return g
def active(self):
"The currrently active gids."
return self.deck.conf['activeGroups']
def selected(self):
"The currently selected gid, or None if whole collection."
return self.deck.conf['curGroup']
def select(self, gid):
"Select a new group. If gid is None, select whole collection."
if not gid:
self.deck.conf['topGroup'] = 1
self.deck.conf['curGroup'] = None
self.deck.conf['activeGroups'] = []
return
# save the top level group
name = self.groups[str(gid)]['name']
path = name.split("::")
self.deck.conf['topGroup'] = self.id(path[0])
# current group
self.deck.conf['curGroup'] = gid
# and active groups (current + all children)
actv = [gid]
for g in self.all():
if g['name'].startswith(name + "::"):
actv.append(g['id'])
self.deck.conf['activeGroups'] = actv

View file

@ -6,6 +6,7 @@ import simplejson, copy
from anki.utils import intTime, hexifyID, joinFields, splitFields, ids2str, \
timestampID
from anki.lang import _
from anki.consts import *
# Models
##########################################################################
@ -16,6 +17,7 @@ defaultModel = {
'sortf': 0,
'gid': 1,
'clozectx': False,
'newOrder': NEW_CARDS_DUE,
'latexPre': """\
\\documentclass[12pt]{article}
\\special{papersize=3in,5in}
@ -87,7 +89,13 @@ class ModelManager(object):
def current(self):
"Get current model."
return self.get(self.deck.conf['currentModelId'])
try:
return self.get(self.deck.groups.top()['curModel'])
except:
return self.models.values()[0]
def setCurrent(self, m):
self.deck.groups.top()['curModel'] = m['id']
def get(self, id):
"Get model with ID."
@ -117,6 +125,7 @@ class ModelManager(object):
def rem(self, m):
"Delete model, and all its cards/facts."
self.deck.modSchema()
current = self.current()['id'] == m['id']
# delete facts/cards
self.deck.remCards(self.deck.db.list("""
select id from cards where fid in (select id from facts where mid = ?)""",
@ -125,14 +134,14 @@ select id from cards where fid in (select id from facts where mid = ?)""",
del self.models[m['id']]
self.save()
# GUI should ensure last model is not deleted
if self.deck.conf['currentModelId'] == m['id']:
self.deck.conf['currentModelId'] = int(self.models.keys()[0])
if current:
self.setCurrent(self.models.values()[0])
def _add(self, m):
self._setID(m)
self.models[m['id']] = m
self.save(m)
self.deck.conf['currentModelId'] = m['id']
self.setCurrent(m)
return m
def _setID(self, m):
@ -155,6 +164,9 @@ select id from cards where fid in (select id from facts where mid = ?)""",
return self.deck.db.scalar(
"select count() from facts where mid = ?", m['id'])
def randomNew(self):
return self.current()['newOrder'] == NEW_CARDS_RANDOM
# Copying
##################################################

View file

@ -49,7 +49,7 @@ class Scheduler(object):
# put it in the learn queue
card.queue = 1
card.type = 1
self.deck.conf['newToday'][1] += 1
self.deck.groups.top()['newToday'][1] += 1
if card.queue == 1:
self._answerLrnCard(card, ease)
elif card.queue == 2:
@ -98,20 +98,6 @@ order by due""" % self._groupLimit(),
# Counts
##########################################################################
def selCounts(self):
"Return counts for selected groups, without building queue."
self._resetCounts()
return self.counts()
def allCounts(self):
"Return counts for all groups, without building queue."
conf = self.deck.conf['groups']
if conf:
self.deck.conf['groups'] = []
self._resetCounts()
self.deck.conf['groups'] = conf
return self.counts()
def _resetCounts(self):
self._updateCutoff()
self._resetLrnCount()
@ -212,7 +198,7 @@ from cards group by gid""", self.today):
# FIXME: need to keep track of reps for timebox and new card introduction
def _resetNewCount(self):
l = self.deck.conf
l = self.deck.groups.top()
if l['newToday'][0] != self.today:
# it's a new day; reset counts
l['newToday'] = [self.today, 0]
@ -241,7 +227,7 @@ queue = 0 %s order by due limit %d""" % (self._groupLimit(),
if self.newQueue:
(id, due) = self.newQueue.pop()
# move any siblings to the end?
if self.deck.conf['newTodayOrder'] == NEW_TODAY_ORD:
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())
@ -253,7 +239,7 @@ queue = 0 %s order by due limit %d""" % (self._groupLimit(),
return id
def _updateNewCardRatio(self):
if self.deck.conf['newSpread'] == NEW_CARDS_DISTRIBUTE:
if self.deck.groups.top()['newSpread'] == NEW_CARDS_DISTRIBUTE:
if self.newCount:
self.newCardModulus = (
(self.newCount + self.revCount) / self.newCount)
@ -267,9 +253,9 @@ queue = 0 %s order by due limit %d""" % (self._groupLimit(),
"True if it's time to display a new card when distributing."
if not self.newCount:
return False
if self.deck.conf['newSpread'] == NEW_CARDS_LAST:
if self.deck.groups.top()['newSpread'] == NEW_CARDS_LAST:
return False
elif self.deck.conf['newSpread'] == NEW_CARDS_FIRST:
elif self.deck.groups.top()['newSpread'] == NEW_CARDS_FIRST:
return True
elif self.newCardModulus:
return self.reps and self.reps % self.newCardModulus == 0
@ -282,7 +268,7 @@ queue = 0 %s order by due limit %d""" % (self._groupLimit(),
select count() from (select id from cards where
queue = 1 %s and due < ? limit %d)""" % (
self._groupLimit(), self.reportLimit),
intTime() + self.deck.conf['collapseTime'])
intTime() + self.deck.groups.top()['collapseTime'])
def _resetLrn(self):
self.lrnQueue = self.deck.db.all("""
@ -294,7 +280,7 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
if self.lrnQueue:
cutoff = time.time()
if collapse:
cutoff += self.deck.conf['collapseTime']
cutoff += self.deck.groups.top()['collapseTime']
if self.lrnQueue[0][0] < cutoff:
id = heappop(self.lrnQueue)[1]
self.lrnCount -= 1
@ -327,7 +313,7 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
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.conf['collapseTime']:
if delay <= self.deck.groups.top()['collapseTime']:
self.lrnCount += 1
self._logLrn(card, ease, conf, leaving, type)
@ -578,7 +564,7 @@ queue = 2 %s and due <= :lim order by %s limit %d""" % (
return self.deck.groups.conf(card.gid)
def _groupLimit(self):
l = self.deck.conf['groups']
l = self.deck.groups.active()
if not l:
# everything
return ""
@ -605,16 +591,10 @@ queue = 2 %s and due <= :lim order by %s limit %d""" % (
def finishedMsg(self):
return (
"<h1>"+_("Congratulations!")+"</h1>"+
self._finishedSubtitle()+
_("You have finished the selected groups for now.") +
"<br><br>"+
self._nextDueMsg())
def _finishedSubtitle(self):
if self.deck.conf['groups']:
return _("You have finished the selected groups for now.")
else:
return _("You have finished the deck for now.")
def _nextDueMsg(self):
line = []
rev = self.revTomorrow() + self.lrnTomorrow()
@ -650,7 +630,7 @@ queue = 2 %s and due <= :lim order by %s limit %d""" % (
def newTomorrow(self):
"Number of new cards tomorrow."
lim = self.deck.conf['newPerDay']
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))
@ -764,7 +744,7 @@ queue = 2 %s and due <= :lim order by %s limit %d""" % (
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.randomNew())
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)."
@ -816,6 +796,8 @@ and due >= ?""" % scids, now, shiftby, low)
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)

View file

@ -675,7 +675,7 @@ $(function () {
return ""
def _revlogLimit(self):
lim = self.deck.conf['groups']
lim = self.deck.groups.active()
if self.selective and lim:
return ("cid in (select id from cards where gid in %s)" %
ids2str(lim))

View file

@ -131,11 +131,15 @@ values(1,0,0,0,%(v)s,0,'',0,'','{}','','','{}');
import anki.deck
import anki.groups
if setDeckConf:
g = anki.groups.defaultTopConf.copy()
g['id'] = 1
g['name'] = _("Default")
g['conf'] = 1
g['mod'] = intTime()
db.execute("""
update deck set conf = ?, groups = ?, gconf = ?""",
simplejson.dumps(anki.deck.defaultConf),
simplejson.dumps({'1': {'name': _("Default"), 'conf': 1,
'mod': intTime()}}),
simplejson.dumps({'1': g}),
simplejson.dumps({'1': anki.groups.defaultConf}))
def _updateIndices(db):
@ -339,28 +343,29 @@ def _migrateDeckTbl(db):
insert or replace into deck select id, cast(created as int), :t,
:t, 99, 0, ifnull(syncName, ""), cast(lastSync as int),
"", "", "", "", "" from decks""", t=intTime())
# update selective study
# prepare a group to store the old deck options
import anki.groups
g = anki.groups.defaultTopConf.copy()
g['id'] = 1
g['name'] = _("Default")
g['conf'] = 1
g['mod'] = intTime()
# and deck conf
conf = anki.deck.defaultConf.copy()
# delete old selective study settings, which we can't auto-upgrade easily
keys = ("newActive", "newInactive", "revActive", "revInactive")
for k in keys:
db.execute("delete from deckVars where key=:k", k=k)
# copy other settings, ignoring deck order as there's a new default
conf['newSpread'] = db.scalar(
"select newCardSpacing from decks")
conf['newOrder'] = db.scalar(
"select newCardOrder from decks")
conf['newPerDay'] = db.scalar(
"select newCardsPerDay from decks")
# fetch remaining settings from decks table
data = {}
keys = ("sessionRepLimit", "sessionTimeLimit")
for k in keys:
conf[k] = db.scalar("select %s from decks" % k)
# random and due options merged
conf['revOrder'] = 2
g['newSpread'] = db.scalar("select newCardSpacing from decks")
g['newPerDay'] = db.scalar("select newCardsPerDay from decks")
g['repLim'] = db.scalar("select sessionRepLimit from decks")
g['timeLim'] = db.scalar("select sessionTimeLimit from decks")
# this needs to be placed in the model later on
conf['oldNewOrder'] = db.scalar("select newCardOrder from decks")
# no reverse option anymore
conf['newOrder'] = min(1, conf['newOrder'])
conf['oldNewOrder'] = min(1, conf['oldNewOrder'])
# add any deck vars and save
dkeys = ("hexCache", "cssCache")
for (k, v) in db.execute("select * from deckVars").fetchall():
@ -368,10 +373,9 @@ insert or replace into deck select id, cast(created as int), :t,
pass
else:
conf[k] = v
import anki.groups
db.execute("update deck set conf=:c,groups=:g,gconf=:gc",
c=simplejson.dumps(conf),
g=simplejson.dumps({'1': {'name': _("Default"), 'conf': 1}}),
g=simplejson.dumps({'1': g}),
gc=simplejson.dumps({'1': anki.groups.defaultConf}))
# clean up
db.execute("drop table decks")
@ -479,10 +483,12 @@ def _postSchemaUpgrade(deck):
"Handle the rest of the upgrade to 2.0."
import anki.deck
# make sure we have a current model id
deck.conf['currentModelId'] = deck.models.models.keys()[0]
# regenerate css
deck.models.setCurrent(deck.models.models.values()[0])
# regenerate css, and set new card order
for m in deck.models.all():
m['newOrder'] = deck.conf['oldNewOrder']
deck.models.save(m)
del deck.conf['oldNewOrder']
# fix creation time
deck.sched._updateCutoff()
d = datetime.datetime.today()
@ -521,7 +527,7 @@ update cards set due = cast(
((due-:stamp)/86400) as int)+:today where type = 2
""", stamp=deck.sched.dayCutoff, today=deck.sched.today)
# possibly re-randomize
if deck.randomNew():
if deck.models.randomNew():
deck.sched.randomizeCards()
# update insertion id
deck.conf['nextPos'] = deck.db.scalar("select max(id) from facts")+1

View file

@ -18,7 +18,7 @@ def test_genCards():
assert deck.cardCount() == 2
assert cards[0].due == f.id
# should work on random mode too
deck.conf['newOrder'] = NEW_CARDS_RANDOM
deck.models.current()['newOrder'] = NEW_CARDS_RANDOM
f = deck.newFact()
f['Front'] = u'1'
f['Back'] = u'2'
@ -74,6 +74,6 @@ def test_misc():
f['Back'] = u'2'
d.addFact(f)
c = f.cards()[0]
id = d.conf['currentModelId']
id = d.models.current()['id']
assert c.cssClass() == "cm%s-0" % hexifyID(id)
assert c.template()['ord'] == 0

View file

@ -137,13 +137,30 @@ def test_groups():
# it should have an id of 1
assert deck.groups.name(1)
# create a new group
g = deck.groups.id("new group")
assert g
parentId = deck.groups.id("new group")
assert parentId
assert len(deck.groups.groups) == 2
# should get the same id
assert deck.groups.id("new group") == g
assert deck.groups.id("new group") == parentId
# by default, everything should be shown
assert not deck.conf['groups']
assert not deck.groups.selected()
assert not deck.groups.active()
# and the default group is used
assert deck.groups.top()['id'] == 1
# we can select the default explicitly
deck.groups.select(1)
assert deck.groups.selected() == 1
assert deck.groups.active() == [1]
assert deck.groups.top()['id'] == 1
# let's create a child and select that
childId = deck.groups.id("new group::child")
deck.groups.select(childId)
assert deck.groups.selected() == childId
assert deck.groups.active() == [childId]
assert deck.groups.top()['id'] == parentId
# if we select the parent, the child gets included
deck.groups.select(parentId)
assert sorted(deck.groups.active()) == [parentId, childId]
def test_selective():
deck = getEmptyDeck()

View file

@ -10,7 +10,7 @@ def test_modelDelete():
f['Back'] = u'2'
deck.addFact(f)
assert deck.cardCount() == 1
deck.models.rem(deck.models.get(deck.conf['currentModelId']))
deck.models.rem(deck.models.current())
assert deck.cardCount() == 0
def test_modelCopy():
@ -109,7 +109,7 @@ def test_text():
def test_cloze():
d = getEmptyDeck()
d.conf['currentModelId'] = d.models.byName("Cloze")['id']
d.models.setCurrent(d.models.byName("Cloze"))
f = d.newFact()
assert f.model()['name'] == "Cloze"
# a cloze model with no clozes is empty

View file

@ -581,11 +581,11 @@ def test_ordcycle():
def test_counts():
d = getEmptyDeck()
# add a second group
assert d.groups.id("new group")
grp = d.groups.id("new group")
# for each card type
for type in range(3):
# and each of the groups
for gid in (1,2):
for gid in (1,grp):
# create a new fact
f = d.newFact()
f['Front'] = u"one"
@ -601,13 +601,9 @@ def test_counts():
# with the default settings, there's no count limit
assert d.sched.counts() == (2,2,2)
# check limit to one group
d.conf['groups'] = [1]
d.groups.select(1)
d.reset()
assert d.sched.counts() == (1,1,1)
# we don't need to build the queue to get the counts
assert d.sched.allCounts() == (2,2,2)
assert d.sched.selCounts() == (1,1,1)
assert d.sched.allCounts() == (2,2,2)
def test_counts2():
d = getEmptyDeck()

View file

@ -7,9 +7,8 @@ def test_op():
# should have no undo by default
assert not d.undoName()
# let's adjust a study option
assert d.conf['repLim'] == 0
d.save("studyopts")
d.conf['repLim'] = 10
d.conf['revOrder'] = 5
# it should be listed as undoable
assert d.undoName() == "studyopts"
# with about 5 minutes until it's clobbered
@ -17,7 +16,7 @@ def test_op():
# undoing should restore the old value
d.undo()
assert not d.undoName()
assert d.conf['repLim'] == 0
assert d.conf['revOrder'] != 5
# an (auto)save will clear the undo
d.save("foo")
assert d.undoName() == "foo"