diff --git a/proto/backend.proto b/proto/backend.proto index 79d921694..eb9892a71 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -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 fields = 2; +message RenderCardIn { + string question_template = 1; + string answer_template = 2; + map 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 { diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index e0a36594e..8c61d437a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -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 diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 7167b3d33..a230b9731 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -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) diff --git a/pylib/anki/template.py b/pylib/anki/template.py index c93a68c70..46d82b968 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -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_". 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: diff --git a/pylib/tests/test_template.py b/pylib/tests/test_template.py index d4338b9aa..ee8d3e609 100644 --- a/pylib/tests/test_template.py +++ b/pylib/tests/test_template.py @@ -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() diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index ae693c17f..34a2b8387 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -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 { + 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) -> Vec { ords.iter().map(|ord| *ord as u32).collect() } +fn rendered_nodes_to_proto(nodes: Vec) -> Vec { + 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), diff --git a/rslib/src/template.rs b/rslib/src/template.rs index bfee3723a..5e53f119c 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -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>); -static ALT_HANDLEBAR_DIRECTIVE: &str = "{{=<% %>=}}"; - -/// Convert legacy alternate syntax to standard syntax. -pub fn without_legacy_template_directives(text: &str) -> Cow { - 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>>>( } } +// Legacy support +//---------------------------------------- + +static ALT_HANDLEBAR_DIRECTIVE: &str = "{{=<% %>=}}"; + +/// Convert legacy alternate syntax to standard syntax. +pub fn without_legacy_template_directives(text: &str) -> Cow { + 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>, +} + 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 { + fn render(&self, context: &RenderContext) -> 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 } @@ -279,8 +290,7 @@ impl ParsedTemplate<'_> { fn render_into( rendered_nodes: &mut Vec, 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, Vec) { + // 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) -> 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) + } } diff --git a/rslib/src/template_filters.rs b/rslib/src/template_filters.rs index 750597fa7..0fda7dfc4 100644 --- a/rslib/src/template_filters.rs +++ b/rslib/src/template_filters.rs @@ -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) { let mut text: Cow = 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) { +fn apply_filter<'a>( + filter_name: &str, + text: &'a str, + field_name: &str, + context: &RenderContext, +) -> (bool, Option) { 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 { +fn reveal_cloze_text(text: &str, cloze_ord: u16, question: bool) -> Cow { 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 { .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 { }) } -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 { //---------------------------------------- /// 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;"> {} - + "##, - 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 - +field + "## ); } #[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 #[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), - "one two" - ); - assert_eq!( - cloze_filter(text, "1", true), + cloze_filter(text, &ctx), "[...] 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"); } }