diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py
index 10c67d9a9..a94f271be 100644
--- a/pylib/anki/cards.py
+++ b/pylib/anki/cards.py
@@ -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 "" % 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:
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index f47df04db..2a865f93e 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -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"
{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 += "
" + _(
- "Please edit this note and add some cloze deletions. (%s)"
- ) % ("%s" % (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
##########################################################################
diff --git a/pylib/anki/hooks.py b/pylib/anki/hooks.py
index 6a7caaad8..a9ee53464 100644
--- a/pylib/anki/hooks.py
+++ b/pylib/anki/hooks.py
@@ -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:
diff --git a/pylib/anki/latex.py b/pylib/anki/latex.py
index 94db38bc6..0ed1325d2 100644
--- a/pylib/anki/latex.py
+++ b/pylib/anki/latex.py
@@ -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)
diff --git a/pylib/anki/models.py b/pylib/anki/models.py
index 9206b0c5c..858604b60 100644
--- a/pylib/anki/models.py
+++ b/pylib/anki/models.py
@@ -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"])
diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py
index a312ac43a..266733d64 100644
--- a/pylib/anki/notes.py
+++ b/pylib/anki/notes.py
@@ -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__(
diff --git a/pylib/anki/template.py b/pylib/anki/template.py
index 5604cc87b..1c667b861 100644
--- a/pylib/anki/template.py
+++ b/pylib/anki/template.py
@@ -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"
{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"{help}"
+ 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,
diff --git a/pylib/tools/genhooks.py b/pylib/tools/genhooks.py
index 0152fc85a..50e7f54fa 100644
--- a/pylib/tools/genhooks.py
+++ b/pylib/tools/genhooks.py
@@ -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.",
),
]