mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
d2865235df
commit
2a0b480103
6 changed files with 159 additions and 179 deletions
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.",
|
||||
),
|
||||
]
|
||||
|
||||
|
|
Loading…
Reference in a new issue