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, "aoeu
ao") == ["foo.jpg"]
assert mf(mid, "aoeu
ao") == ["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()