mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
more template rendering tweaks
- The front and back are rendered in one call now. If the front side contains no custom filters, we can bake {{FrontSide}} into the rear side. If it did contain custom filters, we return the partially complete rear template instead, and the calling code can inject the FrontSide in after it has been fully rendered. - Instead of modifying "cloze" into something like "cq-2", the card ordinal and whether we're rendering the question or answer are now passed in to the rendering filters as context. - The Rust code doesn't need to support filter names split on '-' anymore. - Drop the "Show" part of hint descriptions so i18n support can be deferred. - Ignore blank filter names caused by user using two colons instead of one. - Fixed hint field and text transposition.
This commit is contained in:
parent
be70997e5a
commit
9bb0348fdd
8 changed files with 330 additions and 167 deletions
|
@ -14,7 +14,7 @@ message BackendInput {
|
|||
Empty deck_tree = 18;
|
||||
FindCardsIn find_cards = 19;
|
||||
BrowserRowsIn browser_rows = 20;
|
||||
RenderTemplateIn render_template = 21;
|
||||
RenderCardIn render_card = 21;
|
||||
|
||||
PlusOneIn plus_one = 2046; // temporary, for testing
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ message BackendOutput {
|
|||
DeckTreeOut deck_tree = 18;
|
||||
FindCardsOut find_cards = 19;
|
||||
BrowserRowsOut browser_rows = 20;
|
||||
RenderTemplateOut render_template = 21;
|
||||
RenderCardOut render_card = 21;
|
||||
|
||||
PlusOneOut plus_one = 2046; // temporary, for testing
|
||||
|
||||
|
@ -128,13 +128,16 @@ message BrowserRowsOut {
|
|||
repeated string sort_fields = 1;
|
||||
}
|
||||
|
||||
message RenderTemplateIn {
|
||||
string template_text = 1;
|
||||
map<string,string> fields = 2;
|
||||
message RenderCardIn {
|
||||
string question_template = 1;
|
||||
string answer_template = 2;
|
||||
map<string,string> fields = 3;
|
||||
int32 card_ordinal = 4;
|
||||
}
|
||||
|
||||
message RenderTemplateOut {
|
||||
repeated RenderedTemplateNode nodes = 1;
|
||||
message RenderCardOut {
|
||||
repeated RenderedTemplateNode question_nodes = 1;
|
||||
repeated RenderedTemplateNode answer_nodes = 2;
|
||||
}
|
||||
|
||||
message RenderedTemplateNode {
|
||||
|
|
|
@ -30,7 +30,7 @@ from anki.rsbackend import RustBackend
|
|||
from anki.sched import Scheduler as V1Scheduler
|
||||
from anki.schedv2 import Scheduler as V2Scheduler
|
||||
from anki.tags import TagManager
|
||||
from anki.template import render_qa_from_field_map
|
||||
from anki.template import render_card
|
||||
from anki.types import NoteType, QAData, Template
|
||||
from anki.utils import (
|
||||
devMode,
|
||||
|
@ -666,7 +666,7 @@ where c.nid = n.id and c.id in %s group by nid"""
|
|||
fields = runFilter("mungeFields", fields, model, data, self)
|
||||
|
||||
# render fields
|
||||
qatext = render_qa_from_field_map(self, qfmt, afmt, fields, card_ord)
|
||||
qatext = render_card(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
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
# pylint: skip-file
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Union
|
||||
from typing import Dict, List, Tuple, Union
|
||||
|
||||
import ankirspy # pytype: disable=import-error
|
||||
|
||||
|
@ -53,6 +53,27 @@ class TemplateReplacement:
|
|||
filters: List[str]
|
||||
|
||||
|
||||
TemplateReplacementList = List[Union[str, TemplateReplacement]]
|
||||
|
||||
|
||||
def proto_replacement_list_to_native(
|
||||
nodes: List[pb.RenderedTemplateNode],
|
||||
) -> TemplateReplacementList:
|
||||
results: TemplateReplacementList = []
|
||||
for node in nodes:
|
||||
if node.WhichOneof("value") == "text":
|
||||
results.append(node.text)
|
||||
else:
|
||||
results.append(
|
||||
TemplateReplacement(
|
||||
field_name=node.replacement.field_name,
|
||||
current_text=node.replacement.current_text,
|
||||
filters=list(node.replacement.filters),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class RustBackend:
|
||||
def __init__(self, path: str):
|
||||
self._backend = ankirspy.Backend(path)
|
||||
|
@ -105,26 +126,21 @@ class RustBackend:
|
|||
)
|
||||
).sched_timing_today
|
||||
|
||||
def render_template(
|
||||
self, template: str, fields: Dict[str, str]
|
||||
) -> List[Union[str, TemplateReplacement]]:
|
||||
def render_card(
|
||||
self, qfmt: str, afmt: str, fields: Dict[str, str], card_ord: int
|
||||
) -> Tuple[TemplateReplacementList, TemplateReplacementList]:
|
||||
out = self._run_command(
|
||||
pb.BackendInput(
|
||||
render_template=pb.RenderTemplateIn(
|
||||
template_text=template, fields=fields
|
||||
render_card=pb.RenderCardIn(
|
||||
question_template=qfmt,
|
||||
answer_template=afmt,
|
||||
fields=fields,
|
||||
card_ordinal=card_ord,
|
||||
)
|
||||
)
|
||||
).render_template
|
||||
results: List[Union[str, TemplateReplacement]] = []
|
||||
for node in out.nodes:
|
||||
if node.WhichOneof("value") == "text":
|
||||
results.append(node.text)
|
||||
else:
|
||||
results.append(
|
||||
TemplateReplacement(
|
||||
field_name=node.replacement.field_name,
|
||||
current_text=node.replacement.current_text,
|
||||
filters=list(node.replacement.filters),
|
||||
)
|
||||
)
|
||||
return results
|
||||
).render_card
|
||||
|
||||
qnodes = proto_replacement_list_to_native(out.question_nodes) # type: ignore
|
||||
anodes = proto_replacement_list_to_native(out.answer_nodes) # type: ignore
|
||||
|
||||
return (qnodes, anodes)
|
||||
|
|
|
@ -10,7 +10,7 @@ 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.
|
||||
the filter is skipped.
|
||||
|
||||
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
|
||||
|
@ -29,23 +29,14 @@ template_legacy.py file.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Tuple, Union
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import anki
|
||||
from anki.hooks import runFilter
|
||||
from anki.rsbackend import TemplateReplacement
|
||||
from anki.sound import stripSounds
|
||||
from anki.rsbackend import TemplateReplacementList
|
||||
|
||||
|
||||
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(
|
||||
def render_card(
|
||||
col: anki.storage._Collection,
|
||||
qfmt: str,
|
||||
afmt: str,
|
||||
|
@ -53,39 +44,38 @@ def render_qa_from_field_map(
|
|||
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)
|
||||
(qnodes, anodes) = col.backend.render_card(qfmt, afmt, fields, card_ord)
|
||||
|
||||
qtext = apply_custom_filters(qnodes, fields, front_side=None)
|
||||
atext = apply_custom_filters(anodes, fields, front_side=qtext)
|
||||
|
||||
return qtext, atext
|
||||
|
||||
|
||||
def apply_custom_filters(
|
||||
rendered: List[Union[str, TemplateReplacement]], fields: Dict[str, str]
|
||||
rendered: TemplateReplacementList, fields: Dict[str, str], front_side: Optional[str]
|
||||
) -> str:
|
||||
"Complete rendering by applying any pending custom filters."
|
||||
# template already fully rendered?
|
||||
if len(rendered) == 1 and isinstance(rendered[0], str):
|
||||
return rendered[0]
|
||||
|
||||
res = ""
|
||||
for node in rendered:
|
||||
if isinstance(node, str):
|
||||
res += node
|
||||
else:
|
||||
# do we need to inject in FrontSide?
|
||||
if node.field_name == "FrontSide" and front_side is not None:
|
||||
node.current_text = front_side
|
||||
|
||||
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:
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from anki.template_legacy import _removeFormattingFromMathjax
|
||||
from tests.shared import getEmptyCol
|
||||
|
||||
|
||||
def test_remove_formatting_from_mathjax():
|
||||
|
@ -14,3 +15,17 @@ def test_remove_formatting_from_mathjax():
|
|||
|
||||
txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]"
|
||||
assert _removeFormattingFromMathjax(txt, 1) == (r"\(a\) {{c1::b}} \[ {{C1::c}} \]")
|
||||
|
||||
|
||||
def test_deferred_frontside():
|
||||
d = getEmptyCol()
|
||||
m = d.models.current()
|
||||
m["tmpls"][0]["qfmt"] = "{{custom:Front}}"
|
||||
d.models.save(m)
|
||||
|
||||
f = d.newNote()
|
||||
f["Front"] = "xxtest"
|
||||
f["Back"] = ""
|
||||
d.addNote(f)
|
||||
|
||||
assert "xxtest" in f.cards()[0].a()
|
||||
|
|
|
@ -7,7 +7,8 @@ use crate::backend_proto::RenderedTemplateReplacement;
|
|||
use crate::err::{AnkiError, Result};
|
||||
use crate::sched::sched_timing_today;
|
||||
use crate::template::{
|
||||
without_legacy_template_directives, FieldMap, FieldRequirements, ParsedTemplate, RenderedNode,
|
||||
render_card, without_legacy_template_directives, FieldMap, FieldRequirements, ParsedTemplate,
|
||||
RenderedNode,
|
||||
};
|
||||
use prost::Message;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
@ -96,7 +97,7 @@ impl Backend {
|
|||
Value::DeckTree(_) => todo!(),
|
||||
Value::FindCards(_) => todo!(),
|
||||
Value::BrowserRows(_) => todo!(),
|
||||
Value::RenderTemplate(input) => OValue::RenderTemplate(self.render_template(input)?),
|
||||
Value::RenderCard(input) => OValue::RenderCard(self.render_template(input)),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -160,26 +161,26 @@ impl Backend {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_template(&self, input: pt::RenderTemplateIn) -> Result<pt::RenderTemplateOut> {
|
||||
fn render_template(&self, input: pt::RenderCardIn) -> pt::RenderCardOut {
|
||||
// convert string map to &str
|
||||
let fields: HashMap<_, _> = input
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_ref(), v.as_ref()))
|
||||
.collect();
|
||||
|
||||
let normalized = without_legacy_template_directives(&input.template_text);
|
||||
match ParsedTemplate::from_text(normalized.as_ref()) {
|
||||
Ok(tmpl) => {
|
||||
let nodes = tmpl.render(&fields);
|
||||
let out_nodes = nodes
|
||||
.into_iter()
|
||||
.map(|n| pt::RenderedTemplateNode {
|
||||
value: Some(rendered_node_to_proto(n)),
|
||||
})
|
||||
.collect();
|
||||
Ok(pt::RenderTemplateOut { nodes: out_nodes })
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
// render
|
||||
let (qnodes, anodes) = render_card(
|
||||
&input.question_template,
|
||||
&input.answer_template,
|
||||
&fields,
|
||||
input.card_ordinal as u16,
|
||||
);
|
||||
|
||||
// return
|
||||
pt::RenderCardOut {
|
||||
question_nodes: rendered_nodes_to_proto(qnodes),
|
||||
answer_nodes: rendered_nodes_to_proto(anodes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -188,6 +189,15 @@ fn ords_hash_to_set(ords: HashSet<u16>) -> Vec<u32> {
|
|||
ords.iter().map(|ord| *ord as u32).collect()
|
||||
}
|
||||
|
||||
fn rendered_nodes_to_proto(nodes: Vec<RenderedNode>) -> Vec<pt::RenderedTemplateNode> {
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|n| pt::RenderedTemplateNode {
|
||||
value: Some(rendered_node_to_proto(n)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rendered_node_to_proto(node: RenderedNode) -> pt::rendered_template_node::Value {
|
||||
match node {
|
||||
RenderedNode::Text { text } => pt::rendered_template_node::Value::Text(text),
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
use crate::err::{AnkiError, Result};
|
||||
use crate::template_filters::apply_filters;
|
||||
use crate::text::strip_sounds;
|
||||
use lazy_static::lazy_static;
|
||||
use nom;
|
||||
use nom::branch::alt;
|
||||
|
@ -126,21 +127,6 @@ enum ParsedNode<'a> {
|
|||
#[derive(Debug)]
|
||||
pub struct ParsedTemplate<'a>(Vec<ParsedNode<'a>>);
|
||||
|
||||
static ALT_HANDLEBAR_DIRECTIVE: &str = "{{=<% %>=}}";
|
||||
|
||||
/// Convert legacy alternate syntax to standard syntax.
|
||||
pub fn without_legacy_template_directives(text: &str) -> Cow<str> {
|
||||
if text.trim_start().starts_with(ALT_HANDLEBAR_DIRECTIVE) {
|
||||
text.trim_start()
|
||||
.trim_start_matches(ALT_HANDLEBAR_DIRECTIVE)
|
||||
.replace("<%", "{{")
|
||||
.replace("%>", "}}")
|
||||
.into()
|
||||
} else {
|
||||
text.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTemplate<'_> {
|
||||
/// Create a template from the provided text.
|
||||
///
|
||||
|
@ -199,6 +185,24 @@ fn parse_inner<'a, I: Iterator<Item = Result<Token<'a>>>>(
|
|||
}
|
||||
}
|
||||
|
||||
// Legacy support
|
||||
//----------------------------------------
|
||||
|
||||
static ALT_HANDLEBAR_DIRECTIVE: &str = "{{=<% %>=}}";
|
||||
|
||||
/// Convert legacy alternate syntax to standard syntax.
|
||||
pub fn without_legacy_template_directives(text: &str) -> Cow<str> {
|
||||
if text.trim_start().starts_with(ALT_HANDLEBAR_DIRECTIVE) {
|
||||
text.trim_start()
|
||||
.trim_start_matches(ALT_HANDLEBAR_DIRECTIVE)
|
||||
.replace("<%", "{{")
|
||||
.replace("%>", "}}")
|
||||
.into()
|
||||
} else {
|
||||
text.into()
|
||||
}
|
||||
}
|
||||
|
||||
// Checking if template is empty
|
||||
//----------------------------------------
|
||||
|
||||
|
@ -260,17 +264,24 @@ pub enum RenderedNode {
|
|||
},
|
||||
}
|
||||
|
||||
pub(crate) struct RenderContext<'a> {
|
||||
pub fields: &'a HashMap<&'a str, &'a str>,
|
||||
pub nonempty_fields: &'a HashSet<&'a str>,
|
||||
pub question_side: bool,
|
||||
pub card_ord: u16,
|
||||
pub front_text: Option<Cow<'a, str>>,
|
||||
}
|
||||
|
||||
impl ParsedTemplate<'_> {
|
||||
/// Render the template with the provided fields.
|
||||
///
|
||||
/// Replacements that use only standard filters will become part of
|
||||
/// a text node. If a non-standard filter is encountered, a partially
|
||||
/// rendered Replacement is returned for the calling code to complete.
|
||||
pub fn render(&self, fields: &HashMap<&str, &str>) -> Vec<RenderedNode> {
|
||||
fn render(&self, context: &RenderContext) -> Vec<RenderedNode> {
|
||||
let mut rendered = vec![];
|
||||
let nonempty = nonempty_fields(fields);
|
||||
|
||||
render_into(&mut rendered, self.0.as_ref(), fields, &nonempty);
|
||||
render_into(&mut rendered, self.0.as_ref(), context);
|
||||
|
||||
rendered
|
||||
}
|
||||
|
@ -279,8 +290,7 @@ impl ParsedTemplate<'_> {
|
|||
fn render_into(
|
||||
rendered_nodes: &mut Vec<RenderedNode>,
|
||||
nodes: &[ParsedNode],
|
||||
fields: &HashMap<&str, &str>,
|
||||
nonempty: &HashSet<&str>,
|
||||
context: &RenderContext,
|
||||
) {
|
||||
use ParsedNode::*;
|
||||
for node in nodes {
|
||||
|
@ -288,9 +298,28 @@ fn render_into(
|
|||
Text(text) => {
|
||||
append_str_to_nodes(rendered_nodes, text);
|
||||
}
|
||||
Replacement {
|
||||
key: key @ "FrontSide",
|
||||
..
|
||||
} => {
|
||||
if let Some(front_side) = &context.front_text {
|
||||
// a fully rendered front side is available, so we can
|
||||
// bake it into the output
|
||||
append_str_to_nodes(rendered_nodes, front_side.as_ref());
|
||||
} else {
|
||||
// the front side contains unknown filters, and must
|
||||
// be completed by the Python code
|
||||
rendered_nodes.push(RenderedNode::Replacement {
|
||||
field_name: (*key).to_string(),
|
||||
filters: vec![],
|
||||
current_text: "".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Replacement { key, filters } => {
|
||||
let (text, remaining_filters) = match fields.get(key) {
|
||||
Some(text) => apply_filters(text, filters, key),
|
||||
// apply built in filters if field exists
|
||||
let (text, remaining_filters) = match context.fields.get(key) {
|
||||
Some(text) => apply_filters(text, filters, key, context),
|
||||
None => (unknown_field_message(key, filters).into(), vec![]),
|
||||
};
|
||||
|
||||
|
@ -306,13 +335,13 @@ fn render_into(
|
|||
}
|
||||
}
|
||||
Conditional { key, children } => {
|
||||
if nonempty.contains(key) {
|
||||
render_into(rendered_nodes, children.as_ref(), fields, nonempty);
|
||||
if context.nonempty_fields.contains(key) {
|
||||
render_into(rendered_nodes, children.as_ref(), context);
|
||||
}
|
||||
}
|
||||
NegatedConditional { key, children } => {
|
||||
if !nonempty.contains(key) {
|
||||
render_into(rendered_nodes, children.as_ref(), fields, nonempty);
|
||||
if !context.nonempty_fields.contains(key) {
|
||||
render_into(rendered_nodes, children.as_ref(), context);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -378,6 +407,53 @@ fn unknown_field_message(field_name: &str, filters: &[&str]) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
// Rendering both sides
|
||||
//----------------------------------------
|
||||
|
||||
#[allow(clippy::implicit_hasher)]
|
||||
pub fn render_card(
|
||||
qfmt: &str,
|
||||
afmt: &str,
|
||||
field_map: &HashMap<&str, &str>,
|
||||
card_ord: u16,
|
||||
) -> (Vec<RenderedNode>, Vec<RenderedNode>) {
|
||||
// prepare context
|
||||
let mut context = RenderContext {
|
||||
fields: field_map,
|
||||
nonempty_fields: &nonempty_fields(field_map),
|
||||
question_side: true,
|
||||
card_ord,
|
||||
front_text: None,
|
||||
};
|
||||
|
||||
// question side
|
||||
let qnorm = without_legacy_template_directives(qfmt);
|
||||
let qnodes = match ParsedTemplate::from_text(qnorm.as_ref()) {
|
||||
Ok(tmpl) => tmpl.render(&context),
|
||||
Err(e) => vec![RenderedNode::Text {
|
||||
text: format!("{:?}", e),
|
||||
}],
|
||||
};
|
||||
|
||||
// if the question side didn't have any unknown filters, we can pass
|
||||
// FrontSide in now
|
||||
if let [RenderedNode::Text { ref text }] = *qnodes.as_slice() {
|
||||
context.front_text = Some(strip_sounds(text));
|
||||
}
|
||||
|
||||
// answer side
|
||||
context.question_side = false;
|
||||
let anorm = without_legacy_template_directives(afmt);
|
||||
let anodes = match ParsedTemplate::from_text(anorm.as_ref()) {
|
||||
Ok(tmpl) => tmpl.render(&context),
|
||||
Err(e) => vec![RenderedNode::Text {
|
||||
text: format!("{:?}", e),
|
||||
}],
|
||||
};
|
||||
|
||||
(qnodes, anodes)
|
||||
}
|
||||
|
||||
// Field requirements
|
||||
//----------------------------------------
|
||||
|
||||
|
@ -437,7 +513,11 @@ impl ParsedTemplate<'_> {
|
|||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::{FieldMap, ParsedNode::*, ParsedTemplate as PT};
|
||||
use crate::template::{field_is_empty, without_legacy_template_directives, FieldRequirements};
|
||||
use crate::template::{
|
||||
field_is_empty, nonempty_fields, render_card, without_legacy_template_directives,
|
||||
FieldRequirements, RenderContext, RenderedNode,
|
||||
};
|
||||
use crate::text::strip_html;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter::FromIterator;
|
||||
|
||||
|
@ -568,15 +648,23 @@ mod test {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_render() {
|
||||
fn test_render_single() {
|
||||
let map: HashMap<_, _> = vec![("F", "f"), ("B", "b"), ("E", " ")]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let ctx = RenderContext {
|
||||
fields: &map,
|
||||
nonempty_fields: &nonempty_fields(&map),
|
||||
question_side: true,
|
||||
card_ord: 1,
|
||||
front_text: None,
|
||||
};
|
||||
|
||||
use crate::template::RenderedNode as FN;
|
||||
let mut tmpl = PT::from_text("{{B}}A{{F}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Text {
|
||||
text: "bAf".to_owned()
|
||||
},]
|
||||
|
@ -584,12 +672,12 @@ mod test {
|
|||
|
||||
// empty
|
||||
tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap();
|
||||
assert_eq!(tmpl.render(&map), vec![]);
|
||||
assert_eq!(tmpl.render(&ctx), vec![]);
|
||||
|
||||
// missing
|
||||
tmpl = PT::from_text("{{^M}}A{{/M}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Text {
|
||||
text: "A".to_owned()
|
||||
},]
|
||||
|
@ -598,7 +686,7 @@ mod test {
|
|||
// nested
|
||||
tmpl = PT::from_text("{{^E}}1{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}{{/E}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Text {
|
||||
text: "12f".to_owned()
|
||||
},]
|
||||
|
@ -607,7 +695,7 @@ mod test {
|
|||
// unknown filters
|
||||
tmpl = PT::from_text("{{one:two:B}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Replacement {
|
||||
field_name: "B".to_owned(),
|
||||
filters: vec!["two".to_string(), "one".to_string()],
|
||||
|
@ -616,9 +704,10 @@ mod test {
|
|||
);
|
||||
|
||||
// partially unknown filters
|
||||
tmpl = PT::from_text("{{one:text:B}}").unwrap();
|
||||
// excess colons are ignored
|
||||
tmpl = PT::from_text("{{one::text:B}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Replacement {
|
||||
field_name: "B".to_owned(),
|
||||
filters: vec!["one".to_string()],
|
||||
|
@ -629,7 +718,7 @@ mod test {
|
|||
// known filter
|
||||
tmpl = PT::from_text("{{text:B}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Text {
|
||||
text: "b".to_owned()
|
||||
}]
|
||||
|
@ -638,7 +727,7 @@ mod test {
|
|||
// unknown field
|
||||
tmpl = PT::from_text("{{X}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Text {
|
||||
text: "{unknown field X}".to_owned()
|
||||
}]
|
||||
|
@ -647,10 +736,44 @@ mod test {
|
|||
// unknown field with filters
|
||||
tmpl = PT::from_text("{{foo:text:X}}").unwrap();
|
||||
assert_eq!(
|
||||
tmpl.render(&map),
|
||||
tmpl.render(&ctx),
|
||||
vec![FN::Text {
|
||||
text: "{unknown field foo:text:X}".to_owned()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn get_complete_template(nodes: &Vec<RenderedNode>) -> Option<&str> {
|
||||
if let [RenderedNode::Text { ref text }] = nodes.as_slice() {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_full() {
|
||||
// make sure front and back side renders cloze differently
|
||||
let fmt = "{{cloze:Text}}";
|
||||
let clozed_text = "{{c1::one}} {{c2::two::hint}}";
|
||||
let map: HashMap<_, _> = vec![("Text", clozed_text)].into_iter().collect();
|
||||
|
||||
let (qnodes, anodes) = render_card(fmt, fmt, &map, 0);
|
||||
assert_eq!(
|
||||
strip_html(get_complete_template(&qnodes).unwrap()),
|
||||
"[...] two"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_html(get_complete_template(&anodes).unwrap()),
|
||||
"one two"
|
||||
);
|
||||
|
||||
// FrontSide should render if only standard modifiers were used
|
||||
let (_qnodes, anodes) = render_card("{{kana:text:Text}}", "{{FrontSide}}", &map, 1);
|
||||
assert_eq!(get_complete_template(&anodes).unwrap(), clozed_text);
|
||||
|
||||
// But if a custom modifier was used, it's deferred to the Python code
|
||||
let (_qnodes, anodes) = render_card("{{custom:Text}}", "{{FrontSide}}", &map, 1);
|
||||
assert_eq!(get_complete_template(&anodes).is_none(), true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
use crate::template::RenderContext;
|
||||
use crate::text::strip_html;
|
||||
use blake3::Hasher;
|
||||
use lazy_static::lazy_static;
|
||||
|
@ -18,6 +19,7 @@ pub(crate) fn apply_filters<'a>(
|
|||
text: &'a str,
|
||||
filters: &[&str],
|
||||
field_name: &str,
|
||||
context: &RenderContext,
|
||||
) -> (Cow<'a, str>, Vec<String>) {
|
||||
let mut text: Cow<str> = text.into();
|
||||
|
||||
|
@ -29,7 +31,7 @@ pub(crate) fn apply_filters<'a>(
|
|||
};
|
||||
|
||||
for (idx, &filter_name) in filters.iter().enumerate() {
|
||||
match apply_filter(filter_name, text.as_ref(), field_name) {
|
||||
match apply_filter(filter_name, text.as_ref(), field_name, context) {
|
||||
(true, None) => {
|
||||
// filter did not change text
|
||||
}
|
||||
|
@ -55,24 +57,26 @@ pub(crate) fn apply_filters<'a>(
|
|||
///
|
||||
/// Returns true if filter was valid.
|
||||
/// Returns string if input text changed.
|
||||
fn apply_filter<'a>(filter_name: &str, text: &'a str, field_name: &str) -> (bool, Option<String>) {
|
||||
fn apply_filter<'a>(
|
||||
filter_name: &str,
|
||||
text: &'a str,
|
||||
field_name: &str,
|
||||
context: &RenderContext,
|
||||
) -> (bool, Option<String>) {
|
||||
let output_text = match filter_name {
|
||||
"text" => strip_html(text),
|
||||
"furigana" => furigana_filter(text),
|
||||
"kanji" => kanji_filter(text),
|
||||
"kana" => kana_filter(text),
|
||||
other => {
|
||||
let split: Vec<_> = other.splitn(2, '-').collect();
|
||||
let base = split[0];
|
||||
let filter_args = *split.get(1).unwrap_or(&"");
|
||||
match base {
|
||||
"type" => type_filter(text, filter_args, field_name),
|
||||
"hint" => hint_filter(text, field_name),
|
||||
"cq" => cloze_filter(text, filter_args, true),
|
||||
"ca" => cloze_filter(text, filter_args, false),
|
||||
// unrecognized filter
|
||||
_ => return (false, None),
|
||||
}
|
||||
"type" => type_filter(field_name),
|
||||
"type-cloze" => type_cloze_filter(field_name),
|
||||
"hint" => hint_filter(text, field_name),
|
||||
"cloze" => cloze_filter(text, context),
|
||||
// an empty filter name (caused by using two colons) is ignored
|
||||
"" => text.into(),
|
||||
_ => {
|
||||
// unrecognized filter
|
||||
return (false, None);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -126,7 +130,7 @@ mod mathjax_caps {
|
|||
pub const CLOSING_TAG: usize = 3;
|
||||
}
|
||||
|
||||
fn reveal_cloze_text(text: &str, ord: u16, question: bool) -> Cow<str> {
|
||||
fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow<str> {
|
||||
let output = CLOZE.replace_all(text, |caps: &Captures| {
|
||||
let captured_ord = caps
|
||||
.get(cloze_caps::ORD)
|
||||
|
@ -135,7 +139,7 @@ fn reveal_cloze_text(text: &str, ord: u16, question: bool) -> Cow<str> {
|
|||
.parse()
|
||||
.unwrap_or(0);
|
||||
|
||||
if captured_ord != ord {
|
||||
if captured_ord != cloze_ord {
|
||||
// other cloze deletions are unchanged
|
||||
return caps.get(cloze_caps::TEXT).unwrap().as_str().to_owned();
|
||||
}
|
||||
|
@ -173,11 +177,12 @@ fn strip_html_inside_mathjax(text: &str) -> Cow<str> {
|
|||
})
|
||||
}
|
||||
|
||||
fn cloze_filter<'a>(text: &'a str, filter_args: &str, question: bool) -> Cow<'a, str> {
|
||||
let cloze_ord = filter_args.parse().unwrap_or(0);
|
||||
strip_html_inside_mathjax(reveal_cloze_text(text, cloze_ord, question).as_ref())
|
||||
.into_owned()
|
||||
.into()
|
||||
fn cloze_filter<'a>(text: &'a str, context: &RenderContext) -> Cow<'a, str> {
|
||||
strip_html_inside_mathjax(
|
||||
reveal_cloze_text(text, context.card_ord + 1, context.question_side).as_ref(),
|
||||
)
|
||||
.into_owned()
|
||||
.into()
|
||||
}
|
||||
|
||||
// Ruby filters
|
||||
|
@ -239,16 +244,14 @@ fn furigana_filter(text: &str) -> Cow<str> {
|
|||
//----------------------------------------
|
||||
|
||||
/// convert to [[type:...]] for the gui code to process
|
||||
fn type_filter<'a>(_text: &'a str, filter_args: &str, field_name: &str) -> Cow<'a, str> {
|
||||
if filter_args.is_empty() {
|
||||
format!("[[type:{}]]", field_name)
|
||||
} else {
|
||||
format!("[[type:{}:{}]]", filter_args, field_name)
|
||||
}
|
||||
.into()
|
||||
fn type_filter<'a>(field_name: &str) -> Cow<'a, str> {
|
||||
format!("[[type:{}]]", field_name).into()
|
||||
}
|
||||
|
||||
fn type_cloze_filter<'a>(field_name: &str) -> Cow<'a, str> {
|
||||
format!("[[type:cloze:{}]]", field_name).into()
|
||||
}
|
||||
|
||||
// fixme: i18n
|
||||
fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> {
|
||||
if text.trim().is_empty() {
|
||||
return text.into();
|
||||
|
@ -267,9 +270,9 @@ onclick="this.style.display='none';
|
|||
document.getElementById('hint{}').style.display='block';
|
||||
return false;">
|
||||
{}</a>
|
||||
<div id="hint{}" class=hint style="display: none">Show {}</div>
|
||||
<div id="hint{}" class=hint style="display: none">{}</div>
|
||||
"##,
|
||||
id, text, id, field_name
|
||||
id, field_name, id, text
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
@ -279,9 +282,10 @@ return false;">
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::template::RenderContext;
|
||||
use crate::template_filters::{
|
||||
apply_filters, cloze_filter, furigana_filter, hint_filter, kana_filter, kanji_filter,
|
||||
type_filter,
|
||||
type_cloze_filter, type_filter,
|
||||
};
|
||||
use crate::text::strip_html;
|
||||
|
||||
|
@ -305,21 +309,25 @@ mod test {
|
|||
onclick="this.style.display='none';
|
||||
document.getElementById('hint83fe48607f0f3a66').style.display='block';
|
||||
return false;">
|
||||
foo</a>
|
||||
<div id="hint83fe48607f0f3a66" class=hint style="display: none">Show field</div>
|
||||
field</a>
|
||||
<div id="hint83fe48607f0f3a66" class=hint style="display: none">foo</div>
|
||||
"##
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type() {
|
||||
assert_eq!(type_filter("ignored", "", "Front"), "[[type:Front]]");
|
||||
assert_eq!(type_filter("Front"), "[[type:Front]]");
|
||||
assert_eq!(type_cloze_filter("Front"), "[[type:cloze:Front]]");
|
||||
let ctx = RenderContext {
|
||||
fields: &Default::default(),
|
||||
nonempty_fields: &Default::default(),
|
||||
question_side: false,
|
||||
card_ord: 0,
|
||||
front_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
type_filter("ignored", "cloze", "Front"),
|
||||
"[[type:cloze:Front]]"
|
||||
);
|
||||
assert_eq!(
|
||||
apply_filters("ignored", &["cloze", "type"], "Text"),
|
||||
apply_filters("ignored", &["cloze", "type"], "Text", &ctx),
|
||||
("[[type:cloze:Text]]".into(), vec![])
|
||||
);
|
||||
}
|
||||
|
@ -327,25 +335,23 @@ foo</a>
|
|||
#[test]
|
||||
fn test_cloze() {
|
||||
let text = "{{c1::one}} {{c2::two::hint}}";
|
||||
let mut ctx = RenderContext {
|
||||
fields: &Default::default(),
|
||||
nonempty_fields: &Default::default(),
|
||||
question_side: true,
|
||||
card_ord: 0,
|
||||
front_text: None,
|
||||
};
|
||||
assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "[...] two");
|
||||
assert_eq!(
|
||||
strip_html(&cloze_filter(text, "1", true)).as_ref(),
|
||||
"[...] two"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_html(&cloze_filter(text, "2", true)).as_ref(),
|
||||
"one [hint]"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_html(&cloze_filter(text, "1", false)).as_ref(),
|
||||
"one two"
|
||||
);
|
||||
assert_eq!(
|
||||
cloze_filter(text, "1", false),
|
||||
"<span class=cloze>one</span> two"
|
||||
);
|
||||
assert_eq!(
|
||||
cloze_filter(text, "1", true),
|
||||
cloze_filter(text, &ctx),
|
||||
"<span class=cloze>[...]</span> two"
|
||||
);
|
||||
|
||||
ctx.card_ord = 1;
|
||||
assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "one [hint]");
|
||||
|
||||
ctx.question_side = false;
|
||||
assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "one two");
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue