# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import copy import re import time from typing import Any, Dict, List, Optional, Tuple, Union import anki # pylint: disable=unused-import import anki.backend_pb2 as pb from anki import hooks from anki.consts import * from anki.lang import _ from anki.utils import checksum, ids2str, intTime, joinFields, splitFields # types NoteType = Dict[str, Any] Field = Dict[str, Any] Template = Dict[str, Union[str, int, None]] # fixme: memory leaks # fixme: syncing, beforeUpload # Models ########################################################################## # - careful not to add any lists/dicts/etc here, as they aren't deep copied defaultModel: NoteType = { "id": 0, "sortf": 0, "did": 1, "latexPre": """\ \\documentclass[12pt]{article} \\special{papersize=3in,5in} \\usepackage[utf8]{inputenc} \\usepackage{amssymb,amsmath} \\pagestyle{empty} \\setlength{\\parindent}{0in} \\begin{document} """, "latexPost": "\\end{document}", "mod": 0, "usn": 0, "req": [], "type": MODEL_STD, "css": """\ .card { font-family: arial; font-size: 20px; text-align: center; color: black; background-color: white; } """, } defaultField: Field = { "name": "", "ord": None, "sticky": False, # the following alter editing, and are used as defaults for the # template wizard "rtl": False, "font": "Arial", "size": 20, # reserved for future use "media": [], } defaultTemplate: Template = { "name": "", "ord": None, "qfmt": "", "afmt": "", "did": None, "bqfmt": "", "bafmt": "", # we don't define these so that we pick up system font size until set #'bfont': "Arial", #'bsize': 12, } class ModelsDictProxy: def __init__(self, col: anki.storage._Collection): self._col = col.weakref() def _warn(self): print("add-on should use methods on col.models, not col.models.models dict") def __getitem__(self, item): self._warn() return self._col.models.get(int(item)) def __setitem__(self, key, val): self._warn() self._col.models.save(val) def __len__(self): self._warn() return len(self._col.models.all_names_and_ids()) def keys(self): self._warn() return [str(nt.id) for nt in self._col.models.all_names_and_ids()] def values(self): self._warn() return self._col.models.all() def items(self): self._warn() return [(str(nt["id"]), nt) for nt in self._col.models.all()] def __contains__(self, item): self._warn() self._col.models.have(item) class ModelManager: # Saving/loading registry ############################################################# def __init__(self, col: anki.storage._Collection) -> None: self.col = col.weakref() self.models = ModelsDictProxy(col) # do not access this directly! self._cache = {} def save( self, m: NoteType = None, # no longer used templates: bool = False, updateReqs: bool = True, ) -> None: "Save changes made to provided note type." if not m: print("col.models.save() should be passed the changed notetype") return self.update(m, preserve_usn=False) # fixme: badly named; also fires on updates hooks.note_type_added(m) # legacy def flush(self) -> None: pass # fixme: enforce at lower level def ensureNotEmpty(self) -> Optional[bool]: if not self.all_names_and_ids(): from anki.stdmodels import addBasicModel addBasicModel(self.col) return True return None # Caching ############################################################# # A lot of existing code expects to be able to quickly and # frequently obtain access to an entire notetype, so we currently # need to cache responses from the backend. Please do not # access the cache directly! _cache: Dict[int, NoteType] = {} def _update_cache(self, nt: NoteType) -> None: self._cache[nt["id"]] = nt def _remove_from_cache(self, ntid: int) -> None: if ntid in self._cache: del self._cache[ntid] def _get_cached(self, ntid: int) -> Optional[NoteType]: return self._cache.get(ntid) def _clear_cache(self): self._cache = {} # Listing note types ############################################################# def all_names_and_ids(self) -> List[pb.NoteTypeNameID]: return self.col.backend.get_notetype_names_and_ids() def all_use_counts(self) -> List[pb.NoteTypeNameIDUseCount]: return self.col.backend.get_notetype_use_counts() # legacy def allNames(self) -> List[str]: return [n.name for n in self.all_names_and_ids()] def ids(self) -> List[int]: return [n.id for n in self.all_names_and_ids()] # only used by importing code def have(self, id: int) -> bool: if isinstance(id, str): id = int(id) return any(True for e in self.all_names_and_ids() if e.id == id) # Current note type ############################################################# def current(self, forDeck: bool = True) -> Any: "Get current model." m = self.get(self.col.decks.current().get("mid")) if not forDeck or not m: m = self.get(self.col.conf["curModel"]) if m: return m return self.get(self.all_names_and_ids()[0].id) def setCurrent(self, m: NoteType) -> None: self.col.conf["curModel"] = m["id"] self.col.setMod() # Retrieving and creating models ############################################################# def id_for_name(self, name: str) -> Optional[int]: return self.col.backend.get_notetype_id_by_name(name) def get(self, id: int) -> Optional[NoteType]: "Get model with ID, or None." # deal with various legacy input types if id is None: return None elif isinstance(id, str): id = int(id) nt = self._get_cached(id) if not nt: nt = self.col.backend.get_notetype_legacy(id) if nt: self._update_cache(nt) return nt def all(self) -> List[NoteType]: "Get all models." return [self.get(nt.id) for nt in self.all_names_and_ids()] def byName(self, name: str) -> Optional[NoteType]: "Get model with NAME." id = self.id_for_name(name) if id: return self.get(id) else: return None def new(self, name: str) -> NoteType: "Create a new model, and return it." # caller should call save() after modifying m = defaultModel.copy() m["name"] = name m["mod"] = intTime() m["flds"] = [] m["tmpls"] = [] m["tags"] = [] m["id"] = 0 return m def rem(self, m: NoteType) -> None: "Delete model, and all its cards/notes." self.remove(m["id"]) def remove_all_notetypes(self): self.col.modSchema(check=True) for nt in self.all_names_and_ids(): self._remove_from_cache(nt.id) self.col.backend.remove_notetype(nt.id) def remove(self, id: int) -> None: self.col.modSchema(check=True) self._remove_from_cache(id) was_current = self.current()["id"] == id self.col.backend.remove_notetype(id) # fixme: handle in backend if was_current: self.col.conf["curModel"] = self.all_names_and_ids()[0].id def add(self, m: NoteType) -> None: self.save(m) def ensureNameUnique(self, m: NoteType) -> None: existing_id = self.id_for_name(m["name"]) if existing_id is not None and existing_id != m["id"]: m["name"] += "-" + checksum(str(time.time()))[:5] def update(self, m: NoteType, preserve_usn=True) -> None: "Add or update an existing model. Use .save() instead." self._remove_from_cache(m["id"]) self.ensureNameUnique(m) self.col.backend.add_or_update_notetype(m, preserve_usn=preserve_usn) self.setCurrent(m) self._mutate_after_write(m) def _mutate_after_write(self, nt: NoteType) -> None: # existing code expects the note type to be mutated to reflect # the changes made when adding, such as ordinal assignment :-( updated = self.get(nt["id"]) nt.update(updated) # Tools ################################################## def nids(self, m: NoteType) -> Any: "Note ids for M." return self.col.db.list("select id from notes where mid = ?", m["id"]) def useCount(self, m: NoteType) -> Any: "Number of note using M." return self.col.db.scalar("select count() from notes where mid = ?", m["id"]) # Copying ################################################## def copy(self, m: NoteType) -> Any: "Copy, save and return." m2 = copy.deepcopy(m) m2["name"] = _("%s copy") % m2["name"] m2["id"] = 0 self.add(m2) return m2 # Fields ################################################## def fieldMap(self, m: NoteType) -> Dict[str, Tuple[int, Field]]: "Mapping of field name -> (ord, field)." return dict((f["name"], (f["ord"], f)) for f in m["flds"]) def fieldNames(self, m: NoteType) -> List[str]: return [f["name"] for f in m["flds"]] def sortIdx(self, m: NoteType) -> Any: return m["sortf"] def setSortIdx(self, m: NoteType, idx: int) -> None: assert 0 <= idx < len(m["flds"]) self.col.modSchema(check=True) m["sortf"] = idx self.save(m) # Adding & changing fields ################################################## def newField(self, name: str) -> Field: assert isinstance(name, str) f = defaultField.copy() f["name"] = name return f def addField(self, m: NoteType, field: Field, save=True) -> None: if m["id"]: self.col.modSchema(check=True) m["flds"].append(field) if m["id"] and save: self.save(m) def remField(self, m: NoteType, field: Field, save=True) -> None: self.col.modSchema(check=True) m["flds"].remove(field) if save: self.save(m) def moveField(self, m: NoteType, field: Field, idx: int, save=True) -> None: self.col.modSchema(check=True) oldidx = m["flds"].index(field) if oldidx == idx: return m["flds"].remove(field) m["flds"].insert(idx, field) if save: self.save(m) def renameField(self, m: NoteType, field: Field, newName: str, save=True) -> None: assert field in m["flds"] field["name"] = newName if save: self.save(m) # Adding & changing templates ################################################## def newTemplate(self, name: str) -> Template: t = defaultTemplate.copy() t["name"] = name return t def addTemplate(self, m: NoteType, template: Template) -> None: if m["id"]: self.col.modSchema(check=True) m["tmpls"].append(template) if m["id"]: self.save(m) def remTemplate(self, m: NoteType, template: Template) -> None: assert len(m["tmpls"]) > 1 self.col.modSchema(check=True) m["tmpls"].remove(template) self.save(m) def moveTemplate(self, m: NoteType, template: Template, idx: int) -> None: self.col.modSchema(check=True) oldidx = m["tmpls"].index(template) if oldidx == idx: return m["tmpls"].remove(template) m["tmpls"].insert(idx, template) self.save(m) # Model changing ########################################################################## # - maps are ord->ord, and there should not be duplicate targets # - newModel should be self if model is not changing def change( self, m: NoteType, nids: List[int], newModel: NoteType, fmap: Any, cmap: Any ) -> None: self.col.modSchema(check=True) assert newModel["id"] == m["id"] or (fmap and cmap) if fmap: self._changeNotes(nids, newModel, fmap) if cmap: self._changeCards(nids, m, newModel, cmap) self.col.genCards(nids) def _changeNotes( self, nids: List[int], newModel: NoteType, map: Dict[int, Union[None, int]] ) -> None: d = [] nfields = len(newModel["flds"]) for (nid, flds) in self.col.db.execute( "select id, flds from notes where id in " + ids2str(nids) ): newflds = {} flds = splitFields(flds) for old, new in list(map.items()): newflds[new] = flds[old] flds = [] for c in range(nfields): flds.append(newflds.get(c, "")) flds = joinFields(flds) d.append((flds, newModel["id"], intTime(), self.col.usn(), nid,)) self.col.db.executemany( "update notes set flds=?,mid=?,mod=?,usn=? where id = ?", d ) self.col.updateFieldCache(nids) def _changeCards( self, nids: List[int], oldModel: NoteType, newModel: NoteType, map: Dict[int, Union[None, int]], ) -> None: d = [] deleted = [] for (cid, ord) in self.col.db.execute( "select id, ord from cards where nid in " + ids2str(nids) ): # if the src model is a cloze, we ignore the map, as the gui # doesn't currently support mapping them if oldModel["type"] == MODEL_CLOZE: new = ord if newModel["type"] != MODEL_CLOZE: # if we're mapping to a regular note, we need to check if # the destination ord is valid if len(newModel["tmpls"]) <= ord: new = None else: # mapping from a regular note, so the map should be valid new = map[ord] if new is not None: d.append((new, self.col.usn(), intTime(), cid)) else: deleted.append(cid) self.col.db.executemany("update cards set ord=?,usn=?,mod=? where id=?", d) self.col.remCards(deleted) # Schema hash ########################################################################## def scmhash(self, m: NoteType) -> str: "Return a hash of the schema, to see if models are compatible." s = "" for f in m["flds"]: s += f["name"] for t in m["tmpls"]: s += t["name"] return checksum(s) # Required field/text cache ########################################################################## # fixme: genCards(), clayout, importing, cards.isEmpty def availOrds(self, m: NoteType, flds: str) -> List: "Given a joined field string, return available template ordinals." if m["type"] == MODEL_CLOZE: return self._availClozeOrds(m, flds) fields = {} for c, f in enumerate(splitFields(flds)): fields[c] = f.strip() avail = [] for ord, type, req in m["req"]: # unsatisfiable template if type == "none": continue # AND requirement? elif type == "all": ok = True for idx in req: if not fields[idx]: # missing and was required ok = False break if not ok: continue # OR requirement? elif type == "any": ok = False for idx in req: if fields[idx]: ok = True break if not ok: continue avail.append(ord) return avail def _availClozeOrds(self, m: NoteType, flds: str, allowEmpty: bool = True) -> List: sflds = splitFields(flds) map = self.fieldMap(m) ords = set() matches = re.findall("{{[^}]*?cloze:(?:[^}]?:)*(.+?)}}", m["tmpls"][0]["qfmt"]) matches += re.findall("<%cloze:(.+?)%>", m["tmpls"][0]["qfmt"]) for fname in matches: if fname not in map: continue ord = map[fname][0] ords.update( [int(m) - 1 for m in re.findall(r"(?s){{c(\d+)::.+?}}", sflds[ord])] ) if -1 in ords: ords.remove(-1) if not ords and allowEmpty: # empty clozes use first ord return [0] return list(ords) # Sync handling ########################################################################## def beforeUpload(self) -> None: for m in self.all(): m["usn"] = 0 self.save()