diff --git a/LICENSE b/LICENSE
index 861e7e585..3054ca8e7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -6,6 +6,7 @@ The following included source code items use a license other than AGPL3:
In the pylib folder:
+ * The anki/template/ folder is based off pystache: MIT.
* The SuperMemo importer: GPL3.
* The Pauker importer: BSD-3.
* statsbg.py: CC BY-SA 3.0.
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index e0a36594e..25e42a04e 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -29,8 +29,8 @@ 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 stripSounds
from anki.tags import TagManager
-from anki.template import render_qa_from_field_map
from anki.types import NoteType, QAData, Template
from anki.utils import (
devMode,
@@ -629,58 +629,51 @@ where c.nid = n.id and c.id in %s group by nid"""
raise Exception()
return [self._renderQA(*row) for row in self._qaData(where)]
- # 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]]:
+ def _renderQA(self, data: QAData, qfmt: None = None, afmt: None = None) -> Dict:
"Returns hash of id, question, answer."
- # extract info from data
- split_fields = splitFields(data[6])
- card_ord = data[4]
+ # data is [cid, nid, mid, did, ord, tags, flds, cardFlags]
+ # unpack fields and create dict
+ flist = splitFields(data[6])
+ fields = {}
model = self.models.get(data[2])
+ assert model
+ for (name, (idx, conf)) in list(self.models.fieldMap(model).items()):
+ fields[name] = flist[idx]
+ fields["Tags"] = data[5].strip()
+ fields["Type"] = model["name"]
+ fields["Deck"] = self.decks.name(data[3])
+ fields["Subdeck"] = fields["Deck"].split("::")[-1]
+ fields["CardFlag"] = self._flagNameFromCardFlags(data[7])
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]
+ fields["Card"] = template["name"]
+ fields["c%d" % (data[4] + 1)] = "1"
+ # render q & a
+ d: Dict[str, Any] = dict(id=data[0])
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"
-
- fields = runFilter("mungeFields", fields, model, data, self)
-
- # render fields
- qatext = render_qa_from_field_map(self, qfmt, afmt, fields, card_ord)
- ret: Dict[str, Any] = dict(q=qatext[0], a=qatext[1], id=card_id)
-
- # allow add-ons to modify the generated result
- for type in "q", "a":
- ret[type] = runFilter("mungeQA", ret[type], type, fields, model, data, self)
-
- # empty cloze?
- if type == "q" and model["type"] == MODEL_CLOZE:
- if not self.models._availClozeOrds(model, data[6], False):
- ret["q"] += "
" + _(
- "Please edit this note and add some cloze deletions. (%s)"
- ) % ("%s" % (HELP_SITE, _("help")))
-
- return ret
+ for (type, format) in (("q", qfmt), ("a", afmt)):
+ if type == "q":
+ format = re.sub(
+ "{{(?!type:)(.*?)cloze:", r"{{\1cq-%d:" % (data[4] + 1), format
+ )
+ format = format.replace("<%cloze:", "<%%cq:%d:" % (data[4] + 1))
+ else:
+ format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (data[4] + 1), format)
+ format = format.replace("<%cloze:", "<%%ca:%d:" % (data[4] + 1))
+ fields["FrontSide"] = stripSounds(d["q"])
+ fields = runFilter("mungeFields", fields, model, data, self)
+ html = anki.template.render(format, fields)
+ d[type] = runFilter("mungeQA", html, type, fields, model, data, self)
+ # empty cloze?
+ if type == "q" and model["type"] == MODEL_CLOZE:
+ if not self.models._availClozeOrds(model, data[6], False):
+ d["q"] += "
" + _(
+ "Please edit this note and add some cloze deletions. (%s)"
+ ) % ("%s" % (HELP_SITE, _("help")))
+ return d
def _qaData(self, where="") -> Any:
"Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query"
diff --git a/pylib/anki/media.py b/pylib/anki/media.py
index 5d365fa48..684e711f5 100644
--- a/pylib/anki/media.py
+++ b/pylib/anki/media.py
@@ -18,7 +18,6 @@ from anki.consts import *
from anki.db import DB, DBError
from anki.lang import _
from anki.latex import mungeQA
-from anki.template import expand_clozes
from anki.utils import checksum, isMac, isWin
@@ -217,7 +216,7 @@ create table meta (dirMod int, lastUsn int); insert into meta values (0, 0);
if model["type"] == MODEL_CLOZE and "{{c" in string:
# if the field has clozes in it, we'll need to expand the
# possibilities so we can render latex
- strings = expand_clozes(string)
+ strings = self._expandClozes(string)
else:
strings = [string]
for string in strings:
@@ -232,6 +231,31 @@ create table meta (dirMod int, lastUsn int); insert into meta values (0, 0);
l.append(fname)
return l
+ def _expandClozes(self, string: str) -> List[str]:
+ ords = set(re.findall(r"{{c(\d+)::.+?}}", string))
+ strings = []
+ from anki.template.template import (
+ clozeReg,
+ CLOZE_REGEX_MATCH_GROUP_HINT,
+ CLOZE_REGEX_MATCH_GROUP_CONTENT,
+ )
+
+ def qrepl(m):
+ if m.group(CLOZE_REGEX_MATCH_GROUP_HINT):
+ return "[%s]" % m.group(CLOZE_REGEX_MATCH_GROUP_HINT)
+ else:
+ return "[...]"
+
+ def arepl(m):
+ return m.group(CLOZE_REGEX_MATCH_GROUP_CONTENT)
+
+ for ord in ords:
+ s = re.sub(clozeReg % ord, qrepl, string)
+ s = re.sub(clozeReg % ".+?", arepl, s)
+ strings.append(s)
+ strings.append(re.sub(clozeReg % ".+?", arepl, string))
+ return strings
+
def transformNames(self, txt: str, func: Callable) -> Any:
for reg in self.regexps:
txt = re.sub(reg, func, txt)
diff --git a/pylib/anki/template.py b/pylib/anki/template.py
deleted file mode 100644
index c93a68c70..000000000
--- a/pylib/anki/template.py
+++ /dev/null
@@ -1,145 +0,0 @@
-# Copyright: Ankitects Pty Ltd and contributors
-# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-"""
-This file contains the Python portion of the template rendering code.
-
-Templates can have filters applied to field replacements. The Rust template
-rendering code will apply any built in filters, and stop at the first
-unrecognized filter. The remaining filters are returned to Python,
-and applied using the hook system. For example,
-{{myfilter:hint:text:Field}} will apply the built in text and hint filters,
-and then attempt to apply myfilter. If no add-ons have provided the filter,
-the text is not modified.
-
-Add-ons can register a filter by adding a hook to "fmod_".
-As standard filters will not be run after a custom filter, it is up to the
-add-on to do any further processing that is required.
-
-The hook is called with the arguments
-(field_text, filter_args, field_map, field_name, "").
-The last argument is no longer used.
-If the field name contains a hyphen, it is split on the hyphen, eg
-{{foo-bar:baz}} calls fmod_foo with filter_args set to "bar".
-
-A Python implementation of the standard filters is currently available in the
-template_legacy.py file.
-"""
-
-from __future__ import annotations
-
-import re
-from typing import Dict, List, Tuple, Union
-
-import anki
-from anki.hooks import runFilter
-from anki.rsbackend import TemplateReplacement
-from anki.sound import stripSounds
-
-
-def render_template(
- col: anki.storage._Collection, format: str, fields: Dict[str, str]
-) -> str:
- "Render a single template."
- rendered = col.backend.render_template(format, fields)
- return apply_custom_filters(rendered, fields)
-
-
-def render_qa_from_field_map(
- col: anki.storage._Collection,
- qfmt: str,
- afmt: str,
- fields: Dict[str, str],
- card_ord: int,
-) -> Tuple[str, str]:
- "Renders the provided templates, returning rendered q & a text."
- # question
- format = re.sub("{{(?!type:)(.*?)cloze:", r"{{\1cq-%d:" % (card_ord + 1), qfmt)
- format = format.replace("<%cloze:", "<%%cq:%d:" % (card_ord + 1))
- qtext = render_template(col, format, fields)
-
- # answer
- format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (card_ord + 1), afmt)
- format = format.replace("<%cloze:", "<%%ca:%d:" % (card_ord + 1))
- fields["FrontSide"] = stripSounds(qtext)
- atext = render_template(col, format, fields)
-
- return qtext, atext
-
-
-def apply_custom_filters(
- rendered: List[Union[str, TemplateReplacement]], fields: Dict[str, str]
-) -> str:
- "Complete rendering by applying any pending custom filters."
- res = ""
- for node in rendered:
- if isinstance(node, str):
- res += node
- else:
- res += apply_field_filters(
- node.field_name, node.current_text, fields, node.filters
- )
- return res
-
-
-# Filters
-##########################################################################
-
-
-def apply_field_filters(
- field_name: str, field_text: str, fields: Dict[str, str], filters: List[str]
-) -> str:
- """Apply filters to field text, returning modified text."""
- for filter in filters:
- if "-" in filter:
- filter_base, filter_args = filter.split("-", maxsplit=1)
- else:
- filter_base = filter
- filter_args = ""
-
- # the fifth argument is no longer used
- field_text = runFilter(
- "fmod_" + filter_base, field_text, filter_args, fields, field_name, ""
- )
- return field_text
-
-
-# Cloze handling
-##########################################################################
-
-# Matches a {{c123::clozed-out text::hint}} Cloze deletion, case-insensitively.
-# The regex should be interpolated with a regex number and creates the following
-# named groups:
-# - tag: The lowercase or uppercase 'c' letter opening the Cloze.
-# The c/C difference is only relevant to the legacy code.
-# - content: Clozed-out content.
-# - hint: Cloze hint, if provided.
-clozeReg = r"(?si)\{\{(?Pc)%s::(?P.*?)(::(?P.*?))?\}\}"
-
-# Constants referring to group names within clozeReg.
-CLOZE_REGEX_MATCH_GROUP_TAG = "tag"
-CLOZE_REGEX_MATCH_GROUP_CONTENT = "content"
-CLOZE_REGEX_MATCH_GROUP_HINT = "hint"
-
-# used by the media check functionality
-def expand_clozes(string: str) -> List[str]:
- "Render all clozes in string."
- ords = set(re.findall(r"{{c(\d+)::.+?}}", string))
- strings = []
-
- def qrepl(m):
- if m.group(CLOZE_REGEX_MATCH_GROUP_HINT):
- return "[%s]" % m.group(CLOZE_REGEX_MATCH_GROUP_HINT)
- else:
- return "[...]"
-
- def arepl(m):
- return m.group(CLOZE_REGEX_MATCH_GROUP_CONTENT)
-
- for ord in ords:
- s = re.sub(clozeReg % ord, qrepl, string)
- s = re.sub(clozeReg % ".+?", arepl, s)
- strings.append(s)
- strings.append(re.sub(clozeReg % ".+?", arepl, string))
-
- return strings
diff --git a/pylib/anki/template/LICENSE b/pylib/anki/template/LICENSE
new file mode 100644
index 000000000..2745bcce5
--- /dev/null
+++ b/pylib/anki/template/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2009 Chris Wanstrath
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/pylib/anki/template/README.anki b/pylib/anki/template/README.anki
new file mode 100644
index 000000000..d1e9223dc
--- /dev/null
+++ b/pylib/anki/template/README.anki
@@ -0,0 +1,8 @@
+Anki uses a modified version of Pystache to provide Mustache-like syntax.
+Behaviour is a little different from standard Mustache:
+
+- {{text}} returns text verbatim with no HTML escaping
+- {{{text}}} does the same and exists for backwards compatibility
+- partial rendering is disabled for security reasons
+- certain keywords like 'cloze' are treated specially
+
diff --git a/pylib/anki/template/__init__.py b/pylib/anki/template/__init__.py
new file mode 100644
index 000000000..39a0012a7
--- /dev/null
+++ b/pylib/anki/template/__init__.py
@@ -0,0 +1,14 @@
+from typing import Any
+
+from . import furigana, hint
+from .template import Template
+
+furigana.install()
+
+hint.install()
+
+
+def render(template, context=None, **kwargs) -> Any:
+ context = context and context.copy() or {}
+ context.update(kwargs)
+ return Template(template, context).render()
diff --git a/pylib/anki/template/furigana.py b/pylib/anki/template/furigana.py
new file mode 100644
index 000000000..93e15f724
--- /dev/null
+++ b/pylib/anki/template/furigana.py
@@ -0,0 +1,44 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+# Based off Kieran Clancy's initial implementation.
+
+import re
+from typing import Any, Callable
+
+from anki.hooks import addHook
+
+r = r" ?([^ >]+?)\[(.+?)\]"
+ruby = r"\1"
+
+
+def noSound(repl) -> Callable[[Any], Any]:
+ def func(match):
+ if match.group(2).startswith("sound:"):
+ # return without modification
+ return match.group(0)
+ else:
+ return re.sub(r, repl, match.group(0))
+
+ return func
+
+
+def _munge(s) -> Any:
+ return s.replace(" ", " ")
+
+
+def kanji(txt, *args) -> str:
+ return re.sub(r, noSound(r"\1"), _munge(txt))
+
+
+def kana(txt, *args) -> str:
+ return re.sub(r, noSound(r"\2"), _munge(txt))
+
+
+def furigana(txt, *args) -> str:
+ return re.sub(r, noSound(ruby), _munge(txt))
+
+
+def install() -> None:
+ addHook("fmod_kanji", kanji)
+ addHook("fmod_kana", kana)
+ addHook("fmod_furigana", furigana)
diff --git a/pylib/anki/template/hint.py b/pylib/anki/template/hint.py
new file mode 100644
index 000000000..52733b68b
--- /dev/null
+++ b/pylib/anki/template/hint.py
@@ -0,0 +1,26 @@
+# Copyright: Ankitects Pty Ltd and contributors
+# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+from anki.hooks import addHook
+from anki.lang import _
+
+
+def hint(txt, extra, context, tag, fullname) -> str:
+ if not txt.strip():
+ return ""
+ # random id
+ domid = "hint%d" % id(txt)
+ return """
+
+%s%s
+""" % (
+ domid,
+ _("Show %s") % tag,
+ domid,
+ txt,
+ )
+
+
+def install() -> None:
+ addHook("fmod_hint", hint)
diff --git a/pylib/anki/template/template.py b/pylib/anki/template/template.py
new file mode 100644
index 000000000..b96936458
--- /dev/null
+++ b/pylib/anki/template/template.py
@@ -0,0 +1,307 @@
+import re
+from typing import Any, Callable, Dict, Pattern
+
+from anki.hooks import runFilter
+from anki.utils import stripHTML, stripHTMLMedia
+
+# Matches a {{c123::clozed-out text::hint}} Cloze deletion, case-insensitively.
+# The regex should be interpolated with a regex number and creates the following
+# named groups:
+# - tag: The lowercase or uppercase 'c' letter opening the Cloze.
+# - content: Clozed-out content.
+# - hint: Cloze hint, if provided.
+clozeReg = r"(?si)\{\{(?Pc)%s::(?P.*?)(::(?P.*?))?\}\}"
+
+# Constants referring to group names within clozeReg.
+CLOZE_REGEX_MATCH_GROUP_TAG = "tag"
+CLOZE_REGEX_MATCH_GROUP_CONTENT = "content"
+CLOZE_REGEX_MATCH_GROUP_HINT = "hint"
+
+modifiers: Dict[str, Callable] = {}
+
+
+def modifier(symbol) -> Callable[[Any], Any]:
+ """Decorator for associating a function with a Mustache tag modifier.
+
+ @modifier('P')
+ def render_tongue(self, tag_name=None, context=None):
+ return ":P %s" % tag_name
+
+ {{P yo }} => :P yo
+ """
+
+ def set_modifier(func):
+ modifiers[symbol] = func
+ return func
+
+ return set_modifier
+
+
+def get_or_attr(obj, name, default=None) -> Any:
+ try:
+ return obj[name]
+ except KeyError:
+ return default
+ except:
+ try:
+ return getattr(obj, name)
+ except AttributeError:
+ return default
+
+
+class Template:
+ # The regular expression used to find a #section
+ section_re: Pattern = None
+
+ # The regular expression used to find a tag.
+ tag_re: Pattern = None
+
+ # Opening tag delimiter
+ otag = "{{"
+
+ # Closing tag delimiter
+ ctag = "}}"
+
+ def __init__(self, template, context=None) -> None:
+ self.template = template
+ self.context = context or {}
+ self.compile_regexps()
+
+ def render(self, template=None, context=None, encoding=None) -> str:
+ """Turns a Mustache template into something wonderful."""
+ template = template or self.template
+ context = context or self.context
+
+ template = self.render_sections(template, context)
+ result = self.render_tags(template, context)
+ # if encoding is not None:
+ # result = result.encode(encoding)
+ return result
+
+ def compile_regexps(self) -> None:
+ """Compiles our section and tag regular expressions."""
+ tags = {"otag": re.escape(self.otag), "ctag": re.escape(self.ctag)}
+
+ section = r"%(otag)s[\#|^]([^\}]*)%(ctag)s(.+?)%(otag)s/\1%(ctag)s"
+ self.section_re = re.compile(section % tags, re.M | re.S)
+
+ tag = r"%(otag)s(#|=|&|!|>|\{)?(.+?)\1?%(ctag)s+"
+ self.tag_re = re.compile(tag % tags)
+
+ def render_sections(self, template, context) -> str:
+ """Expands sections."""
+ while 1:
+ match = self.section_re.search(template)
+ if match is None:
+ break
+
+ section, section_name, inner = match.group(0, 1, 2)
+ section_name = section_name.strip()
+
+ # check for cloze
+ val = None
+ m = re.match(r"c[qa]:(\d+):(.+)", section_name)
+ if m:
+ # get full field text
+ txt = get_or_attr(context, m.group(2), None)
+ m = re.search(clozeReg % m.group(1), txt)
+ if m:
+ val = m.group(CLOZE_REGEX_MATCH_GROUP_TAG)
+ else:
+ val = get_or_attr(context, section_name, None)
+
+ replacer = ""
+ inverted = section[2] == "^"
+ if val:
+ val = stripHTMLMedia(val).strip()
+ if (val and not inverted) or (not val and inverted):
+ replacer = inner
+
+ template = template.replace(section, replacer)
+
+ return template
+
+ def render_tags(self, template, context) -> str:
+ """Renders all the tags in a template for a context."""
+ repCount = 0
+ while 1:
+ if repCount > 100:
+ print("too many replacements")
+ break
+ repCount += 1
+
+ match = self.tag_re.search(template)
+ if match is None:
+ break
+
+ tag, tag_type, tag_name = match.group(0, 1, 2)
+ tag_name = tag_name.strip()
+ try:
+ func = modifiers[tag_type]
+ replacement = func(self, tag_name, context)
+ template = template.replace(tag, replacement)
+ except (SyntaxError, KeyError):
+ return "{{invalid template}}"
+
+ return template
+
+ # {{{ functions just like {{ in anki
+ @modifier("{")
+ def render_tag(self, tag_name, context) -> Any:
+ return self.render_unescaped(tag_name, context)
+
+ @modifier("!")
+ def render_comment(self, tag_name=None, context=None) -> str:
+ """Rendering a comment always returns nothing."""
+ return ""
+
+ @modifier(None)
+ def render_unescaped(self, tag_name=None, context=None) -> Any:
+ """Render a tag without escaping it."""
+ txt = get_or_attr(context, tag_name)
+ if txt is not None:
+ # some field names could have colons in them
+ # avoid interpreting these as field modifiers
+ # better would probably be to put some restrictions on field names
+ return txt
+
+ # field modifiers
+ parts = tag_name.split(":")
+ extra = None
+ if len(parts) == 1 or parts[0] == "":
+ return "{unknown field %s}" % tag_name
+ else:
+ mods, tag = parts[:-1], parts[-1] # py3k has *mods, tag = parts
+
+ txt = get_or_attr(context, tag)
+
+ # Since 'text:' and other mods can affect html on which Anki relies to
+ # process clozes, we need to make sure clozes are always
+ # treated after all the other mods, regardless of how they're specified
+ # in the template, so that {{cloze:text: == {{text:cloze:
+ # For type:, we return directly since no other mod than cloze (or other
+ # pre-defined mods) can be present and those are treated separately
+ mods.reverse()
+ mods.sort(key=lambda s: not s == "type")
+
+ for mod in mods:
+ # built-in modifiers
+ if mod == "text":
+ # strip html
+ txt = stripHTML(txt) if txt else ""
+ elif mod == "type":
+ # type answer field; convert it to [[type:...]] for the gui code
+ # to process
+ return "[[%s]]" % tag_name
+ elif mod.startswith("cq-") or mod.startswith("ca-"):
+ # cloze deletion
+ mod, extra = mod.split("-")
+ txt = self.clozeText(txt, extra, mod[1]) if txt and extra else ""
+ else:
+ # hook-based field modifier
+ m = re.search(r"^(.*?)(?:\((.*)\))?$", mod)
+ if not m:
+ return "invalid field modifier " + mod
+ mod, extra = m.groups()
+ txt = runFilter(
+ "fmod_" + mod, txt or "", extra or "", context, tag, tag_name
+ )
+ if txt is None:
+ return "{unknown field %s}" % tag_name
+ return txt
+
+ @classmethod
+ def clozeText(cls, txt: str, ord: str, type: str) -> str:
+ """Processe the given Cloze deletion within the given template."""
+ reg = clozeReg
+ currentRegex = clozeReg % ord
+ if not re.search(currentRegex, txt):
+ # No Cloze deletion was found in txt.
+ return ""
+ txt = cls._removeFormattingFromMathjax(txt, ord)
+
+ def repl(m):
+ # replace chosen cloze with type
+ if type == "q":
+ if m.group(CLOZE_REGEX_MATCH_GROUP_HINT):
+ buf = "[%s]" % m.group(CLOZE_REGEX_MATCH_GROUP_HINT)
+ else:
+ buf = "[...]"
+ else:
+ buf = m.group(CLOZE_REGEX_MATCH_GROUP_CONTENT)
+ # uppercase = no formatting
+ if m.group(CLOZE_REGEX_MATCH_GROUP_TAG) == "c":
+ buf = "%s" % buf
+ return buf
+
+ txt = re.sub(currentRegex, repl, txt)
+ # and display other clozes normally
+ return re.sub(reg % r"\d+", "\\2", txt)
+
+ @classmethod
+ def _removeFormattingFromMathjax(cls, txt, ord) -> str:
+ """Marks all clozes within MathJax to prevent formatting them.
+
+ Active Cloze deletions within MathJax should not be wrapped inside
+ a Cloze , as that would interfere with MathJax.
+
+ This method finds all Cloze deletions number `ord` in `txt` which are
+ inside MathJax inline or display formulas, and replaces their opening
+ '{{c123' with a '{{C123'. The clozeText method interprets the upper-case
+ C as "don't wrap this Cloze in a ".
+ """
+ creg = clozeReg.replace("(?si)", "")
+
+ # Scan the string left to right.
+ # After a MathJax opening - \( or \[ - flip in_mathjax to True.
+ # After a MathJax closing - \) or \] - flip in_mathjax to False.
+ # When a Cloze pattern number `ord` is found and we are in MathJax,
+ # replace its '{{c' with '{{C'.
+ #
+ # TODO: Report mismatching opens/closes - e.g. '\(\]'
+ # TODO: Report errors in this method better than printing to stdout.
+ # flags in middle of expression deprecated
+ in_mathjax = False
+
+ def replace(match):
+ nonlocal in_mathjax
+ if match.group("mathjax_open"):
+ if in_mathjax:
+ print("MathJax opening found while already in MathJax")
+ in_mathjax = True
+ elif match.group("mathjax_close"):
+ if not in_mathjax:
+ print("MathJax close found while not in MathJax")
+ in_mathjax = False
+ elif match.group("cloze"):
+ if in_mathjax:
+ return match.group(0).replace(
+ "{{c{}::".format(ord), "{{C{}::".format(ord)
+ )
+ else:
+ print("Unexpected: no expected capture group is present")
+ return match.group(0)
+
+ # The following regex matches one of:
+ # - MathJax opening
+ # - MathJax close
+ # - Cloze deletion number `ord`
+ return re.sub(
+ r"(?si)"
+ r"(?P\\[([])|"
+ r"(?P\\[\])])|"
+ r"(?P" + (creg % ord) + ")",
+ replace,
+ txt,
+ )
+
+ @modifier("=")
+ def render_delimiter(self, tag_name=None, context=None) -> str:
+ """Changes the Mustache delimiter."""
+ try:
+ self.otag, self.ctag = tag_name.split(" ")
+ except ValueError:
+ # invalid
+ return ""
+ self.compile_regexps()
+ return ""
diff --git a/pylib/anki/template/view.py b/pylib/anki/template/view.py
new file mode 100644
index 000000000..ae384742d
--- /dev/null
+++ b/pylib/anki/template/view.py
@@ -0,0 +1,118 @@
+import os.path
+import re
+from typing import Any
+
+from .template import Template
+
+
+class View:
+ # Path where this view's template(s) live
+ template_path = "."
+
+ # Extension for templates
+ template_extension = "mustache"
+
+ # The name of this template. If none is given the View will try
+ # to infer it based on the class name.
+ template_name: str = None
+
+ # Absolute path to the template itself. Pystache will try to guess
+ # if it's not provided.
+ template_file: str = None
+
+ # Contents of the template.
+ template: str = None
+
+ # Character encoding of the template file. If None, Pystache will not
+ # do any decoding of the template.
+ template_encoding: str = None
+
+ def __init__(self, template=None, context=None, **kwargs) -> None:
+ self.template = template
+ self.context = context or {}
+
+ # If the context we're handed is a View, we want to inherit
+ # its settings.
+ if isinstance(context, View):
+ self.inherit_settings(context)
+
+ if kwargs:
+ self.context.update(kwargs)
+
+ def inherit_settings(self, view) -> None:
+ """Given another View, copies its settings."""
+ if view.template_path:
+ self.template_path = view.template_path
+
+ if view.template_name:
+ self.template_name = view.template_name
+
+ def load_template(self) -> Any:
+ if self.template:
+ return self.template
+
+ if self.template_file:
+ return self._load_template()
+
+ name = self.get_template_name() + "." + self.template_extension
+
+ if isinstance(self.template_path, str):
+ self.template_file = os.path.join(self.template_path, name)
+ return self._load_template()
+
+ for path in self.template_path:
+ self.template_file = os.path.join(path, name)
+ if os.path.exists(self.template_file):
+ return self._load_template()
+
+ raise IOError('"%s" not found in "%s"' % (name, ":".join(self.template_path),))
+
+ def _load_template(self) -> str:
+ f = open(self.template_file, "r")
+ try:
+ template = f.read()
+ if self.template_encoding and isinstance(template, bytes):
+ template = str(template, self.template_encoding)
+ finally:
+ f.close()
+ return template
+
+ def get_template_name(self, name=None) -> Any:
+ """TemplatePartial => template_partial
+ Takes a string but defaults to using the current class' name or
+ the `template_name` attribute
+ """
+ if self.template_name:
+ return self.template_name
+
+ if not name:
+ name = self.__class__.__name__
+
+ def repl(match):
+ return "_" + match.group(0).lower()
+
+ return re.sub("[A-Z]", repl, name)[1:]
+
+ def __contains__(self, needle) -> bool:
+ return needle in self.context or hasattr(self, needle)
+
+ def __getitem__(self, attr) -> Any:
+ val = self.get(attr, None)
+ if not val:
+ raise KeyError("No such key.")
+ return val
+
+ def get(self, attr, default) -> Any:
+ attr = self.context.get(attr, getattr(self, attr, default))
+
+ if hasattr(attr, "__call__"):
+ return attr()
+ else:
+ return attr
+
+ def render(self, encoding=None) -> str:
+ template = self.load_template()
+ return Template(template, self).render(encoding=encoding)
+
+ def __str__(self) -> str:
+ return self.render()
diff --git a/pylib/anki/template_legacy.py b/pylib/anki/template_legacy.py
deleted file mode 100644
index ad9850fc8..000000000
--- a/pylib/anki/template_legacy.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# Copyright: Ankitects Pty Ltd and contributors
-# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
-"""
-This file contains code that is no longer used by Anki, but left around
-for the benefit of add-ons. It may go away in the future, so please copy
-any routines you need into your own add-on instead of using them directly
-from this module.
-
-If your add-on was previously calling anki.template.render(), you now
-need to call anki.template.render_template(), passing col in as the first
-argument.
-"""
-
-from __future__ import annotations
-
-import re
-from typing import Any, Callable
-
-from anki.lang import _
-from anki.template import (
- CLOZE_REGEX_MATCH_GROUP_CONTENT,
- CLOZE_REGEX_MATCH_GROUP_HINT,
- CLOZE_REGEX_MATCH_GROUP_TAG,
- clozeReg,
-)
-from anki.utils import stripHTML
-
-# Cloze filter
-##########################################################################
-
-
-def _clozeText(txt: str, ord: str, type: str) -> str:
- """Process the given Cloze deletion within the given template."""
- reg = clozeReg
- currentRegex = clozeReg % ord
- if not re.search(currentRegex, txt):
- # No Cloze deletion was found in txt.
- return ""
- txt = _removeFormattingFromMathjax(txt, ord)
-
- def repl(m):
- # replace chosen cloze with type
- if type == "q":
- if m.group(CLOZE_REGEX_MATCH_GROUP_HINT):
- buf = "[%s]" % m.group(CLOZE_REGEX_MATCH_GROUP_HINT)
- else:
- buf = "[...]"
- else:
- buf = m.group(CLOZE_REGEX_MATCH_GROUP_CONTENT)
- # uppercase = no formatting
- if m.group(CLOZE_REGEX_MATCH_GROUP_TAG) == "c":
- buf = "%s" % buf
- return buf
-
- txt = re.sub(currentRegex, repl, txt)
- # and display other clozes normally
- return re.sub(reg % r"\d+", "\\2", txt)
-
-
-def _removeFormattingFromMathjax(txt, ord) -> str:
- """Marks all clozes within MathJax to prevent formatting them.
-
- Active Cloze deletions within MathJax should not be wrapped inside
- a Cloze , as that would interfere with MathJax.
-
- This method finds all Cloze deletions number `ord` in `txt` which are
- inside MathJax inline or display formulas, and replaces their opening
- '{{c123' with a '{{C123'. The clozeText method interprets the upper-case
- C as "don't wrap this Cloze in a ".
- """
- creg = clozeReg.replace("(?si)", "")
-
- # Scan the string left to right.
- # After a MathJax opening - \( or \[ - flip in_mathjax to True.
- # After a MathJax closing - \) or \] - flip in_mathjax to False.
- # When a Cloze pattern number `ord` is found and we are in MathJax,
- # replace its '{{c' with '{{C'.
- #
- # TODO: Report mismatching opens/closes - e.g. '\(\]'
- # TODO: Report errors in this method better than printing to stdout.
- # flags in middle of expression deprecated
- in_mathjax = False
-
- def replace(match):
- nonlocal in_mathjax
- if match.group("mathjax_open"):
- if in_mathjax:
- print("MathJax opening found while already in MathJax")
- in_mathjax = True
- elif match.group("mathjax_close"):
- if not in_mathjax:
- print("MathJax close found while not in MathJax")
- in_mathjax = False
- elif match.group("cloze"):
- if in_mathjax:
- return match.group(0).replace(
- "{{c{}::".format(ord), "{{C{}::".format(ord)
- )
- else:
- print("Unexpected: no expected capture group is present")
- return match.group(0)
-
- # The following regex matches one of:
- # - MathJax opening
- # - MathJax close
- # - Cloze deletion number `ord`
- return re.sub(
- r"(?si)"
- r"(?P\\[([])|"
- r"(?P\\[\])])|"
- r"(?P" + (creg % ord) + ")",
- replace,
- txt,
- )
-
-
-def _cloze_filter(field_text: str, filter_args: str, q_or_a: str):
- return _clozeText(field_text, filter_args, q_or_a)
-
-
-def cloze_qfilter(field_text: str, filter_args: str, *args):
- return _cloze_filter(field_text, filter_args, "q")
-
-
-def cloze_afilter(field_text: str, filter_args: str, *args):
- return _cloze_filter(field_text, filter_args, "a")
-
-
-# addHook("fmod_cq", cloze_qfilter)
-# addHook("fmod_ca", cloze_afilter)
-
-# Other filters
-##########################################################################
-
-
-def hint_filter(txt: str, args, context, tag: str, fullname) -> str:
- if not txt.strip():
- return ""
- # random id
- domid = "hint%d" % id(txt)
- return """
-
-%s%s
-""" % (
- domid,
- _("Show %s") % tag,
- domid,
- txt,
- )
-
-
-FURIGANA_RE = r" ?([^ >]+?)\[(.+?)\]"
-RUBY_REPL = r"\1"
-
-
-def replace_if_not_audio(repl: str) -> Callable[[Any], Any]:
- def func(match):
- if match.group(2).startswith("sound:"):
- # return without modification
- return match.group(0)
- else:
- return re.sub(FURIGANA_RE, repl, match.group(0))
-
- return func
-
-
-def without_nbsp(s: str) -> str:
- return s.replace(" ", " ")
-
-
-def kanji_filter(txt: str, *args) -> str:
- return re.sub(FURIGANA_RE, replace_if_not_audio(r"\1"), without_nbsp(txt))
-
-
-def kana_filter(txt: str, *args) -> str:
- return re.sub(FURIGANA_RE, replace_if_not_audio(r"\2"), without_nbsp(txt))
-
-
-def furigana_filter(txt: str, *args) -> str:
- return re.sub(FURIGANA_RE, replace_if_not_audio(RUBY_REPL), without_nbsp(txt))
-
-
-def text_filter(txt: str, *args) -> str:
- return stripHTML(txt)
-
-
-def type_answer_filter(txt: str, filter_args: str, context, tag: str, dummy) -> str:
- # convert it to [[type:...]] for the gui code to process
- if filter_args:
- return f"[[type:{filter_args}:{tag}]]"
- else:
- return f"[[type:{tag}]]"
-
-
-# addHook("fmod_text", text_filter)
-# addHook("fmod_type", type_answer_filter)
-# addHook("fmod_hint", hint_filter)
-# addHook("fmod_kanji", kanji_filter)
-# addHook("fmod_kana", kana_filter)
-# addHook("fmod_furigana", furigana_filter)
diff --git a/pylib/tests/test_models.py b/pylib/tests/test_models.py
index 884c44cda..1c5c0cccb 100644
--- a/pylib/tests/test_models.py
+++ b/pylib/tests/test_models.py
@@ -1,6 +1,7 @@
# coding: utf-8
import time
+import anki.template
from anki.consts import MODEL_CLOZE
from anki.utils import isWin, joinFields, stripHTML
from tests.shared import getEmptyCol
@@ -219,18 +220,6 @@ def test_cloze_mathjax():
)
-def test_typecloze():
- d = getEmptyCol()
- m = d.models.byName("Cloze")
- d.models.setCurrent(m)
- m["tmpls"][0]["qfmt"] = "{{type:cloze:Text}}"
- d.models.save(m)
- f = d.newNote()
- f["Text"] = "hello {{c1::world}}"
- d.addNote(f)
- assert "[[type:cloze:Text]]" in f.cards()[0].q()
-
-
def test_chained_mods():
d = getEmptyCol()
d.models.setCurrent(d.models.byName("Cloze"))
@@ -349,6 +338,15 @@ def test_modelChange():
assert deck.db.scalar("select count() from cards where nid = ?", f.id) == 1
+def test_templates2():
+ d = dict(Foo="x", Bar="y")
+ assert anki.template.render("{{Foo}}", d) == "x"
+ assert anki.template.render("{{#Foo}}{{Foo}}{{/Foo}}", d) == "x"
+ assert anki.template.render("{{#Foo}}{{Foo}}{{/Foo}}", d) == "x"
+ assert anki.template.render("{{#Bar}}{{#Foo}}{{Foo}}{{/Foo}}{{/Bar}}", d) == "x"
+ assert anki.template.render("{{#Baz}}{{#Foo}}{{Foo}}{{/Foo}}{{/Baz}}", d) == ""
+
+
def test_availOrds():
d = getEmptyCol()
m = d.models.current()
diff --git a/pylib/tests/test_template.py b/pylib/tests/test_template.py
index d4338b9aa..5a61e11f8 100644
--- a/pylib/tests/test_template.py
+++ b/pylib/tests/test_template.py
@@ -1,8 +1,9 @@
-from anki.template_legacy import _removeFormattingFromMathjax
+from anki.template import Template
def test_remove_formatting_from_mathjax():
- assert _removeFormattingFromMathjax(r"\(2^{{c3::2}}\)", 3) == r"\(2^{{C3::2}}\)"
+ t = Template("")
+ assert t._removeFormattingFromMathjax(r"\(2^{{c3::2}}\)", 3) == r"\(2^{{C3::2}}\)"
txt = (
r"{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) "
@@ -10,7 +11,9 @@ def test_remove_formatting_from_mathjax():
)
# Cloze 2 is not in MathJax, so it should not get protected against
# formatting.
- assert _removeFormattingFromMathjax(txt, 2) == txt
+ assert t._removeFormattingFromMathjax(txt, 2) == txt
txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]"
- assert _removeFormattingFromMathjax(txt, 1) == (r"\(a\) {{c1::b}} \[ {{C1::c}} \]")
+ assert t._removeFormattingFromMathjax(txt, 1) == (
+ r"\(a\) {{c1::b}} \[ {{C1::c}} \]"
+ )