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\2" + + +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

+""" % ( + 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 -""" % ( - domid, - _("Show %s") % tag, - domid, - txt, - ) - - -FURIGANA_RE = r" ?([^ >]+?)\[(.+?)\]" -RUBY_REPL = r"\1\2" - - -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}} \]" + )