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:
Damien Elmes 2020-01-12 15:15:46 +10:00
parent be70997e5a
commit 9bb0348fdd
8 changed files with 330 additions and 167 deletions

View file

@ -14,7 +14,7 @@ message BackendInput {
Empty deck_tree = 18; Empty deck_tree = 18;
FindCardsIn find_cards = 19; FindCardsIn find_cards = 19;
BrowserRowsIn browser_rows = 20; BrowserRowsIn browser_rows = 20;
RenderTemplateIn render_template = 21; RenderCardIn render_card = 21;
PlusOneIn plus_one = 2046; // temporary, for testing PlusOneIn plus_one = 2046; // temporary, for testing
} }
@ -27,7 +27,7 @@ message BackendOutput {
DeckTreeOut deck_tree = 18; DeckTreeOut deck_tree = 18;
FindCardsOut find_cards = 19; FindCardsOut find_cards = 19;
BrowserRowsOut browser_rows = 20; BrowserRowsOut browser_rows = 20;
RenderTemplateOut render_template = 21; RenderCardOut render_card = 21;
PlusOneOut plus_one = 2046; // temporary, for testing PlusOneOut plus_one = 2046; // temporary, for testing
@ -128,13 +128,16 @@ message BrowserRowsOut {
repeated string sort_fields = 1; repeated string sort_fields = 1;
} }
message RenderTemplateIn { message RenderCardIn {
string template_text = 1; string question_template = 1;
map<string,string> fields = 2; string answer_template = 2;
map<string,string> fields = 3;
int32 card_ordinal = 4;
} }
message RenderTemplateOut { message RenderCardOut {
repeated RenderedTemplateNode nodes = 1; repeated RenderedTemplateNode question_nodes = 1;
repeated RenderedTemplateNode answer_nodes = 2;
} }
message RenderedTemplateNode { message RenderedTemplateNode {

View file

@ -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.template import render_qa_from_field_map from anki.template import render_card
from anki.types import NoteType, QAData, Template from anki.types import NoteType, QAData, Template
from anki.utils import ( from anki.utils import (
devMode, 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) fields = runFilter("mungeFields", fields, model, data, self)
# render fields # 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) ret: Dict[str, Any] = dict(q=qatext[0], a=qatext[1], id=card_id)
# allow add-ons to modify the generated result # allow add-ons to modify the generated result

View file

@ -2,7 +2,7 @@
# 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
# pylint: skip-file # pylint: skip-file
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Union from typing import Dict, List, Tuple, Union
import ankirspy # pytype: disable=import-error import ankirspy # pytype: disable=import-error
@ -53,6 +53,27 @@ class TemplateReplacement:
filters: List[str] 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: class RustBackend:
def __init__(self, path: str): def __init__(self, path: str):
self._backend = ankirspy.Backend(path) self._backend = ankirspy.Backend(path)
@ -105,26 +126,21 @@ class RustBackend:
) )
).sched_timing_today ).sched_timing_today
def render_template( def render_card(
self, template: str, fields: Dict[str, str] self, qfmt: str, afmt: str, fields: Dict[str, str], card_ord: int
) -> List[Union[str, TemplateReplacement]]: ) -> Tuple[TemplateReplacementList, TemplateReplacementList]:
out = self._run_command( out = self._run_command(
pb.BackendInput( pb.BackendInput(
render_template=pb.RenderTemplateIn( render_card=pb.RenderCardIn(
template_text=template, fields=fields question_template=qfmt,
answer_template=afmt,
fields=fields,
card_ordinal=card_ord,
) )
) )
).render_template ).render_card
results: List[Union[str, TemplateReplacement]] = []
for node in out.nodes: qnodes = proto_replacement_list_to_native(out.question_nodes) # type: ignore
if node.WhichOneof("value") == "text": anodes = proto_replacement_list_to_native(out.answer_nodes) # type: ignore
results.append(node.text)
else: return (qnodes, anodes)
results.append(
TemplateReplacement(
field_name=node.replacement.field_name,
current_text=node.replacement.current_text,
filters=list(node.replacement.filters),
)
)
return results

View file

@ -10,7 +10,7 @@ unrecognized filter. The remaining filters are returned to Python,
and applied using the hook system. For example, and applied using the hook system. For example,
{{myfilter:hint:text:Field}} will apply the built in text and hint filters, {{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, 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>". 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 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 from __future__ import annotations
import re import re
from typing import Dict, List, Tuple, Union from typing import Dict, List, Optional, Tuple
import anki import anki
from anki.hooks import runFilter from anki.hooks import runFilter
from anki.rsbackend import TemplateReplacement from anki.rsbackend import TemplateReplacementList
from anki.sound import stripSounds
def render_template( def render_card(
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, col: anki.storage._Collection,
qfmt: str, qfmt: str,
afmt: str, afmt: str,
@ -53,39 +44,38 @@ def render_qa_from_field_map(
card_ord: int, card_ord: int,
) -> Tuple[str, str]: ) -> Tuple[str, str]:
"Renders the provided templates, returning rendered q & a text." "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 (qnodes, anodes) = col.backend.render_card(qfmt, afmt, fields, card_ord)
format = re.sub("{{(.*?)cloze:", r"{{\1ca-%d:" % (card_ord + 1), afmt)
format = format.replace("<%cloze:", "<%%ca:%d:" % (card_ord + 1)) qtext = apply_custom_filters(qnodes, fields, front_side=None)
fields["FrontSide"] = stripSounds(qtext) atext = apply_custom_filters(anodes, fields, front_side=qtext)
atext = render_template(col, format, fields)
return qtext, atext return qtext, atext
def apply_custom_filters( def apply_custom_filters(
rendered: List[Union[str, TemplateReplacement]], fields: Dict[str, str] rendered: TemplateReplacementList, fields: Dict[str, str], front_side: Optional[str]
) -> str: ) -> str:
"Complete rendering by applying any pending custom filters." "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 = "" res = ""
for node in rendered: for node in rendered:
if isinstance(node, str): if isinstance(node, str):
res += node res += node
else: 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( res += apply_field_filters(
node.field_name, node.current_text, fields, node.filters node.field_name, node.current_text, fields, node.filters
) )
return res return res
# Filters
##########################################################################
def apply_field_filters( def apply_field_filters(
field_name: str, field_text: str, fields: Dict[str, str], filters: List[str] field_name: str, field_text: str, fields: Dict[str, str], filters: List[str]
) -> str: ) -> str:

View file

@ -1,4 +1,5 @@
from anki.template_legacy import _removeFormattingFromMathjax from anki.template_legacy import _removeFormattingFromMathjax
from tests.shared import getEmptyCol
def test_remove_formatting_from_mathjax(): def test_remove_formatting_from_mathjax():
@ -14,3 +15,17 @@ def test_remove_formatting_from_mathjax():
txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]"
assert _removeFormattingFromMathjax(txt, 1) == (r"\(a\) {{c1::b}} \[ {{C1::c}} \]") assert _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()

View file

@ -7,7 +7,8 @@ use crate::backend_proto::RenderedTemplateReplacement;
use crate::err::{AnkiError, Result}; use crate::err::{AnkiError, Result};
use crate::sched::sched_timing_today; use crate::sched::sched_timing_today;
use crate::template::{ 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 prost::Message;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -96,7 +97,7 @@ impl Backend {
Value::DeckTree(_) => todo!(), Value::DeckTree(_) => todo!(),
Value::FindCards(_) => todo!(), Value::FindCards(_) => todo!(),
Value::BrowserRows(_) => 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 let fields: HashMap<_, _> = input
.fields .fields
.iter() .iter()
.map(|(k, v)| (k.as_ref(), v.as_ref())) .map(|(k, v)| (k.as_ref(), v.as_ref()))
.collect(); .collect();
let normalized = without_legacy_template_directives(&input.template_text); // render
match ParsedTemplate::from_text(normalized.as_ref()) { let (qnodes, anodes) = render_card(
Ok(tmpl) => { &input.question_template,
let nodes = tmpl.render(&fields); &input.answer_template,
let out_nodes = nodes &fields,
.into_iter() input.card_ordinal as u16,
.map(|n| pt::RenderedTemplateNode { );
value: Some(rendered_node_to_proto(n)),
}) // return
.collect(); pt::RenderCardOut {
Ok(pt::RenderTemplateOut { nodes: out_nodes }) question_nodes: rendered_nodes_to_proto(qnodes),
} answer_nodes: rendered_nodes_to_proto(anodes),
Err(e) => Err(e),
} }
} }
} }
@ -188,6 +189,15 @@ fn ords_hash_to_set(ords: HashSet<u16>) -> Vec<u32> {
ords.iter().map(|ord| *ord as u32).collect() 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 { fn rendered_node_to_proto(node: RenderedNode) -> pt::rendered_template_node::Value {
match node { match node {
RenderedNode::Text { text } => pt::rendered_template_node::Value::Text(text), RenderedNode::Text { text } => pt::rendered_template_node::Value::Text(text),

View file

@ -3,6 +3,7 @@
use crate::err::{AnkiError, Result}; use crate::err::{AnkiError, Result};
use crate::template_filters::apply_filters; use crate::template_filters::apply_filters;
use crate::text::strip_sounds;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use nom; use nom;
use nom::branch::alt; use nom::branch::alt;
@ -126,21 +127,6 @@ enum ParsedNode<'a> {
#[derive(Debug)] #[derive(Debug)]
pub struct ParsedTemplate<'a>(Vec<ParsedNode<'a>>); 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<'_> { impl ParsedTemplate<'_> {
/// Create a template from the provided text. /// 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 // 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<'_> { impl ParsedTemplate<'_> {
/// Render the template with the provided fields. /// Render the template with the provided fields.
/// ///
/// Replacements that use only standard filters will become part of /// Replacements that use only standard filters will become part of
/// a text node. If a non-standard filter is encountered, a partially /// a text node. If a non-standard filter is encountered, a partially
/// rendered Replacement is returned for the calling code to complete. /// 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 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 rendered
} }
@ -279,8 +290,7 @@ impl ParsedTemplate<'_> {
fn render_into( fn render_into(
rendered_nodes: &mut Vec<RenderedNode>, rendered_nodes: &mut Vec<RenderedNode>,
nodes: &[ParsedNode], nodes: &[ParsedNode],
fields: &HashMap<&str, &str>, context: &RenderContext,
nonempty: &HashSet<&str>,
) { ) {
use ParsedNode::*; use ParsedNode::*;
for node in nodes { for node in nodes {
@ -288,9 +298,28 @@ fn render_into(
Text(text) => { Text(text) => {
append_str_to_nodes(rendered_nodes, 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 } => { Replacement { key, filters } => {
let (text, remaining_filters) = match fields.get(key) { // apply built in filters if field exists
Some(text) => apply_filters(text, filters, key), 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![]), None => (unknown_field_message(key, filters).into(), vec![]),
}; };
@ -306,13 +335,13 @@ fn render_into(
} }
} }
Conditional { key, children } => { Conditional { key, children } => {
if nonempty.contains(key) { if context.nonempty_fields.contains(key) {
render_into(rendered_nodes, children.as_ref(), fields, nonempty); render_into(rendered_nodes, children.as_ref(), context);
} }
} }
NegatedConditional { key, children } => { NegatedConditional { key, children } => {
if !nonempty.contains(key) { if !context.nonempty_fields.contains(key) {
render_into(rendered_nodes, children.as_ref(), fields, nonempty); 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 // Field requirements
//---------------------------------------- //----------------------------------------
@ -437,7 +513,11 @@ impl ParsedTemplate<'_> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::{FieldMap, ParsedNode::*, ParsedTemplate as PT}; 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::collections::{HashMap, HashSet};
use std::iter::FromIterator; use std::iter::FromIterator;
@ -568,15 +648,23 @@ mod test {
} }
#[test] #[test]
fn test_render() { fn test_render_single() {
let map: HashMap<_, _> = vec![("F", "f"), ("B", "b"), ("E", " ")] let map: HashMap<_, _> = vec![("F", "f"), ("B", "b"), ("E", " ")]
.into_iter() .into_iter()
.collect(); .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; use crate::template::RenderedNode as FN;
let mut tmpl = PT::from_text("{{B}}A{{F}}").unwrap(); let mut tmpl = PT::from_text("{{B}}A{{F}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Text { vec![FN::Text {
text: "bAf".to_owned() text: "bAf".to_owned()
},] },]
@ -584,12 +672,12 @@ mod test {
// empty // empty
tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap(); tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap();
assert_eq!(tmpl.render(&map), vec![]); assert_eq!(tmpl.render(&ctx), vec![]);
// missing // missing
tmpl = PT::from_text("{{^M}}A{{/M}}").unwrap(); tmpl = PT::from_text("{{^M}}A{{/M}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Text { vec![FN::Text {
text: "A".to_owned() text: "A".to_owned()
},] },]
@ -598,7 +686,7 @@ mod test {
// nested // nested
tmpl = PT::from_text("{{^E}}1{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}{{/E}}").unwrap(); tmpl = PT::from_text("{{^E}}1{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}{{/E}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Text { vec![FN::Text {
text: "12f".to_owned() text: "12f".to_owned()
},] },]
@ -607,7 +695,7 @@ mod test {
// unknown filters // unknown filters
tmpl = PT::from_text("{{one:two:B}}").unwrap(); tmpl = PT::from_text("{{one:two:B}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Replacement { vec![FN::Replacement {
field_name: "B".to_owned(), field_name: "B".to_owned(),
filters: vec!["two".to_string(), "one".to_string()], filters: vec!["two".to_string(), "one".to_string()],
@ -616,9 +704,10 @@ mod test {
); );
// partially unknown filters // 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!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Replacement { vec![FN::Replacement {
field_name: "B".to_owned(), field_name: "B".to_owned(),
filters: vec!["one".to_string()], filters: vec!["one".to_string()],
@ -629,7 +718,7 @@ mod test {
// known filter // known filter
tmpl = PT::from_text("{{text:B}}").unwrap(); tmpl = PT::from_text("{{text:B}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Text { vec![FN::Text {
text: "b".to_owned() text: "b".to_owned()
}] }]
@ -638,7 +727,7 @@ mod test {
// unknown field // unknown field
tmpl = PT::from_text("{{X}}").unwrap(); tmpl = PT::from_text("{{X}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Text { vec![FN::Text {
text: "{unknown field X}".to_owned() text: "{unknown field X}".to_owned()
}] }]
@ -647,10 +736,44 @@ mod test {
// unknown field with filters // unknown field with filters
tmpl = PT::from_text("{{foo:text:X}}").unwrap(); tmpl = PT::from_text("{{foo:text:X}}").unwrap();
assert_eq!( assert_eq!(
tmpl.render(&map), tmpl.render(&ctx),
vec![FN::Text { vec![FN::Text {
text: "{unknown field foo:text:X}".to_owned() 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)
}
} }

View file

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// 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
use crate::template::RenderContext;
use crate::text::strip_html; use crate::text::strip_html;
use blake3::Hasher; use blake3::Hasher;
use lazy_static::lazy_static; use lazy_static::lazy_static;
@ -18,6 +19,7 @@ pub(crate) fn apply_filters<'a>(
text: &'a str, text: &'a str,
filters: &[&str], filters: &[&str],
field_name: &str, field_name: &str,
context: &RenderContext,
) -> (Cow<'a, str>, Vec<String>) { ) -> (Cow<'a, str>, Vec<String>) {
let mut text: Cow<str> = text.into(); 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() { 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) => { (true, None) => {
// filter did not change text // filter did not change text
} }
@ -55,24 +57,26 @@ pub(crate) fn apply_filters<'a>(
/// ///
/// Returns true if filter was valid. /// Returns true if filter was valid.
/// Returns string if input text changed. /// 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 { let output_text = match filter_name {
"text" => strip_html(text), "text" => strip_html(text),
"furigana" => furigana_filter(text), "furigana" => furigana_filter(text),
"kanji" => kanji_filter(text), "kanji" => kanji_filter(text),
"kana" => kana_filter(text), "kana" => kana_filter(text),
other => { "type" => type_filter(field_name),
let split: Vec<_> = other.splitn(2, '-').collect(); "type-cloze" => type_cloze_filter(field_name),
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), "hint" => hint_filter(text, field_name),
"cq" => cloze_filter(text, filter_args, true), "cloze" => cloze_filter(text, context),
"ca" => cloze_filter(text, filter_args, false), // an empty filter name (caused by using two colons) is ignored
"" => text.into(),
_ => {
// unrecognized filter // unrecognized filter
_ => return (false, None), return (false, None);
}
} }
}; };
@ -126,7 +130,7 @@ mod mathjax_caps {
pub const CLOSING_TAG: usize = 3; 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 output = CLOZE.replace_all(text, |caps: &Captures| {
let captured_ord = caps let captured_ord = caps
.get(cloze_caps::ORD) .get(cloze_caps::ORD)
@ -135,7 +139,7 @@ fn reveal_cloze_text(text: &str, ord: u16, question: bool) -> Cow<str> {
.parse() .parse()
.unwrap_or(0); .unwrap_or(0);
if captured_ord != ord { if captured_ord != cloze_ord {
// other cloze deletions are unchanged // other cloze deletions are unchanged
return caps.get(cloze_caps::TEXT).unwrap().as_str().to_owned(); return caps.get(cloze_caps::TEXT).unwrap().as_str().to_owned();
} }
@ -173,9 +177,10 @@ fn strip_html_inside_mathjax(text: &str) -> Cow<str> {
}) })
} }
fn cloze_filter<'a>(text: &'a str, filter_args: &str, question: bool) -> Cow<'a, str> { fn cloze_filter<'a>(text: &'a str, context: &RenderContext) -> Cow<'a, str> {
let cloze_ord = filter_args.parse().unwrap_or(0); strip_html_inside_mathjax(
strip_html_inside_mathjax(reveal_cloze_text(text, cloze_ord, question).as_ref()) reveal_cloze_text(text, context.card_ord + 1, context.question_side).as_ref(),
)
.into_owned() .into_owned()
.into() .into()
} }
@ -239,16 +244,14 @@ fn furigana_filter(text: &str) -> Cow<str> {
//---------------------------------------- //----------------------------------------
/// convert to [[type:...]] for the gui code to process /// 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> { fn type_filter<'a>(field_name: &str) -> Cow<'a, str> {
if filter_args.is_empty() { format!("[[type:{}]]", field_name).into()
format!("[[type:{}]]", field_name) }
} else {
format!("[[type:{}:{}]]", filter_args, field_name) fn type_cloze_filter<'a>(field_name: &str) -> Cow<'a, str> {
} format!("[[type:cloze:{}]]", field_name).into()
.into()
} }
// fixme: i18n
fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> { fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> {
if text.trim().is_empty() { if text.trim().is_empty() {
return text.into(); return text.into();
@ -267,9 +270,9 @@ onclick="this.style.display='none';
document.getElementById('hint{}').style.display='block'; document.getElementById('hint{}').style.display='block';
return false;"> return false;">
{}</a> {}</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() .into()
} }
@ -279,9 +282,10 @@ return false;">
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::template::RenderContext;
use crate::template_filters::{ use crate::template_filters::{
apply_filters, cloze_filter, furigana_filter, hint_filter, kana_filter, kanji_filter, apply_filters, cloze_filter, furigana_filter, hint_filter, kana_filter, kanji_filter,
type_filter, type_cloze_filter, type_filter,
}; };
use crate::text::strip_html; use crate::text::strip_html;
@ -305,21 +309,25 @@ mod test {
onclick="this.style.display='none'; onclick="this.style.display='none';
document.getElementById('hint83fe48607f0f3a66').style.display='block'; document.getElementById('hint83fe48607f0f3a66').style.display='block';
return false;"> return false;">
foo</a> field</a>
<div id="hint83fe48607f0f3a66" class=hint style="display: none">Show field</div> <div id="hint83fe48607f0f3a66" class=hint style="display: none">foo</div>
"## "##
); );
} }
#[test] #[test]
fn test_type() { 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!( assert_eq!(
type_filter("ignored", "cloze", "Front"), apply_filters("ignored", &["cloze", "type"], "Text", &ctx),
"[[type:cloze:Front]]"
);
assert_eq!(
apply_filters("ignored", &["cloze", "type"], "Text"),
("[[type:cloze:Text]]".into(), vec![]) ("[[type:cloze:Text]]".into(), vec![])
); );
} }
@ -327,25 +335,23 @@ foo</a>
#[test] #[test]
fn test_cloze() { fn test_cloze() {
let text = "{{c1::one}} {{c2::two::hint}}"; 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!( assert_eq!(
strip_html(&cloze_filter(text, "1", true)).as_ref(), cloze_filter(text, &ctx),
"[...] 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),
"<span class=cloze>[...]</span> two" "<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");
} }
} }