mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00
drop pystache and move legacy code into separate file
This commit is contained in:
parent
dc8b854ada
commit
a51a4e4d31
13 changed files with 171 additions and 484 deletions
1
LICENSE
1
LICENSE
|
@ -6,7 +6,6 @@ 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.
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
Anki is licensed under the GNU Affero General Public License, version 3 or
|
|
||||||
later, with portions contributed by Anki users licensed under the BSD-3
|
|
||||||
license: https://github.com/ankitects/anki-contributors.
|
|
||||||
|
|
||||||
The following included source code items use a license other than AGPL3:
|
|
||||||
|
|
||||||
* 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.
|
|
|
@ -30,7 +30,7 @@ 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.tags import TagManager
|
from anki.tags import TagManager
|
||||||
from anki.template2 import render_qa_from_field_map
|
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,
|
||||||
|
|
|
@ -18,7 +18,7 @@ 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.template2 import expand_clozes
|
from anki.template import expand_clozes
|
||||||
from anki.utils import checksum, isMac, isWin
|
from anki.utils import checksum, isMac, isWin
|
||||||
|
|
||||||
|
|
||||||
|
|
145
pylib/anki/template.py
Normal file
145
pylib/anki/template.py
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
# 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
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
|
@ -1,8 +0,0 @@
|
||||||
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
|
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .template import Template
|
|
||||||
|
|
||||||
|
|
||||||
def render(template, context=None, **kwargs) -> Any:
|
|
||||||
context = context and context.copy() or {}
|
|
||||||
context.update(kwargs)
|
|
||||||
return Template(template, context).render()
|
|
|
@ -1,167 +0,0 @@
|
||||||
import re
|
|
||||||
from typing import Any, Callable, Dict, Pattern
|
|
||||||
|
|
||||||
from anki.template2 import apply_field_filters
|
|
||||||
|
|
||||||
modifiers: Dict[str, Callable] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def field_is_not_empty(field_text: str) -> bool:
|
|
||||||
# fixme: this is an overkill way of preventing a field with only
|
|
||||||
# a <br> or <div> from appearing non-empty
|
|
||||||
from anki.utils import stripHTMLMedia
|
|
||||||
|
|
||||||
field_text = stripHTMLMedia(field_text)
|
|
||||||
|
|
||||||
return field_text.strip() != ""
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
val = get_or_attr(context, section_name, None)
|
|
||||||
|
|
||||||
replacer = ""
|
|
||||||
inverted = section[2] == "^"
|
|
||||||
nonempty = field_is_not_empty(val or "")
|
|
||||||
if (nonempty and not inverted) or (not nonempty 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."""
|
|
||||||
# split out field modifiers
|
|
||||||
*mods, tag = tag_name.split(":")
|
|
||||||
|
|
||||||
# return an error if field doesn't exist
|
|
||||||
txt = get_or_attr(context, tag)
|
|
||||||
if txt is None:
|
|
||||||
return "{unknown field %s}" % tag_name
|
|
||||||
|
|
||||||
# the filter closest to the field name is applied first
|
|
||||||
mods.reverse()
|
|
||||||
return apply_field_filters(tag, txt, context, mods)
|
|
||||||
|
|
||||||
@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 ""
|
|
|
@ -1,118 +0,0 @@
|
||||||
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()
|
|
|
@ -2,125 +2,33 @@
|
||||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This file contains some code related to templates that is not directly
|
This file contains code that is no longer used by Anki, but left around
|
||||||
connected to pystache. It may be renamed in the future.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable, Dict, List, Tuple, Union
|
from typing import Any, Callable
|
||||||
|
|
||||||
import anki
|
|
||||||
from anki.hooks import addHook, runFilter
|
|
||||||
from anki.lang import _
|
from anki.lang import _
|
||||||
from anki.rsbackend import TemplateReplacement
|
from anki.template import (
|
||||||
from anki.sound import stripSounds
|
CLOZE_REGEX_MATCH_GROUP_CONTENT,
|
||||||
|
CLOZE_REGEX_MATCH_GROUP_HINT,
|
||||||
|
CLOZE_REGEX_MATCH_GROUP_TAG,
|
||||||
|
clozeReg,
|
||||||
|
)
|
||||||
from anki.utils import stripHTML
|
from anki.utils import stripHTML
|
||||||
|
|
||||||
|
|
||||||
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 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 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
|
|
||||||
##########################################################################
|
|
||||||
#
|
|
||||||
# Applies field filters defined by hooks. Given {{filterName:field}} in
|
|
||||||
# the template, the hook fmod_filterName is called with the arguments
|
|
||||||
# (field_text, filter_args, fields, 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".
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
|
||||||
filters = _adjust_filters(filters)
|
|
||||||
|
|
||||||
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, ""
|
|
||||||
)
|
|
||||||
if not isinstance(field_text, str):
|
|
||||||
return "{field modifier '%s' on template invalid}" % filter
|
|
||||||
return field_text
|
|
||||||
|
|
||||||
|
|
||||||
def _adjust_filters(filters: List[str]) -> List[str]:
|
|
||||||
"Handle the type:cloze: special case."
|
|
||||||
if filters == ["cloze", "type"]:
|
|
||||||
filters = ["type-cloze"]
|
|
||||||
return filters
|
|
||||||
|
|
||||||
|
|
||||||
# Cloze filter
|
# Cloze filter
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
|
|
||||||
def _clozeText(txt: str, ord: str, type: str) -> str:
|
def _clozeText(txt: str, ord: str, type: str) -> str:
|
||||||
"""Process the given Cloze deletion within the given template."""
|
"""Process the given Cloze deletion within the given template."""
|
||||||
|
@ -207,29 +115,6 @@ def _removeFormattingFromMathjax(txt, ord) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _cloze_filter(field_text: str, filter_args: str, q_or_a: str):
|
def _cloze_filter(field_text: str, filter_args: str, q_or_a: str):
|
||||||
return _clozeText(field_text, filter_args, q_or_a)
|
return _clozeText(field_text, filter_args, q_or_a)
|
||||||
|
|
||||||
|
@ -242,8 +127,8 @@ def cloze_afilter(field_text: str, filter_args: str, *args):
|
||||||
return _cloze_filter(field_text, filter_args, "a")
|
return _cloze_filter(field_text, filter_args, "a")
|
||||||
|
|
||||||
|
|
||||||
addHook("fmod_cq", cloze_qfilter)
|
# addHook("fmod_cq", cloze_qfilter)
|
||||||
addHook("fmod_ca", cloze_afilter)
|
# addHook("fmod_ca", cloze_afilter)
|
||||||
|
|
||||||
# Other filters
|
# Other filters
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
@ -309,9 +194,9 @@ def type_answer_filter(txt: str, filter_args: str, context, tag: str, dummy) ->
|
||||||
return f"[[type:{tag}]]"
|
return f"[[type:{tag}]]"
|
||||||
|
|
||||||
|
|
||||||
addHook("fmod_text", text_filter)
|
# addHook("fmod_text", text_filter)
|
||||||
addHook("fmod_type", type_answer_filter)
|
# addHook("fmod_type", type_answer_filter)
|
||||||
addHook("fmod_hint", hint_filter)
|
# addHook("fmod_hint", hint_filter)
|
||||||
addHook("fmod_kanji", kanji_filter)
|
# addHook("fmod_kanji", kanji_filter)
|
||||||
addHook("fmod_kana", kana_filter)
|
# addHook("fmod_kana", kana_filter)
|
||||||
addHook("fmod_furigana", furigana_filter)
|
# addHook("fmod_furigana", furigana_filter)
|
|
@ -1,7 +1,6 @@
|
||||||
# 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
|
||||||
|
@ -350,15 +349,6 @@ 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,4 +1,4 @@
|
||||||
from anki.template2 import _removeFormattingFromMathjax
|
from anki.template_legacy import _removeFormattingFromMathjax
|
||||||
|
|
||||||
|
|
||||||
def test_remove_formatting_from_mathjax():
|
def test_remove_formatting_from_mathjax():
|
||||||
|
|
Loading…
Reference in a new issue