diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index dbaa6b281..40d1a501e 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -672,7 +672,12 @@ where c.nid = n.id and c.id in %s group by nid""" (qfmt, afmt) = hooks.card_will_render((qfmt, afmt), fields, model, data) # render fields - qatext = render_card(self, qfmt, afmt, fields, card_ord) + try: + qatext = render_card(self, qfmt, afmt, fields, card_ord) + except anki.rsbackend.BackendException as e: + errmsg = f"Card template has a problem:
{e}" + qatext = (errmsg, errmsg) + 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 a230b9731..50c0aff86 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -23,7 +23,7 @@ class BackendException(Exception): if kind == "invalid_input": return f"invalid input: {err.invalid_input.info}" elif kind == "template_parse": - return f"template parse: {err.template_parse.info}" + return err.template_parse.info else: return f"unhandled error: {err}" diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 7352c79b9..febb145e1 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -44,7 +44,9 @@ def render_card( fields: Dict[str, str], card_ord: int, ) -> Tuple[str, str]: - "Renders the provided templates, returning rendered q & a text." + """Renders the provided templates, returning rendered q & a text. + + Will raise if the template is invalid.""" (qnodes, anodes) = col.backend.render_card(qfmt, afmt, fields, card_ord) qtext = apply_custom_filters(qnodes, fields, front_side=None) diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index 34a2b8387..44f20e2ec 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -97,7 +97,7 @@ impl Backend { Value::DeckTree(_) => todo!(), Value::FindCards(_) => todo!(), Value::BrowserRows(_) => todo!(), - Value::RenderCard(input) => OValue::RenderCard(self.render_template(input)), + Value::RenderCard(input) => OValue::RenderCard(self.render_template(input)?), }) } @@ -161,7 +161,7 @@ impl Backend { } } - fn render_template(&self, input: pt::RenderCardIn) -> pt::RenderCardOut { + fn render_template(&self, input: pt::RenderCardIn) -> Result { // convert string map to &str let fields: HashMap<_, _> = input .fields @@ -175,13 +175,13 @@ impl Backend { &input.answer_template, &fields, input.card_ordinal as u16, - ); + )?; // return - pt::RenderCardOut { + Ok(pt::RenderCardOut { question_nodes: rendered_nodes_to_proto(qnodes), answer_nodes: rendered_nodes_to_proto(anodes), - } + }) } } diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 6f3328386..919b1da8c 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -16,11 +16,30 @@ pub enum AnkiError { // error helpers impl AnkiError { - pub(crate) fn parse>(s: S) -> AnkiError { - AnkiError::TemplateParseError { info: s.into() } - } - pub(crate) fn invalid_input>(s: S) -> AnkiError { AnkiError::InvalidInput { info: s.into() } } } + +#[derive(Debug)] +pub enum TemplateError { + NoClosingBrackets(String), + ConditionalNotClosed(String), + ConditionalNotOpen(String), +} + +impl From for AnkiError { + fn from(terr: TemplateError) -> Self { + AnkiError::TemplateParseError { + info: match terr { + TemplateError::NoClosingBrackets(context) => { + format!("expected '{{{{field name}}}}', found '{}'", context) + } + TemplateError::ConditionalNotClosed(tag) => format!("missing '{{{{/{}}}}}'", tag), + TemplateError::ConditionalNotOpen(tag) => { + format!("missing '{{{{#{}}}}}' or '{{{{^{}}}}}'", tag, tag) + } + }, + } + } +} diff --git a/rslib/src/template.rs b/rslib/src/template.rs index 86ebc4704..3c0b98b2d 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::err::{AnkiError, Result}; +use crate::err::{Result, TemplateError}; use crate::template_filters::apply_filters; use crate::text::strip_sounds; use lazy_static::lazy_static; @@ -87,7 +87,7 @@ fn next_token(input: &str) -> nom::IResult<&str, Token> { alt((handle_token, text_token))(input) } -fn tokens(template: &str) -> impl Iterator> { +fn tokens(template: &str) -> impl Iterator> { let mut data = template; std::iter::from_fn(move || { @@ -99,7 +99,7 @@ fn tokens(template: &str) -> impl Iterator> { data = i; Some(Ok(o)) } - Err(e) => Some(Err(AnkiError::parse(format!("{:?}", e)))), + Err(_e) => Some(Err(TemplateError::NoClosingBrackets(data.to_string()))), } }) } @@ -132,16 +132,16 @@ impl ParsedTemplate<'_> { /// /// The legacy alternate syntax is not supported, so the provided text /// should be run through without_legacy_template_directives() first. - pub fn from_text(template: &str) -> Result { + pub fn from_text(template: &str) -> std::result::Result { let mut iter = tokens(template); Ok(Self(parse_inner(&mut iter, None)?)) } } -fn parse_inner<'a, I: Iterator>>>( +fn parse_inner<'a, I: Iterator, TemplateError>>>( iter: &mut I, open_tag: Option<&'a str>, -) -> Result>> { +) -> std::result::Result>, TemplateError> { let mut nodes = vec![]; while let Some(token) = iter.next() { @@ -170,16 +170,13 @@ fn parse_inner<'a, I: Iterator>>>( return Ok(nodes); } } - return Err(AnkiError::parse(format!( - "unbalanced closing tag: {:?} / {}", - open_tag, t - ))); + return Err(TemplateError::ConditionalNotOpen(t.to_string())); } }); } if let Some(open) = open_tag { - Err(AnkiError::parse(format!("unclosed conditional {}", open))) + Err(TemplateError::ConditionalNotClosed(open.to_string())) } else { Ok(nodes) } @@ -426,7 +423,7 @@ pub fn render_card( afmt: &str, field_map: &HashMap<&str, &str>, card_ord: u16, -) -> (Vec, Vec) { +) -> Result<(Vec, Vec)> { // prepare context let mut context = RenderContext { fields: field_map, @@ -438,12 +435,7 @@ pub fn render_card( // 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), - }], - }; + let qnodes = ParsedTemplate::from_text(qnorm.as_ref())?.render(&context); // if the question side didn't have any unknown filters, we can pass // FrontSide in now @@ -454,14 +446,9 @@ pub fn render_card( // 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), - }], - }; + let anodes = ParsedTemplate::from_text(anorm.as_ref())?.render(&context); - (qnodes, anodes) + Ok((qnodes, anodes)) } // Field requirements @@ -779,7 +766,7 @@ mod test { 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); + let (qnodes, anodes) = render_card(fmt, fmt, &map, 0).unwrap(); assert_eq!( strip_html(get_complete_template(&qnodes).unwrap()), "[...] two" @@ -790,11 +777,12 @@ mod test { ); // FrontSide should render if only standard modifiers were used - let (_qnodes, anodes) = render_card("{{kana:text:Text}}", "{{FrontSide}}", &map, 1); + let (_qnodes, anodes) = + render_card("{{kana:text:Text}}", "{{FrontSide}}", &map, 1).unwrap(); 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); + let (_qnodes, anodes) = render_card("{{custom:Text}}", "{{FrontSide}}", &map, 1).unwrap(); assert_eq!(get_complete_template(&anodes).is_none(), true) } }