hook up new note and note type handling

- notetypes are fetched from the DB as needed, and cached in Python
- handle note type changes in the backend. Multiple operations can now
be performed in one go, but this is not currently exposed in the GUI.
- extra methods to grab sorted note type names quickly, and fetch by
name
- col.models.save() without a provided notetype is now a no-op
- note loading/saving handled in the backend
- notes with no valid cards can now be added
- templates can now be deleted even if they would previously
orphan notes

a number of fixmes have been left in notes.py and models.py
This commit is contained in:
Damien Elmes 2020-04-25 20:13:46 +10:00
parent 8b0121b0ac
commit f637ac957d
14 changed files with 365 additions and 403 deletions

View file

@ -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."""

View file

@ -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)

View file

@ -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)

View file

@ -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"]:
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]
break
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)
if m["id"]:
self.save(m)
def remTemplate(self, m: NoteType, template: Template) -> bool:
"False if removing template would leave orphan notes."
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
##########################################################################

View file

@ -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)

View file

@ -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]

View file

@ -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())

View file

@ -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():

View file

@ -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

View file

@ -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<img src='foo.jpg'>ao") == ["foo.jpg"]
assert mf(mid, "aoeu<img src='foo.jpg' style='test'>ao") == ["foo.jpg"]

View file

@ -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]

View file

@ -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)

View file

@ -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):

View file

@ -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()