add context to card rendering, and rework related hooks

- the context exists for the lifecycle of one card's render,
and caches calls to things like .card() to avoid add-ons needing to
do their own cache management.
- add-ons can optionally add extra data to the context if they need
it across multiple filters
- removed card_will_render. the legacy hook is still available for
now
- card_did_render is now called only once, with both front and back
text
This commit is contained in:
Damien Elmes 2020-01-17 09:30:42 +10:00
parent d2865235df
commit 2a0b480103
6 changed files with 159 additions and 179 deletions

View file

@ -31,7 +31,7 @@ from anki.rsbackend import RustBackend
from anki.sched import Scheduler as V1Scheduler from anki.sched import Scheduler as V1Scheduler
from anki.schedv2 import Scheduler as V2Scheduler from anki.schedv2 import Scheduler as V2Scheduler
from anki.tags import TagManager 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.types import NoteType, QAData, Template
from anki.utils import ( from anki.utils import (
devMode, devMode,
@ -635,7 +635,6 @@ where c.nid = n.id and c.id in %s group by nid"""
def _renderQA( def _renderQA(
self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None
) -> Dict[str, Union[str, int]]: ) -> Dict[str, Union[str, int]]:
"Returns hash of id, question, answer."
# extract info from data # extract info from data
split_fields = splitFields(data[6]) split_fields = splitFields(data[6])
card_ord = data[4] 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["CardFlag"] = self._flagNameFromCardFlags(flag)
fields["c%d" % (card_ord + 1)] = "1" fields["c%d" % (card_ord + 1)] = "1"
# legacy # legacy hook
fields = runFilter("mungeFields", fields, model, data, self) fields = runFilter("mungeFields", fields, model, data, self)
# allow add-ons to modify the available fields & templates ctx = TemplateRenderContext(self, data, fields)
(qfmt, afmt) = hooks.card_will_render((qfmt, afmt), fields, model, data)
# render fields # render fields. if any custom filters are encountered,
# the field_filter hook will be called.
try: try:
qatext = render_card(self, qfmt, afmt, fields, card_ord) qtext, atext = render_card(self, qfmt, afmt, ctx)
except anki.rsbackend.BackendException as e: except anki.rsbackend.BackendException as e:
errmsg = _("Card template has a problem:") + f"<br>{e}" errmsg = _("Card template has a problem:") + f"<br>{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
+ "<p>"
+ _("Please edit this note and add some cloze deletions. (%s)")
% ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))
)
# allow add-ons to modify the generated result # allow add-ons to modify the generated result
for type in "q", "a": (qtext, atext) = hooks.card_did_render((qtext, atext), ctx)
ret[type] = hooks.card_did_render(
ret[type], type, fields, model, data, self
)
# empty cloze? # legacy hook
if type == "q" and model["type"] == MODEL_CLOZE: qtext = runFilter("mungeQA", qtext, "q", fields, model, data, self)
if not self.models._availClozeOrds(model, data[6], False): atext = runFilter("mungeQA", atext, "a", fields, model, data, self)
ret["q"] += "<p>" + _(
"Please edit this note and add some cloze deletions. (%s)"
) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))
return ret return dict(q=qtext, a=atext, id=card_id)
def _qaData(self, where="") -> Any: def _qaData(self, where="") -> Any:
"Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query"

View file

@ -18,7 +18,7 @@ import decorator
import anki import anki
from anki.cards import Card from anki.cards import Card
from anki.types import QAData from anki.template import TemplateRenderContext
# New hook/filter handling # New hook/filter handling
############################################################################## ##############################################################################
@ -58,71 +58,31 @@ class _CardDidRenderFilter:
"""Can modify the resulting text after rendering completes.""" """Can modify the resulting text after rendering completes."""
_hooks: List[ _hooks: List[
Callable[ Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]]
[
str,
str,
Dict[str, str],
Dict[str, Any],
QAData,
"anki.storage._Collection",
],
str,
]
] = [] ] = []
def append( def append(
self, self, cb: Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]]
cb: Callable[
[
str,
str,
Dict[str, str],
Dict[str, Any],
QAData,
"anki.storage._Collection",
],
str,
],
) -> None: ) -> 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) self._hooks.append(cb)
def remove( def remove(
self, self, cb: Callable[[Tuple[str, str], TemplateRenderContext], Tuple[str, str]]
cb: Callable[
[
str,
str,
Dict[str, str],
Dict[str, Any],
QAData,
"anki.storage._Collection",
],
str,
],
) -> None: ) -> None:
if cb in self._hooks: if cb in self._hooks:
self._hooks.remove(cb) self._hooks.remove(cb)
def __call__( def __call__(
self, self, text: Tuple[str, str], ctx: TemplateRenderContext
text: str, ) -> Tuple[str, str]:
side: str,
fields: Dict[str, str],
notetype: Dict[str, Any],
data: QAData,
col: anki.storage._Collection,
) -> str:
for filter in self._hooks: for filter in self._hooks:
try: try:
text = filter(text, side, fields, notetype, data, col) text = filter(text, ctx)
except: except:
# if the hook fails, remove it # if the hook fails, remove it
self._hooks.remove(filter) self._hooks.remove(filter)
raise raise
# legacy support
runFilter("mungeQA", text, side, fields, notetype, data, col)
return text return text
@ -153,53 +113,6 @@ class _CardOdueWasInvalidHook:
card_odue_was_invalid = _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: class _DeckAddedHook:
_hooks: List[Callable[[Dict[str, Any]], None]] = [] _hooks: List[Callable[[Dict[str, Any]], None]] = []
@ -253,22 +166,31 @@ exporters_list_created = _ExportersListCreatedHook()
class _FieldFilterFilter: 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: _hooks: List[Callable[[str, str, str, TemplateRenderContext], str]] = []
"""(field_text: str, field_name: str, filter_name: str, fields: Dict[str, 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) 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: if cb in self._hooks:
self._hooks.remove(cb) self._hooks.remove(cb)
def __call__( 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: ) -> str:
for filter in self._hooks: for filter in self._hooks:
try: try:
field_text = filter(field_text, field_name, filter_name, fields) field_text = filter(field_text, field_name, filter_name, ctx)
except: except:
# if the hook fails, remove it # if the hook fails, remove it
self._hooks.remove(filter) self._hooks.remove(filter)

View file

@ -7,12 +7,13 @@ import html
import os import os
import re import re
import shutil import shutil
from typing import Any, Dict, Optional from typing import Any, Optional, Tuple
import anki import anki
from anki import hooks from anki import hooks
from anki.lang import _ 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 from anki.utils import call, checksum, isMac, namedtmp, stripHTML, tmpdir
pngCommands = [ pngCommands = [
@ -47,15 +48,18 @@ def stripLatex(text) -> Any:
return text return text
# media code and some add-ons depend on the current name def on_card_did_render(
def mungeQA( text: Tuple[str, str], ctx: TemplateRenderContext
html: str, ) -> Tuple[str, str]:
type: str, qtext, atext = text
fields: Dict[str, str],
model: NoteType, qtext = render_latex(qtext, ctx.note_type(), ctx.col())
data: QAData, atext = render_latex(atext, ctx.note_type(), ctx.col())
col: anki.storage._Collection,
) -> str: return (qtext, atext)
def render_latex(html: str, model: NoteType, col: anki.storage._Collection,) -> str:
"Convert TEXT with embedded latex tags to image links." "Convert TEXT with embedded latex tags to image links."
for match in regexps["standard"].finditer(html): for match in regexps["standard"].finditer(html):
html = html.replace(match.group(), _imgLink(col, match.group(1), model)) 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 # 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

View file

@ -17,7 +17,7 @@ from typing import Any, Callable, List, Optional, Tuple, Union
from anki.consts import * from anki.consts import *
from anki.db import DB, DBError from anki.db import DB, DBError
from anki.lang import _ from anki.lang import _
from anki.latex import mungeQA from anki.latex import render_latex
from anki.template import expand_clozes from anki.template import expand_clozes
from anki.utils import checksum, isMac, isWin 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] strings = [string]
for string in strings: for string in strings:
# handle latex # handle latex
string = mungeQA(string, None, None, model, None, self.col) string = render_latex(string, model, self.col)
# extract filenames # extract filenames
for reg in self.regexps: for reg in self.regexps:
for match in re.finditer(reg, string): for match in re.finditer(reg, string):

View file

@ -15,7 +15,7 @@ the filter is skipped.
Add-ons can register a filter with the following code: Add-ons can register a filter with the following code:
from anki import hooks 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. 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 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 from __future__ import annotations
import re import re
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import anki import anki
from anki import hooks from anki import hooks
from anki.hooks import runFilter
from anki.rsbackend import TemplateReplacementList from anki.rsbackend import TemplateReplacementList
from anki.sound import stripSounds 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( def render_card(
col: anki.storage._Collection, col: anki.storage._Collection, qfmt: str, afmt: str, ctx: TemplateRenderContext
qfmt: str,
afmt: str,
fields: Dict[str, str],
card_ord: int,
) -> Tuple[str, str]: ) -> Tuple[str, str]:
"""Renders the provided templates, returning rendered q & a text. """Renders the provided templates, returning rendered q & a text.
Will raise if the template is invalid.""" 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) qtext = apply_custom_filters(qnodes, ctx, front_side=None)
atext = apply_custom_filters(anodes, fields, front_side=qtext) atext = apply_custom_filters(anodes, ctx, front_side=qtext)
return qtext, atext return qtext, atext
def apply_custom_filters( def apply_custom_filters(
rendered: TemplateReplacementList, fields: Dict[str, str], front_side: Optional[str] rendered: TemplateReplacementList,
ctx: TemplateRenderContext,
front_side: Optional[str],
) -> str: ) -> str:
"Complete rendering by applying any pending custom filters." "Complete rendering by applying any pending custom filters."
# template already fully rendered? # template already fully rendered?
@ -76,11 +139,16 @@ def apply_custom_filters(
field_text = node.current_text field_text = node.current_text
for filter_name in node.filters: for filter_name in node.filters:
field_text = hooks.field_filter( 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 # legacy hook - the second and fifth argument are no longer used.
field_text = runFilter( field_text = anki.hooks.runFilter(
"fmod_" + filter_name, field_text, "", fields, node.field_name, "" "fmod_" + filter_name,
field_text,
"",
ctx.fields(),
node.field_name,
"",
) )
res += field_text res += field_text

View file

@ -55,42 +55,25 @@ hooks = [
Hook( Hook(
name="tag_added", args=["tag: str"], legacy_hook="newTag", legacy_no_args=True, 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( Hook(
name="field_filter", name="field_filter",
args=[ args=[
"field_text: str", "field_text: str",
"field_name: str", "field_name: str",
"filter_name: str", "filter_name: str",
"fields: Dict[str, str]", "ctx: TemplateRenderContext",
], ],
return_type="str", 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.",
), ),
] ]