Anki/pylib/anki/notes.py
Damien Elmes 616db33c0e refactor protobuf handling for split/import
In order to split backend.proto into a more manageable size, the protobuf
handling needed to be updated. This took more time than I would have
liked, as each language handles protobuf differently:

- The Python Protobuf code ignores "package" directives, and relies
solely on how the files are laid out on disk. While it would have been
nice to keep the generated files in a private subpackage, Protobuf gets
confused if the files are located in a location that does not match
their original .proto layout, so the old approach of storing them in
_backend/ will not work. They now clutter up pylib/anki instead. I'm
rather annoyed by that, but alternatives seem to be having to add an extra
level to the Protobuf path, making the other languages suffer, or trying
to hack around the issue by munging sys.modules.
- Protobufjs fails to expose packages if they don't start with a capital
letter, despite the fact that lowercase packages are the norm in most
languages :-( This required a patch to fix.
- Rust was the easiest, as Prost is relatively straightforward compared
to Google's tools.

The Protobuf files are now stored in /proto/anki, with a separate package
for each file. I've split backend.proto into a few files as a test, but
the majority of that work is still to come.

The Python Protobuf building is a bit of a hack at the moment, hard-coding
"proto" as the top level folder, but it seems to get the job done for now.

Also changed the workspace name, as there seems to be a number of Bazel
repos moving away from the more awkward reverse DNS naming style.
2021-07-10 19:17:05 +10:00

198 lines
5.8 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# pylint: enable=invalid-name
from __future__ import annotations
import copy
from typing import Any, List, NewType, Optional, Sequence, Tuple, Union
import anki # pylint: disable=unused-import
import anki.backend_pb2 as _pb
from anki import hooks
from anki._legacy import DeprecatedNamesMixin
from anki.consts import MODEL_STD
from anki.models import NotetypeDict, NotetypeId, TemplateDict
from anki.utils import joinFields
DuplicateOrEmptyResult = _pb.NoteFieldsCheckResponse.State
NoteFieldsCheckResult = _pb.NoteFieldsCheckResponse.State
# types
NoteId = NewType("NoteId", int)
class Note(DeprecatedNamesMixin):
# not currently exposed
flags = 0
data = ""
id: NoteId
mid: NotetypeId
def __init__(
self,
col: anki.collection.Collection,
model: Optional[Union[NotetypeDict, NotetypeId]] = None,
id: Optional[NoteId] = None,
) -> None:
assert not (model and id)
notetype_id = model["id"] if isinstance(model, dict) else model
self.col = col.weakref()
if id:
# existing note
self.id = id
self.load()
else:
# new note for provided notetype
self._load_from_backend_note(self.col._backend.new_note(notetype_id))
def load(self) -> None:
note = self.col._backend.get_note(self.id)
assert note
self._load_from_backend_note(note)
def _load_from_backend_note(self, note: _pb.Note) -> None:
self.id = NoteId(note.id)
self.guid = note.guid
self.mid = NotetypeId(note.notetype_id)
self.mod = note.mtime_secs
self.usn = note.usn
self.tags = list(note.tags)
self.fields = list(note.fields)
self._fmap = self.col.models.field_map(self.note_type())
def _to_backend_note(self) -> _pb.Note:
hooks.note_will_flush(self)
return _pb.Note(
id=self.id,
guid=self.guid,
notetype_id=self.mid,
mtime_secs=self.mod,
usn=self.usn,
tags=self.tags,
fields=self.fields,
)
def flush(self) -> None:
"""This preserves any current checkpoint.
For an undo entry, use col.update_note() instead."""
assert self.id != 0
self.col._backend.update_note(
note=self._to_backend_note(), skip_undo_entry=True
)
def joined_fields(self) -> str:
return joinFields(self.fields)
def ephemeral_card(
self,
ord: int = 0,
*,
custom_note_type: NotetypeDict = None,
custom_template: TemplateDict = None,
fill_empty: bool = False,
) -> anki.cards.Card:
card = anki.cards.Card(self.col)
card.ord = ord
card.did = anki.decks.DEFAULT_DECK_ID
model = custom_note_type or self.note_type()
template = copy.copy(
custom_template
or (
model["tmpls"][ord] if model["type"] == MODEL_STD else model["tmpls"][0]
)
)
# may differ in cloze case
template["ord"] = card.ord
output = anki.template.TemplateRenderContext.from_card_layout(
self,
card,
notetype=model,
template=template,
fill_empty=fill_empty,
).render()
card.set_render_output(output)
card._note = self
return card
def cards(self) -> List[anki.cards.Card]:
return [self.col.getCard(id) for id in self.card_ids()]
def card_ids(self) -> Sequence[anki.cards.CardId]:
return self.col.card_ids_of_note(self.id)
def note_type(self) -> Optional[NotetypeDict]:
return self.col.models.get(self.mid)
_note_type = property(note_type)
def cloze_numbers_in_fields(self) -> Sequence[int]:
return self.col._backend.cloze_numbers_in_note(self._to_backend_note())
# Dict interface
##################################################
def keys(self) -> List[str]:
return list(self._fmap.keys())
def values(self) -> List[str]:
return self.fields
def items(self) -> List[Tuple[str, str]]:
return [(f["name"], self.fields[ord]) for ord, f in sorted(self._fmap.values())]
def _field_index(self, key: str) -> int:
try:
return self._fmap[key][0]
except Exception as exc:
raise KeyError(key) from exc
def __getitem__(self, key: str) -> str:
return self.fields[self._field_index(key)]
def __setitem__(self, key: str, value: str) -> None:
self.fields[self._field_index(key)] = value
def __contains__(self, key: str) -> bool:
return key in self._fmap
# Tags
##################################################
def has_tag(self, tag: str) -> bool:
return self.col.tags.inList(tag, self.tags)
def remove_tag(self, tag: str) -> None:
rem = []
for tag_ in self.tags:
if tag_.lower() == tag.lower():
rem.append(tag_)
for tag_ in rem:
self.tags.remove(tag_)
def add_tag(self, tag: str) -> None:
"Add tag. Duplicates will be stripped on save."
self.tags.append(tag)
def string_tags(self) -> Any:
return self.col.tags.join(self.col.tags.canonify(self.tags))
def set_tags_from_str(self, tags: str) -> None:
self.tags = self.col.tags.split(tags)
# Unique/duplicate/cloze check
##################################################
def fields_check(self) -> NoteFieldsCheckResult.V:
return self.col._backend.note_fields_check(self._to_backend_note()).state
dupeOrEmpty = duplicate_or_empty = fields_check
Note.register_deprecated_aliases(
delTag=Note.remove_tag, _fieldOrd=Note._field_index, model=Note.note_type
)