diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 10c658f84..4aa9f0e0c 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -31,7 +31,7 @@ from anki.rsbackend import RustBackend
from anki.sched import Scheduler as V1Scheduler
from anki.schedv2 import Scheduler as V2Scheduler
from anki.tags import TagManager
-from anki.template import render_card
+from anki.template import TemplateRenderContext, render_card
from anki.types import NoteType, QAData, Template
from anki.utils import (
devMode,
@@ -635,7 +635,6 @@ where c.nid = n.id and c.id in %s group by nid"""
def _renderQA(
self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None
) -> Dict[str, Union[str, int]]:
- "Returns hash of id, question, answer."
# extract info from data
split_fields = splitFields(data[6])
card_ord = data[4]
@@ -665,35 +664,39 @@ where c.nid = n.id and c.id in %s group by nid"""
fields["CardFlag"] = self._flagNameFromCardFlags(flag)
fields["c%d" % (card_ord + 1)] = "1"
- # legacy
+ # legacy hook
fields = runFilter("mungeFields", fields, model, data, self)
- # allow add-ons to modify the available fields & templates
- (qfmt, afmt) = hooks.card_will_render((qfmt, afmt), fields, model, data)
+ ctx = TemplateRenderContext(self, data, fields)
- # render fields
+ # render fields. if any custom filters are encountered,
+ # the field_filter hook will be called.
try:
- qatext = render_card(self, qfmt, afmt, fields, card_ord)
+ qtext, atext = render_card(self, qfmt, afmt, ctx)
except anki.rsbackend.BackendException as e:
errmsg = _("Card template has a problem:") + f"
{e}"
- qatext = (errmsg, errmsg)
+ qtext = errmsg
+ atext = errmsg
- ret: Dict[str, Any] = dict(q=qatext[0], a=qatext[1], id=card_id)
+ # 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):
+ qtext = (
+ qtext
+ + "
" + + _("Please edit this note and add some cloze deletions. (%s)") + % ("%s" % (HELP_SITE, _("help"))) + ) # allow add-ons to modify the generated result - for type in "q", "a": - ret[type] = hooks.card_did_render( - ret[type], type, fields, model, data, self - ) + (qtext, atext) = hooks.card_did_render((qtext, atext), ctx) - # empty cloze? - if type == "q" and model["type"] == MODEL_CLOZE: - if not self.models._availClozeOrds(model, data[6], False): - ret["q"] += "
" + _( - "Please edit this note and add some cloze deletions. (%s)" - ) % ("%s" % (HELP_SITE, _("help"))) + # legacy hook + qtext = runFilter("mungeQA", qtext, "q", fields, model, data, self) + atext = runFilter("mungeQA", atext, "a", fields, model, data, self) - return ret + return dict(q=qtext, a=atext, id=card_id) def _qaData(self, where="") -> Any: "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" diff --git a/pylib/anki/hooks.py b/pylib/anki/hooks.py index 2539605f8..41de717d0 100644 --- a/pylib/anki/hooks.py +++ b/pylib/anki/hooks.py @@ -18,7 +18,7 @@ import decorator import anki from anki.cards import Card -from anki.types import QAData +from anki.template import TemplateRenderContext # New hook/filter handling ############################################################################## @@ -58,71 +58,31 @@ class _CardDidRenderFilter: """Can modify the resulting text after rendering completes.""" _hooks: List[ - Callable[ - [ - str, - str, - Dict[str, str], - Dict[str, Any], - QAData, - "anki.storage._Collection", - ], - str, - ] + Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]] ] = [] def append( - self, - cb: Callable[ - [ - str, - str, - Dict[str, str], - Dict[str, Any], - QAData, - "anki.storage._Collection", - ], - str, - ], + self, cb: Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]] ) -> None: - """(text: str, side: str, fields: Dict[str, str], notetype: Dict[str, Any], data: QAData, col: anki.storage._Collection)""" + """(text: Tuple[str, str], ctx: TemplateRenderContext)""" self._hooks.append(cb) def remove( - self, - cb: Callable[ - [ - str, - str, - Dict[str, str], - Dict[str, Any], - QAData, - "anki.storage._Collection", - ], - str, - ], + self, cb: Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]] ) -> None: if cb in self._hooks: self._hooks.remove(cb) def __call__( - self, - text: str, - side: str, - fields: Dict[str, str], - notetype: Dict[str, Any], - data: QAData, - col: anki.storage._Collection, - ) -> str: + self, text: Tuple[str, str], ctx: TemplateRenderContext + ) -> Tuple[str, str]: for filter in self._hooks: try: - text = filter(text, side, fields, notetype, data, col) + text = filter(text, ctx) except: # if the hook fails, remove it self._hooks.remove(filter) raise - # legacy support - runFilter("mungeQA", text, side, fields, notetype, data, col) return text @@ -153,53 +113,6 @@ class _CardOdueWasInvalidHook: card_odue_was_invalid = _CardOdueWasInvalidHook() -class _CardWillRenderFilter: - """Can modify the available fields and question/answer templates prior to rendering.""" - - _hooks: List[ - Callable[ - [Tuple[str, str], Dict[str, str], Dict[str, Any], QAData], Tuple[str, str] - ] - ] = [] - - def append( - self, - cb: Callable[ - [Tuple[str, str], Dict[str, str], Dict[str, Any], QAData], Tuple[str, str] - ], - ) -> None: - """(templates: Tuple[str, str], fields: Dict[str, str], notetype: Dict[str, Any], data: QAData)""" - self._hooks.append(cb) - - def remove( - self, - cb: Callable[ - [Tuple[str, str], Dict[str, str], Dict[str, Any], QAData], Tuple[str, str] - ], - ) -> None: - if cb in self._hooks: - self._hooks.remove(cb) - - def __call__( - self, - templates: Tuple[str, str], - fields: Dict[str, str], - notetype: Dict[str, Any], - data: QAData, - ) -> Tuple[str, str]: - for filter in self._hooks: - try: - templates = filter(templates, fields, notetype, data) - except: - # if the hook fails, remove it - self._hooks.remove(filter) - raise - return templates - - -card_will_render = _CardWillRenderFilter() - - class _DeckAddedHook: _hooks: List[Callable[[Dict[str, Any]], None]] = [] @@ -253,22 +166,31 @@ exporters_list_created = _ExportersListCreatedHook() class _FieldFilterFilter: - _hooks: List[Callable[[str, str, str, Dict[str, str]], str]] = [] + """Allows you to define custom {{filters:..}} + + Your add-on can check filter_name to decide whether it should modify + field_text or not before returning it.""" - def append(self, cb: Callable[[str, str, str, Dict[str, str]], str]) -> None: - """(field_text: str, field_name: str, filter_name: str, fields: Dict[str, str])""" + _hooks: List[Callable[[str, str, str, TemplateRenderContext], str]] = [] + + def append(self, cb: Callable[[str, str, str, TemplateRenderContext], str]) -> None: + """(field_text: str, field_name: str, filter_name: str, ctx: TemplateRenderContext)""" self._hooks.append(cb) - def remove(self, cb: Callable[[str, str, str, Dict[str, str]], str]) -> None: + def remove(self, cb: Callable[[str, str, str, TemplateRenderContext], str]) -> None: if cb in self._hooks: self._hooks.remove(cb) def __call__( - self, field_text: str, field_name: str, filter_name: str, fields: Dict[str, str] + self, + field_text: str, + field_name: str, + filter_name: str, + ctx: TemplateRenderContext, ) -> str: for filter in self._hooks: try: - field_text = filter(field_text, field_name, filter_name, fields) + field_text = filter(field_text, field_name, filter_name, ctx) except: # if the hook fails, remove it self._hooks.remove(filter) diff --git a/pylib/anki/latex.py b/pylib/anki/latex.py index 917ace15f..3df5b2179 100644 --- a/pylib/anki/latex.py +++ b/pylib/anki/latex.py @@ -7,12 +7,13 @@ import html import os import re import shutil -from typing import Any, Dict, Optional +from typing import Any, Optional, Tuple import anki from anki import hooks from anki.lang import _ -from anki.types import NoteType, QAData +from anki.template import TemplateRenderContext +from anki.types import NoteType from anki.utils import call, checksum, isMac, namedtmp, stripHTML, tmpdir pngCommands = [ @@ -47,15 +48,18 @@ def stripLatex(text) -> Any: return text -# media code and some add-ons depend on the current name -def mungeQA( - html: str, - type: str, - fields: Dict[str, str], - model: NoteType, - data: QAData, - col: anki.storage._Collection, -) -> str: +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 render_latex(html: str, model: NoteType, col: anki.storage._Collection,) -> str: "Convert TEXT with embedded latex tags to image links." for match in regexps["standard"].finditer(html): html = html.replace(match.group(), _imgLink(col, match.group(1), model)) @@ -184,4 +188,4 @@ def _errMsg(type: str, texpath: str) -> Any: # setup q/a filter - type ignored due to import cycle -hooks.card_did_render.append(mungeQA) # type: ignore +hooks.card_did_render.append(on_card_did_render) # type: ignore diff --git a/pylib/anki/media.py b/pylib/anki/media.py index 5d365fa48..68c51a621 100644 --- a/pylib/anki/media.py +++ b/pylib/anki/media.py @@ -17,7 +17,7 @@ from typing import Any, Callable, List, Optional, Tuple, Union from anki.consts import * from anki.db import DB, DBError from anki.lang import _ -from anki.latex import mungeQA +from anki.latex import render_latex from anki.template import expand_clozes from anki.utils import checksum, isMac, isWin @@ -222,7 +222,7 @@ create table meta (dirMod int, lastUsn int); insert into meta values (0, 0); strings = [string] for string in strings: # handle latex - string = mungeQA(string, None, None, model, None, self.col) + string = render_latex(string, model, self.col) # extract filenames for reg in self.regexps: for match in re.finditer(reg, string): diff --git a/pylib/anki/template.py b/pylib/anki/template.py index b4cf77874..55018957f 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -15,7 +15,7 @@ the filter is skipped. Add-ons can register a filter with the following code: from anki import hooks -hooks.field_replacement.append(myfunc) +hooks.field_filter.append(myfunc) This will call myfunc, passing the field text in as the first argument. Your function should decide if it wants to modify the text by checking @@ -29,35 +29,98 @@ template_legacy.py file, using the legacy addHook() system. from __future__ import annotations import re -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import anki from anki import hooks -from anki.hooks import runFilter from anki.rsbackend import TemplateReplacementList from anki.sound import stripSounds +from anki.types import NoteType, QAData + + +class TemplateRenderContext: + """Holds information for the duration of one card render. + + This may fetch information lazily in the future, so please avoid + using the _private fields directly.""" + + def __init__( + self, col: anki.storage._Collection, qadata: QAData, fields: Dict[str, str] + ) -> None: + self._col = col + self._qadata = qadata + self._fields = fields + + self._note_type: Optional[NoteType] = None + self._card: Optional[anki.cards.Card] = None + self._note: Optional[anki.notes.Note] = None + + # if you need to store extra state to share amongst rendering + # hooks, you can insert it into this dictionary + self.extra_state: Dict[str, Any] = {} + + def col(self) -> anki.storage._Collection: + return self._col + + 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. + + 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()) + + 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._note_type def render_card( - col: anki.storage._Collection, - qfmt: str, - afmt: str, - fields: Dict[str, str], - card_ord: int, + col: anki.storage._Collection, qfmt: str, afmt: str, ctx: TemplateRenderContext ) -> Tuple[str, str]: """Renders the provided templates, returning rendered q & a text. Will raise if the template is invalid.""" - (qnodes, anodes) = col.backend.render_card(qfmt, afmt, fields, card_ord) + (qnodes, anodes) = col.backend.render_card(qfmt, afmt, ctx.fields(), ctx.card_ord()) - qtext = apply_custom_filters(qnodes, fields, front_side=None) - atext = apply_custom_filters(anodes, fields, front_side=qtext) + qtext = apply_custom_filters(qnodes, ctx, front_side=None) + atext = apply_custom_filters(anodes, ctx, front_side=qtext) return qtext, atext def apply_custom_filters( - rendered: TemplateReplacementList, fields: Dict[str, str], front_side: Optional[str] + rendered: TemplateReplacementList, + ctx: TemplateRenderContext, + front_side: Optional[str], ) -> str: "Complete rendering by applying any pending custom filters." # template already fully rendered? @@ -76,11 +139,16 @@ def apply_custom_filters( field_text = node.current_text for filter_name in node.filters: field_text = hooks.field_filter( - field_text, node.field_name, filter_name, fields + field_text, node.field_name, filter_name, ctx ) - # legacy hook - the second and fifth argument are no longer used - field_text = runFilter( - "fmod_" + filter_name, field_text, "", fields, node.field_name, "" + # legacy hook - the second and fifth argument are no longer used. + field_text = anki.hooks.runFilter( + "fmod_" + filter_name, + field_text, + "", + ctx.fields(), + node.field_name, + "", ) res += field_text diff --git a/pylib/tools/genhooks.py b/pylib/tools/genhooks.py index 8948a9d36..68be0a515 100644 --- a/pylib/tools/genhooks.py +++ b/pylib/tools/genhooks.py @@ -55,42 +55,25 @@ hooks = [ Hook( name="tag_added", args=["tag: str"], legacy_hook="newTag", legacy_no_args=True, ), - Hook( - name="card_will_render", - args=[ - "templates: Tuple[str, str]", - "fields: Dict[str, str]", - "notetype: Dict[str, Any]", - "data: QAData", - ], - return_type="Tuple[str, str]", - doc="Can modify the available fields and question/answer templates prior to rendering.", - ), - Hook( - name="card_did_render", - args=[ - "text: str", - "side: str", - "fields: Dict[str, str]", - "notetype: Dict[str, Any]", - "data: QAData", - # the hook in latex.py needs access to the collection and - # can't rely on the GUI's mw.col - "col: anki.storage._Collection", - ], - return_type="str", - legacy_hook="mungeQA", - doc="Can modify the resulting text after rendering completes.", - ), Hook( name="field_filter", args=[ "field_text: str", "field_name: str", "filter_name: str", - "fields: Dict[str, str]", + "ctx: TemplateRenderContext", ], return_type="str", + doc="""Allows you to define custom {{filters:..}} + + Your add-on can check filter_name to decide whether it should modify + field_text or not before returning it.""", + ), + Hook( + name="card_did_render", + args=["text: Tuple[str, str]", "ctx: TemplateRenderContext",], + return_type="Tuple[str, str]", + doc="Can modify the resulting text after rendering completes.", ), ]