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.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"<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
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"] += "<p>" + _(
"Please edit this note and add some cloze deletions. (%s)"
) % ("<a href=%s#cloze>%s</a>" % (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"

View file

@ -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)

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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.",
),
]