flatten->render; pass field content in

This is paving the way to move the standard filters into Rust.

Non-empty fields are now determined in Rust, using a single regex
instead of the overkill stripHTMLMedia(). The old implementation
has been moved into the Pystache code for now.
This commit is contained in:
Damien Elmes 2020-01-10 14:59:29 +10:00
parent c010bb0a37
commit f7ff0d1c17
7 changed files with 119 additions and 78 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;
FlattenTemplateIn flatten_template = 21; RenderTemplateIn render_template = 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;
FlattenTemplateOut flatten_template = 21; RenderTemplateOut render_template = 21;
PlusOneOut plus_one = 2046; // temporary, for testing PlusOneOut plus_one = 2046; // temporary, for testing
@ -128,23 +128,23 @@ message BrowserRowsOut {
repeated string sort_fields = 1; repeated string sort_fields = 1;
} }
message FlattenTemplateIn { message RenderTemplateIn {
string template_text = 1; string template_text = 1;
repeated string nonempty_field_names = 2; map<string,string> fields = 2;
} }
message FlattenTemplateOut { message RenderTemplateOut {
repeated FlattenedTemplateNode nodes = 1; repeated RenderedTemplateNode nodes = 1;
} }
message FlattenedTemplateNode { message RenderedTemplateNode {
oneof value { oneof value {
string text = 1; string text = 1;
FlattenedTemplateReplacement replacement = 2; RenderedTemplateReplacement replacement = 2;
} }
} }
message FlattenedTemplateReplacement { message RenderedTemplateReplacement {
string field_name = 1; string field_name = 1;
repeated string filters = 2; repeated string filters = 2;
} }

View file

@ -104,16 +104,16 @@ class RustBackend:
) )
).sched_timing_today ).sched_timing_today
def flatten_template( def render_template(
self, template: str, nonempty_fields: List[str] self, template: str, fields: Dict[str, str]
) -> List[Union[str, TemplateReplacement]]: ) -> List[Union[str, TemplateReplacement]]:
out = self._run_command( out = self._run_command(
pb.BackendInput( pb.BackendInput(
flatten_template=pb.FlattenTemplateIn( render_template=pb.RenderTemplateIn(
template_text=template, nonempty_field_names=nonempty_fields template_text=template, fields=fields
) )
) )
).flatten_template ).render_template
results: List[Union[str, TemplateReplacement]] = [] results: List[Union[str, TemplateReplacement]] = []
for node in out.nodes: for node in out.nodes:
if node.WhichOneof("value") == "text": if node.WhichOneof("value") == "text":

View file

@ -1,11 +1,21 @@
import re import re
from typing import Any, Callable, Dict, Pattern from typing import Any, Callable, Dict, Pattern
from anki.template2 import apply_field_filters, field_is_not_empty from anki.template2 import apply_field_filters
modifiers: Dict[str, Callable] = {} modifiers: Dict[str, Callable] = {}
def field_is_not_empty(field_text: str) -> bool:
# fixme: this is an overkill way of preventing a field with only
# a <br> or <div> from appearing non-empty
from anki.utils import stripHTMLMedia
field_text = stripHTMLMedia(field_text)
return field_text.strip() != ""
def modifier(symbol) -> Callable[[Any], Any]: def modifier(symbol) -> Callable[[Any], Any]:
"""Decorator for associating a function with a Mustache tag modifier. """Decorator for associating a function with a Mustache tag modifier.

View file

@ -16,7 +16,7 @@ from anki.hooks import addHook, runFilter
from anki.lang import _ from anki.lang import _
from anki.rsbackend import TemplateReplacement from anki.rsbackend import TemplateReplacement
from anki.sound import stripSounds from anki.sound import stripSounds
from anki.utils import stripHTML, stripHTMLMedia from anki.utils import stripHTML
def render_qa_from_field_map( def render_qa_from_field_map(
@ -47,9 +47,8 @@ def render_template(
"Render a single template." "Render a single template."
old_output = anki.template.render(format, fields) old_output = anki.template.render(format, fields)
nonempty = nonempty_fields(fields) rendered = col.backend.render_template(format, fields)
flattened = col.backend.flatten_template(format, nonempty) new_output = render_flattened_template(rendered, fields)
new_output = render_flattened_template(flattened, fields)
if old_output != new_output: if old_output != new_output:
print( print(
@ -62,11 +61,11 @@ def render_template(
def render_flattened_template( def render_flattened_template(
flattened: List[Union[str, TemplateReplacement]], fields: Dict[str, str] rendered: List[Union[str, TemplateReplacement]], fields: Dict[str, str]
) -> str: ) -> str:
"Render a list of strings or replacements into a string." "Render a list of strings or replacements into a string."
res = "" res = ""
for node in flattened: for node in rendered:
if isinstance(node, str): if isinstance(node, str):
res += node res += node
else: else:
@ -78,22 +77,6 @@ def render_flattened_template(
return res return res
def field_is_not_empty(field_text: str) -> bool:
# fixme: this is an overkill way of preventing a field with only
# a <br> or <div> from appearing non-empty
field_text = stripHTMLMedia(field_text)
return field_text.strip() != ""
def nonempty_fields(fields: Dict[str, str]) -> List[str]:
res = []
for field, text in fields.items():
if field_is_not_empty(text):
res.append(field)
return res
def unknown_field_message(node: TemplateReplacement) -> str: def unknown_field_message(node: TemplateReplacement) -> str:
# mirror the pystache message for now # mirror the pystache message for now
field = node.field_name field = node.field_name

View file

@ -11,6 +11,8 @@ failure = "0.1.6"
prost = "0.5.0" prost = "0.5.0"
bytes = "0.4" bytes = "0.4"
chrono = "0.4.10" chrono = "0.4.10"
lazy_static = "1.4.0"
regex = "1.3.3"
[build-dependencies] [build-dependencies]
prost-build = "0.5.0" prost-build = "0.5.0"

View file

@ -1,13 +1,13 @@
use crate::backend_proto as pt; use crate::backend_proto as pt;
use crate::backend_proto::backend_input::Value; use crate::backend_proto::backend_input::Value;
use crate::backend_proto::FlattenedTemplateReplacement; 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, FlattenedNode, ParsedTemplate, without_legacy_template_directives, FieldMap, FieldRequirements, ParsedTemplate, RenderedNode,
}; };
use prost::Message; use prost::Message;
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
pub struct Backend { pub struct Backend {
@ -93,7 +93,7 @@ impl Backend {
Value::DeckTree(_) => todo!(), Value::DeckTree(_) => todo!(),
Value::FindCards(_) => todo!(), Value::FindCards(_) => todo!(),
Value::BrowserRows(_) => todo!(), Value::BrowserRows(_) => todo!(),
Value::FlattenTemplate(input) => OValue::FlattenTemplate(self.flatten_template(input)?), Value::RenderTemplate(input) => OValue::RenderTemplate(self.render_template(input)?),
}) })
} }
@ -157,24 +157,24 @@ impl Backend {
} }
} }
fn flatten_template(&self, input: pt::FlattenTemplateIn) -> Result<pt::FlattenTemplateOut> { fn render_template(&self, input: pt::RenderTemplateIn) -> Result<pt::RenderTemplateOut> {
let field_refs: HashSet<_> = input let fields: HashMap<_, _> = input
.nonempty_field_names .fields
.iter() .iter()
.map(AsRef::as_ref) .map(|(k, v)| (k.as_ref(), v.as_ref()))
.collect(); .collect();
let normalized = without_legacy_template_directives(&input.template_text); let normalized = without_legacy_template_directives(&input.template_text);
match ParsedTemplate::from_text(normalized.as_ref()) { match ParsedTemplate::from_text(normalized.as_ref()) {
Ok(tmpl) => { Ok(tmpl) => {
let nodes = tmpl.flatten(&field_refs); let nodes = tmpl.render(&fields);
let out_nodes = nodes let out_nodes = nodes
.into_iter() .into_iter()
.map(|n| pt::FlattenedTemplateNode { .map(|n| pt::RenderedTemplateNode {
value: Some(flattened_node_to_proto(n)), value: Some(rendered_node_to_proto(n)),
}) })
.collect(); .collect();
Ok(pt::FlattenTemplateOut { nodes: out_nodes }) Ok(pt::RenderTemplateOut { nodes: out_nodes })
} }
Err(e) => Err(e), Err(e) => Err(e),
} }
@ -185,11 +185,11 @@ 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 flattened_node_to_proto(node: FlattenedNode) -> pt::flattened_template_node::Value { fn rendered_node_to_proto(node: RenderedNode) -> pt::rendered_template_node::Value {
match node { match node {
FlattenedNode::Text { text } => pt::flattened_template_node::Value::Text(text), RenderedNode::Text { text } => pt::rendered_template_node::Value::Text(text),
FlattenedNode::Replacement { field, filters } => { RenderedNode::Replacement { field, filters } => {
pt::flattened_template_node::Value::Replacement(FlattenedTemplateReplacement { pt::rendered_template_node::Value::Replacement(RenderedTemplateReplacement {
field_name: field, field_name: field,
filters, filters,
}) })

View file

@ -1,9 +1,11 @@
use crate::err::{AnkiError, Result}; use crate::err::{AnkiError, Result};
use lazy_static::lazy_static;
use nom; use nom;
use nom::branch::alt; use nom::branch::alt;
use nom::bytes::complete::tag; use nom::bytes::complete::tag;
use nom::error::ErrorKind; use nom::error::ErrorKind;
use nom::sequence::delimited; use nom::sequence::delimited;
use regex::Regex;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -237,11 +239,11 @@ fn template_is_empty<'a>(nonempty_fields: &HashSet<&str>, nodes: &[ParsedNode<'a
true true
} }
// Flattening // Rendering
//---------------------------------------- //----------------------------------------
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum FlattenedNode { pub enum RenderedNode {
Text { Text {
text: String, text: String,
}, },
@ -257,50 +259,81 @@ impl ParsedTemplate<'_> {
/// ///
/// This leaves the field replacement (with any filters that were provided) /// This leaves the field replacement (with any filters that were provided)
/// up to the calling code to handle. /// up to the calling code to handle.
pub fn flatten(&self, nonempty_fields: &HashSet<&str>) -> Vec<FlattenedNode> { pub fn render(&self, fields: &HashMap<&str, &str>) -> Vec<RenderedNode> {
let mut flattened = vec![]; let mut rendered = vec![];
let nonempty = nonempty_fields(fields);
flatten_into(&mut flattened, self.0.as_ref(), nonempty_fields); render_into(&mut rendered, self.0.as_ref(), fields, &nonempty);
flattened rendered
} }
} }
fn flatten_into( fn render_into(
rendered_nodes: &mut Vec<FlattenedNode>, rendered_nodes: &mut Vec<RenderedNode>,
nodes: &[ParsedNode], nodes: &[ParsedNode],
fields: &HashSet<&str>, fields: &HashMap<&str, &str>,
nonempty: &HashSet<&str>,
) { ) {
use ParsedNode::*; use ParsedNode::*;
for node in nodes { for node in nodes {
match node { match node {
Text(t) => { Text(t) => {
if let Some(FlattenedNode::Text { ref mut text }) = rendered_nodes.last_mut() { if let Some(RenderedNode::Text { ref mut text }) = rendered_nodes.last_mut() {
text.push_str(t) text.push_str(t)
} else { } else {
rendered_nodes.push(FlattenedNode::Text { rendered_nodes.push(RenderedNode::Text {
text: (*t).to_string(), text: (*t).to_string(),
}) })
} }
} }
Replacement { key, filters } => rendered_nodes.push(FlattenedNode::Replacement { Replacement { key, filters } => rendered_nodes.push(RenderedNode::Replacement {
field: (*key).to_string(), field: (*key).to_string(),
filters: filters.iter().map(|&e| e.to_string()).collect(), filters: filters.iter().map(|&e| e.to_string()).collect(),
}), }),
Conditional { key, children } => { Conditional { key, children } => {
if fields.contains(key) { if nonempty.contains(key) {
flatten_into(rendered_nodes, children.as_ref(), fields); render_into(rendered_nodes, children.as_ref(), fields, nonempty);
} }
} }
NegatedConditional { key, children } => { NegatedConditional { key, children } => {
if !fields.contains(key) { if !nonempty.contains(key) {
flatten_into(rendered_nodes, children.as_ref(), fields); render_into(rendered_nodes, children.as_ref(), fields, nonempty);
} }
} }
}; };
} }
} }
/// True if provided text contains only whitespace and/or empty BR/DIV tags.
fn field_is_empty(text: &str) -> bool {
lazy_static! {
static ref RE: Regex = Regex::new(
r#"(?xsi)
^(?:
[[:space:]]
|
</?(?:br|div)\ ?/?>
)*$
"#
)
.unwrap();
}
RE.is_match(text)
}
fn nonempty_fields<'a>(fields: &'a HashMap<&str, &str>) -> HashSet<&'a str> {
fields
.iter()
.filter_map(|(name, val)| {
if !field_is_empty(val) {
Some(*name)
} else {
None
}
})
.collect()
}
// Field requirements // Field requirements
//---------------------------------------- //----------------------------------------
@ -360,10 +393,21 @@ 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::{without_legacy_template_directives, FieldRequirements}; use crate::template::{field_is_empty, without_legacy_template_directives, FieldRequirements};
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::iter::FromIterator; use std::iter::FromIterator;
#[test]
fn test_field_empty() {
assert_eq!(field_is_empty(""), true);
assert_eq!(field_is_empty(" "), true);
assert_eq!(field_is_empty("x"), false);
assert_eq!(field_is_empty("<BR>"), true);
assert_eq!(field_is_empty("<div />"), true);
assert_eq!(field_is_empty(" <div> <br> </div>\n"), true);
assert_eq!(field_is_empty(" <div>x</div>\n"), false);
}
#[test] #[test]
fn test_parsing() { fn test_parsing() {
let tmpl = PT::from_text("foo {{bar}} {{#baz}} quux {{/baz}}").unwrap(); let tmpl = PT::from_text("foo {{bar}} {{#baz}} quux {{/baz}}").unwrap();
@ -481,12 +525,14 @@ mod test {
#[test] #[test]
fn test_render() { fn test_render() {
let map: HashSet<_> = vec!["F", "B"].into_iter().collect(); let map: HashMap<_, _> = vec![("F", "1"), ("B", "2"), ("E", " ")]
.into_iter()
.collect();
use crate::template::FlattenedNode 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.flatten(&map), tmpl.render(&map),
vec![ vec![
FN::Replacement { FN::Replacement {
field: "B".to_owned(), field: "B".to_owned(),
@ -504,12 +550,12 @@ mod test {
// empty // empty
tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap(); tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap();
assert_eq!(tmpl.flatten(&map), vec![]); assert_eq!(tmpl.render(&map), vec![]);
// missing // missing
tmpl = PT::from_text("{{^M}}A{{/M}}").unwrap(); tmpl = PT::from_text("{{^M}}A{{/M}}").unwrap();
assert_eq!( assert_eq!(
tmpl.flatten(&map), tmpl.render(&map),
vec![FN::Text { vec![FN::Text {
text: "A".to_owned() text: "A".to_owned()
},] },]
@ -518,7 +564,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.flatten(&map), tmpl.render(&map),
vec![ vec![
FN::Text { FN::Text {
text: "12".to_owned() text: "12".to_owned()
@ -533,7 +579,7 @@ mod test {
// filters // filters
tmpl = PT::from_text("{{one:two:B}}{{three:X}}").unwrap(); tmpl = PT::from_text("{{one:two:B}}{{three:X}}").unwrap();
assert_eq!( assert_eq!(
tmpl.flatten(&map), tmpl.render(&map),
vec![ vec![
FN::Replacement { FN::Replacement {
field: "B".to_owned(), field: "B".to_owned(),