diff --git a/anki/deck.py b/anki/deck.py index 17f9e99a8..32a080980 100644 --- a/anki/deck.py +++ b/anki/deck.py @@ -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'MS 明朝',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 ########################################################################## diff --git a/anki/groups.py b/anki/groups.py index d75e3e397..1a27bbe0e 100644 --- a/anki/groups.py +++ b/anki/groups.py @@ -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 diff --git a/anki/models.py b/anki/models.py index d94815890..d59a533b3 100644 --- a/anki/models.py +++ b/anki/models.py @@ -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 ################################################## diff --git a/anki/sched.py b/anki/sched.py index a4ac45fb5..188f5c3d2 100644 --- a/anki/sched.py +++ b/anki/sched.py @@ -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 ( "

"+_("Congratulations!")+"

"+ - self._finishedSubtitle()+ + _("You have finished the selected groups for now.") + "

"+ 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) diff --git a/anki/stats.py b/anki/stats.py index 0ef63b67f..7668e4f67 100644 --- a/anki/stats.py +++ b/anki/stats.py @@ -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)) diff --git a/anki/storage.py b/anki/storage.py index 1a9d99021..2204f9074 100644 --- a/anki/storage.py +++ b/anki/storage.py @@ -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 diff --git a/tests/test_cards.py b/tests/test_cards.py index 0ee8f5209..5bfd16208 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -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 diff --git a/tests/test_deck.py b/tests/test_deck.py index 8f47f8629..3a21e256d 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -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() diff --git a/tests/test_models.py b/tests/test_models.py index 6d00eccf6..82b4c8270 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_sched.py b/tests/test_sched.py index 0bafdef5b..390ce67b3 100644 --- a/tests/test_sched.py +++ b/tests/test_sched.py @@ -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() diff --git a/tests/test_undo.py b/tests/test_undo.py index dd909fc6a..8150d7ebe 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -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"