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.decks = self.backend.get_all_decks()
self.decks.changed = False self.decks.changed = False
self.models.models = self.backend.get_all_notetypes()
self.models.changed = False
def setMod(self) -> None: def setMod(self) -> None:
"""Mark DB modified. """Mark DB modified.
@ -232,6 +230,7 @@ crt=?, mod=?, scm=?, dty=?, usn=?, ls=?""",
self.save(trx=False) self.save(trx=False)
else: else:
self.db.rollback() self.db.rollback()
self.models._clear_cache()
self.backend.close_collection(downgrade=downgrade) self.backend.close_collection(downgrade=downgrade)
self.db = None self.db = None
self.media.close() self.media.close()
@ -319,21 +318,12 @@ crt=?, mod=?, scm=?, dty=?, usn=?, ls=?""",
"Return a new note with the current model." "Return a new note with the current model."
return Note(self, self.models.current(forDeck)) 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: def addNote(self, note: Note) -> int:
"""Add a note to the collection. Return number of new cards.""" self.add_note(note, note.model()["did"])
# check we have card models available, then save return len(note.cards())
cms = self.findTemplates(note)
if not cms:
return 0
note.flush()
# deck conf governs which of these are used
due = self.nextID("pos")
# add cards
ncards = 0
for template in cms:
self._newCard(note, template, due)
ncards += 1
return ncards
def remNotes(self, ids: Iterable[int]) -> None: def remNotes(self, ids: Iterable[int]) -> None:
"""Deletes notes with the given IDs.""" """Deletes notes with the given IDs."""

View file

@ -226,7 +226,7 @@ class AnkiExporter(Exporter):
# need to reset card state # need to reset card state
self.dst.sched.resetCards(cids) self.dst.sched.resetCards(cids)
# models - start with zero # models - start with zero
self.dst.models.models = {} self.dst.models.remove_all_notetypes()
for m in self.src.models.all(): for m in self.src.models.all():
if int(m["id"]) in mids: if int(m["id"]) in mids:
self.dst.models.update(m) self.dst.models.update(m)

View file

@ -10,7 +10,7 @@ import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from typing import Any, Callable, List, Optional, Tuple, Union from typing import Any, Callable, List, Optional, Tuple
import anki import anki
from anki.consts import * from anki.consts import *
@ -122,7 +122,7 @@ class MediaManager:
########################################################################## ##########################################################################
def filesInStr( def filesInStr(
self, mid: Union[int, str], string: str, includeRemote: bool = False self, mid: int, string: str, includeRemote: bool = False
) -> List[str]: ) -> List[str]:
l = [] l = []
model = self.col.models.get(mid) model = self.col.models.get(mid)

View file

@ -6,9 +6,10 @@ from __future__ import annotations
import copy import copy
import re import re
import time 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 # pylint: disable=unused-import
import anki.backend_pb2 as pb
from anki import hooks from anki import hooks
from anki.consts import * from anki.consts import *
from anki.lang import _ from anki.lang import _
@ -23,12 +24,16 @@ TemplateRequirementType = str # Union["all", "any", "none"]
TemplateRequiredFieldOrds = Tuple[int, TemplateRequirementType, List[int]] TemplateRequiredFieldOrds = Tuple[int, TemplateRequirementType, List[int]]
AllTemplateReqs = List[TemplateRequiredFieldOrds] AllTemplateReqs = List[TemplateRequiredFieldOrds]
# fixme: memory leaks
# fixme: syncing, beforeUpload
# Models # Models
########################################################################## ##########################################################################
# - careful not to add any lists/dicts/etc here, as they aren't deep copied # - careful not to add any lists/dicts/etc here, as they aren't deep copied
defaultModel: NoteType = { defaultModel: NoteType = {
"id": 0,
"sortf": 0, "sortf": 0,
"did": 1, "did": 1,
"latexPre": """\ "latexPre": """\
@ -43,7 +48,7 @@ defaultModel: NoteType = {
"latexPost": "\\end{document}", "latexPost": "\\end{document}",
"mod": 0, "mod": 0,
"usn": 0, "usn": 0,
"vers": [], # FIXME: remove when other clients have caught up "req": [],
"type": MODEL_STD, "type": MODEL_STD,
"css": """\ "css": """\
.card { .card {
@ -83,50 +88,131 @@ defaultTemplate: Template = {
} }
class ModelManager: class ModelsDictProxy:
models: Dict[str, NoteType] 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 # Saving/loading registry
############################################################# #############################################################
def __init__(self, col: anki.storage._Collection) -> None: def __init__(self, col: anki.storage._Collection) -> None:
self.col = col.weakref() self.col = col.weakref()
self.models = {} self.models = ModelsDictProxy(col)
self.changed = False # do not access this directly!
self._cache = {}
def save( def save(
self, self,
m: Optional[NoteType] = None, m: NoteType = None,
# no longer used
templates: bool = False, templates: bool = False,
updateReqs: bool = True, updateReqs: bool = True,
) -> None: ) -> None:
"Mark M modified if provided, and schedule registry flush." "Save changes made to provided note type."
if m and m["id"]: if not m:
m["mod"] = intTime() print("col.models.save() should be passed the changed notetype")
m["usn"] = self.col.usn() return
if updateReqs:
self._updateRequired(m) self.update(m, preserve_usn=False)
if templates:
self._syncTemplates(m) # fixme: badly named; also fires on updates
self.changed = True
hooks.note_type_added(m) hooks.note_type_added(m)
# legacy
def flush(self) -> None: def flush(self) -> None:
"Flush the registry if any models were changed." pass
if self.changed:
self.ensureNotEmpty()
self.col.backend.set_all_notetypes(self.models)
self.changed = False
# fixme: enforce at lower level
def ensureNotEmpty(self) -> Optional[bool]: def ensureNotEmpty(self) -> Optional[bool]:
if not self.models: if not self.all_names_and_ids():
from anki.stdmodels import addBasicModel from anki.stdmodels import addBasicModel
addBasicModel(self.col) addBasicModel(self.col)
return True return True
return None 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: def current(self, forDeck: bool = True) -> Any:
@ -134,33 +220,46 @@ class ModelManager:
m = self.get(self.col.decks.current().get("mid")) m = self.get(self.col.decks.current().get("mid"))
if not forDeck or not m: if not forDeck or not m:
m = self.get(self.col.conf["curModel"]) 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: def setCurrent(self, m: NoteType) -> None:
self.col.conf["curModel"] = m["id"] self.col.conf["curModel"] = m["id"]
self.col.setMod() 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." "Get model with ID, or None."
id = str(id) # deal with various legacy input types
if id in self.models: if id is None:
return self.models[id] 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." "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: def byName(self, name: str) -> Optional[NoteType]:
return [m["name"] for m in self.all()]
def byName(self, name: str) -> Any:
"Get model with NAME." "Get model with NAME."
for m in list(self.models.values()): id = self.id_for_name(name)
if m["name"] == name: if id:
return m return self.get(id)
else:
return None
def new(self, name: str) -> NoteType: 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 # caller should call save() after modifying
m = defaultModel.copy() m = defaultModel.copy()
m["name"] = name m["name"] = name
@ -168,59 +267,50 @@ class ModelManager:
m["flds"] = [] m["flds"] = []
m["tmpls"] = [] m["tmpls"] = []
m["tags"] = [] m["tags"] = []
m["id"] = None m["id"] = 0
return m return m
def rem(self, m: NoteType) -> None: def rem(self, m: NoteType) -> None:
"Delete model, and all its cards/notes." "Delete model, and all its cards/notes."
self.remove(m["id"])
def remove_all_notetypes(self):
self.col.modSchema(check=True) self.col.modSchema(check=True)
current = self.current()["id"] == m["id"] for nt in self.all_names_and_ids():
# delete notes/cards self._remove_from_cache(nt.id)
self.col.remCards( self.col.backend.remove_notetype(nt.id)
self.col.db.list(
""" def remove(self, id: int) -> None:
select id from cards where nid in (select id from notes where mid = ?)""", self.col.modSchema(check=True)
m["id"], self._remove_from_cache(id)
) was_current = self.current()["id"] == id
) self.col.backend.remove_notetype(id)
# then the model
del self.models[str(m["id"])] # fixme: handle in backend
self.save() if was_current:
# GUI should ensure last model is not deleted self.col.conf["curModel"] = self.all_names_and_ids()[0].id
if current:
self.setCurrent(list(self.models.values())[0])
def add(self, m: NoteType) -> None: def add(self, m: NoteType) -> None:
self._setID(m)
self.update(m)
self.setCurrent(m)
self.save(m) self.save(m)
def ensureNameUnique(self, m: NoteType) -> None: def ensureNameUnique(self, m: NoteType) -> None:
for mcur in self.all(): existing_id = self.id_for_name(m["name"])
if mcur["name"] == m["name"] and mcur["id"] != m["id"]: if existing_id is not None and existing_id != m["id"]:
m["name"] += "-" + checksum(str(time.time()))[:5] m["name"] += "-" + checksum(str(time.time()))[:5]
break
def update(self, m: NoteType) -> None: def update(self, m: NoteType, preserve_usn=True) -> None:
"Add or update an existing model. Used for syncing and merging." "Add or update an existing model. Use .save() instead."
self._remove_from_cache(m["id"])
self.ensureNameUnique(m) self.ensureNameUnique(m)
self.models[str(m["id"])] = m self.col.backend.add_or_update_notetype(m, preserve_usn=preserve_usn)
# mark registry changed, but don't bump mod time self.setCurrent(m)
self.save() self._mutate_after_write(m)
def _setID(self, m: NoteType) -> None: def _mutate_after_write(self, nt: NoteType) -> None:
while 1: # existing code expects the note type to be mutated to reflect
id = str(intTime(1000)) # the changes made when adding, such as ordinal assignment :-(
if id not in self.models: updated = self.get(nt["id"])
break nt.update(updated)
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())
# Tools # Tools
################################################## ##################################################
@ -231,17 +321,9 @@ select id from cards where nid in (select id from notes where mid = ?)""",
def useCount(self, m: NoteType) -> Any: def useCount(self, m: NoteType) -> Any:
"Number of note using M." "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"]) 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 # Copying
################################################## ##################################################
@ -249,18 +331,13 @@ and notes.mid = ? and cards.ord = ?""",
"Copy, save and return." "Copy, save and return."
m2 = copy.deepcopy(m) m2 = copy.deepcopy(m)
m2["name"] = _("%s copy") % m2["name"] m2["name"] = _("%s copy") % m2["name"]
m2["id"] = 0
self.add(m2) self.add(m2)
return m2 return m2
# Fields # 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]]: def fieldMap(self, m: NoteType) -> Dict[str, Tuple[int, Field]]:
"Mapping of field name -> (ord, field)." "Mapping of field name -> (ord, field)."
return dict((f["name"], (f["ord"], f)) for f in m["flds"]) 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: def setSortIdx(self, m: NoteType, idx: int) -> None:
assert 0 <= idx < len(m["flds"]) assert 0 <= idx < len(m["flds"])
self.col.modSchema(check=True) 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: m["sortf"] = idx
# only mod schema if model isn't new
if m["id"]:
self.col.modSchema(check=True)
m["flds"].append(field)
self._updateFieldOrds(m)
self.save(m) self.save(m)
def add(fields): # Adding & changing fields
fields.append("") ##################################################
return 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: def remField(self, m: NoteType, field: Field) -> None:
self.col.modSchema(check=True) self.col.modSchema(check=True)
# save old sort field
sortFldName = m["flds"][m["sortf"]]["name"]
idx = m["flds"].index(field)
m["flds"].remove(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): self.save(m)
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)
def moveField(self, m: NoteType, field: Field, idx: int) -> None: def moveField(self, m: NoteType, field: Field, idx: int) -> None:
self.col.modSchema(check=True) self.col.modSchema(check=True)
oldidx = m["flds"].index(field) oldidx = m["flds"].index(field)
if oldidx == idx: if oldidx == idx:
return return
# remember old sort field
sortf = m["flds"][m["sortf"]]
# move
m["flds"].remove(field) m["flds"].remove(field)
m["flds"].insert(idx, 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) self.save(m)
def _updateFieldOrds(self, m: NoteType) -> None: def renameField(self, m: NoteType, field: Field, newName: str) -> None:
for c, f in enumerate(m["flds"]): assert field in m["flds"]
f["ord"] = c
def _transformFields(self, m: NoteType, fn: Callable) -> None: field["name"] = newName
# 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)
# Templates self.save(m)
# Adding & changing templates
################################################## ##################################################
def newTemplate(self, name: str) -> Template: def newTemplate(self, name: str) -> Template:
@ -387,84 +408,33 @@ and notes.mid = ? and cards.ord = ?""",
return t return t
def addTemplate(self, m: NoteType, template: Template) -> None: def addTemplate(self, m: NoteType, template: Template) -> None:
"Note: should col.genCards() afterwards."
if m["id"]: if m["id"]:
self.col.modSchema(check=True) self.col.modSchema(check=True)
m["tmpls"].append(template) m["tmpls"].append(template)
self._updateTemplOrds(m)
self.save(m)
def remTemplate(self, m: NoteType, template: Template) -> bool: if m["id"]:
"False if removing template would leave orphan notes." self.save(m)
def remTemplate(self, m: NoteType, template: Template) -> None:
assert len(m["tmpls"]) > 1 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.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: m["tmpls"].remove(template)
for c, t in enumerate(m["tmpls"]):
t["ord"] = c self.save(m)
def moveTemplate(self, m: NoteType, template: Template, idx: int) -> None: def moveTemplate(self, m: NoteType, template: Template, idx: int) -> None:
self.col.modSchema(check=True)
oldidx = m["tmpls"].index(template) oldidx = m["tmpls"].index(template)
if oldidx == idx: if oldidx == idx:
return return
oldidxs = dict((id(t), t["ord"]) for t in m["tmpls"])
m["tmpls"].remove(template) m["tmpls"].remove(template)
m["tmpls"].insert(idx, 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: self.save(m)
rem = self.col.genCards(self.nids(m))
# Model changing # Model changing
########################################################################## ##########################################################################

View file

@ -3,35 +3,19 @@
from __future__ import annotations 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 import anki # pylint: disable=unused-import
from anki import hooks from anki import hooks
from anki.models import Field, NoteType from anki.models import NoteType
from anki.utils import ( from anki.rsbackend import BackendNote
fieldChecksum, from anki.utils import fieldChecksum, joinFields, splitFields, stripHTMLMedia
guid64,
intTime,
joinFields,
splitFields,
stripHTMLMedia,
timestampID,
)
class Note: class Note:
col: anki.storage._Collection # not currently exposed
newlyAdded: bool flags = 0
id: int data = ""
guid: str
_model: NoteType
mid: int
tags: List[str]
fields: List[str]
flags: int
data: str
_fmap: Dict[str, Tuple[int, Field]]
scm: int
def __init__( def __init__(
self, self,
@ -41,78 +25,51 @@ class Note:
) -> None: ) -> None:
assert not (model and id) assert not (model and id)
self.col = col.weakref() self.col = col.weakref()
self.newlyAdded = False # self.newlyAdded = False
if id: if id:
# existing note
self.id = id self.id = id
self.load() self.load()
else: else:
self.id = timestampID(col.db, "notes") # new note for provided notetype
self.guid = guid64() self._load_from_backend_note(self.col.backend.new_note(model["id"]))
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
def load(self) -> None: def load(self) -> None:
( n = self.col.backend.get_note(self.id)
self.guid, assert n
self.mid, self._load_from_backend_note(n)
self.mod,
self.usn, def _load_from_backend_note(self, n: BackendNote) -> None:
tags, self.id = n.id
fields, self.guid = n.guid
self.flags, self.mid = n.ntid
self.data, self.mod = n.mtime_secs
) = self.col.db.first( self.usn = n.usn
""" self.tags = list(n.tags)
select guid, mid, mod, usn, tags, flds, flags, data self.fields = list(n.fields)
from notes where id = ?""",
self.id,
)
self.fields = splitFields(fields)
self.tags = self.col.tags.split(tags)
self._model = self.col.models.get(self.mid) self._model = self.col.models.get(self.mid)
self._fmap = self.col.models.fieldMap(self._model) self._fmap = self.col.models.fieldMap(self._model)
self.scm = self.col.scm
def flush(self, mod: Optional[int] = None) -> None: # fixme: only save tags in list on save
"If fields or tags have changed, write changes to disk." def to_backend_note(self) -> BackendNote:
assert self.scm == self.col.scm hooks.note_will_flush(self)
self._preFlush() return BackendNote(
sfld = stripHTMLMedia(self.fields[self.col.models.sortIdx(self._model)]) id=self.id,
tags = self.stringTags() guid=self.guid,
fields = self.joinedFields() ntid=self.mid,
if not mod and self.col.db.scalar( mtime_secs=self.mod,
"select 1 from notes where id = ? and tags = ? and flds = ?", usn=self.usn,
self.id, # fixme: catch spaces in individual tags
tags, tags=" ".join(self.tags).split(" "),
fields, fields=self.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,
) )
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: def joinedFields(self) -> str:
return joinFields(self.fields) return joinFields(self.fields)
@ -198,22 +155,3 @@ insert or replace into notes values (?,?,?,?,?,?,?,?,?,?,?)""",
if stripHTMLMedia(splitFields(flds)[0]) == stripHTMLMedia(self.fields[0]): if stripHTMLMedia(splitFields(flds)[0]) == stripHTMLMedia(self.fields[0]):
return 2 return 2
return False 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 SchedTimingToday = pb.SchedTimingTodayOut
BuiltinSortKind = pb.BuiltinSearchOrder.BuiltinSortKind BuiltinSortKind = pb.BuiltinSearchOrder.BuiltinSortKind
BackendCard = pb.Card BackendCard = pb.Card
BackendNote = pb.Note
TagUsnTuple = pb.TagUsnTuple TagUsnTuple = pb.TagUsnTuple
NoteType = pb.NoteType NoteType = pb.NoteType
@ -98,6 +99,10 @@ class TemplateError(StringError):
pass pass
class NotFoundError(Exception):
pass
def proto_exception_to_native(err: pb.BackendError) -> Exception: def proto_exception_to_native(err: pb.BackendError) -> Exception:
val = err.WhichOneof("value") val = err.WhichOneof("value")
if val == "interrupted": if val == "interrupted":
@ -116,6 +121,8 @@ def proto_exception_to_native(err: pb.BackendError) -> Exception:
return StringError(err.localized) return StringError(err.localized)
elif val == "json_error": elif val == "json_error":
return StringError(err.localized) return StringError(err.localized)
elif val == "not_found_error":
return NotFoundError()
else: else:
assert_impossible_literal(val) assert_impossible_literal(val)
@ -609,15 +616,12 @@ class RustBackend:
def set_all_config(self, conf: Dict[str, Any]): def set_all_config(self, conf: Dict[str, Any]):
self._run_command(pb.BackendInput(set_all_config=orjson.dumps(conf))) 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( jstr = self._run_command(
pb.BackendInput(get_all_notetypes=pb.Empty()) pb.BackendInput(get_changed_notetypes=usn)
).get_all_notetypes ).get_changed_notetypes
return orjson.loads(jstr) 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]]: def get_all_decks(self) -> Dict[str, Dict[str, Any]]:
jstr = self._run_command( jstr = self._run_command(
pb.BackendInput(get_all_decks=pb.Empty()) pb.BackendInput(get_all_decks=pb.Empty())
@ -634,6 +638,67 @@ class RustBackend:
).all_stock_notetypes.notetypes ).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( def translate_string_in(
key: TR, **kwargs: Union[str, int, float] key: TR, **kwargs: Union[str, int, float]

View file

@ -69,6 +69,7 @@ def test_genrem():
mm.save(m, templates=True) mm.save(m, templates=True)
assert len(f.cards()) == 2 assert len(f.cards()) == 2
# if the template is changed to remove cards, they'll be removed # if the template is changed to remove cards, they'll be removed
t = m["tmpls"][1]
t["qfmt"] = "{{Back}}" t["qfmt"] = "{{Back}}"
mm.save(m, templates=True) mm.save(m, templates=True)
d.remCards(d.emptyCids()) d.remCards(d.emptyCids())

View file

@ -60,11 +60,6 @@ def test_noteAddDelete():
t["afmt"] = "{{Front}}" t["afmt"] = "{{Front}}"
mm.addTemplate(m, t) mm.addTemplate(m, t)
mm.save(m) 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 assert deck.cardCount() == 2
# creating new notes should use both cards # creating new notes should use both cards
f = deck.newNote() f = deck.newNote()
@ -124,10 +119,10 @@ def test_addDelTags():
def test_timestamps(): def test_timestamps():
deck = getEmptyCol() deck = getEmptyCol()
assert len(deck.models.models) == len(models) assert len(deck.models.all_names_and_ids()) == len(models)
for i in range(100): for i in range(100):
addBasicModel(deck) addBasicModel(deck)
assert len(deck.models.models) == 100 + len(models) assert len(deck.models.all_names_and_ids()) == 100 + len(models)
def test_furigana(): def test_furigana():

View file

@ -17,8 +17,8 @@ def test_findCards():
f["Front"] = "dog" f["Front"] = "dog"
f["Back"] = "cat" f["Back"] = "cat"
f.tags.append("monkey animal_1 * %") f.tags.append("monkey animal_1 * %")
f1id = f.id
deck.addNote(f) deck.addNote(f)
f1id = f.id
firstCardId = f.cards()[0].id firstCardId = f.cards()[0].id
f = deck.newNote() f = deck.newNote()
f["Front"] = "goats are fun" f["Front"] = "goats are fun"
@ -32,6 +32,7 @@ def test_findCards():
deck.addNote(f) deck.addNote(f)
catCard = f.cards()[0] catCard = f.cards()[0]
m = deck.models.current() m = deck.models.current()
m = deck.models.copy(m)
mm = deck.models mm = deck.models
t = mm.newTemplate("Reverse") t = mm.newTemplate("Reverse")
t["qfmt"] = "{{Back}}" t["qfmt"] = "{{Back}}"
@ -130,8 +131,8 @@ def test_findCards():
!= firstCardId != firstCardId
) )
# model # model
assert len(deck.findCards("note:basic")) == 5 assert len(deck.findCards("note:basic")) == 3
assert len(deck.findCards("-note:basic")) == 0 assert len(deck.findCards("-note:basic")) == 2
assert len(deck.findCards("-note:foo")) == 5 assert len(deck.findCards("-note:foo")) == 5
# deck # deck
assert len(deck.findCards("deck:default")) == 5 assert len(deck.findCards("deck:default")) == 5

View file

@ -26,7 +26,7 @@ def test_add():
def test_strings(): def test_strings():
d = getEmptyCol() d = getEmptyCol()
mf = d.media.filesInStr mf = d.media.filesInStr
mid = list(d.models.models.keys())[0] mid = d.models.current()["id"]
assert mf(mid, "aoeu") == [] assert mf(mid, "aoeu") == []
assert mf(mid, "aoeu<img src='foo.jpg'>ao") == ["foo.jpg"] assert mf(mid, "aoeu<img src='foo.jpg'>ao") == ["foo.jpg"]
assert mf(mid, "aoeu<img src='foo.jpg' style='test'>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.getNote(d.models.nids(m)[0]).fields == ["1", "2", ""]
assert d.models.scmhash(m) != h assert d.models.scmhash(m) != h
# rename it # rename it
f = m["flds"][2]
d.models.renameField(m, f, "bar") d.models.renameField(m, f, "bar")
assert d.getNote(d.models.nids(m)[0])["bar"] == "" assert d.getNote(d.models.nids(m)[0])["bar"] == ""
# delete back # delete back
@ -102,7 +103,7 @@ def test_templates():
assert c.ord == 1 assert c.ord == 1
assert c2.ord == 0 assert c2.ord == 0
# removing a template should delete its cards # 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 assert d.cardCount() == 1
# and should have updated the other cards' ordinals # and should have updated the other cards' ordinals
c = f.cards()[0] c = f.cards()[0]
@ -111,7 +112,11 @@ def test_templates():
# it shouldn't be possible to orphan notes by removing templates # it shouldn't be possible to orphan notes by removing templates
t = mm.newTemplate("template name") t = mm.newTemplate("template name")
mm.addTemplate(m, t) 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(): def test_cloze_ordinals():
@ -269,7 +274,6 @@ def test_chained_mods():
def test_modelChange(): def test_modelChange():
deck = getEmptyCol() deck = getEmptyCol()
basic = deck.models.byName("Basic")
cloze = deck.models.byName("Cloze") cloze = deck.models.byName("Cloze")
# enable second template and add a note # enable second template and add a note
m = deck.models.current() m = deck.models.current()
@ -279,6 +283,7 @@ def test_modelChange():
t["afmt"] = "{{Front}}" t["afmt"] = "{{Front}}"
mm.addTemplate(m, t) mm.addTemplate(m, t)
mm.save(m) mm.save(m)
basic = m
f = deck.newNote() f = deck.newNote()
f["Front"] = "f" f["Front"] = "f"
f["Back"] = "b123" f["Back"] = "b123"
@ -334,8 +339,9 @@ def test_modelChange():
f["Front"] = "f2" f["Front"] = "f2"
f["Back"] = "b2" f["Back"] = "b2"
deck.addNote(f) deck.addNote(f)
assert deck.models.useCount(basic) == 2 counts = deck.models.all_use_counts()
assert deck.models.useCount(cloze) == 0 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} map = {0: 0, 1: 1}
deck.models.change(basic, [f.id], cloze, map, map) deck.models.change(basic, [f.id], cloze, map, map)
f.load() f.load()
@ -362,13 +368,16 @@ def test_availOrds():
mm.save(m, templates=True) mm.save(m, templates=True)
assert not mm.availOrds(m, joinFields(f.fields)) assert not mm.availOrds(m, joinFields(f.fields))
# AND # AND
t = m["tmpls"][0]
t["qfmt"] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}" t["qfmt"] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}"
mm.save(m, templates=True) mm.save(m, templates=True)
assert not mm.availOrds(m, joinFields(f.fields)) assert not mm.availOrds(m, joinFields(f.fields))
t = m["tmpls"][0]
t["qfmt"] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}" t["qfmt"] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}"
mm.save(m, templates=True) mm.save(m, templates=True)
assert not mm.availOrds(m, joinFields(f.fields)) assert not mm.availOrds(m, joinFields(f.fields))
# OR # OR
t = m["tmpls"][0]
t["qfmt"] = "{{Front}}\n{{Back}}" t["qfmt"] = "{{Front}}\n{{Back}}"
mm.save(m, templates=True) mm.save(m, templates=True)
assert mm.availOrds(m, joinFields(f.fields)) == [0] assert mm.availOrds(m, joinFields(f.fields)) == [0]

View file

@ -1176,11 +1176,11 @@ QTableView {{ gridline-color: {grid} }}
def _modelTree(self, root) -> None: def _modelTree(self, root) -> None:
assert self.col 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( item = SidebarItem(
m["name"], m.name,
":/icons/notetype.svg", ":/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) root.addChild(item)

View file

@ -239,21 +239,12 @@ class CardLayout(QDialog):
if len(self.model["tmpls"]) < 2: if len(self.model["tmpls"]) < 2:
return showInfo(_("At least one card type is required.")) return showInfo(_("At least one card type is required."))
idx = self.ord 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( 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): if not askUser(msg):
return return
if not self.mm.remTemplate(self.model, self.cards[idx].template()): 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.redraw() self.redraw()
def removeColons(self): def removeColons(self):

View file

@ -4,10 +4,12 @@ import collections
import re import re
from operator import itemgetter from operator import itemgetter
from typing import Optional from typing import Optional
from typing import List
import aqt.clayout import aqt.clayout
from anki import stdmodels from anki import stdmodels
from anki.lang import _, ngettext from anki.lang import _, ngettext
from anki.rsbackend import pb
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
@ -34,6 +36,7 @@ class Models(QDialog):
self.form = aqt.forms.models.Ui_Dialog() self.form = aqt.forms.models.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
qconnect(self.form.buttonBox.helpRequested, lambda: openHelp("notetypes")) qconnect(self.form.buttonBox.helpRequested, lambda: openHelp("notetypes"))
self.models: List[pb.NoteTypeNameIDUseCount] = []
self.setupModels() self.setupModels()
restoreGeom(self, "models") restoreGeom(self, "models")
self.exec_() self.exec_()
@ -76,13 +79,12 @@ class Models(QDialog):
row = self.form.modelsList.currentRow() row = self.form.modelsList.currentRow()
if row == -1: if row == -1:
row = 0 row = 0
self.models = self.col.models.all() self.models = self.col.models.all_use_counts()
self.models.sort(key=itemgetter("name"))
self.form.modelsList.clear() self.form.modelsList.clear()
for m in self.models: for m in self.models:
mUse = self.mm.useCount(m) mUse = m.use_count
mUse = ngettext("%d note", "%d notes", mUse) % mUse 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.addItem(item)
self.form.modelsList.setCurrentRow(row) self.form.modelsList.setCurrentRow(row)
@ -90,7 +92,7 @@ class Models(QDialog):
if self.model: if self.model:
self.saveModel() self.saveModel()
idx = self.form.modelsList.currentRow() idx = self.form.modelsList.currentRow()
self.model = self.models[idx] self.model = self.col.models.get(self.models[idx].id)
def onAdd(self): def onAdd(self):
m = AddModel(self.mw, self).get() m = AddModel(self.mw, self).get()