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:
|
||||
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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):
|
||||
ret["q"] += "<p>" + _(
|
||||
d["q"] += "<p>" + _(
|
||||
"Please edit this note and add some cloze deletions. (%s)"
|
||||
) % ("<a href=%s#cloze>%s</a>" % (HELP_SITE, _("help")))
|
||||
|
||||
return ret
|
||||
return d
|
||||
|
||||
def _qaData(self, where="") -> Any:
|
||||
"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.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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}} \]"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue