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.
This commit is contained in:
Damien Elmes 2020-01-24 13:44:13 +10:00
parent 836213e587
commit f900f24f60
8 changed files with 180 additions and 240 deletions

View file

@ -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 "<style>%s</style>" % 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:

View file

@ -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"<br>{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 += "<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
(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
##########################################################################

View file

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

View file

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

View file

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

View file

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

View file

@ -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"<br>{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"<a href='{HELP_SITE}'>{help}</a>"
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,

View file

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