# -*- coding: utf-8 -*- # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import pprint import re import time import os import random import stat import datetime import copy import traceback import json from anki.lang import _, ngettext from anki.utils import ids2str, fieldChecksum, \ intTime, splitFields, joinFields, maxID, devMode, stripHTMLMedia from anki.hooks import runFilter, runHook from anki.models import ModelManager from anki.media import MediaManager from anki.decks import DeckManager from anki.tags import TagManager from anki.consts import * from anki.errors import AnkiError from anki.sound import stripSounds import anki.latex # sets up hook import anki.cards import anki.notes import anki.template import anki.find defaultConf = { # review options 'activeDecks': [1], 'curDeck': 1, 'newSpread': NEW_CARDS_DISTRIBUTE, 'collapseTime': 1200, 'timeLim': 0, 'estTimes': True, 'dueCounts': True, # other config 'curModel': None, 'nextPos': 1, 'sortType': "noteFld", 'sortBackwards': False, 'addToCur': True, # add new to currently selected deck? 'dayLearnFirst': False, } # this is initialized by storage.Collection class _Collection: def __init__(self, db, server=False, log=False): self._debugLog = log self.db = db self.path = db._path self._openLog() self.log(self.path, anki.version) self.server = server self._lastSave = time.time() self.clearUndo() self.media = MediaManager(self, server) self.models = ModelManager(self) self.decks = DeckManager(self) self.tags = TagManager(self) self.load() if not self.crt: d = datetime.datetime.today() d -= datetime.timedelta(hours=4) d = datetime.datetime(d.year, d.month, d.day) d += datetime.timedelta(hours=4) self.crt = int(time.mktime(d.timetuple())) self._loadScheduler() if not self.conf.get("newBury", False): self.conf['newBury'] = True self.setMod() def name(self): n = os.path.splitext(os.path.basename(self.path))[0] return n # Scheduler ########################################################################## defaultSchedulerVersion = 1 supportedSchedulerVersions = (1, 2) def schedVer(self): ver = self.conf.get("schedVer", self.defaultSchedulerVersion) if ver in self.supportedSchedulerVersions: return ver else: raise Exception("Unsupported scheduler version") def _loadScheduler(self): ver = self.schedVer() if ver == 1: from anki.sched import Scheduler elif ver == 2: from anki.schedv2 import Scheduler self.sched = Scheduler(self) def changeSchedulerVer(self, ver): if ver == self.schedVer(): return if ver not in self.supportedSchedulerVersions: raise Exception("Unsupported scheduler version") self.modSchema(check=True) from anki.schedv2 import Scheduler v2Sched = Scheduler(self) if ver == 1: v2Sched.moveToV1() else: v2Sched.moveToV2() self.conf['schedVer'] = ver self.setMod() self._loadScheduler() # DB-related ########################################################################## def load(self): (self.crt, self.mod, self.scm, self.dty, # no longer used self._usn, self.ls, self.conf, models, decks, dconf, tags) = self.db.first(""" select crt, mod, scm, dty, usn, ls, conf, models, decks, dconf, tags from col""") self.conf = json.loads(self.conf) self.models.load(models) self.decks.load(decks, dconf) self.tags.load(tags) def setMod(self): """Mark DB modified. DB operations and the deck/tag/model managers do this automatically, so this is only necessary if you modify properties of this object or the conf dict.""" self.db.mod = True def flush(self, mod=None): "Flush state to DB, updating mod time." self.mod = intTime(1000) if mod is None else mod self.db.execute( """update col set crt=?, mod=?, scm=?, dty=?, usn=?, ls=?, conf=?""", self.crt, self.mod, self.scm, self.dty, self._usn, self.ls, json.dumps(self.conf)) def save(self, name=None, mod=None): "Flush, commit DB, and take out another write lock." # let the managers conditionally flush self.models.flush() self.decks.flush() self.tags.flush() # and flush deck + bump mod if db has been changed if self.db.mod: self.flush(mod=mod) self.db.commit() self.lock() self.db.mod = False self._markOp(name) self._lastSave = time.time() def autosave(self): "Save if 5 minutes has passed since last save. True if saved." if time.time() - self._lastSave > 300: self.save() return True def lock(self): # make sure we don't accidentally bump mod time mod = self.db.mod self.db.execute("update col set mod=mod") self.db.mod = mod def close(self, save=True): "Disconnect from DB." if self.db: if save: self.save() else: self.db.rollback() if not self.server: self.db.setAutocommit(True) self.db.execute("pragma journal_mode = delete") self.db.setAutocommit(False) self.db.close() self.db = None self.media.close() self._closeLog() def reopen(self): "Reconnect to DB (after changing threads, etc)." import anki.db if not self.db: self.db = anki.db.DB(self.path) self.media.connect() self._openLog() def rollback(self): self.db.rollback() self.load() self.lock() def modSchema(self, check): "Mark schema modified. Call this first so user can abort if necessary." if not self.schemaChanged(): if check and not runFilter("modSchema", True): raise AnkiError("abortSchemaMod") self.scm = intTime(1000) self.setMod() def schemaChanged(self): "True if schema changed since last sync." return self.scm > self.ls def usn(self): return self._usn if self.server else -1 def beforeUpload(self): "Called before a full upload." tbls = "notes", "cards", "revlog" for t in tbls: self.db.execute("update %s set usn=0 where usn=-1" % t) # we can save space by removing the log of deletions self.db.execute("delete from graves") self._usn += 1 self.models.beforeUpload() self.tags.beforeUpload() self.decks.beforeUpload() self.modSchema(check=False) self.ls = self.scm # ensure db is compacted before upload self.db.setAutocommit(True) self.db.execute("vacuum") self.db.execute("analyze") self.close() # Object creation helpers ########################################################################## def getCard(self, id): return anki.cards.Card(self, id) def getNote(self, id): return anki.notes.Note(self, id=id) # Utils ########################################################################## def nextID(self, type, inc=True): type = "next"+type.capitalize() id = self.conf.get(type, 1) if inc: self.conf[type] = id+1 return id def reset(self): "Rebuild the queue and reload data after DB modified." self.sched.reset() # Deletion logging ########################################################################## def _logRem(self, ids, type): self.db.executemany("insert into graves values (%d, ?, %d)" % ( self.usn(), type), ([x] for x in ids)) # Notes ########################################################################## def noteCount(self): return self.db.scalar("select count() from notes") def newNote(self, forDeck=True): "Return a new note with the current model." return anki.notes.Note(self, self.models.current(forDeck)) def addNote(self, note): "Add a note to the collection. Return number of new cards." # check we have card models available, then save cms = self.findTemplates(note) if not cms: return 0 note.flush() # deck conf governs which of these are used due = self.nextID("pos") # add cards ncards = 0 for template in cms: self._newCard(note, template, due) ncards += 1 return ncards def remNotes(self, ids): self.remCards(self.db.list("select id from cards where nid in "+ ids2str(ids))) def _remNotes(self, ids): "Bulk delete notes by ID. Don't call this directly." if not ids: return strids = ids2str(ids) # we need to log these independently of cards, as one side may have # more card templates runHook("remNotes", self, ids) self._logRem(ids, REM_NOTE) self.db.execute("delete from notes where id in %s" % strids) # Card creation ########################################################################## def findTemplates(self, note): "Return (active), non-empty templates." model = note.model() avail = self.models.availOrds(model, joinFields(note.fields)) return self._tmplsFromOrds(model, avail) def _tmplsFromOrds(self, model, avail): ok = [] if model['type'] == MODEL_STD: for t in model['tmpls']: if t['ord'] in avail: ok.append(t) else: # cloze - generate temporary templates from first for ord in avail: t = copy.copy(model['tmpls'][0]) t['ord'] = ord ok.append(t) return ok def genCards(self, nids): "Generate cards for non-empty templates, return ids to remove." # build map of (nid,ord) so we don't create dupes snids = ids2str(nids) have = {} dids = {} dues = {} for id, nid, ord, did, due, odue, odid in self.db.execute( "select id, nid, ord, did, due, odue, odid from cards where nid in "+snids): # existing cards if nid not in have: have[nid] = {} have[nid][ord] = id # if in a filtered deck, add new cards to original deck if odid != 0: did = odid # and their dids if nid in dids: if dids[nid] and dids[nid] != did: # cards are in two or more different decks; revert to # model default dids[nid] = None else: # first card or multiple cards in same deck dids[nid] = did # save due if odid != 0: due = odue if nid not in dues: dues[nid] = due # build cards for each note data = [] ts = maxID(self.db) now = intTime() rem = [] usn = self.usn() for nid, mid, flds in self.db.execute( "select id, mid, flds from notes where id in "+snids): model = self.models.get(mid) avail = self.models.availOrds(model, flds) did = dids.get(nid) or model['did'] due = dues.get(nid) # add any missing cards for t in self._tmplsFromOrds(model, avail): doHave = nid in have and t['ord'] in have[nid] if not doHave: # check deck is not a cram deck did = t['did'] or did if self.decks.isDyn(did): did = 1 # if the deck doesn't exist, use default instead did = self.decks.get(did)['id'] # use sibling due# if there is one, else use a new id if due is None: due = self.nextID("pos") data.append((ts, nid, did, t['ord'], now, usn, due)) ts += 1 # note any cards that need removing if nid in have: for ord, id in list(have[nid].items()): if ord not in avail: rem.append(id) # bulk update self.db.executemany(""" insert into cards values (?,?,?,?,?,?,0,0,?,0,0,0,0,0,0,0,0,"")""", data) return rem # type 0 - when previewing in add dialog, only non-empty # type 1 - when previewing edit, only existing # type 2 - when previewing in models dialog, all templates def previewCards(self, note, type=0, did=None): if type == 0: cms = self.findTemplates(note) elif type == 1: cms = [c.template() for c in note.cards()] else: cms = note.model()['tmpls'] if not cms: return [] cards = [] for template in cms: cards.append(self._newCard(note, template, 1, flush=False, did=did)) return cards def _newCard(self, note, template, due, flush=True, did=None): "Create a new card." card = anki.cards.Card(self) card.nid = note.id card.ord = template['ord'] card.did = self.db.scalar("select did from cards where nid = ? and ord = ?", card.nid, card.ord) # Use template did (deck override) if valid, otherwise did in argument, otherwise model did if not card.did: if template['did'] and str(template['did']) in self.decks.decks: card.did = template['did'] elif did: card.did = did else: card.did = note.model()['did'] # if invalid did, use default instead deck = self.decks.get(card.did) if deck['dyn']: # must not be a filtered deck card.did = 1 else: card.did = deck['id'] card.due = self._dueForDid(card.did, due) if flush: card.flush() return card def _dueForDid(self, did, due): conf = self.decks.confForDid(did) # in order due? if conf['new']['order'] == NEW_CARDS_DUE: return due else: # random mode; seed with note ts so all cards of this note get the # same random number r = random.Random() r.seed(due) return r.randrange(1, max(due, 1000)) # Cards ########################################################################## def isEmpty(self): return not self.db.scalar("select 1 from cards limit 1") def cardCount(self): return self.db.scalar("select count() from cards") def remCards(self, ids, notes=True): "Bulk delete cards by ID." if not ids: return sids = ids2str(ids) nids = self.db.list("select nid from cards where id in "+sids) # remove cards self._logRem(ids, REM_CARD) self.db.execute("delete from cards where id in "+sids) # then notes if not notes: return nids = self.db.list(""" select id from notes where id in %s and id not in (select nid from cards)""" % ids2str(nids)) self._remNotes(nids) def emptyCids(self): rem = [] for m in self.models.all(): rem += self.genCards(self.models.nids(m)) return rem def emptyCardReport(self, cids): rep = "" for ords, cnt, flds in self.db.all(""" select group_concat(ord+1), count(), flds from cards c, notes n where c.nid = n.id and c.id in %s group by nid""" % ids2str(cids)): rep += _("Empty card numbers: %(c)s\nFields: %(f)s\n\n") % dict( c=ords, f=flds.replace("\x1f", " / ")) return rep # Field checksums and sorting fields ########################################################################## def _fieldData(self, snids): return self.db.execute( "select id, mid, flds from notes where id in "+snids) def updateFieldCache(self, nids): "Update field checksums and sort cache, after find&replace, etc." snids = ids2str(nids) r = [] for (nid, mid, flds) in self._fieldData(snids): fields = splitFields(flds) model = self.models.get(mid) if not model: # note points to invalid model continue r.append((stripHTMLMedia(fields[self.models.sortIdx(model)]), fieldChecksum(fields[0]), nid)) # apply, relying on calling code to bump usn+mod self.db.executemany("update notes set sfld=?, csum=? where id=?", r) # Q/A generation ########################################################################## def renderQA(self, ids=None, type="card"): # gather metadata if type == "card": where = "and c.id in " + ids2str(ids) elif type == "note": where = "and f.id in " + ids2str(ids) elif type == "model": where = "and m.id in " + ids2str(ids) elif type == "all": where = "" else: raise Exception() return [self._renderQA(row) for row in self._qaData(where)] def _renderQA(self, data, qfmt=None, afmt=None): "Returns hash of id, question, answer." # data is [cid, nid, mid, did, ord, tags, flds, cardFlags] # unpack fields and create dict flist = splitFields(data[6]) fields = {} model = self.models.get(data[2]) for (name, (idx, conf)) in list(self.models.fieldMap(model).items()): fields[name] = flist[idx] fields['Tags'] = data[5].strip() fields['Type'] = model['name'] fields['Deck'] = self.decks.name(data[3]) fields['Subdeck'] = fields['Deck'].split('::')[-1] fields['CardFlag'] = self._flagNameFromCardFlags(data[7]) if model['type'] == MODEL_STD: template = model['tmpls'][data[4]] else: template = model['tmpls'][0] fields['Card'] = template['name'] fields['c%d' % (data[4]+1)] = "1" # render q & a d = dict(id=data[0]) qfmt = qfmt or template['qfmt'] afmt = afmt or template['afmt'] for (type, format) in (("q", qfmt), ("a", afmt)): if type == "q": format = re.sub("{{(?!type:)(.*?)cloze:", r"{{\1cq-%d:" % (data[4]+1), format) format = format.replace("<%cloze:", "<%%cq:%d:" % ( data[4]+1)) else: format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (data[4]+1), format) format = format.replace("<%cloze:", "<%%ca:%d:" % ( data[4]+1)) fields['FrontSide'] = stripSounds(d['q']) fields = runFilter("mungeFields", fields, model, data, self) html = anki.template.render(format, fields) d[type] = runFilter( "mungeQA", html, type, fields, model, data, self) # empty cloze? if type == 'q' and model['type'] == MODEL_CLOZE: if not self.models._availClozeOrds(model, data[6], False): d['q'] += ("

" + _( "Please edit this note and add some cloze deletions. (%s)") % ( "%s" % (HELP_SITE, _("help")))) return d def _qaData(self, where=""): "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" return self.db.execute(""" select c.id, f.id, f.mid, c.did, c.ord, f.tags, f.flds, c.flags from cards c, notes f where c.nid == f.id %s""" % where) def _flagNameFromCardFlags(self, flags): flag = flags & 0b111 if not flag: return "" return "flag%d" % flag # Finding cards ########################################################################## def findCards(self, query, order=False): return anki.find.Finder(self).findCards(query, order) def findNotes(self, query): return anki.find.Finder(self).findNotes(query) def findReplace(self, nids, src, dst, regex=None, field=None, fold=True): return anki.find.findReplace(self, nids, src, dst, regex, field, fold) def findDupes(self, fieldName, search=""): return anki.find.findDupes(self, fieldName, search) # Stats ########################################################################## def cardStats(self, card): from anki.stats import CardStats return CardStats(self, card).report() def stats(self): from anki.stats import CollectionStats return CollectionStats(self) # Timeboxing ########################################################################## def startTimebox(self): self._startTime = time.time() self._startReps = self.sched.reps def timeboxReached(self): "Return (elapsedTime, reps) if timebox reached, or False." if not self.conf['timeLim']: # timeboxing disabled return False elapsed = time.time() - self._startTime if elapsed > self.conf['timeLim']: return (self.conf['timeLim'], self.sched.reps - self._startReps) # Undo ########################################################################## def clearUndo(self): # [type, undoName, data] # type 1 = review; type 2 = checkpoint self._undo = None def undoName(self): "Undo menu item name, or None if undo unavailable." if not self._undo: return None return self._undo[1] def undo(self): if self._undo[0] == 1: return self._undoReview() else: self._undoOp() def markReview(self, card): old = [] if self._undo: if self._undo[0] == 1: old = self._undo[2] self.clearUndo() wasLeech = card.note().hasTag("leech") or False self._undo = [1, _("Review"), old + [copy.copy(card)], wasLeech] def _undoReview(self): data = self._undo[2] wasLeech = self._undo[3] c = data.pop() if not data: self.clearUndo() # remove leech tag if it didn't have it before if not wasLeech and c.note().hasTag("leech"): c.note().delTag("leech") c.note().flush() # write old data c.flush() # and delete revlog entry last = self.db.scalar( "select id from revlog where cid = ? " "order by id desc limit 1", c.id) self.db.execute("delete from revlog where id = ?", last) # restore any siblings self.db.execute( "update cards set queue=type,mod=?,usn=? where queue=-2 and nid=?", intTime(), self.usn(), c.nid) # and finally, update daily counts n = 1 if c.queue == 3 else c.queue type = ("new", "lrn", "rev")[n] self.sched._updateStats(c, type, -1) self.sched.reps -= 1 return c.id def _markOp(self, name): "Call via .save()" if name: self._undo = [2, name] else: # saving disables old checkpoint, but not review undo if self._undo and self._undo[0] == 2: self.clearUndo() def _undoOp(self): self.rollback() self.clearUndo() # DB maintenance ########################################################################## def basicCheck(self): "Basic integrity check for syncing. True if ok." # cards without notes if self.db.scalar(""" select 1 from cards where nid not in (select id from notes) limit 1"""): return # notes without cards or models if self.db.scalar(""" select 1 from notes where id not in (select distinct nid from cards) or mid not in %s limit 1""" % ids2str(self.models.ids())): return # invalid ords for m in self.models.all(): # ignore clozes if m['type'] != MODEL_STD: continue if self.db.scalar(""" select 1 from cards where ord not in %s and nid in ( select id from notes where mid = ?) limit 1""" % ids2str([t['ord'] for t in m['tmpls']]), m['id']): return return True def fixIntegrity(self): "Fix possible problems and rebuild caches." problems = [] self.save() oldSize = os.stat(self.path)[stat.ST_SIZE] if self.db.scalar("pragma integrity_check") != "ok": return (_("Collection is corrupt. Please see the manual."), False) # note types with a missing model ids = self.db.list(""" select id from notes where mid not in """ + ids2str(self.models.ids())) if ids: problems.append( ngettext("Deleted %d note with missing note type.", "Deleted %d notes with missing note type.", len(ids)) % len(ids)) self.remNotes(ids) # for each model for m in self.models.all(): for t in m['tmpls']: if t['did'] == "None": t['did'] = None problems.append(_("Fixed AnkiDroid deck override bug.")) self.models.save(m) if m['type'] == MODEL_STD: # model with missing req specification if 'req' not in m: self.models._updateRequired(m) problems.append(_("Fixed note type: %s") % m['name']) # cards with invalid ordinal ids = self.db.list(""" select id from cards where ord not in %s and nid in ( select id from notes where mid = ?)""" % ids2str([t['ord'] for t in m['tmpls']]), m['id']) if ids: problems.append( ngettext("Deleted %d card with missing template.", "Deleted %d cards with missing template.", len(ids)) % len(ids)) self.remCards(ids) # notes with invalid field count ids = [] for id, flds in self.db.execute( "select id, flds from notes where mid = ?", m['id']): if (flds.count("\x1f") + 1) != len(m['flds']): ids.append(id) if ids: problems.append( ngettext("Deleted %d note with wrong field count.", "Deleted %d notes with wrong field count.", len(ids)) % len(ids)) self.remNotes(ids) # delete any notes with missing cards ids = self.db.list(""" select id from notes where id not in (select distinct nid from cards)""") if ids: cnt = len(ids) problems.append( ngettext("Deleted %d note with no cards.", "Deleted %d notes with no cards.", cnt) % cnt) self._remNotes(ids) # cards with missing notes ids = self.db.list(""" select id from cards where nid not in (select id from notes)""") if ids: cnt = len(ids) problems.append( ngettext("Deleted %d card with missing note.", "Deleted %d cards with missing note.", cnt) % cnt) self.remCards(ids) # cards with odue set when it shouldn't be ids = self.db.list(""" select id from cards where odue > 0 and (type=1 or queue=2) and not odid""") if ids: cnt = len(ids) problems.append( ngettext("Fixed %d card with invalid properties.", "Fixed %d cards with invalid properties.", cnt) % cnt) self.db.execute("update cards set odue=0 where id in "+ ids2str(ids)) # cards with odid set when not in a dyn deck dids = [id for id in self.decks.allIds() if not self.decks.isDyn(id)] ids = self.db.list(""" select id from cards where odid > 0 and did in %s""" % ids2str(dids)) if ids: cnt = len(ids) problems.append( ngettext("Fixed %d card with invalid properties.", "Fixed %d cards with invalid properties.", cnt) % cnt) self.db.execute("update cards set odid=0, odue=0 where id in "+ ids2str(ids)) # tags self.tags.registerNotes() # field cache for m in self.models.all(): self.updateFieldCache(self.models.nids(m)) # new cards can't have a due position > 32 bits self.db.execute(""" update cards set due = 1000000, mod = ?, usn = ? where due > 1000000 and type = 0""", intTime(), self.usn()) # new card position self.conf['nextPos'] = self.db.scalar( "select max(due)+1 from cards where type = 0") or 0 # reviews should have a reasonable due # ids = self.db.list( "select id from cards where queue = 2 and due > 100000") if ids: problems.append("Reviews had incorrect due date.") self.db.execute( "update cards set due = ?, ivl = 1, mod = ?, usn = ? where id in %s" % ids2str(ids), self.sched.today, intTime(), self.usn()) # v2 sched had a bug that could create decimal intervals curs = self.db.cursor() curs.execute("update cards set ivl=round(ivl),due=round(due) where ivl!=round(ivl) or due!=round(due)") if curs.rowcount: problems.append("Fixed %d cards with v2 scheduler bug." % curs.rowcount) curs.execute("update revlog set ivl=round(ivl),lastIvl=round(lastIvl) where ivl!=round(ivl) or lastIvl!=round(lastIvl)") if curs.rowcount: problems.append("Fixed %d review history entries with v2 scheduler bug." % curs.rowcount) # and finally, optimize self.optimize() newSize = os.stat(self.path)[stat.ST_SIZE] txt = _("Database rebuilt and optimized.") ok = not problems problems.append(txt) # if any problems were found, force a full sync if not ok: self.modSchema(check=False) self.save() return ("\n".join(problems), ok) def optimize(self): self.db.setAutocommit(True) self.db.execute("vacuum") self.db.execute("analyze") self.db.setAutocommit(False) self.lock() # Logging ########################################################################## def log(self, *args, **kwargs): if not self._debugLog: return def customRepr(x): if isinstance(x, str): return x return pprint.pformat(x) path, num, fn, y = traceback.extract_stack( limit=2+kwargs.get("stack", 0))[0] buf = "[%s] %s:%s(): %s" % (intTime(), os.path.basename(path), fn, ", ".join([customRepr(x) for x in args])) self._logHnd.write(buf + "\n") if devMode: print(buf) def _openLog(self): if not self._debugLog: return lpath = re.sub(r"\.anki2$", ".log", self.path) if os.path.exists(lpath) and os.path.getsize(lpath) > 10*1024*1024: lpath2 = lpath + ".old" if os.path.exists(lpath2): os.unlink(lpath2) os.rename(lpath, lpath2) self._logHnd = open(lpath, "a", encoding="utf8") def _closeLog(self): if not self._debugLog: return self._logHnd.close() self._logHnd = None # Card Flags ########################################################################## def setUserFlag(self, flag, cids): assert 0 <= flag <= 7 self.db.execute("update cards set flags = (flags & ~?) | ?, usn=?, mod=? where id in %s" % ids2str(cids), 0b111, flag, self._usn, intTime())