mirror of
https://github.com/ankitects/anki.git
synced 2025-12-30 23:32:57 -05:00
revert the template changes for the 2.1.17 release
This commit is contained in:
parent
84d22046d4
commit
c69ccb5015
14 changed files with 619 additions and 410 deletions
1
LICENSE
1
LICENSE
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 = runFilter("mungeFields", fields, model, data, self)
|
||||||
fields["Card"] = template["name"]
|
html = anki.template.render(format, fields)
|
||||||
fields["CardFlag"] = self._flagNameFromCardFlags(flag)
|
d[type] = runFilter("mungeQA", html, type, fields, model, data, self)
|
||||||
fields["c%d" % (card_ord + 1)] = "1"
|
# empty cloze?
|
||||||
|
if type == "q" and model["type"] == MODEL_CLOZE:
|
||||||
fields = runFilter("mungeFields", fields, model, data, self)
|
if not self.models._availClozeOrds(model, data[6], False):
|
||||||
|
d["q"] += "<p>" + _(
|
||||||
# render fields
|
"Please edit this note and add some cloze deletions. (%s)"
|
||||||
qatext = render_qa_from_field_map(self, qfmt, afmt, fields, card_ord)
|
) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))
|
||||||
ret: Dict[str, Any] = dict(q=qatext[0], a=qatext[1], id=card_id)
|
return d
|
||||||
|
|
||||||
# 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"] += "<p>" + _(
|
|
||||||
"Please edit this note and add some cloze deletions. (%s)"
|
|
||||||
) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))
|
|
||||||
|
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
20
pylib/anki/template/LICENSE
Normal file
20
pylib/anki/template/LICENSE
Normal 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.
|
||||||
8
pylib/anki/template/README.anki
Normal file
8
pylib/anki/template/README.anki
Normal 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
|
||||||
|
|
||||||
14
pylib/anki/template/__init__.py
Normal file
14
pylib/anki/template/__init__.py
Normal 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()
|
||||||
44
pylib/anki/template/furigana.py
Normal file
44
pylib/anki/template/furigana.py
Normal 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(" ", " ")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
26
pylib/anki/template/hint.py
Normal file
26
pylib/anki/template/hint.py
Normal 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)
|
||||||
307
pylib/anki/template/template.py
Normal file
307
pylib/anki/template/template.py
Normal 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
118
pylib/anki/template/view.py
Normal 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()
|
||||||
|
|
@ -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(" ", " ")
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}} \]"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue