class _Collection: db: Optional[DBProxy] sched: Union[V1Scheduler, V2Scheduler] crt: int mod: int scm: int dty: bool # no longer used _usn: int ls: int _undo: List[Any] def __init__( self, db: DBProxy, backend: RustBackend, server: Optional["anki.storage.ServerData"] = None, ) -> None: self.backend = backend self._debugLog = not server 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 is not None) self.models = ModelManager(self) self.decks = DeckManager(self) self.tags = TagManager(self) self.conf = ConfigManager(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) -> Any: n = os.path.splitext(os.path.basename(self.path))[0] return n def tr(self, key: TR, **kwargs: Union[str, int, float]) -> str: return self.backend.translate(key, **kwargs) def weakref(self) -> anki.storage._Collection: "Shortcut to create a weak reference that doesn't break code completion." return weakref.proxy(self) # Scheduler ########################################################################## supportedSchedulerVersions = (1, 2) def schedVer(self) -> Any: ver = self.conf.get("schedVer", 1) if ver in self.supportedSchedulerVersions: return ver else: raise Exception("Unsupported scheduler version") def _loadScheduler(self) -> None: ver = self.schedVer() if ver == 1: self.sched = V1Scheduler(self) elif ver == 2: self.sched = V2Scheduler(self) def changeSchedulerVer(self, ver: int) -> None: if ver == self.schedVer(): return if ver not in self.supportedSchedulerVersions: raise Exception("Unsupported scheduler version") self.modSchema(check=True) self.clearUndo() v2Sched = V2Scheduler(self) if ver == 1: v2Sched.moveToV1() else: v2Sched.moveToV2() self.conf["schedVer"] = ver self.setMod() self._loadScheduler() # the sync code uses this to send the local timezone to AnkiWeb def localOffset(self) -> Optional[int]: "Minutes west of UTC. Only applies to V2 scheduler." if isinstance(self.sched, V1Scheduler): return None else: return self.backend.local_minutes_west(intTime()) # DB-related ########################################################################## def load(self) -> None: ( self.crt, self.mod, self.scm, self.dty, # no longer used self._usn, self.ls, decks, ) = self.db.first(""" select crt, mod, scm, dty, usn, ls, decks from col""") self.decks.decks = self.backend.get_all_decks() self.decks.changed = False self.models.models = self.backend.get_all_notetypes() self.models.changed = False def setMod(self) -> None: """Mark DB modified. DB operations and the deck/model managers do this automatically, so this is only necessary if you modify properties of this object.""" self.db.mod = True def flush(self, mod: Optional[int] = None) -> 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=?""", self.crt, self.mod, self.scm, self.dty, self._usn, self.ls, ) def flush_all_changes(self, mod: Optional[int] = None): self.models.flush() self.decks.flush() # set mod flag if mtime changed by backend if self.db.scalar("select mod from col") != self.mod: self.db.mod = True if self.db.mod: self.flush(mod) def save(self, name: Optional[str] = None, mod: Optional[int] = None, trx: bool = True) -> None: "Flush, commit DB, and take out another write lock if trx=True." self.flush_all_changes(mod) # and flush deck + bump mod if db has been changed if self.db.mod: self.db.commit() self.db.mod = False if trx: self.db.begin() elif not trx: # if no changes were pending but calling code expects to be # outside of a transaction, we need to roll back self.db.rollback() self._markOp(name) self._lastSave = time.time() def autosave(self) -> Optional[bool]: "Save if 5 minutes has passed since last save. True if saved." if time.time() - self._lastSave > 300: self.save() return True return None def close(self, save: bool = True, downgrade: bool = False) -> None: "Disconnect from DB." if self.db: if save: self.save(trx=False) else: self.db.rollback() self.backend.close_collection(downgrade=downgrade) self.db = None self.media.close() self._closeLog() def rollback(self) -> None: self.db.rollback() self.db.begin() self.load() def modSchema(self, check: bool) -> None: "Mark schema modified. Call this first so user can abort if necessary." if not self.schemaChanged(): if check and not hooks.schema_will_change(proceed=True): raise AnkiError("abortSchemaMod") self.scm = intTime(1000) self.setMod() self.save() def schemaChanged(self) -> Any: "True if schema changed since last sync." return self.scm > self.ls def usn(self) -> Any: return self._usn if self.server else -1 def beforeUpload(self) -> None: "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.decks.beforeUpload() self.backend.before_upload() self.modSchema(check=False) self.ls = self.scm # ensure db is compacted before upload self.save(trx=False) self.db.execute("vacuum") self.db.execute("analyze") self.close(save=False, downgrade=True) # Object creation helpers ########################################################################## def getCard(self, id: int) -> Card: return Card(self, id) def getNote(self, id: int) -> Note: return Note(self, id=id) # Utils ########################################################################## def nextID(self, type: str, inc: bool = True) -> Any: type = "next" + type.capitalize() id = self.conf.get(type, 1) if inc: self.conf[type] = id + 1 return id def reset(self) -> None: "Rebuild the queue and reload data after DB modified." self.sched.reset() # Deletion logging ########################################################################## def _logRem(self, ids: List[int], type: int) -> None: self.db.executemany( "insert into graves values (%d, ?, %d)" % (self.usn(), type), ([x] for x in ids), ) # Notes ########################################################################## def noteCount(self) -> Any: return self.db.scalar("select count() from notes") def newNote(self, forDeck: bool = True) -> Note: "Return a new note with the current model." return Note(self, self.models.current(forDeck)) def addNote(self, note: Note) -> int: """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: Iterable[int]) -> None: """Deletes notes with the given IDs.""" self.remCards( self.db.list("select id from cards where nid in " + ids2str(ids))) def _remNotes(self, ids: List[int]) -> None: """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 hooks.notes_will_be_deleted(self, ids) self._logRem(ids, REM_NOTE) self.db.execute("delete from notes where id in %s" % strids) # Card creation ########################################################################## def findTemplates(self, note: Note) -> List: "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: NoteType, avail: List[int]) -> List: 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: List[int]) -> List[int]: "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: Dict[int, Dict[int, int]] = {} dids: Dict[int, Optional[int]] = {} dues: Dict[int, int] = {} for id, nid, ord, did, due, odue, odid, type in self.db.execute( "select id, nid, ord, did, due, odue, odid, type 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 and type == 0: # Add due to new card only if it's the due of a new sibling 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) assert model 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 is no longer used def previewCards(self, note: Note, type: int = 0, did: Optional[int] = None) -> List: existing_cards = {} for card in note.cards(): existing_cards[card.ord] = card all_cards = [] for idx, template in enumerate(note.model()["tmpls"]): if idx in existing_cards: all_cards.append(existing_cards[idx]) else: # card not currently in database, generate an ephemeral one all_cards.append( self._newCard(note, template, 1, flush=False, did=did)) return all_cards def _newCard( self, note: Note, template: Template, due: int, flush: bool = True, did: Optional[int] = None, ) -> Card: "Create a new card." card = Card(self) card.nid = note.id card.ord = template["ord"] # type: ignore 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 = int(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) assert deck 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: int, due: int) -> int: 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) -> bool: return not self.db.scalar("select 1 from cards limit 1") def cardCount(self) -> Any: return self.db.scalar("select count() from cards") def remCards(self, ids: List[int], notes: bool = True) -> None: "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) -> List[int]: """Returns IDs of empty cards.""" rem: List[int] = [] for m in self.models.all(): rem += self.genCards(self.models.nids(m)) return rem def emptyCardReport(self, cids) -> str: 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 += self.tr( TR.EMPTY_CARDS_CARD_LINE, **{ "card-numbers": ords, "fields": flds.replace("\x1f", " / ") }, ) rep += "\n\n" return rep # Field checksums and sorting fields ########################################################################## def _fieldData(self, snids: str) -> Any: return self.db.execute("select id, mid, flds from notes where id in " + snids) def updateFieldCache(self, nids: List[int]) -> None: "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) # Finding cards ########################################################################## # if order=True, use the sort order stored in the collection config # if order=False, do no ordering # # if order is a string, that text is added after 'order by' in the sql statement. # you must add ' asc' or ' desc' to the order, as Anki will replace asc with # desc and vice versa when reverse is set in the collection config, eg # order="c.ivl asc, c.due desc" # # if order is an int enum, sort using that builtin sort, eg # col.find_cards("", order=BuiltinSortKind.CARD_DUE) # the reverse argument only applies when a BuiltinSortKind is provided; # otherwise the collection config defines whether reverse is set or not def find_cards( self, query: str, order: Union[bool, str, int] = False, reverse: bool = False, ) -> Sequence[int]: self.flush_all_changes() return self.backend.search_cards(query, order, reverse) def find_notes(self, query: str) -> Sequence[int]: self.flush_all_changes() return self.backend.search_notes(query) def findReplace( self, nids: List[int], src: str, dst: str, regex: Optional[bool] = None, field: Optional[str] = None, fold: bool = True, ) -> int: return anki.find.findReplace(self, nids, src, dst, regex, field, fold) def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: return anki.find.findDupes(self, fieldName, search) findCards = find_cards findNotes = find_notes # Config ########################################################################## def get_config(self, key: str, default: Any = None) -> Any: try: return self.conf.get_immutable(key) except KeyError: return default def set_config(self, key: str, val: Any): self.setMod() self.conf.set(key, val) def remove_config(self, key): self.setMod() self.conf.remove(key) # Stats ########################################################################## def cardStats(self, card: Card) -> str: from anki.stats import CardStats return CardStats(self, card).report() def stats(self) -> "anki.stats.CollectionStats": from anki.stats import CollectionStats return CollectionStats(self) # Timeboxing ########################################################################## def startTimebox(self) -> None: self._startTime = time.time() self._startReps = self.sched.reps # FIXME: Use Literal[False] when on Python 3.8 def timeboxReached(self) -> Union[bool, Tuple[Any, int]]: "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) return False # Undo ########################################################################## def clearUndo(self) -> None: # [type, undoName, data] # type 1 = review; type 2 = checkpoint self._undo = None def undoName(self) -> Any: "Undo menu item name, or None if undo unavailable." if not self._undo: return None return self._undo[1] def undo(self) -> Any: if self._undo[0] == 1: return self._undoReview() else: self._undoOp() def markReview(self, card: Card) -> None: old: List[Any] = [] 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) -> Any: data = self._undo[2] wasLeech = self._undo[3] c = data.pop() # pytype: disable=attribute-error 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: Optional[str]) -> None: "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) -> None: self.rollback() self.clearUndo() # DB maintenance ########################################################################## def basicCheck(self) -> bool: "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 False # 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 False # 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 False return True def fixIntegrity(self) -> Tuple[str, bool]: """Fix possible problems and rebuild caches. Returns tuple of (error: str, ok: bool). 'ok' will be true if no problems were found. """ problems = [] # problems that don't require a full sync syncable_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, updateReqs=False) 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)) # notes with non-normalized tags cnt = self._normalize_tags() if cnt > 0: syncable_problems.append( self.tr(TR.DATABASE_CHECK_FIXED_NON_NORMALIZED_TAGS, count=cnt)) # 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, so wrap items over # 2 million back to 1 million self.db.execute( """ update cards set due=1000000+due%1000000,mod=?,usn=? where due>=1000000 and type=0""", intTime(), self.usn(), ) rowcount = self.db.scalar("select changes()") if rowcount: syncable_problems.append( "Found %d new cards with a due number >= 1,000,000 - consider repositioning them in the Browse screen." % rowcount) # 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 self.db.execute( "update cards set ivl=round(ivl),due=round(due) where ivl!=round(ivl) or due!=round(due)" ) rowcount = self.db.scalar("select changes()") if rowcount: problems.append("Fixed %d cards with v2 scheduler bug." % rowcount) self.db.execute( "update revlog set ivl=round(ivl),lastIvl=round(lastIvl) where ivl!=round(ivl) or lastIvl!=round(lastIvl)" ) rowcount = self.db.scalar("select changes()") if rowcount: problems.append( "Fixed %d review history entries with v2 scheduler bug." % rowcount) # models if self.models.ensureNotEmpty(): problems.append("Added missing note type.") # 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() problems.extend(syncable_problems) return ("\n".join(problems), ok) def _normalize_tags(self) -> int: to_fix = [] for id, tags in self.db.execute("select id, tags from notes"): norm = unicodedata.normalize("NFC", tags) if not norm.startswith(" ") or not norm.endswith(" "): norm = " " + norm + " " if norm != tags: to_fix.append((norm, self.usn(), intTime(), id)) if to_fix: self.db.executemany( "update notes set tags=?, usn=?, mod=? where id=?", to_fix) return len(to_fix) def optimize(self) -> None: self.save(trx=False) self.db.execute("vacuum") self.db.execute("analyze") self.db.begin() # Logging ########################################################################## def log(self, *args, **kwargs) -> None: 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) -> None: 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) -> None: if not self._debugLog: return self._logHnd.close() self._logHnd = None # Card Flags ########################################################################## def setUserFlag(self, flag: int, cids: List[int]) -> None: 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(), )
class _Collection(object): 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.models = ModelManager(self) self.decks = DeckManager(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.sched = Scheduler(self) 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 # 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) 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." if time.time() - self._lastSave > 300: self.save() 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.rollback() if not self.server: self.db.execute("pragma journal_mode = delete") 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.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 = {} for id, nid, ord, did in self.db.execute( "select id, nid, ord, did from cards where nid in "+snids): # existing cards if nid not in have: have[nid] = {} have[nid][ord] = id # 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 # 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'] # 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'] # we'd like to use the same due# as sibling cards, but we # can't retrieve that quickly, so we give it a new id # instead data.append((ts, nid, did, t['ord'], now, usn, self.nextID("pos"))) ts += 1 # note any cards that need removing if nid in have: for ord, id in 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): 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)) return cards def _newCard(self, note, template, due, flush=True): "Create a new card." card = anki.cards.Card(self) card.nid = note.id card.ord = template['ord'] # Use template did (deck override) if valid, otherwise model did if template['did'] and unicode(template['did']) in self.decks.decks: card.did = template['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((stripHTML(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] # unpack fields and create dict flist = splitFields(data[6]) fields = {} model = self.models.get(data[2]) for (name, (idx, conf)) in 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] 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'] += ("<p>" + _( "Please edit this note and add some cloze deletions. (%s)") % ( "<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))) return d def _qaData(self, where=""): "Return [cid, nid, mid, did, ord, tags, flds] db query" return self.db.execute(""" select c.id, f.id, f.mid, c.did, c.ord, f.tags, f.flds from cards c, notes f where c.nid == f.id %s""" % where) # 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 queue = 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 > 10000") if ids: problems.append("Reviews had incorrect due date.") self.db.execute( "update cards set due = 0, mod = ?, usn = ? where id in %s" % ids2str(ids), intTime(), self.usn()) # 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.execute("vacuum") self.db.execute("analyze") self.lock() # Logging ########################################################################## def log(self, *args, **kwargs): if not self._debugLog: return def customRepr(x): if isinstance(x, basestring): return x return pprint.pformat(x) path, num, fn, y = traceback.extract_stack( limit=2+kwargs.get("stack", 0))[0] buf = u"[%s] %s:%s(): %s" % (intTime(), os.path.basename(path), fn, ", ".join([customRepr(x) for x in args])) self._logHnd.write(buf.encode("utf8") + "\n") if os.environ.get("ANKIDEV"): print buf def _openLog(self): if not self._debugLog: return lpath = re.sub("\.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, "ab") def _closeLog(self): self._logHnd = None
class _Collection(object): def __init__(self, db, server=False): self.db = db self.path = db._path self.server = server self._lastSave = time.time() self.clearUndo() self.media = MediaManager(self) 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.undoEnabled = False self.sessionStartReps = 0 self.sessionStartTime = 0 self.lastSessionStart = 0 self._stdSched = Scheduler(self) self.sched = self._stdSched # check for improper shutdown self.cleanup() def name(self): n = os.path.splitext(os.path.basename(self.path))[0] return n # DB-related ########################################################################## def load(self): (self.crt, self.mod, self.scm, self.dty, 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 = simplejson.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, simplejson.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." if time.time() - self._lastSave > 300: self.save() 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: self.cleanup() if save: self.save() else: self.rollback() if not self.server: self.db.execute("pragma journal_mode = delete") self.db.close() self.db = None self.media.close() 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() def rollback(self): self.db.rollback() self.load() self.lock() def modSchema(self, check=True): "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) def schemaChanged(self): "True if schema changed since last sync." return self.scm > self.ls def setDirty(self): "Signal there are temp. suspended cards that need cleaning up on close." self.dty = True def cleanup(self): "Unsuspend any temporarily suspended cards." if self.dty: self.sched.onClose() self.dty = False def usn(self): return self._usn if self.server else -1 def beforeUpload(self): "Called before a full upload." tbls = "notes", "cards", "revlog", "graves" for t in tbls: self.db.execute("update %s set usn=0 where usn=-1" % t) self._usn += 1 self.models.beforeUpload() self.tags.beforeUpload() self.decks.beforeUpload() self.modSchema() self.ls = self.scm 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): "Return a new note with the current model." return anki.notes.Note(self, self.models.current()) 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 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." ok = [] model = note.model() avail = self.models.availOrds(model, joinFields(note.fields)) ok = [] for t in model['tmpls']: if t['ord'] in avail: 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 = {} for id, nid, ord in self.db.execute( "select id, nid, ord from cards where nid in "+snids): if nid not in have: have[nid] = {} have[nid][ord] = id # build cards for each note data = [] ts = maxID(self.db) now = intTime() rem = [] usn = self.usn() for nid, mid, did, flds in self.db.execute( "select id, mid, did, flds from notes where id in "+snids): model = self.models.get(mid) avail = self.models.availOrds(model, flds) ok = [] for t in model['tmpls']: doHave = nid in have and t['ord'] in have[nid] # if have ord but empty, add cid to remove list # (may not have nid if generating before any cards added) if doHave and t['ord'] not in avail: rem.append(have[nid][t['ord']]) # if missing ord and is available, generate if not doHave and t['ord'] in avail: data.append((ts, nid, t['did'] or did, t['ord'], now, usn, nid)) ts += 1 # bulk update self.db.executemany(""" insert into cards values (?,?,?,?,?,?,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): 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)) return cards def _newCard(self, note, template, due, flush=True): "Create a new card." card = anki.cards.Card(self) card.nid = note.id card.ord = template['ord'] card.did = template['did'] or note.did 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']: 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, 2**32-1) # 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): "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) self.db.execute("delete from revlog where cid in "+sids) # then notes 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 remEmptyCards(self, ids): if not ids: return if runFilter("remEmptyCards", len(ids), True): self.remCards(ids) # 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) r.append((stripHTML(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): "Returns hash of id, question, answer." # data is [cid, nid, mid, did, ord, tags, flds] # unpack fields and create dict flist = splitFields(data[6]) fields = {} model = self.models.get(data[2]) for (name, (idx, conf)) in self.models.fieldMap(model).items(): fields[name] = flist[idx] fields['Tags'] = data[5] fields['Type'] = model['name'] fields['Deck'] = self.decks.name(data[3]) template = model['tmpls'][data[4]] fields['Card'] = template['name'] # render q & a d = dict(id=data[0]) for (type, format) in (("q", template['qfmt']), ("a", template['afmt'])): if type == "q": format = format.replace("cloze:", "cq:") else: format = format.replace("cloze:", "ca:") fields = runFilter("mungeFields", fields, model, data, self) html = anki.template.render(format, fields) d[type] = runFilter( "mungeQA", html, type, fields, model, data, self) return d def _qaData(self, where=""): "Return [cid, nid, mid, did, ord, tags, flds] db query" return self.db.execute(""" select c.id, f.id, f.mid, c.did, c.ord, f.tags, f.flds from cards c, notes f where c.nid == f.id %s""" % where) # Finding cards ########################################################################## def findCards(self, query, full=False): return anki.find.Finder(self).findCards(query, full) def findReplace(self, nids, src, dst, regex=None, field=None, fold=True): return anki.find.findReplace(self, nids, src, dst, regex, field, fold) def findDuplicates(self, fmids): return anki.find.findDuplicates(self, fmids) # 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.lastSessionStart = self.sessionStartTime self.sessionStartTime = time.time() self.sessionStartReps = self.repsToday def stopTimebox(self): self.sessionStartTime = 0 def timeboxStarted(self): return self.sessionStartTime def timeboxReached(self): if not self.sessionStartTime: # not started return False if (self.sessionTimeLimit and time.time() > (self.sessionStartTime + self.sessionTimeLimit)): return True if (self.sessionRepLimit and self.sessionRepLimit <= self.repsToday - self.sessionStartReps): return True return False # Schedulers and cramming ########################################################################## def stdSched(self): "True if scheduler changed." if self.sched.name != "std": self.cleanup() self.sched = self._stdSched return True def cramDecks(self, order="mod desc", min=0, max=None): self.stdSched() self.sched = anki.cram.CramScheduler(self, order, min, max) # 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: self._undoReview() else: self._undoOp() def markReview(self, card): old = [] if self._undo: if self._undo[0] == 1: old = self._undo[2] self.clearUndo() self._undo = [1, _("Review"), old + [copy.copy(card)]] def _undoReview(self): data = self._undo[2] c = data.pop() if not data: self.clearUndo() # 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) # and finally, update daily counts # fixme: what to do in cramming case? type = ("new", "lrn", "rev")[c.queue] self.sched._updateStats(c, type, -1) 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 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.") # delete any notes with missing cards ids = self.db.list(""" select id from notes where id not in (select distinct nid from cards)""") self._remNotes(ids) # tags self.tags.registerNotes() # field cache for m in self.models.all(): self.updateFieldCache(self.models.nids(m)) # and finally, optimize self.optimize() newSize = os.stat(self.path)[stat.ST_SIZE] save = (oldSize - newSize)/1024 txt = _("Database rebuilt and optimized.") if save > 0: txt += "\n" + _("Saved %dKB.") % save problems.append(txt) self.save() return "\n".join(problems) def optimize(self): self.db.execute("vacuum") self.db.execute("analyze") self.lock()
class _Collection(object): 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.sched = Scheduler(self) 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 # 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." if time.time() - self._lastSave > 300: self.save() 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.rollback() if not self.server: self.db.execute("pragma journal_mode = delete") 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.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 = {} for id, nid, ord, did in self.db.execute( "select id, nid, ord, did from cards where nid in "+snids): # existing cards if nid not in have: have[nid] = {} have[nid][ord] = id # 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 # 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'] # 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'] # we'd like to use the same due# as sibling cards, but we # can't retrieve that quickly, so we give it a new id # instead data.append((ts, nid, did, t['ord'], now, usn, self.nextID("pos"))) ts += 1 # note any cards that need removing if nid in have: for ord, id in 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): 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)) return cards def _newCard(self, note, template, due, flush=True): "Create a new card." card = anki.cards.Card(self) card.nid = note.id card.ord = template['ord'] card.did = template['did'] or 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((stripHTML(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] # unpack fields and create dict flist = splitFields(data[6]) fields = {} model = self.models.get(data[2]) for (name, (idx, conf)) in 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] 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'] += ("<p>" + _( "Please edit this note and add some cloze deletions. (%s)") % ( "<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))) return d def _qaData(self, where=""): "Return [cid, nid, mid, did, ord, tags, flds] db query" return self.db.execute(""" select c.id, f.id, f.mid, c.did, c.ord, f.tags, f.flds from cards c, notes f where c.nid == f.id %s""" % where) # 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 queue = 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 > 10000") if ids: problems.append("Reviews had incorrect due date.") self.db.execute( "update cards set due = 0, mod = ?, usn = ? where id in %s" % ids2str(ids), intTime(), self.usn()) # 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.execute("vacuum") self.db.execute("analyze") self.lock() # Logging ########################################################################## def log(self, *args, **kwargs): if not self._debugLog: return def customRepr(x): if isinstance(x, basestring): return x return pprint.pformat(x) path, num, fn, y = traceback.extract_stack( limit=2+kwargs.get("stack", 0))[0] buf = u"[%s] %s:%s(): %s" % (intTime(), os.path.basename(path), fn, ", ".join([customRepr(x) for x in args])) self._logHnd.write(buf.encode("utf8") + "\n") if os.environ.get("ANKIDEV"): print buf def _openLog(self): if not self._debugLog: return lpath = re.sub("\.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, "ab") def _closeLog(self): self._logHnd = None
class _Collection: db: Optional[DB] sched: Union[V1Scheduler, V2Scheduler] crt: int mod: int scm: int dty: bool # no longer used _usn: int ls: int conf: Dict[str, Any] _undo: List[Any] backend: RustBackend def __init__( self, db: DB, backend: RustBackend, server: Optional["anki.storage.ServerData"] = None, log: bool = False, ) -> None: self.backend = backend 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 is not None) 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) -> Any: n = os.path.splitext(os.path.basename(self.path))[0] return n # Scheduler ########################################################################## supportedSchedulerVersions = (1, 2) def schedVer(self) -> Any: ver = self.conf.get("schedVer", 1) if ver in self.supportedSchedulerVersions: return ver else: raise Exception("Unsupported scheduler version") def _loadScheduler(self) -> None: ver = self.schedVer() if ver == 1: self.sched = V1Scheduler(self) elif ver == 2: self.sched = V2Scheduler(self) if not self.server: self.conf["localOffset"] = self.sched.currentTimezoneOffset() elif self.server.minutes_west is not None: self.conf["localOffset"] = self.server.minutes_west def changeSchedulerVer(self, ver: int) -> None: if ver == self.schedVer(): return if ver not in self.supportedSchedulerVersions: raise Exception("Unsupported scheduler version") self.modSchema(check=True) self.clearUndo() v2Sched = V2Scheduler(self) if ver == 1: v2Sched.moveToV1() else: v2Sched.moveToV2() self.conf["schedVer"] = ver self.setMod() self._loadScheduler() def localOffset(self) -> Optional[int]: "Minutes west of UTC. Only applies to V2 scheduler." if isinstance(self.sched, V1Scheduler): return None else: return self.sched.currentTimezoneOffset() # DB-related ########################################################################## def load(self) -> None: ( self.crt, self.mod, self.scm, self.dty, # no longer used self._usn, self.ls, 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(conf) # type: ignore self.models.load(models) self.decks.load(decks, dconf) self.tags.load(tags) def setMod(self) -> None: """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: Optional[int] = None) -> 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: Optional[str] = None, mod: Optional[int] = None) -> 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) -> Optional[bool]: "Save if 5 minutes has passed since last save. True if saved." if time.time() - self._lastSave > 300: self.save() return True return None def lock(self) -> None: # 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: bool = True) -> None: "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) -> None: "Reconnect to DB (after changing threads, etc)." if not self.db: self.db = DB(self.path) self.media.connect() self._openLog() def rollback(self) -> None: self.db.rollback() self.load() self.lock() def modSchema(self, check: bool) -> None: "Mark schema modified. Call this first so user can abort if necessary." if not self.schemaChanged(): if check and not hooks.run_mod_schema_filter(proceed=True): raise AnkiError("abortSchemaMod") self.scm = intTime(1000) self.setMod() def schemaChanged(self) -> Any: "True if schema changed since last sync." return self.scm > self.ls def usn(self) -> Any: return self._usn if self.server else -1 def beforeUpload(self) -> None: "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: int) -> Card: return Card(self, id) def getNote(self, id: int) -> Note: return Note(self, id=id) # Utils ########################################################################## def nextID(self, type: str, inc: bool = True) -> Any: type = "next" + type.capitalize() id = self.conf.get(type, 1) if inc: self.conf[type] = id + 1 return id def reset(self) -> None: "Rebuild the queue and reload data after DB modified." self.sched.reset() # Deletion logging ########################################################################## def _logRem(self, ids: List[int], type: int) -> None: self.db.executemany( "insert into graves values (%d, ?, %d)" % (self.usn(), type), ([x] for x in ids), ) # Notes ########################################################################## def noteCount(self) -> Any: return self.db.scalar("select count() from notes") def newNote(self, forDeck: bool = True) -> Note: "Return a new note with the current model." return Note(self, self.models.current(forDeck)) def addNote(self, note: Note) -> int: """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: Iterable[int]) -> None: """Deletes notes with the given IDs.""" self.remCards( self.db.list("select id from cards where nid in " + ids2str(ids))) def _remNotes(self, ids: List[int]) -> None: """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 hooks.run_remove_notes_hook(self, ids) self._logRem(ids, REM_NOTE) self.db.execute("delete from notes where id in %s" % strids) # Card creation ########################################################################## def findTemplates(self, note: Note) -> List: "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: NoteType, avail: List[int]) -> List: 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: List[int]) -> List[int]: "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: Dict[int, Dict[int, int]] = {} dids: Dict[int, Optional[int]] = {} dues: Dict[int, int] = {} for id, nid, ord, did, due, odue, odid, type in self.db.execute( "select id, nid, ord, did, due, odue, odid, type 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 and type == 0: # Add due to new card only if it's the due of a new sibling 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) assert model 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: Note, type: int = 0, did: None = None) -> List: 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: Note, template: Template, due: int, flush: bool = True, did: None = None, ) -> Card: "Create a new card." card = 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 = int(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) assert deck 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: int, due: int) -> int: 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) -> bool: return not self.db.scalar("select 1 from cards limit 1") def cardCount(self) -> Any: return self.db.scalar("select count() from cards") def remCards(self, ids: List[int], notes: bool = True) -> None: "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) -> List[int]: """Returns IDs of empty cards.""" rem: List[int] = [] for m in self.models.all(): rem += self.genCards(self.models.nids(m)) return rem def emptyCardReport(self, cids) -> str: 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: str) -> Any: return self.db.execute("select id, mid, flds from notes where id in " + snids) def updateFieldCache(self, nids: List[int]) -> None: "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") -> List: # 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)] # data is [cid, nid, mid, did, ord, tags, flds, cardFlags] def _renderQA(self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None) -> Dict[str, Union[str, int]]: "Returns hash of id, question, answer." # extract info from data split_fields = splitFields(data[6]) card_ord = data[4] model = self.models.get(data[2]) if model["type"] == MODEL_STD: template = model["tmpls"][data[4]] else: template = model["tmpls"][0] flag = data[7] deck_id = data[3] card_id = data[0] tags = data[5] qfmt = qfmt or template["qfmt"] afmt = afmt or template["afmt"] # create map of field names -> field content fields: Dict[str, str] = {} for (name, (idx, conf)) in list(self.models.fieldMap(model).items()): fields[name] = split_fields[idx] # add special fields fields["Tags"] = tags.strip() fields["Type"] = model["name"] fields["Deck"] = self.decks.name(deck_id) fields["Subdeck"] = fields["Deck"].split("::")[-1] fields["Card"] = template["name"] fields["CardFlag"] = self._flagNameFromCardFlags(flag) fields["c%d" % (card_ord + 1)] = "1" # allow add-ons to modify the available fields hooks.run_modify_fields_for_rendering_hook(fields, model, data) fields = runFilter("mungeFields", fields, model, data, self) # legacy # render fields qatext = render_card(self, qfmt, afmt, fields, card_ord) ret: Dict[str, Any] = dict(q=qatext[0], a=qatext[1], id=card_id) # allow add-ons to modify the generated result for type in "q", "a": ret[type] = hooks.run_rendered_card_template_filter( ret[type], type, fields, model, data, self) # empty cloze? if type == "q" and model["type"] == MODEL_CLOZE: if not self.models._availClozeOrds(model, data[6], False): ret["q"] += "<p>" + _( "Please edit this note and add some cloze deletions. (%s)" ) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help"))) return ret def _qaData(self, where="") -> Any: "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" # NOTE: order selected from database must match order of QAData fields. 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: int) -> str: flag = flags & 0b111 if not flag: return "" return "flag%d" % flag # Finding cards ########################################################################## def findCards(self, query: str, order: Union[bool, str] = False) -> Any: return anki.find.Finder(self).findCards(query, order) def findNotes(self, query: str) -> Any: return anki.find.Finder(self).findNotes(query) def findReplace( self, nids: List[int], src: str, dst: str, regex: Optional[bool] = None, field: Optional[str] = None, fold: bool = True, ) -> int: return anki.find.findReplace(self, nids, src, dst, regex, field, fold) def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: return anki.find.findDupes(self, fieldName, search) # Stats ########################################################################## def cardStats(self, card: Card) -> str: from anki.stats import CardStats return CardStats(self, card).report() def stats(self) -> "anki.stats.CollectionStats": from anki.stats import CollectionStats return CollectionStats(self) # Timeboxing ########################################################################## def startTimebox(self) -> None: self._startTime = time.time() self._startReps = self.sched.reps def timeboxReached(self) -> Union[bool, Tuple[Any, int]]: "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) return False # Undo ########################################################################## def clearUndo(self) -> None: # [type, undoName, data] # type 1 = review; type 2 = checkpoint self._undo = None def undoName(self) -> Any: "Undo menu item name, or None if undo unavailable." if not self._undo: return None return self._undo[1] def undo(self) -> Any: if self._undo[0] == 1: return self._undoReview() else: self._undoOp() def markReview(self, card: Card) -> None: old: List[Any] = [] 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) -> Any: data = self._undo[2] wasLeech = self._undo[3] c = data.pop() # pytype: disable=attribute-error 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: Optional[str]) -> None: "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) -> None: self.rollback() self.clearUndo() # DB maintenance ########################################################################## def basicCheck(self) -> bool: "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 False # 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 False # 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 False return True def fixIntegrity(self) -> Tuple[str, bool]: """Fix possible problems and rebuild caches. Returns tuple of (error: str, ok: bool). 'ok' will be true if no problems were found. """ problems = [] curs = self.db.cursor() 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, updateReqs=False) 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, so wrap items over # 2 million back to 1 million curs.execute( """ update cards set due=1000000+due%1000000,mod=?,usn=? where due>=1000000 and type=0""", [intTime(), self.usn()], ) if curs.rowcount: problems.append( "Found %d new cards with a due number >= 1,000,000 - consider repositioning them in the Browse screen." % curs.rowcount) # 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.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) # models if self.models.ensureNotEmpty(): problems.append("Added missing note type.") # 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) -> None: self.db.setAutocommit(True) self.db.execute("vacuum") self.db.execute("analyze") self.db.setAutocommit(False) self.lock() # Logging ########################################################################## def log(self, *args, **kwargs) -> None: 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) -> None: 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) -> None: if not self._debugLog: return self._logHnd.close() self._logHnd = None # Card Flags ########################################################################## def setUserFlag(self, flag: int, cids: List[int]) -> None: 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(), )
class _Collection(object): def __init__(self, db, server=False): self.db = db self.path = db._path self.server = server self._lastSave = time.time() self.clearUndo() self.media = MediaManager(self) 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.undoEnabled = False self.sessionStartReps = 0 self.sessionStartTime = 0 self.lastSessionStart = 0 self._stdSched = Scheduler(self) self.sched = self._stdSched # check for improper shutdown self.cleanup() def name(self): n = os.path.splitext(os.path.basename(self.path))[0] return n # DB-related ########################################################################## def load(self): (self.crt, self.mod, self.scm, self.dty, 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 = simplejson.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, simplejson.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." if time.time() - self._lastSave > 300: self.save() 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: self.cleanup() if save: self.save() else: self.rollback() if not self.server: self.db.execute("pragma journal_mode = delete") self.db.close() self.db = None self.media.close() 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() def rollback(self): self.db.rollback() self.load() self.lock() def modSchema(self, check=True): "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) def schemaChanged(self): "True if schema changed since last sync." return self.scm > self.ls def setDirty(self): "Signal there are temp. suspended cards that need cleaning up on close." self.dty = True def cleanup(self): "Unsuspend any temporarily suspended cards." if self.dty: self.sched.onClose() self.dty = False def usn(self): return self._usn if self.server else -1 def beforeUpload(self): "Called before a full upload." tbls = "notes", "cards", "revlog", "graves" for t in tbls: self.db.execute("update %s set usn=0 where usn=-1" % t) self._usn += 1 self.models.beforeUpload() self.tags.beforeUpload() self.decks.beforeUpload() self.modSchema() self.ls = self.scm 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): "Return a new note with the current model." return anki.notes.Note(self, self.models.current()) 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 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." ok = [] model = note.model() avail = self.models.availOrds(model, joinFields(note.fields)) ok = [] for t in model['tmpls']: if t['ord'] in avail: 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 = {} for id, nid, ord in self.db.execute( "select id, nid, ord from cards where nid in " + snids): if nid not in have: have[nid] = {} have[nid][ord] = id # build cards for each note data = [] ts = maxID(self.db) now = intTime() rem = [] usn = self.usn() for nid, mid, did, flds in self.db.execute( "select id, mid, did, flds from notes where id in " + snids): model = self.models.get(mid) avail = self.models.availOrds(model, flds) ok = [] for t in model['tmpls']: doHave = nid in have and t['ord'] in have[nid] # if have ord but empty, add cid to remove list # (may not have nid if generating before any cards added) if doHave and t['ord'] not in avail: rem.append(have[nid][t['ord']]) # if missing ord and is available, generate if not doHave and t['ord'] in avail: data.append((ts, nid, t['did'] or did, t['ord'], now, usn, nid)) ts += 1 # bulk update self.db.executemany( """ insert into cards values (?,?,?,?,?,?,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): 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)) return cards def _newCard(self, note, template, due, flush=True): "Create a new card." card = anki.cards.Card(self) card.nid = note.id card.ord = template['ord'] card.did = template['did'] or note.did 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']: 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, 2**32 - 1) # 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): "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) self.db.execute("delete from revlog where cid in " + sids) # then notes 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 remEmptyCards(self, ids): if not ids: return if runFilter("remEmptyCards", len(ids), True): self.remCards(ids) # 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) r.append((stripHTML(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): "Returns hash of id, question, answer." # data is [cid, nid, mid, did, ord, tags, flds] # unpack fields and create dict flist = splitFields(data[6]) fields = {} model = self.models.get(data[2]) for (name, (idx, conf)) in self.models.fieldMap(model).items(): fields[name] = flist[idx] fields['Tags'] = data[5] fields['Type'] = model['name'] fields['Deck'] = self.decks.name(data[3]) template = model['tmpls'][data[4]] fields['Card'] = template['name'] # render q & a d = dict(id=data[0]) for (type, format) in (("q", template['qfmt']), ("a", template['afmt'])): if type == "q": format = format.replace("cloze:", "cq:") else: format = format.replace("cloze:", "ca:") fields = runFilter("mungeFields", fields, model, data, self) html = anki.template.render(format, fields) d[type] = runFilter("mungeQA", html, type, fields, model, data, self) return d def _qaData(self, where=""): "Return [cid, nid, mid, did, ord, tags, flds] db query" return self.db.execute(""" select c.id, f.id, f.mid, c.did, c.ord, f.tags, f.flds from cards c, notes f where c.nid == f.id %s""" % where) # Finding cards ########################################################################## def findCards(self, query, full=False): return anki.find.Finder(self).findCards(query, full) def findReplace(self, nids, src, dst, regex=None, field=None, fold=True): return anki.find.findReplace(self, nids, src, dst, regex, field, fold) def findDuplicates(self, fmids): return anki.find.findDuplicates(self, fmids) # 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.lastSessionStart = self.sessionStartTime self.sessionStartTime = time.time() self.sessionStartReps = self.repsToday def stopTimebox(self): self.sessionStartTime = 0 def timeboxStarted(self): return self.sessionStartTime def timeboxReached(self): if not self.sessionStartTime: # not started return False if (self.sessionTimeLimit and time.time() > (self.sessionStartTime + self.sessionTimeLimit)): return True if (self.sessionRepLimit and self.sessionRepLimit <= self.repsToday - self.sessionStartReps): return True return False # Schedulers and cramming ########################################################################## def stdSched(self): "True if scheduler changed." if self.sched.name != "std": self.cleanup() self.sched = self._stdSched return True def cramDecks(self, order="mod desc", min=0, max=None): self.stdSched() self.sched = anki.cram.CramScheduler(self, order, min, max) # 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: self._undoReview() else: self._undoOp() def markReview(self, card): old = [] if self._undo: if self._undo[0] == 1: old = self._undo[2] self.clearUndo() self._undo = [1, _("Review"), old + [copy.copy(card)]] def _undoReview(self): data = self._undo[2] c = data.pop() if not data: self.clearUndo() # 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) # and finally, update daily counts # fixme: what to do in cramming case? type = ("new", "lrn", "rev")[c.queue] self.sched._updateStats(c, type, -1) 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 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.") # delete any notes with missing cards ids = self.db.list(""" select id from notes where id not in (select distinct nid from cards)""") self._remNotes(ids) # tags self.tags.registerNotes() # field cache for m in self.models.all(): self.updateFieldCache(self.models.nids(m)) # and finally, optimize self.optimize() newSize = os.stat(self.path)[stat.ST_SIZE] save = (oldSize - newSize) / 1024 txt = _("Database rebuilt and optimized.") if save > 0: txt += "\n" + _("Saved %dKB.") % save problems.append(txt) self.save() return "\n".join(problems) def optimize(self): self.db.execute("vacuum") self.db.execute("analyze") self.lock()
class _Collection: """A collection is, basically, everything that composed an account in Anki. This object is usually denoted col _lastSave -- time of the last save. Initially time of creation. _undo -- An undo object. See below The collection is an object composed of: usn -- USN of the collection id -- arbitrary number since there is only one row crt -- timestamp of the creation date. It's correct up to the day. For V1 scheduler, the hour corresponds to starting a newday. mod -- last modified in milliseconds scm -- schema mod time: time when "schema" was modified. -- If server scm is different from the client scm a full-sync is required ver -- version dty -- dirty: unused, set to 0 usn -- update sequence number: used for finding diffs when syncing. -- See usn in cards table for more details. ls -- "last sync time" conf -- object containing configuration options that are synced """ """ In the db, not in col objects models -- the model manager In the db: json array of json objects containing the models (aka Note types) decks -- The deck manager -- in the db it is a json array of json objects containing the deck dconf -- json array of json objects containing the deck options tags -- a cache of tags used in the collection (probably for autocomplete etc) """ """ not in the db: activeDecks -- The active decks, that is, the current deck and its descendent. curDeck -- the current deck. That is, the last deck which was selected for review or for adding cards. newSpread -- ?? collapseTime -- timeLim -- estTimes -- dueCounts -- other -- curModel -- A model which is, right now, the default model nextPos -- the highest due of new cards sortType -- sortBackwards -- addToCur -- add new to currently selected deck? """ """An undo object is of the form [type, undoName, data] Here, type is 1 for review, 2 for checkpoint. undoName is the name of the action to undo. Used in the edit menu, and in tooltip stating that undo was done. """ 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. Raise AnkiError("abortSchemaMod") if the change is rejected by the filter (e.g. if the user states to abort). Once the change is accepted, the filter is not run until a synchronization occurs. Change the scm value """ if not self.schemaChanged(): if check and not runFilter("modSchema", True): #default hook is added in aqt/main setupHooks. It is function onSchemaMod from class AnkiQt aqt/main 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): """The card object whose id is id.""" return anki.cards.Card(self, id) def getNote(self, id): """The note object whose id is 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): """See sched's reset documentation""" 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): """Removes all cards associated to the notes whose id is in 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 non-empty templates." model = note.model() avail = self.models.availOrds(model, joinFields(note.fields)) return self._tmplsFromOrds(model, avail) def _tmplsFromOrds(self, model, avail): """Given a list of ordinals, returns a list of templates corresponding to those position/cloze""" 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): """Ids of cards needed to be removed. Generate missing cards of a note with id in nids. """ # build map of (nid,ord) so we don't create dupes snids = ids2str(nids) have = {}#Associated to each nid a dictionnary from card's order to card id. dids = {}#Associate to each nid the only deck id containing its cards. Or None if there are multiple decks dues = {}#Associate to each nid the due value of the last card seen. 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 = []#Tuples for cards to create. Each tuple is newCid, nid, did, ord, now, usn, due ts = maxID(self.db) now = intTime() rem = []#cards to remove 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 def previewCards(self, note, type=0): """Returns a list of new cards, one by template. Those cards are not flushed, and their due is always 1. type 0 - when previewing in add dialog, only non-empty. Seems to be used only in tests. type 1 - when previewing edit, only existing. Seems to be used only in tests. type 2 - when previewing in models dialog (i.e. note type modifier), return the list of cards for every single template of the model. """ #cms is the list of templates to consider 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)) return cards def _newCard(self, note, template, due, flush=True): """A new card object belonging to this collection. Its nid according to note, ord according to template did according to template, or to model, or default if otherwise deck is dynamic Cards is flushed or not according to flush parameter keyword arguments: note -- the note of this card template -- the template of this card due -- The due time of this card, assuming no random flush -- whether this card should be push in the db """ card = anki.cards.Card(self) card.nid = note.id card.ord = template['ord'] # Use template did (deck override) if valid, otherwise model did if template['did'] and str(template['did']) in self.decks.decks: card.did = template['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): """The due date of a card. Itself if not random mode. A random number depending only on the due date otherwise. keyword arguments did -- the deck id of the considered card due -- the due time of the considered card """ 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): """Is there no cards in this collection.""" 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. keyword arguments: notes -- whether note without cards should be deleted." 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, changing model, 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 """TODO The list of renderQA for each cards whose type belongs to ids. Types may be card(default), note, model or all (in this case, ids is not used). It seems to be called nowhere """ 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. Keyword arguments: data -- [cid, nid, mid, did, ord, tags, flds] (see db documentation for more information about those values) This corresponds to the information you can obtain in templates, using {{Tags}}, {{Type}}, etc.. qfmt -- question format string (as in template) afmt -- answer format string (as in template) unpack fields and create dict TODO comment better """ cid, nid, mid, did, ord, tags, flds, cardFlags = data flist = splitFields(flds)#the list of fields fields = {} # #name -> ord for each field, tags # Type: the name of the model, # Deck, Subdeck: their name # Card: the template name # cn: 1 for n being the ord+1 # FrontSide : model = self.models.get(mid) for (name, (idx, conf)) in list(self.models.fieldMap(model).items()):#conf is not used fields[name] = flist[idx] fields['Tags'] = tags.strip() fields['Type'] = model['name'] fields['Deck'] = self.decks.name(did) fields['Subdeck'] = fields['Deck'].split('::')[-1] if model['type'] == MODEL_STD:#Note that model['type'] has not the same meaning as fields['Type'] template = model['tmpls'][ord] else:#for cloze deletions template = model['tmpls'][0] fields['Card'] = template['name'] fields['c%d' % (ord+1)] = "1" # render q & a d = dict(id=cid) # id: card id 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:" % (ord+1), format) #Replace {{'foo'cloze: by {{'foo'cq-(ord+1), where 'foo' does not begins with "type:" format = format.replace("<%cloze:", "<%%cq:%d:" % ( ord+1)) #Replace <%cloze: by <%%cq:(ord+1) else: format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (ord+1), format) #Replace {{'foo'cloze: by {{'foo'ca-(ord+1) format = format.replace("<%cloze:", "<%%ca:%d:" % ( ord+1)) #Replace <%cloze: by <%%ca:(ord+1) fields['FrontSide'] = stripSounds(d['q']) #d['q'] is defined during loop's first iteration fields = runFilter("mungeFields", fields, model, data, self) # TODO check html = anki.template.render(format, fields) #replace everything of the form {{ by its value TODO check d[type] = runFilter( "mungeQA", html, type, fields, model, data, self) # TODO check # empty cloze? if type == 'q' and model['type'] == MODEL_CLOZE: if not self.models._availClozeOrds(model, flds, False): d['q'] += ("<p>" + _( "Please edit this note and add some cloze deletions. (%s)") % ( "<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))) #in the case where there is a cloze note type #without {{cn in fields indicated by #{{cloze:fieldName; an error message should be #shown return d def _qaData(self, where=""): """The list of [cid, nid, mid, did, ord, tags, flds, cardFlags] for each pair cards satisfying where. Where should start with an and.""" 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 ########################################################################## # [type, undoName, data] # type 1 = review; type 2 = checkpoint def clearUndo(self): """Erase all undo information from the collection.""" self._undo = None def undoName(self): """The name of the action which could potentially be undone. None if nothing can be undone. This let test whether something can be undone. """ if not self._undo: return None return self._undo[1] def undo(self): "Undo the last operation. Assuming an undo object exists."""
class _Collection: """A collection is, basically, everything that composed an account in Anki. This object is usually denoted col _lastSave -- time of the last save. Initially time of creation. _undo -- An undo object. See below The collection is an object composed of: usn -- USN of the collection id -- arbitrary number since there is only one row crt -- timestamp of the creation date. It's correct up to the day. For V1 scheduler, the hour corresponds to starting a newday. mod -- last modified in milliseconds scm -- schema mod time: time when "schema" was modified. -- If server scm is different from the client scm a full-sync is required ver -- version dty -- dirty: unused, set to 0 usn -- update sequence number: used for finding diffs when syncing. -- See usn in cards table for more details. ls -- "last sync time" conf -- json object containing configuration options that are synced """ """ In the db, not in col objects: json array of json objects containing the models (aka Note types) decks -- The deck manager -- in the db it is a json array of json objects containing the deck dconf -- json array of json objects containing the deck options tags -- a cache of tags used in the collection (probably for autocomplete etc) """ """ conf -- ("conf" in the database.) "curDeck": "The id (as int) of the last deck selectionned (review, adding card, changing the deck of a card)", "activeDecks": "The list containing the current deck id and its descendent (as ints)", "newSpread": "In which order to view to review the cards. This can be selected in Preferences>Basic. Possible values are: 0 -- NEW_CARDS_DISTRIBUTE (Mix new cards and reviews) 1 -- NEW_CARDS_LAST (see new cards after review) 2 -- NEW_CARDS_FIRST (See new card before review)", "collapseTime": "'Preferences>Basic>Learn ahead limit'*60. If there are no other card to review, then we can review cards in learning in advance if they are due in less than this number of seconds.", "timeLim": "'Preferences>Basic>Timebox time limit'*60. Each time this number of second elapse, anki tell you how many card you reviewed.", "estTimes": "'Preferences>Basic>Show next review time above answer buttons'. A Boolean." "dueCounts": "'Preferences>Basic>Show remaining card count during review'. A Boolean." "curModel": "Id (as string) of the last note type (a.k.a. model) used (i.e. either when creating a note, or changing the note type of a note).", "nextPos": "This is the highest value of a due value of a new card. It allows to decide the due number to give to the next note created. (This is useful to ensure that cards are seen in order in which they are added.", "sortType": "A string representing how the browser must be sorted. Its value should be one of the possible value of 'aqt.browsers.DataModel.activeCols' (or equivalently of 'activeCols' but not any of ('question', 'answer', 'template', 'deck', 'note', 'noteTags')", "sortBackwards": "A Boolean stating whether the browser sorting must be in increasing or decreasing order", "addToCur": "A Boolean. True for 'When adding, default to current deck' in Preferences>Basic. False for 'Change deck depending on note type'.", "dayLearnFirst": "A Boolean. It corresponds to the option 'Show learning cards with larger steps before reviews'. But this option does not seems to appear in the preference box", "newBury": "A Boolean. Always set to true and not read anywhere in the code but at the place where it is set to True if it is not already true. Hence probably quite useful.", "lastUnburied":"The date of the last time the scheduler was initialized or reset. If it's not today, then buried notes must be unburied. This is not in the json until scheduler is used once.", "activeCols":"the list of name of columns to show in the browser. Possible values are listed in aqt.browser.Browser.setupColumns. They are: 'question' -- the browser column'Question', 'answer' -- the browser column'Answer', 'template' -- the browser column'Card', 'deck' -- the browser column'Deck', 'noteFld' -- the browser column'Sort Field', 'noteCrt' -- the browser column'Created', 'noteMod' -- the browser column'Edited', 'cardMod' -- the browser column'Changed', 'cardDue' -- the browser column'Due', 'cardIvl' -- the browser column'Interval', 'cardEase' -- the browser column'Ease', 'cardReps' -- the browser column'Reviews', 'cardLapses' -- the browser column'Lapses', 'noteTags' -- the browser column'Tags', 'note' -- the browser column'Note', The default columns are: noteFld, template, cardDue and deck This is not in the json at creaton. It's added when the browser is open. " """ """An undo object is of the form [type, undoName, data] Here, type is 1 for review, 2 for checkpoint. undoName is the name of the action to undo. Used in the edit menu, and in tooltip stating that undo was done. server -- Whether to pretend to be the server. Only set to true during anki.sync.Syncer.remove; i.e. while removing what the server says to remove. When set to true: * the usn returned by self.usn is self._usn, otherwise -1. * media manager does not connect nor close database connexion (I've no idea why) ======= not in the db: activeDecks -- The active decks, that is, the current deck and its descendent. curDeck -- the current deck. That is, the last deck which was selected for review or for adding cards. newSpread -- ?? collapseTime -- timeLim -- estTimes -- dueCounts -- other -- curModel -- A model which is, right now, the default model nextPos -- the highest due of new cards sortType -- sortBackwards -- addToCur -- add new to currently selected deck? >>>>>>> Merge """ 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. name -- """ # 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): """TODO. """ mod = self.db.mod # make sure we don't accidentally bump mod time self.db.execute("update col set mod=mod") self.db.mod = mod def close(self, save=True): """Save or rollback collection's db according to save. Close collection's db, media's db and log. """ 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. Raise AnkiError("abortSchemaMod") if the change is rejected by the filter (e.g. if the user states to abort). Once the change is accepted, the filter is not run until a synchronization occurs. Change the scm value """ if not self.schemaChanged(): if check and not runFilter("modSchema", True): #default hook is added in aqt/main setupHooks. It is function onSchemaMod from class AnkiQt aqt/main 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 the synchronization number to use. Usually, -1, since no actions are synchronized. The exception being actions requested by synchronization itself, when self.server is true. In which case _usn number. """ return self._usn if self.server else -1 def beforeUpload(self): """Called before a full upload. * change usn -1 to 0 in notes, card and revlog, and all models, tags, decks, deck options. * empty graves. * Update usn * set modSchema to true (no nead for new upload) * update last sync time to current schema * Save or rollback collection's db according to save. * Close collection's db, media's db and log. """ 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): """The card object whose id is id.""" return anki.cards.Card(self, id) def getNote(self, id): """The note object whose id is id.""" return anki.notes.Note(self, id=id) # Utils ########################################################################## def nextID(self, type, inc=True): """Get the id next{Type} in the collection's configuration. Increment this id. Use 1 instead if this id does not exists in the collection.""" type = "next" + type.capitalize() id = self.conf.get(type, 1) if inc: self.conf[type] = id + 1 return id def reset(self): """See sched's reset documentation""" 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): """Removes all cards associated to the notes whose id is in 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 non-empty templates." model = note.model() avail = self.models.availOrds(model, joinFields(note.fields)) return self._tmplsFromOrds(model, avail) def _tmplsFromOrds(self, model, avail): """Given a list of ordinals, returns a list of templates corresponding to those position/cloze""" 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): """Ids of cards which needs to be removed. Generate missing cards of a note with id in nids. """ # build map of (nid,ord) so we don't create dupes snids = ids2str(nids) have = { } #Associated to each nid a dictionnary from card's order to card id. dids = { } #Associate to each nid the only deck id containing its cards. Or None if there are multiple decks dues = {} #Associate to each nid the due value of the last card seen. 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 = [ ] #Tuples for cards to create. Each tuple is newCid, nid, did, ord, now, usn, due ts = maxID(self.db) now = intTime() rem = [] #cards to remove 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 def previewCards(self, note, type=0, did=None): """Returns a list of new cards, one by template. Those cards are not flushed, and their due is always 1. type 0 - when previewing in add dialog, only non-empty. Seems to be used only in tests. type 1 - when previewing edit, only existing. Seems to be used only in tests. type 2 - when previewing in models dialog (i.e. note type modifier), return the list of cards for every single template of the model. """ #cms is the list of templates to consider 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): """A new card object belonging to this collection. Its nid according to note, ord according to template did according to template, or to model, or default if otherwise deck is dynamic Cards is flushed or not according to flush parameter keyword arguments: note -- the note of this card template -- the template of this card due -- The due time of this card, assuming no random flush -- whether this card should be push in the db """ 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): """The due date of a card. Itself if not random mode. A random number depending only on the due date otherwise. keyword arguments did -- the deck id of the considered card due -- the due time of the considered card """ 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): """Is there no cards in this collection.""" 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. keyword arguments: notes -- whether note without cards should be deleted.""" 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): """The card id of empty cards of the collection""" 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, changing model, 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 """TODO The list of renderQA for each cards whose type belongs to ids. Types may be card(default), note, model or all (in this case, ids is not used). It seems to be called nowhere """ 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. Keyword arguments: data -- [cid, nid, mid, did, ord, tags, flds] (see db documentation for more information about those values) This corresponds to the information you can obtain in templates, using {{Tags}}, {{Type}}, etc.. qfmt -- question format string (as in template) afmt -- answer format string (as in template) unpack fields and create dict TODO comment better """ cid, nid, mid, did, ord, tags, flds, cardFlags = data flist = splitFields(flds) #the list of fields fields = {} # #name -> ord for each field, tags # Type: the name of the model, # Deck, Subdeck: their name # Card: the template name # cn: 1 for n being the ord+1 # FrontSide : model = self.models.get(mid) for (name, (idx, conf)) in list( self.models.fieldMap(model).items()): #conf is not used fields[name] = flist[idx] fields['Tags'] = tags.strip() fields['Type'] = model['name'] fields['Deck'] = self.decks.name(did) fields['Subdeck'] = fields['Deck'].split('::')[-1] fields['CardFlag'] = self._flagNameFromCardFlags(cardFlags) if model[ 'type'] == MODEL_STD: #model['type'] is distinct from fields['Type'] template = model['tmpls'][ord] else: #for cloze deletions template = model['tmpls'][0] fields['Card'] = template['name'] fields['c%d' % (ord + 1)] = "1" # render q & a d = dict(id=cid) # id: card id qfmt = qfmt or template['qfmt'] afmt = afmt or template['afmt'] for (type, format) in (("q", qfmt), ("a", afmt)): if type == "q": #if/else is in the loop in order for d['q'] to be defined below format = re.sub("{{(?!type:)(.*?)cloze:", r"{{\1cq-%d:" % (ord + 1), format) #Replace {{'foo'cloze: by {{'foo'cq-(ord+1), where 'foo' does not begins with "type:" format = format.replace("<%cloze:", "<%%cq:%d:" % (ord + 1)) #Replace <%cloze: by <%%cq:(ord+1) else: format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (ord + 1), format) #Replace {{'foo'cloze: by {{'foo'ca-(ord+1) format = format.replace("<%cloze:", "<%%ca:%d:" % (ord + 1)) #Replace <%cloze: by <%%ca:(ord+1) fields['FrontSide'] = stripSounds(d['q']) #d['q'] is defined during loop's first iteration fields = runFilter("mungeFields", fields, model, data, self) # TODO check html = anki.template.render( format, fields ) #replace everything of the form {{ by its value TODO check d[type] = runFilter("mungeQA", html, type, fields, model, data, self) # TODO check # empty cloze? if type == 'q' and model['type'] == MODEL_CLOZE: if not self.models._availClozeOrds(model, flds, False): d['q'] += ("<p>" + _( "Please edit this note and add some cloze deletions. (%s)" ) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))) #in the case where there is a cloze note type #without {{cn in fields indicated by #{{cloze:fieldName; an error message should be #shown return d def _qaData(self, where=""): """The list of [cid, nid, mid, did, ord, tags, flds, cardFlags] for each pair cards satisfying where. Where should start with an and.""" 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 a list of notes ids for 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 ########################################################################## # [type, undoName, data] # type 1 = review; type 2 = checkpoint def clearUndo(self): """Erase all undo information from the collection.""" self._undo = None def undoName(self): """The name of the action which could potentially be undone. None if nothing can be undone. This let test whether something can be undone. """ if not self._undo: return None return self._undo[1] def undo(self): """Undo the last operation. Assuming an undo object exists.""" 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 #The or is probably useless. 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): """True if basic integrity is meet. Used before and after sync, or before a full upload. Tests: * whether each card belong to a note * each note has a model * each note has a card * each card's ord is valid according to the note model. ooo """ # 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 = [] curs = self.db.cursor() self.save() oldSize = os.stat(self.path)[stat.ST_SIZE] # whether sqlite find a problem in its database 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(f""" select id from cards where odue > 0 and (type={CARD_LRN} or queue={CARD_DUE}) 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, so wrap items over # 2 million back to 1 million curs.execute( """ update cards set due=1000000+due%1000000,mod=?,usn=? where due>=1000000 and type=0""", [intTime(), self.usn()]) if curs.rowcount: problems.append( "Found %d new cards with a due number >= 1,000,000 - consider repositioning them in the Browse screen." % curs.rowcount) # new card position self.conf['nextPos'] = self.db.scalar( f"select max(due)+1 from cards where type = {CARD_NEW}") 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.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) # models if self.models.ensureNotEmpty(): problems.append("Added missing note type.") # 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): """Tell sqlite to optimize the db""" self.db.setAutocommit(True) self.db.execute("vacuum") self.db.execute("analyze") self.db.setAutocommit(False) self.lock() # Logging ########################################################################## def log(self, *args, **kwargs): """Generate the string [time] path:fn(): args list if args is not string, it is represented using pprint.pformat if self._debugLog is True, it is hadded to _logHnd if devMode is True, this string is printed TODO look traceback/extract stack and fn """ 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] time = datetime.datetime.now() buf = "[%s] %s:%s(): %s" % (time, 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())