diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 9e93c3d53..01845a74b 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -166,8 +166,6 @@ 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. @@ -232,6 +230,7 @@ crt=?, mod=?, scm=?, dty=?, usn=?, ls=?""", self.save(trx=False) else: self.db.rollback() + self.models._clear_cache() self.backend.close_collection(downgrade=downgrade) self.db = None self.media.close() @@ -319,21 +318,12 @@ crt=?, mod=?, scm=?, dty=?, usn=?, ls=?""", "Return a new note with the current model." return Note(self, self.models.current(forDeck)) + def add_note(self, note: Note, deck_id: int) -> None: + note.id = self.backend.add_note(note.to_backend_note(), deck_id) + 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 + self.add_note(note, note.model()["did"]) + return len(note.cards()) def remNotes(self, ids: Iterable[int]) -> None: """Deletes notes with the given IDs.""" diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py index 300d4c97b..0114f2c65 100644 --- a/pylib/anki/exporting.py +++ b/pylib/anki/exporting.py @@ -226,7 +226,7 @@ class AnkiExporter(Exporter): # need to reset card state self.dst.sched.resetCards(cids) # models - start with zero - self.dst.models.models = {} + self.dst.models.remove_all_notetypes() for m in self.src.models.all(): if int(m["id"]) in mids: self.dst.models.update(m) diff --git a/pylib/anki/media.py b/pylib/anki/media.py index dbfddd8fb..410298b7f 100644 --- a/pylib/anki/media.py +++ b/pylib/anki/media.py @@ -10,7 +10,7 @@ import time import urllib.error import urllib.parse import urllib.request -from typing import Any, Callable, List, Optional, Tuple, Union +from typing import Any, Callable, List, Optional, Tuple import anki from anki.consts import * @@ -122,7 +122,7 @@ class MediaManager: ########################################################################## def filesInStr( - self, mid: Union[int, str], string: str, includeRemote: bool = False + self, mid: int, string: str, includeRemote: bool = False ) -> List[str]: l = [] model = self.col.models.get(mid) diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 0759f7a98..47c2aeb2b 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -6,9 +6,10 @@ from __future__ import annotations import copy import re import time -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +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 _ @@ -23,12 +24,16 @@ TemplateRequirementType = str # Union["all", "any", "none"] TemplateRequiredFieldOrds = Tuple[int, TemplateRequirementType, List[int]] AllTemplateReqs = List[TemplateRequiredFieldOrds] +# 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": """\ @@ -43,7 +48,7 @@ defaultModel: NoteType = { "latexPost": "\\end{document}", "mod": 0, "usn": 0, - "vers": [], # FIXME: remove when other clients have caught up + "req": [], "type": MODEL_STD, "css": """\ .card { @@ -83,50 +88,131 @@ defaultTemplate: Template = { } -class ModelManager: - models: Dict[str, NoteType] +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 = {} - self.changed = False + self.models = ModelsDictProxy(col) + # do not access this directly! + self._cache = {} def save( self, - m: Optional[NoteType] = None, + m: NoteType = None, + # no longer used templates: bool = False, updateReqs: bool = True, ) -> None: - "Mark M modified if provided, and schedule registry flush." - if m and m["id"]: - m["mod"] = intTime() - m["usn"] = self.col.usn() - if updateReqs: - self._updateRequired(m) - if templates: - self._syncTemplates(m) - self.changed = True + "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: - "Flush the registry if any models were changed." - if self.changed: - self.ensureNotEmpty() - self.col.backend.set_all_notetypes(self.models) - self.changed = False + pass + # fixme: enforce at lower level def ensureNotEmpty(self) -> Optional[bool]: - if not self.models: + if not self.all_names_and_ids(): from anki.stdmodels import addBasicModel addBasicModel(self.col) return True return None - # Retrieving and creating models + # 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() + + def id_for_name(self, name: str) -> Optional[int]: + return self.col.backend.get_notetype_id_by_name(name) + + # 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: @@ -134,33 +220,46 @@ class ModelManager: m = self.get(self.col.decks.current().get("mid")) if not forDeck or not m: m = self.get(self.col.conf["curModel"]) - return m or list(self.models.values())[0] + 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() - def get(self, id: Any) -> Any: + # Retrieving and creating models + ############################################################# + + def get(self, id: int) -> Optional[NoteType]: "Get model with ID, or None." - id = str(id) - if id in self.models: - return self.models[id] + # deal with various legacy input types + if id is None: + return None + elif isinstance(id, str): + id = int(id) - def all(self) -> List: + 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 list(self.models.values()) + return [self.get(nt.id) for nt in self.all_names_and_ids()] - def allNames(self) -> List: - return [m["name"] for m in self.all()] - - def byName(self, name: str) -> Any: + def byName(self, name: str) -> Optional[NoteType]: "Get model with NAME." - for m in list(self.models.values()): - if m["name"] == name: - return m + 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, save it in the registry, and return it." + "Create a new model, and return it." # caller should call save() after modifying m = defaultModel.copy() m["name"] = name @@ -168,59 +267,50 @@ class ModelManager: m["flds"] = [] m["tmpls"] = [] m["tags"] = [] - m["id"] = None + 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) - current = self.current()["id"] == m["id"] - # delete notes/cards - self.col.remCards( - self.col.db.list( - """ -select id from cards where nid in (select id from notes where mid = ?)""", - m["id"], - ) - ) - # then the model - del self.models[str(m["id"])] - self.save() - # GUI should ensure last model is not deleted - if current: - self.setCurrent(list(self.models.values())[0]) + 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._setID(m) - self.update(m) - self.setCurrent(m) self.save(m) def ensureNameUnique(self, m: NoteType) -> None: - for mcur in self.all(): - if mcur["name"] == m["name"] and mcur["id"] != m["id"]: - m["name"] += "-" + checksum(str(time.time()))[:5] - break + 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) -> None: - "Add or update an existing model. Used for syncing and merging." + 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.models[str(m["id"])] = m - # mark registry changed, but don't bump mod time - self.save() + self.col.backend.add_or_update_notetype(m, preserve_usn=preserve_usn) + self.setCurrent(m) + self._mutate_after_write(m) - def _setID(self, m: NoteType) -> None: - while 1: - id = str(intTime(1000)) - if id not in self.models: - break - m["id"] = id - - def have(self, id: int) -> bool: - return str(id) in self.models - - def ids(self) -> List[str]: - return list(self.models.keys()) + 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 ################################################## @@ -231,17 +321,9 @@ select id from cards where nid in (select id from notes where mid = ?)""", def useCount(self, m: NoteType) -> Any: "Number of note using M." + print("useCount() is slow; prefer all_use_counts()") return self.col.db.scalar("select count() from notes where mid = ?", m["id"]) - def tmplUseCount(self, m: NoteType, ord) -> Any: - return self.col.db.scalar( - """ -select count() from cards, notes where cards.nid = notes.id -and notes.mid = ? and cards.ord = ?""", - m["id"], - ord, - ) - # Copying ################################################## @@ -249,18 +331,13 @@ and notes.mid = ? and cards.ord = ?""", "Copy, save and return." m2 = copy.deepcopy(m) m2["name"] = _("%s copy") % m2["name"] + m2["id"] = 0 self.add(m2) return m2 # Fields ################################################## - def newField(self, name: str) -> Field: - assert isinstance(name, str) - f = defaultField.copy() - f["name"] = name - return f - 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"]) @@ -274,111 +351,55 @@ and notes.mid = ? and cards.ord = ?""", def setSortIdx(self, m: NoteType, idx: int) -> None: assert 0 <= idx < len(m["flds"]) self.col.modSchema(check=True) - m["sortf"] = idx - self.col.updateFieldCache(self.nids(m)) - self.save(m, updateReqs=False) - def addField(self, m: NoteType, field: Field) -> None: - # only mod schema if model isn't new - if m["id"]: - self.col.modSchema(check=True) - m["flds"].append(field) - self._updateFieldOrds(m) + m["sortf"] = idx + self.save(m) - def add(fields): - fields.append("") - return fields + # Adding & changing fields + ################################################## - self._transformFields(m, add) + 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) -> None: + if m["id"]: + self.col.modSchema(check=True) + + m["flds"].append(field) + + if m["id"]: + self.save(m) def remField(self, m: NoteType, field: Field) -> None: self.col.modSchema(check=True) - # save old sort field - sortFldName = m["flds"][m["sortf"]]["name"] - idx = m["flds"].index(field) + m["flds"].remove(field) - # restore old sort field if possible, or revert to first field - m["sortf"] = 0 - for c, f in enumerate(m["flds"]): - if f["name"] == sortFldName: - m["sortf"] = c - break - self._updateFieldOrds(m) - def delete(fields): - del fields[idx] - return fields - - self._transformFields(m, delete) - if m["flds"][m["sortf"]]["name"] != sortFldName: - # need to rebuild sort field - self.col.updateFieldCache(self.nids(m)) - # saves - self.renameField(m, field, None) + self.save(m) def moveField(self, m: NoteType, field: Field, idx: int) -> None: self.col.modSchema(check=True) oldidx = m["flds"].index(field) if oldidx == idx: return - # remember old sort field - sortf = m["flds"][m["sortf"]] - # move + m["flds"].remove(field) m["flds"].insert(idx, field) - # restore sort field - m["sortf"] = m["flds"].index(sortf) - self._updateFieldOrds(m) - self.save(m, updateReqs=False) - def move(fields, oldidx=oldidx): - val = fields[oldidx] - del fields[oldidx] - fields.insert(idx, val) - return fields - - self._transformFields(m, move) - - def renameField(self, m: NoteType, field: Field, newName: Optional[str]) -> None: - self.col.modSchema(check=True) - if newName is not None: - newName = newName.replace(":", "") - pat = r"{{([^{}]*)([:#^/]|[^:#/^}][^:}]*?:|)%s}}" - - def wrap(txt): - def repl(match): - return "{{" + match.group(1) + match.group(2) + txt + "}}" - - return repl - - for t in m["tmpls"]: - for fmt in ("qfmt", "afmt"): - if newName: - t[fmt] = re.sub( - pat % re.escape(field["name"]), wrap(newName), t[fmt] - ) - else: - t[fmt] = re.sub(pat % re.escape(field["name"]), "", t[fmt]) - field["name"] = newName self.save(m) - def _updateFieldOrds(self, m: NoteType) -> None: - for c, f in enumerate(m["flds"]): - f["ord"] = c + def renameField(self, m: NoteType, field: Field, newName: str) -> None: + assert field in m["flds"] - def _transformFields(self, m: NoteType, fn: Callable) -> None: - # model hasn't been added yet? - if not m["id"]: - return - r = [] - for (id, flds) in self.col.db.execute( - "select id, flds from notes where mid = ?", m["id"] - ): - r.append((joinFields(fn(splitFields(flds))), intTime(), self.col.usn(), id)) - self.col.db.executemany("update notes set flds=?,mod=?,usn=? where id = ?", r) + field["name"] = newName - # Templates + self.save(m) + + # Adding & changing templates ################################################## def newTemplate(self, name: str) -> Template: @@ -387,84 +408,33 @@ and notes.mid = ? and cards.ord = ?""", return t def addTemplate(self, m: NoteType, template: Template) -> None: - "Note: should col.genCards() afterwards." if m["id"]: self.col.modSchema(check=True) + m["tmpls"].append(template) - self._updateTemplOrds(m) - self.save(m) - def remTemplate(self, m: NoteType, template: Template) -> bool: - "False if removing template would leave orphan notes." + if m["id"]: + self.save(m) + + def remTemplate(self, m: NoteType, template: Template) -> None: assert len(m["tmpls"]) > 1 - # find cards using this template - ord = m["tmpls"].index(template) - cids = self.col.db.list( - """ -select c.id from cards c, notes f where c.nid=f.id and mid = ? and ord = ?""", - m["id"], - ord, - ) - # all notes with this template must have at least two cards, or we - # could end up creating orphaned notes - if self.col.db.scalar( - """ -select nid, count() from cards where -nid in (select nid from cards where id in %s) -group by nid -having count() < 2 -limit 1""" - % ids2str(cids) - ): - return False - # ok to proceed; remove cards self.col.modSchema(check=True) - self.col.remCards(cids) - # shift ordinals - self.col.db.execute( - """ -update cards set ord = ord - 1, usn = ?, mod = ? - where nid in (select id from notes where mid = ?) and ord > ?""", - self.col.usn(), - intTime(), - m["id"], - ord, - ) - m["tmpls"].remove(template) - self._updateTemplOrds(m) - self.save(m) - return True - def _updateTemplOrds(self, m: NoteType) -> None: - for c, t in enumerate(m["tmpls"]): - t["ord"] = c + 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 - oldidxs = dict((id(t), t["ord"]) for t in m["tmpls"]) + m["tmpls"].remove(template) m["tmpls"].insert(idx, template) - self._updateTemplOrds(m) - # generate change map - map = [] - for t in m["tmpls"]: - map.append("when ord = %d then %d" % (oldidxs[id(t)], t["ord"])) - # apply - self.save(m, updateReqs=False) - self.col.db.execute( - """ -update cards set ord = (case %s end),usn=?,mod=? where nid in ( -select id from notes where mid = ?)""" - % " ".join(map), - self.col.usn(), - intTime(), - m["id"], - ) - def _syncTemplates(self, m: NoteType) -> None: - rem = self.col.genCards(self.nids(m)) + self.save(m) # Model changing ########################################################################## diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py index 5d8a3ddee..a8f04d8d9 100644 --- a/pylib/anki/notes.py +++ b/pylib/anki/notes.py @@ -3,35 +3,19 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, List, Optional, Tuple import anki # pylint: disable=unused-import from anki import hooks -from anki.models import Field, NoteType -from anki.utils import ( - fieldChecksum, - guid64, - intTime, - joinFields, - splitFields, - stripHTMLMedia, - timestampID, -) +from anki.models import NoteType +from anki.rsbackend import BackendNote +from anki.utils import fieldChecksum, joinFields, splitFields, stripHTMLMedia class Note: - col: anki.storage._Collection - newlyAdded: bool - id: int - guid: str - _model: NoteType - mid: int - tags: List[str] - fields: List[str] - flags: int - data: str - _fmap: Dict[str, Tuple[int, Field]] - scm: int + # not currently exposed + flags = 0 + data = "" def __init__( self, @@ -41,78 +25,51 @@ class Note: ) -> None: assert not (model and id) self.col = col.weakref() - self.newlyAdded = False + # self.newlyAdded = False + if id: + # existing note self.id = id self.load() else: - self.id = timestampID(col.db, "notes") - self.guid = guid64() - self._model = model - self.mid = model["id"] - self.tags = [] - self.fields = [""] * len(self._model["flds"]) - self.flags = 0 - self.data = "" - self._fmap = self.col.models.fieldMap(self._model) - self.scm = self.col.scm + # new note for provided notetype + self._load_from_backend_note(self.col.backend.new_note(model["id"])) def load(self) -> None: - ( - self.guid, - self.mid, - self.mod, - self.usn, - tags, - fields, - self.flags, - self.data, - ) = self.col.db.first( - """ -select guid, mid, mod, usn, tags, flds, flags, data -from notes where id = ?""", - self.id, - ) - self.fields = splitFields(fields) - self.tags = self.col.tags.split(tags) + n = self.col.backend.get_note(self.id) + assert n + self._load_from_backend_note(n) + + def _load_from_backend_note(self, n: BackendNote) -> None: + self.id = n.id + self.guid = n.guid + self.mid = n.ntid + self.mod = n.mtime_secs + self.usn = n.usn + self.tags = list(n.tags) + self.fields = list(n.fields) + self._model = self.col.models.get(self.mid) self._fmap = self.col.models.fieldMap(self._model) - self.scm = self.col.scm - def flush(self, mod: Optional[int] = None) -> None: - "If fields or tags have changed, write changes to disk." - assert self.scm == self.col.scm - self._preFlush() - sfld = stripHTMLMedia(self.fields[self.col.models.sortIdx(self._model)]) - tags = self.stringTags() - fields = self.joinedFields() - if not mod and self.col.db.scalar( - "select 1 from notes where id = ? and tags = ? and flds = ?", - self.id, - tags, - fields, - ): - return - csum = fieldChecksum(self.fields[0]) - self.mod = mod if mod else intTime() - self.usn = self.col.usn() - res = self.col.db.execute( - """ -insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)""", - self.id, - self.guid, - self.mid, - self.mod, - self.usn, - tags, - fields, - sfld, - csum, - self.flags, - self.data, + # fixme: only save tags in list on save + def to_backend_note(self) -> BackendNote: + hooks.note_will_flush(self) + return BackendNote( + id=self.id, + guid=self.guid, + ntid=self.mid, + mtime_secs=self.mod, + usn=self.usn, + # fixme: catch spaces in individual tags + tags=" ".join(self.tags).split(" "), + fields=self.fields, ) - self.col.tags.register(self.tags) - self._postFlush() + + def flush(self, mod=None) -> None: + # fixme: mod unused? + assert self.id != 0 + self.col.backend.update_note(self.to_backend_note()) def joinedFields(self) -> str: return joinFields(self.fields) @@ -198,22 +155,3 @@ insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)""", if stripHTMLMedia(splitFields(flds)[0]) == stripHTMLMedia(self.fields[0]): return 2 return False - - # Flushing cloze notes - ################################################## - - def _preFlush(self) -> None: - hooks.note_will_flush(self) - # have we been added yet? - self.newlyAdded = not self.col.db.scalar( - "select 1 from cards where nid = ?", self.id - ) - - def _postFlush(self) -> None: - # generate missing cards - if not self.newlyAdded: - rem = self.col.genCards([self.id]) - # popping up a dialog while editing is confusing; instead we can - # document that the user should open the templates window to - # garbage collect empty cards - # self.col.remEmptyCards(ids) diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 692fed2a5..8acca1811 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -47,6 +47,7 @@ assert ankirspy.buildhash() == anki.buildinfo.buildhash SchedTimingToday = pb.SchedTimingTodayOut BuiltinSortKind = pb.BuiltinSearchOrder.BuiltinSortKind BackendCard = pb.Card +BackendNote = pb.Note TagUsnTuple = pb.TagUsnTuple NoteType = pb.NoteType @@ -98,6 +99,10 @@ class TemplateError(StringError): pass +class NotFoundError(Exception): + pass + + def proto_exception_to_native(err: pb.BackendError) -> Exception: val = err.WhichOneof("value") if val == "interrupted": @@ -116,6 +121,8 @@ def proto_exception_to_native(err: pb.BackendError) -> Exception: return StringError(err.localized) elif val == "json_error": return StringError(err.localized) + elif val == "not_found_error": + return NotFoundError() else: assert_impossible_literal(val) @@ -609,15 +616,12 @@ class RustBackend: def set_all_config(self, conf: Dict[str, Any]): self._run_command(pb.BackendInput(set_all_config=orjson.dumps(conf))) - def get_all_notetypes(self) -> Dict[str, Dict[str, Any]]: + def get_changed_notetypes(self, usn: int) -> Dict[str, Dict[str, Any]]: jstr = self._run_command( - pb.BackendInput(get_all_notetypes=pb.Empty()) - ).get_all_notetypes + pb.BackendInput(get_changed_notetypes=usn) + ).get_changed_notetypes return orjson.loads(jstr) - def set_all_notetypes(self, nts: Dict[str, Dict[str, Any]]): - self._run_command(pb.BackendInput(set_all_notetypes=orjson.dumps(nts))) - def get_all_decks(self) -> Dict[str, Dict[str, Any]]: jstr = self._run_command( pb.BackendInput(get_all_decks=pb.Empty()) @@ -634,6 +638,67 @@ class RustBackend: ).all_stock_notetypes.notetypes ) + def get_notetype_names_and_ids(self) -> List[pb.NoteTypeNameID]: + return list( + self._run_command( + pb.BackendInput(get_notetype_names=pb.Empty()) + ).get_notetype_names.entries + ) + + def get_notetype_use_counts(self) -> List[pb.NoteTypeNameIDUseCount]: + return list( + self._run_command( + pb.BackendInput(get_notetype_names_and_counts=pb.Empty()) + ).get_notetype_names_and_counts.entries + ) + + def get_notetype_legacy(self, ntid: int) -> Optional[Dict]: + try: + bytes = self._run_command( + pb.BackendInput(get_notetype_legacy=ntid) + ).get_notetype_legacy + except NotFoundError: + return None + return orjson.loads(bytes) + + def get_notetype_id_by_name(self, name: str) -> Optional[int]: + return ( + self._run_command( + pb.BackendInput(get_notetype_id_by_name=name) + ).get_notetype_id_by_name + or None + ) + + def add_or_update_notetype(self, nt: Dict[str, Any], preserve_usn: bool) -> None: + bjson = orjson.dumps(nt) + id = self._run_command( + pb.BackendInput( + add_or_update_notetype=pb.AddOrUpdateNotetypeIn( + json=bjson, preserve_usn_and_mtime=preserve_usn + ) + ) + ).add_or_update_notetype + nt["id"] = id + + def remove_notetype(self, ntid: int) -> None: + self._run_command(pb.BackendInput(remove_notetype=ntid)) + + def new_note(self, ntid: int) -> BackendNote: + return self._run_command(pb.BackendInput(new_note=ntid)).new_note + + def add_note(self, note: BackendNote, deck_id: int) -> int: + return self._run_command( + pb.BackendInput(add_note=pb.AddNoteIn(note=note, deck_id=deck_id)) + ).add_note + + def update_note(self, note: BackendNote) -> None: + self._run_command(pb.BackendInput(update_note=note)) + + def get_note(self, nid) -> Optional[BackendNote]: + try: + return self._run_command(pb.BackendInput(get_note=nid)).get_note + except NotFoundError: + return None def translate_string_in( key: TR, **kwargs: Union[str, int, float] diff --git a/pylib/tests/test_cards.py b/pylib/tests/test_cards.py index 6e519c84f..af1ddae58 100644 --- a/pylib/tests/test_cards.py +++ b/pylib/tests/test_cards.py @@ -69,6 +69,7 @@ def test_genrem(): mm.save(m, templates=True) assert len(f.cards()) == 2 # if the template is changed to remove cards, they'll be removed + t = m["tmpls"][1] t["qfmt"] = "{{Back}}" mm.save(m, templates=True) d.remCards(d.emptyCids()) diff --git a/pylib/tests/test_collection.py b/pylib/tests/test_collection.py index cd47fced8..31be6bf6f 100644 --- a/pylib/tests/test_collection.py +++ b/pylib/tests/test_collection.py @@ -60,11 +60,6 @@ def test_noteAddDelete(): t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) - # the default save doesn't generate cards - assert deck.cardCount() == 1 - # but when templates are edited such as in the card layout screen, it - # should generate cards on close - mm.save(m, templates=True, updateReqs=False) assert deck.cardCount() == 2 # creating new notes should use both cards f = deck.newNote() @@ -124,10 +119,10 @@ def test_addDelTags(): def test_timestamps(): deck = getEmptyCol() - assert len(deck.models.models) == len(models) + assert len(deck.models.all_names_and_ids()) == len(models) for i in range(100): addBasicModel(deck) - assert len(deck.models.models) == 100 + len(models) + assert len(deck.models.all_names_and_ids()) == 100 + len(models) def test_furigana(): diff --git a/pylib/tests/test_find.py b/pylib/tests/test_find.py index 66f462c84..b8ea1c055 100644 --- a/pylib/tests/test_find.py +++ b/pylib/tests/test_find.py @@ -17,8 +17,8 @@ def test_findCards(): f["Front"] = "dog" f["Back"] = "cat" f.tags.append("monkey animal_1 * %") - f1id = f.id deck.addNote(f) + f1id = f.id firstCardId = f.cards()[0].id f = deck.newNote() f["Front"] = "goats are fun" @@ -32,6 +32,7 @@ def test_findCards(): deck.addNote(f) catCard = f.cards()[0] m = deck.models.current() + m = deck.models.copy(m) mm = deck.models t = mm.newTemplate("Reverse") t["qfmt"] = "{{Back}}" @@ -130,8 +131,8 @@ def test_findCards(): != firstCardId ) # model - assert len(deck.findCards("note:basic")) == 5 - assert len(deck.findCards("-note:basic")) == 0 + assert len(deck.findCards("note:basic")) == 3 + assert len(deck.findCards("-note:basic")) == 2 assert len(deck.findCards("-note:foo")) == 5 # deck assert len(deck.findCards("deck:default")) == 5 diff --git a/pylib/tests/test_media.py b/pylib/tests/test_media.py index 22ffba4cb..d5b7c454f 100644 --- a/pylib/tests/test_media.py +++ b/pylib/tests/test_media.py @@ -26,7 +26,7 @@ def test_add(): def test_strings(): d = getEmptyCol() mf = d.media.filesInStr - mid = list(d.models.models.keys())[0] + mid = d.models.current()["id"] assert mf(mid, "aoeu") == [] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == ["foo.jpg"] diff --git a/pylib/tests/test_models.py b/pylib/tests/test_models.py index 8209a3311..7414b4cd4 100644 --- a/pylib/tests/test_models.py +++ b/pylib/tests/test_models.py @@ -48,6 +48,7 @@ def test_fields(): assert d.getNote(d.models.nids(m)[0]).fields == ["1", "2", ""] assert d.models.scmhash(m) != h # rename it + f = m["flds"][2] d.models.renameField(m, f, "bar") assert d.getNote(d.models.nids(m)[0])["bar"] == "" # delete back @@ -102,7 +103,7 @@ def test_templates(): assert c.ord == 1 assert c2.ord == 0 # removing a template should delete its cards - assert d.models.remTemplate(m, m["tmpls"][0]) + d.models.remTemplate(m, m["tmpls"][0]) assert d.cardCount() == 1 # and should have updated the other cards' ordinals c = f.cards()[0] @@ -111,7 +112,11 @@ def test_templates(): # it shouldn't be possible to orphan notes by removing templates t = mm.newTemplate("template name") mm.addTemplate(m, t) - assert not d.models.remTemplate(m, m["tmpls"][0]) + d.models.remTemplate(m, m["tmpls"][0]) + assert ( + d.db.scalar("select count() from cards where nid not in (select id from notes)") + == 0 + ) def test_cloze_ordinals(): @@ -269,7 +274,6 @@ def test_chained_mods(): def test_modelChange(): deck = getEmptyCol() - basic = deck.models.byName("Basic") cloze = deck.models.byName("Cloze") # enable second template and add a note m = deck.models.current() @@ -279,6 +283,7 @@ def test_modelChange(): t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) + basic = m f = deck.newNote() f["Front"] = "f" f["Back"] = "b123" @@ -334,8 +339,9 @@ def test_modelChange(): f["Front"] = "f2" f["Back"] = "b2" deck.addNote(f) - assert deck.models.useCount(basic) == 2 - assert deck.models.useCount(cloze) == 0 + counts = deck.models.all_use_counts() + assert next(c.use_count for c in counts if c.name == "Basic") == 2 + assert next(c.use_count for c in counts if c.name == "Cloze") == 0 map = {0: 0, 1: 1} deck.models.change(basic, [f.id], cloze, map, map) f.load() @@ -362,13 +368,16 @@ def test_availOrds(): mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) # AND + t = m["tmpls"][0] t["qfmt"] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) + t = m["tmpls"][0] t["qfmt"] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) # OR + t = m["tmpls"][0] t["qfmt"] = "{{Front}}\n{{Back}}" mm.save(m, templates=True) assert mm.availOrds(m, joinFields(f.fields)) == [0] diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 4f899b5aa..fe850796e 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1176,11 +1176,11 @@ QTableView {{ gridline-color: {grid} }} def _modelTree(self, root) -> None: assert self.col - for m in sorted(self.col.models.all(), key=itemgetter("name")): + for m in self.col.models.all_names_and_ids(): item = SidebarItem( - m["name"], + m.name, ":/icons/notetype.svg", - lambda m=m: self.setFilter("note", m["name"]), # type: ignore + lambda m=m: self.setFilter("note", m.name), # type: ignore ) root.addChild(item) diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 2c36ab16c..d09f0595f 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -239,21 +239,12 @@ class CardLayout(QDialog): if len(self.model["tmpls"]) < 2: return showInfo(_("At least one card type is required.")) idx = self.ord - cards = self.mm.tmplUseCount(self.model, idx) - cards = ngettext("%d card", "%d cards", cards) % cards msg = _("Delete the '%(a)s' card type, and its %(b)s?") % dict( - a=self.model["tmpls"][idx]["name"], b=cards + a=self.model["tmpls"][idx]["name"], b=_("cards") ) if not askUser(msg): return - if not self.mm.remTemplate(self.model, self.cards[idx].template()): - return showWarning( - _( - """\ -Removing this card type would cause one or more notes to be deleted. \ -Please create a new card type first.""" - ) - ) + self.mm.remTemplate(self.model, self.cards[idx].template()) self.redraw() def removeColons(self): diff --git a/qt/aqt/models.py b/qt/aqt/models.py index aac73abfc..54c703f35 100644 --- a/qt/aqt/models.py +++ b/qt/aqt/models.py @@ -4,10 +4,12 @@ import collections import re from operator import itemgetter from typing import Optional +from typing import List import aqt.clayout from anki import stdmodels from anki.lang import _, ngettext +from anki.rsbackend import pb from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.utils import ( @@ -34,6 +36,7 @@ class Models(QDialog): self.form = aqt.forms.models.Ui_Dialog() self.form.setupUi(self) qconnect(self.form.buttonBox.helpRequested, lambda: openHelp("notetypes")) + self.models: List[pb.NoteTypeNameIDUseCount] = [] self.setupModels() restoreGeom(self, "models") self.exec_() @@ -76,13 +79,12 @@ class Models(QDialog): row = self.form.modelsList.currentRow() if row == -1: row = 0 - self.models = self.col.models.all() - self.models.sort(key=itemgetter("name")) + self.models = self.col.models.all_use_counts() self.form.modelsList.clear() for m in self.models: - mUse = self.mm.useCount(m) + mUse = m.use_count mUse = ngettext("%d note", "%d notes", mUse) % mUse - item = QListWidgetItem("%s [%s]" % (m["name"], mUse)) + item = QListWidgetItem("%s [%s]" % (m.name, mUse)) self.form.modelsList.addItem(item) self.form.modelsList.setCurrentRow(row) @@ -90,7 +92,7 @@ class Models(QDialog): if self.model: self.saveModel() idx = self.form.modelsList.currentRow() - self.model = self.models[idx] + self.model = self.col.models.get(self.models[idx].id) def onAdd(self): m = AddModel(self.mw, self).get()