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;
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 {

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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),

View file

@ -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)
}
}

View file

@ -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");
}
}