revert the template changes for the 2.1.17 release

This commit is contained in:
Damien Elmes 2020-01-11 20:15:31 +10:00
parent 84d22046d4
commit c69ccb5015
14 changed files with 619 additions and 410 deletions

View file

@ -6,6 +6,7 @@ The following included source code items use a license other than AGPL3:
In the pylib folder: In the pylib folder:
* The anki/template/ folder is based off pystache: MIT.
* The SuperMemo importer: GPL3. * The SuperMemo importer: GPL3.
* The Pauker importer: BSD-3. * The Pauker importer: BSD-3.
* statsbg.py: CC BY-SA 3.0. * statsbg.py: CC BY-SA 3.0.

View file

@ -29,8 +29,8 @@ from anki.notes import Note
from anki.rsbackend import RustBackend from anki.rsbackend import RustBackend
from anki.sched import Scheduler as V1Scheduler from anki.sched import Scheduler as V1Scheduler
from anki.schedv2 import Scheduler as V2Scheduler from anki.schedv2 import Scheduler as V2Scheduler
from anki.sound import stripSounds
from anki.tags import TagManager from anki.tags import TagManager
from anki.template import render_qa_from_field_map
from anki.types import NoteType, QAData, Template from anki.types import NoteType, QAData, Template
from anki.utils import ( from anki.utils import (
devMode, devMode,
@ -629,58 +629,51 @@ where c.nid = n.id and c.id in %s group by nid"""
raise Exception() raise Exception()
return [self._renderQA(*row) for row in self._qaData(where)] 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: None = None, afmt: None = None) -> Dict:
def _renderQA(
self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None
) -> Dict[str, Union[str, int]]:
"Returns hash of id, question, answer." "Returns hash of id, question, answer."
# extract info from data # data is [cid, nid, mid, did, ord, tags, flds, cardFlags]
split_fields = splitFields(data[6]) # unpack fields and create dict
card_ord = data[4] flist = splitFields(data[6])
fields = {}
model = self.models.get(data[2]) 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: if model["type"] == MODEL_STD:
template = model["tmpls"][data[4]] template = model["tmpls"][data[4]]
else: else:
template = model["tmpls"][0] template = model["tmpls"][0]
flag = data[7] fields["Card"] = template["name"]
deck_id = data[3] fields["c%d" % (data[4] + 1)] = "1"
card_id = data[0] # render q & a
tags = data[5] d: Dict[str, Any] = dict(id=data[0])
qfmt = qfmt or template["qfmt"] qfmt = qfmt or template["qfmt"]
afmt = afmt or template["afmt"] afmt = afmt or template["afmt"]
for (type, format) in (("q", qfmt), ("a", afmt)):
# create map of field names -> field content if type == "q":
fields: Dict[str, str] = {} format = re.sub(
for (name, (idx, conf)) in list(self.models.fieldMap(model).items()): "{{(?!type:)(.*?)cloze:", r"{{\1cq-%d:" % (data[4] + 1), format
fields[name] = split_fields[idx] )
format = format.replace("<%cloze:", "<%%cq:%d:" % (data[4] + 1))
# add special fields else:
fields["Tags"] = tags.strip() format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (data[4] + 1), format)
fields["Type"] = model["name"] format = format.replace("<%cloze:", "<%%ca:%d:" % (data[4] + 1))
fields["Deck"] = self.decks.name(deck_id) fields["FrontSide"] = stripSounds(d["q"])
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) fields = runFilter("mungeFields", fields, model, data, self)
html = anki.template.render(format, fields)
# render fields d[type] = runFilter("mungeQA", html, type, fields, model, data, self)
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? # empty cloze?
if type == "q" and model["type"] == MODEL_CLOZE: if type == "q" and model["type"] == MODEL_CLOZE:
if not self.models._availClozeOrds(model, data[6], False): if not self.models._availClozeOrds(model, data[6], False):
ret["q"] += "<p>" + _( d["q"] += "<p>" + _(
"Please edit this note and add some cloze deletions. (%s)" "Please edit this note and add some cloze deletions. (%s)"
) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help"))) ) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))
return d
return ret
def _qaData(self, where="") -> Any: def _qaData(self, where="") -> Any:
"Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query"

View file

@ -18,7 +18,6 @@ from anki.consts import *
from anki.db import DB, DBError from anki.db import DB, DBError
from anki.lang import _ from anki.lang import _
from anki.latex import mungeQA from anki.latex import mungeQA
from anki.template import expand_clozes
from anki.utils import checksum, isMac, isWin 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 model["type"] == MODEL_CLOZE and "{{c" in string:
# if the field has clozes in it, we'll need to expand the # if the field has clozes in it, we'll need to expand the
# possibilities so we can render latex # possibilities so we can render latex
strings = expand_clozes(string) strings = self._expandClozes(string)
else: else:
strings = [string] strings = [string]
for string in strings: for string in strings:
@ -232,6 +231,31 @@ create table meta (dirMod int, lastUsn int); insert into meta values (0, 0);
l.append(fname) l.append(fname)
return l 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: def transformNames(self, txt: str, func: Callable) -> Any:
for reg in self.regexps: for reg in self.regexps:
txt = re.sub(reg, func, txt) txt = re.sub(reg, func, txt)

View file

@ -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_<filter name>".
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)\{\{(?P<tag>c)%s::(?P<content>.*?)(::(?P<hint>.*?))?\}\}"
# 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

View file

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

View file

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

View file

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

View file

@ -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"<ruby><rb>\1</rb><rt>\2</rt></ruby>"
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("&nbsp;", " ")
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)

View file

@ -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 """
<a class=hint href="#"
onclick="this.style.display='none';document.getElementById('%s').style.display='block';return false;">
%s</a><div id="%s" class=hint style="display: none">%s</div>
""" % (
domid,
_("Show %s") % tag,
domid,
txt,
)
def install() -> None:
addHook("fmod_hint", hint)

View file

@ -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)\{\{(?P<tag>c)%s::(?P<content>.*?)(::(?P<hint>.*?))?\}\}"
# 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 = "<span class=cloze>%s</span>" % 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 <span>, 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 <span>".
"""
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<mathjax_open>\\[([])|"
r"(?P<mathjax_close>\\[\])])|"
r"(?P<cloze>" + (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 ""

118
pylib/anki/template/view.py Normal file
View file

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

View file

@ -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 = "<span class=cloze>%s</span>" % 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 <span>, 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 <span>".
"""
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<mathjax_open>\\[([])|"
r"(?P<mathjax_close>\\[\])])|"
r"(?P<cloze>" + (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 """
<a class=hint href="#"
onclick="this.style.display='none';document.getElementById('%s').style.display='block';return false;">
%s</a><div id="%s" class=hint style="display: none">%s</div>
""" % (
domid,
_("Show %s") % tag,
domid,
txt,
)
FURIGANA_RE = r" ?([^ >]+?)\[(.+?)\]"
RUBY_REPL = r"<ruby><rb>\1</rb><rt>\2</rt></ruby>"
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("&nbsp;", " ")
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)

View file

@ -1,6 +1,7 @@
# coding: utf-8 # coding: utf-8
import time import time
import anki.template
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.utils import isWin, joinFields, stripHTML from anki.utils import isWin, joinFields, stripHTML
from tests.shared import getEmptyCol 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(): def test_chained_mods():
d = getEmptyCol() d = getEmptyCol()
d.models.setCurrent(d.models.byName("Cloze")) 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 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(): def test_availOrds():
d = getEmptyCol() d = getEmptyCol()
m = d.models.current() m = d.models.current()

View file

@ -1,8 +1,9 @@
from anki.template_legacy import _removeFormattingFromMathjax from anki.template import Template
def test_remove_formatting_from_mathjax(): 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 = ( txt = (
r"{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) " 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 # Cloze 2 is not in MathJax, so it should not get protected against
# formatting. # formatting.
assert _removeFormattingFromMathjax(txt, 2) == txt assert t._removeFormattingFromMathjax(txt, 2) == txt
txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" 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}} \]"
)