From f900f24f60e85f150418137b94dd1b8359be96c3 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 24 Jan 2020 13:44:13 +1000 Subject: [PATCH] more changes to the template code - _renderQA() has moved to template.py:render_card() - dropped QAData in favour of a properly typed dict - render_card() returns a TemplateRenderOutput struct instead of a dict - card_did_render now takes that output as the first arg, and can mutate it - TemplateRenderContext now stores the original card, so it can return a card even in the add screen case The old mungeFields and mungeQA hook have been removed as part of this change. mungeQA can be replaced with the card_did_render hook. In the mungeFields case, please switch to using field_filter instead. --- pylib/anki/cards.py | 72 +++++++++---------- pylib/anki/collection.py | 104 --------------------------- pylib/anki/hooks.py | 62 ++++++++++++----- pylib/anki/latex.py | 21 +++--- pylib/anki/models.py | 2 +- pylib/anki/notes.py | 4 +- pylib/anki/template.py | 147 ++++++++++++++++++++++++--------------- pylib/tools/genhooks.py | 8 ++- 8 files changed, 180 insertions(+), 240 deletions(-) diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index 10c67d9a9..a94f271be 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -5,11 +5,12 @@ from __future__ import annotations import pprint import time -from typing import Any, Dict, List, Optional, Union +from typing import List, Optional import anki # pylint: disable=unused-import from anki import hooks from anki.consts import * +from anki.models import NoteType, Template from anki.notes import Note from anki.sound import AVTag from anki.utils import intTime, joinFields, timestampID @@ -27,7 +28,6 @@ from anki.utils import intTime, joinFields, timestampID class Card: - _qa: Optional[Dict[str, Union[str, int, List[AVTag]]]] _note: Optional[Note] timerStarted: Optional[float] lastIvl: int @@ -38,7 +38,7 @@ class Card: ) -> None: self.col = col self.timerStarted = None - self._qa = None + self._render_output: Optional[anki.template.TemplateRenderOutput] = None self._note = None if id: self.id = id @@ -81,7 +81,7 @@ class Card: self.flags, self.data, ) = self.col.db.first("select * from cards where id = ?", self.id) - self._qa = None + self._render_output = None self._note = None def flush(self) -> None: @@ -144,54 +144,44 @@ lapses=?, left=?, odue=?, odid=?, did=? where id = ?""", ) self.col.log(self) - def q(self, reload: bool = False, browser: bool = False) -> str: - return self.css() + self._getQA(reload, browser)["q"] + def question(self, reload: bool = False, browser: bool = False) -> str: + return self.css() + self.render_output(reload, browser).question_text - def a(self) -> str: - return self.css() + self._getQA()["a"] + def answer(self) -> str: + return self.css() + self.render_output().answer_text def question_av_tags(self) -> List[AVTag]: - return self._qa["q_av_tags"] # type: ignore + return self.render_output().question_av_tags def answer_av_tags(self) -> List[AVTag]: - return self._qa["a_av_tags"] # type: ignore + return self.render_output().answer_av_tags def css(self) -> str: return "" % self.model()["css"] - def _getQA(self, reload: bool = False, browser: bool = False) -> Any: - if not self._qa or reload: - f = self.note(reload) - m = self.model() - t = self.template() - if browser: - args = [t.get("bqfmt"), t.get("bafmt")] - else: - args = [] - self._qa = self.col._renderQA( - ( - self.id, - f.id, - m["id"], - self.odid or self.did, - self.ord, - f.stringTags(), - f.joinedFields(), - self.flags, - ), - *args, - ) # type: ignore - return self._qa + def render_output( + self, reload: bool = False, browser: bool = False + ) -> anki.template.TemplateRenderOutput: + if not self._render_output or reload: + note = self.note(reload) + self._render_output = anki.template.render_card( + self.col, self, note, browser + ) + return self._render_output - def note(self, reload: bool = False) -> Any: + def note(self, reload: bool = False) -> Note: if not self._note or reload: self._note = self.col.getNote(self.nid) return self._note - def model(self) -> Any: + def note_type(self) -> NoteType: return self.col.models.get(self.note().mid) - def template(self) -> Any: + q = question + a = answer + model = note_type + + def template(self) -> Template: m = self.model() if m["type"] == MODEL_STD: return self.model()["tmpls"][self.ord] @@ -201,16 +191,16 @@ lapses=?, left=?, odue=?, odid=?, did=? where id = ?""", def startTimer(self) -> None: self.timerStarted = time.time() - def timeLimit(self) -> Any: + def timeLimit(self) -> int: "Time limit for answering in milliseconds." conf = self.col.decks.confForDid(self.odid or self.did) return conf["maxTaken"] * 1000 - def shouldShowTimer(self) -> Any: + def shouldShowTimer(self) -> bool: conf = self.col.decks.confForDid(self.odid or self.did) return conf["timer"] - def timeTaken(self) -> Any: + def timeTaken(self) -> int: "Time taken to answer card, in integer MS." total = int((time.time() - self.timerStarted) * 1000) return min(total, self.timeLimit()) @@ -225,12 +215,12 @@ lapses=?, left=?, odue=?, odid=?, did=? where id = ?""", d = dict(self.__dict__) # remove non-useful elements del d["_note"] - del d["_qa"] + del d["_render_output"] del d["col"] del d["timerStarted"] return pprint.pformat(d, width=300) - def userFlag(self) -> Any: + def userFlag(self) -> int: return self.flags & 0b111 def setUserFlag(self, flag: int) -> None: diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index f47df04db..2a865f93e 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -22,7 +22,6 @@ from anki.consts import * from anki.db import DB from anki.decks import DeckManager from anki.errors import AnkiError -from anki.hooks import runFilter from anki.lang import _, ngettext from anki.media import MediaManager from anki.models import ModelManager, NoteType, Template @@ -30,9 +29,7 @@ from anki.notes import Note from anki.rsbackend import RustBackend from anki.sched import Scheduler as V1Scheduler from anki.schedv2 import Scheduler as V2Scheduler -from anki.sound import AVTag from anki.tags import TagManager -from anki.template import QAData, RenderOutput, TemplateRenderContext, render_card from anki.utils import ( devMode, fieldChecksum, @@ -614,107 +611,6 @@ where c.nid = n.id and c.id in %s group by nid""" # apply, relying on calling code to bump usn+mod self.db.executemany("update notes set sfld=?, csum=? where id=?", r) - # Q/A generation - ########################################################################## - - # data is [cid, nid, mid, did, ord, tags, flds, cardFlags] - def _renderQA( - self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None - ) -> Dict[str, Union[str, int, List[AVTag]]]: - # extract info from data - split_fields = splitFields(data[6]) - card_ord = data[4] - model = self.models.get(data[2]) - if model["type"] == MODEL_STD: - template = model["tmpls"][data[4]] - else: - template = model["tmpls"][0] - flag = data[7] - deck_id = data[3] - card_id = data[0] - tags = data[5] - qfmt = qfmt or template["qfmt"] - afmt = afmt or template["afmt"] - - # create map of field names -> field content - fields: Dict[str, str] = {} - for (name, (idx, conf)) in list(self.models.fieldMap(model).items()): - fields[name] = split_fields[idx] - - # add special fields - fields["Tags"] = tags.strip() - fields["Type"] = model["name"] - fields["Deck"] = self.decks.name(deck_id) - fields["Subdeck"] = fields["Deck"].split("::")[-1] - fields["Card"] = template["name"] - fields["CardFlag"] = self._flagNameFromCardFlags(flag) - fields["c%d" % (card_ord + 1)] = "1" - - # legacy hook - fields = runFilter("mungeFields", fields, model, data, self) - - ctx = TemplateRenderContext(self, data, fields) - - # render fields. if any custom filters are encountered, - # the field_filter hook will be called. - try: - output = render_card(self, qfmt, afmt, ctx) - except anki.rsbackend.BackendException as e: - errmsg = _("Card template has a problem:") + f"
{e}" - output = RenderOutput( - question_text=errmsg, - answer_text=errmsg, - question_av_tags=[], - answer_av_tags=[], - ) - - # avoid showing the user a confusing blank card if they've - # forgotten to add a cloze deletion - if model["type"] == MODEL_CLOZE: - if not self.models._availClozeOrds(model, data[6], False): - output.question_text += "

" + _( - "Please edit this note and add some cloze deletions. (%s)" - ) % ("%s" % (HELP_SITE, _("help"))) - - # allow add-ons to modify the generated result - (output.question_text, output.answer_text) = hooks.card_did_render( - (output.question_text, output.answer_text), ctx - ) - - # legacy hook - output.question_text = runFilter( - "mungeQA", output.question_text, "q", fields, model, data, self - ) - output.answer_text = runFilter( - "mungeQA", output.answer_text, "a", fields, model, data, self - ) - - return dict( - q=output.question_text, - a=output.answer_text, - id=card_id, - q_av_tags=output.question_av_tags, - a_av_tags=output.answer_av_tags, - ) - - def _qaData(self, where="") -> Any: - "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" - # NOTE: order selected from database must match order of QAData fields. - return self.db.execute( - """ -select c.id, f.id, f.mid, c.did, c.ord, f.tags, f.flds, c.flags -from cards c, notes f -where c.nid == f.id -%s""" - % where - ) - - def _flagNameFromCardFlags(self, flags: int) -> str: - flag = flags & 0b111 - if not flag: - return "" - return "flag%d" % flag - # Finding cards ########################################################################## diff --git a/pylib/anki/hooks.py b/pylib/anki/hooks.py index 6a7caaad8..a9ee53464 100644 --- a/pylib/anki/hooks.py +++ b/pylib/anki/hooks.py @@ -18,7 +18,6 @@ import decorator import anki from anki.cards import Card -from anki.template import TemplateRenderContext # New hook/filter handling ############################################################################## @@ -54,39 +53,60 @@ class _CardDidLeechHook: card_did_leech = _CardDidLeechHook() -class _CardDidRenderFilter: +class _CardDidRenderHook: """Can modify the resulting text after rendering completes.""" _hooks: List[ - Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]] + Callable[ + [ + "anki.template.TemplateRenderOutput", + "anki.template.TemplateRenderContext", + ], + None, + ] ] = [] def append( - self, cb: Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]] + self, + cb: Callable[ + [ + "anki.template.TemplateRenderOutput", + "anki.template.TemplateRenderContext", + ], + None, + ], ) -> None: - """(text: Tuple[str, str], ctx: TemplateRenderContext)""" + """(output: anki.template.TemplateRenderOutput, ctx: anki.template.TemplateRenderContext)""" self._hooks.append(cb) def remove( - self, cb: Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]] + self, + cb: Callable[ + [ + "anki.template.TemplateRenderOutput", + "anki.template.TemplateRenderContext", + ], + None, + ], ) -> None: if cb in self._hooks: self._hooks.remove(cb) def __call__( - self, text: Tuple[str, str], ctx: TemplateRenderContext - ) -> Tuple[str, str]: - for filter in self._hooks: + self, + output: anki.template.TemplateRenderOutput, + ctx: anki.template.TemplateRenderContext, + ) -> None: + for hook in self._hooks: try: - text = filter(text, ctx) + hook(output, ctx) except: # if the hook fails, remove it - self._hooks.remove(filter) + self._hooks.remove(hook) raise - return text -card_did_render = _CardDidRenderFilter() +card_did_render = _CardDidRenderHook() class _CardOdueWasInvalidHook: @@ -171,13 +191,19 @@ class _FieldFilterFilter: Your add-on can check filter_name to decide whether it should modify field_text or not before returning it.""" - _hooks: List[Callable[[str, str, str, TemplateRenderContext], str]] = [] + _hooks: List[ + Callable[[str, str, str, "anki.template.TemplateRenderContext"], str] + ] = [] - def append(self, cb: Callable[[str, str, str, TemplateRenderContext], str]) -> None: - """(field_text: str, field_name: str, filter_name: str, ctx: TemplateRenderContext)""" + def append( + self, cb: Callable[[str, str, str, "anki.template.TemplateRenderContext"], str] + ) -> None: + """(field_text: str, field_name: str, filter_name: str, ctx: anki.template.TemplateRenderContext)""" self._hooks.append(cb) - def remove(self, cb: Callable[[str, str, str, TemplateRenderContext], str]) -> None: + def remove( + self, cb: Callable[[str, str, str, "anki.template.TemplateRenderContext"], str] + ) -> None: if cb in self._hooks: self._hooks.remove(cb) @@ -186,7 +212,7 @@ class _FieldFilterFilter: field_text: str, field_name: str, filter_name: str, - ctx: TemplateRenderContext, + ctx: anki.template.TemplateRenderContext, ) -> str: for filter in self._hooks: try: diff --git a/pylib/anki/latex.py b/pylib/anki/latex.py index 94db38bc6..0ed1325d2 100644 --- a/pylib/anki/latex.py +++ b/pylib/anki/latex.py @@ -7,13 +7,13 @@ import html import os import re import shutil -from typing import Any, Optional, Tuple +from typing import Any, Optional import anki from anki import hooks from anki.lang import _ from anki.models import NoteType -from anki.template import TemplateRenderContext +from anki.template import TemplateRenderContext, TemplateRenderOutput from anki.utils import call, checksum, isMac, namedtmp, stripHTML, tmpdir pngCommands = [ @@ -48,15 +48,11 @@ def stripLatex(text) -> Any: return text -def on_card_did_render( - text: Tuple[str, str], ctx: TemplateRenderContext -) -> Tuple[str, str]: - qtext, atext = text - - qtext = render_latex(qtext, ctx.note_type(), ctx.col()) - atext = render_latex(atext, ctx.note_type(), ctx.col()) - - return (qtext, atext) +def on_card_did_render(output: TemplateRenderOutput, ctx: TemplateRenderContext): + output.question_text = render_latex( + output.question_text, ctx.note_type(), ctx.col() + ) + output.answer_text = render_latex(output.answer_text, ctx.note_type(), ctx.col()) def render_latex(html: str, model: NoteType, col: anki.storage._Collection,) -> str: @@ -187,5 +183,4 @@ def _errMsg(type: str, texpath: str) -> Any: return msg -# setup q/a filter - type ignored due to import cycle -hooks.card_did_render.append(on_card_did_render) # type: ignore +hooks.card_did_render.append(on_card_did_render) diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 9206b0c5c..858604b60 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -267,7 +267,7 @@ and notes.mid = ? and cards.ord = ?""", f["name"] = name return f - def fieldMap(self, m: NoteType) -> Dict[str, Tuple[Any, Any]]: + 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"]) diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py index a312ac43a..266733d64 100644 --- a/pylib/anki/notes.py +++ b/pylib/anki/notes.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple import anki # pylint: disable=unused-import -from anki.models import NoteType +from anki.models import Field, NoteType from anki.utils import ( fieldChecksum, guid64, @@ -29,7 +29,7 @@ class Note: fields: List[str] flags: int data: str - _fmap: Dict[str, Tuple[Any, Any]] + _fmap: Dict[str, Tuple[int, Field]] scm: int def __init__( diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 5604cc87b..1c667b861 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -34,30 +34,14 @@ from typing import Any, Dict, List, Optional, Tuple import anki from anki import hooks +from anki.cards import Card +from anki.consts import HELP_SITE +from anki.lang import _ from anki.models import NoteType +from anki.notes import Note from anki.rsbackend import TemplateReplacementList from anki.sound import AVTag -QAData = Tuple[ - # Card ID this QA comes from. Corresponds to 'cid' column. - int, - # Note ID this QA comes from. Corresponds to 'nid' column. - int, - # ID of the model (i.e., NoteType) for this QA's note. Corresponds to 'mid' column. - int, - # Deck ID. Corresponds to 'did' column. - int, - # Index of the card template (within the NoteType) this QA was built - # from. Corresponds to 'ord' column. - int, - # Tags, separated by space. Corresponds to 'tags' column. - str, - # Corresponds to 'flds' column. TODO: document. - str, - # Corresponds to 'cardFlags' column. TODO: document - int, -] - class TemplateRenderContext: """Holds information for the duration of one card render. @@ -66,15 +50,20 @@ class TemplateRenderContext: using the _private fields directly.""" def __init__( - self, col: anki.storage._Collection, qadata: QAData, fields: Dict[str, str] + self, + col: anki.storage._Collection, + card: Card, + note: Note, + fields: Dict[str, str], + qfmt: str, + afmt: str, ) -> None: self._col = col - self._qadata = qadata + self._card = card + self._note = note self._fields = fields - - self._note_type: Optional[NoteType] = None - self._card: Optional[anki.cards.Card] = None - self._note: Optional[anki.notes.Note] = None + self._qfmt = qfmt + self._afmt = afmt # if you need to store extra state to share amongst rendering # hooks, you can insert it into this dictionary @@ -86,46 +75,29 @@ class TemplateRenderContext: def fields(self) -> Dict[str, str]: return self._fields - def card_id(self) -> int: - return self._qadata[0] - - def note_id(self) -> int: - return self._qadata[1] - - def deck_id(self) -> int: - return self._qadata[3] - - def card_ord(self) -> int: - return self._qadata[4] - - def card(self) -> Optional[anki.cards.Card]: - """Returns the card being rendered. Will return None in the add screen. + def card(self) -> Card: + """Returns the card being rendered. Be careful not to call .q() or .a() on the card, or you'll create an infinite loop.""" - if not self._card: - try: - self._card = self.col().getCard(self.card_id()) - except: - return None - return self._card - def note(self) -> anki.notes.Note: - if not self._note: - self._note = self.col().getNote(self.note_id()) - + def note(self) -> Note: return self._note def note_type(self) -> NoteType: - if not self._note_type: - self._note_type = self.col().models.get(self._qadata[2]) + return self.card().note_type() - return self._note_type + def qfmt(self) -> str: + return self._qfmt + + def afmt(self) -> str: + return self._afmt @dataclass -class RenderOutput: +class TemplateRenderOutput: + "Stores the rendered templates and extracted AV tags." question_text: str answer_text: str question_av_tags: List[AVTag] @@ -133,12 +105,71 @@ class RenderOutput: def render_card( - col: anki.storage._Collection, qfmt: str, afmt: str, ctx: TemplateRenderContext -) -> RenderOutput: + col: anki.storage._Collection, card: Card, note: Note, browser: bool +) -> TemplateRenderOutput: + "Render a card." + # collect data + fields = fields_for_rendering(col, card, note) + qfmt, afmt = templates_for_card(card, browser) + ctx = TemplateRenderContext( + col=col, card=card, note=note, fields=fields, qfmt=qfmt, afmt=afmt + ) + + # render + try: + output = render_card_from_context(ctx) + except anki.rsbackend.BackendException as e: + errmsg = _("Card template has a problem:") + f"
{e}" + output = TemplateRenderOutput( + question_text=errmsg, + answer_text=errmsg, + question_av_tags=[], + answer_av_tags=[], + ) + + if not output.question_text.strip(): + msg = _("The front of this card is blank.") + help = _("More info") + msg += f"{help}" + output.question_text = msg + + hooks.card_did_render(output, ctx) + + return output + + +def templates_for_card(card: Card, browser: bool) -> Tuple[str, str]: + template = card.template() + q, a = browser and ("bqfmt", "bafmt") or ("qfmt", "afmt") + return template.get(q), template.get(a) # type: ignore + + +def fields_for_rendering(col: anki.storage._Collection, card: Card, note: Note): + # fields from note + fields = dict(note.items()) + + # add special fields + fields["Tags"] = note.stringTags().strip() + fields["Type"] = card.note_type()["name"] + fields["Deck"] = col.decks.name(card.did) + fields["Subdeck"] = fields["Deck"].split("::")[-1] + fields["Card"] = card.template()["name"] # type: ignore + flag = card.userFlag() + fields["CardFlag"] = flag and f"flag{flag}" or "" + fields["c%d" % (card.ord + 1)] = "1" + + return fields + + +def render_card_from_context(ctx: TemplateRenderContext) -> TemplateRenderOutput: """Renders the provided templates, returning rendered output. Will raise if the template is invalid.""" - (qnodes, anodes) = col.backend.render_card(qfmt, afmt, ctx.fields(), ctx.card_ord()) + col = ctx.col() + + (qnodes, anodes) = col.backend.render_card( + ctx.qfmt(), ctx.afmt(), ctx.fields(), ctx.card().ord + ) qtext = apply_custom_filters(qnodes, ctx, front_side=None) qtext, q_avtags = col.backend.extract_av_tags(qtext, True) @@ -146,7 +177,7 @@ def render_card( atext = apply_custom_filters(anodes, ctx, front_side=qtext) atext, a_avtags = col.backend.extract_av_tags(atext, False) - return RenderOutput( + return TemplateRenderOutput( question_text=qtext, answer_text=atext, question_av_tags=q_avtags, diff --git a/pylib/tools/genhooks.py b/pylib/tools/genhooks.py index 0152fc85a..50e7f54fa 100644 --- a/pylib/tools/genhooks.py +++ b/pylib/tools/genhooks.py @@ -59,7 +59,7 @@ hooks = [ "field_text: str", "field_name: str", "filter_name: str", - "ctx: TemplateRenderContext", + "ctx: anki.template.TemplateRenderContext", ], return_type="str", doc="""Allows you to define custom {{filters:..}} @@ -69,8 +69,10 @@ hooks = [ ), Hook( name="card_did_render", - args=["text: Tuple[str, str]", "ctx: TemplateRenderContext",], - return_type="Tuple[str, str]", + args=[ + "output: anki.template.TemplateRenderOutput", + "ctx: anki.template.TemplateRenderContext", + ], doc="Can modify the resulting text after rendering completes.", ), ]