fetch template and note fields in backend during normal card render

Saves having to serialize the note fields and q/a templates, which
is particularly a win when rendering question/answer in the browse
screen.

Also some work towards being able to preview notes without having to
commit them to the database.
This commit is contained in:
Damien Elmes 2020-05-13 10:35:01 +10:00
parent 9874b19526
commit 826cbb0108
15 changed files with 363 additions and 151 deletions

View file

@ -33,7 +33,7 @@ message BackendInput {
DeckTreeIn deck_tree = 18;
SearchCardsIn search_cards = 19;
SearchNotesIn search_notes = 20;
RenderCardIn render_card = 21;
// RenderCardIn render_card = 21;
int64 local_minutes_west = 22;
string strip_av_tags = 23;
ExtractAVTagsIn extract_av_tags = 24;
@ -97,6 +97,7 @@ message BackendInput {
int32 set_local_minutes_west = 83;
Empty get_preferences = 84;
Preferences set_preferences = 85;
RenderExistingCardIn render_existing_card = 86;
}
}
@ -119,7 +120,7 @@ message BackendOutput {
DeckTreeNode deck_tree = 18;
SearchCardsOut search_cards = 19;
SearchNotesOut search_notes = 20;
RenderCardOut render_card = 21;
// RenderCardOut render_card = 21;
string add_media_file = 26;
Empty sync_media = 27;
MediaCheckOut check_media = 28;
@ -173,6 +174,7 @@ message BackendOutput {
Empty set_local_minutes_west = 83;
Preferences get_preferences = 84;
Empty set_preferences = 85;
RenderCardOut render_existing_card = 86;
BackendError error = 2047;
}
@ -262,11 +264,9 @@ message DeckTreeNode {
uint32 new_count = 8;
}
message RenderCardIn {
string question_template = 1;
string answer_template = 2;
map<string,string> fields = 3;
int32 card_ordinal = 4;
message RenderExistingCardIn {
int64 card_id = 1;
bool browser = 2;
}
message RenderCardOut {

View file

@ -109,10 +109,10 @@ class Card:
self.id = self.col.backend.add_card(card)
def question(self, reload: bool = False, browser: bool = False) -> str:
return self.css() + self.render_output(reload, browser).question_text
return self.render_output(reload, browser).question_and_style()
def answer(self) -> str:
return self.css() + self.render_output().answer_text
return self.render_output().answer_and_style()
def question_av_tags(self) -> List[AVTag]:
return self.render_output().question_av_tags
@ -120,17 +120,17 @@ class Card:
def answer_av_tags(self) -> List[AVTag]:
return self.render_output().answer_av_tags
# legacy
def css(self) -> str:
return "<style>%s</style>" % self.model()["css"]
return "<style>%s</style>" % self.render_output().css
def render_output(
self, reload: bool = False, browser: bool = False
) -> anki.template.TemplateRenderOutput:
if not self._render_output or reload:
note = self.note(reload)
self._render_output = anki.template.render_card(
self.col, self, note, browser
)
self._render_output = anki.template.TemplateRenderContext.from_existing_card(
self, browser
).render()
return self._render_output
def note(self, reload: bool = False) -> Note:

View file

@ -356,9 +356,6 @@ mod=?, scm=?, usn=?, ls=?""",
return all_cards
# fixme: make sure we enforce deck!=dyn requirement when generating cards
# fixme: make sure we enforce random due number when adding into random sorted deck
def _newCard(
self,
note: Note,

View file

@ -167,6 +167,12 @@ class TemplateReplacement:
TemplateReplacementList = List[Union[str, TemplateReplacement]]
@dataclass
class PartiallyRenderedCard:
qnodes: TemplateReplacementList
anodes: TemplateReplacementList
MediaSyncProgress = pb.MediaSyncProgress
MediaCheckOutput = pb.MediaCheckOut
@ -292,24 +298,19 @@ class RustBackend:
pb.BackendInput(sched_timing_today=pb.Empty())
).sched_timing_today
def render_card(
self, qfmt: str, afmt: str, fields: Dict[str, str], card_ord: int
) -> Tuple[TemplateReplacementList, TemplateReplacementList]:
def render_existing_card(self, cid: int, browser: bool) -> PartiallyRenderedCard:
out = self._run_command(
pb.BackendInput(
render_card=pb.RenderCardIn(
question_template=qfmt,
answer_template=afmt,
fields=fields,
card_ordinal=card_ord,
render_existing_card=pb.RenderExistingCardIn(
card_id=cid, browser=browser,
)
)
).render_card
).render_existing_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)
return PartiallyRenderedCard(qnodes, anodes)
def local_minutes_west(self, stamp: int) -> int:
return self._run_command(

View file

@ -37,7 +37,7 @@ from anki.cards import Card
from anki.decks import DeckManager
from anki.models import NoteType
from anki.notes import Note
from anki.rsbackend import TemplateReplacementList
from anki.rsbackend import PartiallyRenderedCard, TemplateReplacementList
from anki.sound import AVTag
CARD_BLANK_HELP = (
@ -51,21 +51,35 @@ class TemplateRenderContext:
This may fetch information lazily in the future, so please avoid
using the _private fields directly."""
@staticmethod
def from_existing_card(card: Card, browser: bool) -> TemplateRenderContext:
return TemplateRenderContext(card.col, card, card.note(), browser)
@classmethod
def from_card_layout(cls, note: Note, card_ord: int) -> TemplateRenderContext:
card = cls.synthesized_card(note.col, card_ord)
return TemplateRenderContext(note.col, card, note)
@classmethod
def synthesized_card(cls, col: anki.storage._Collection, ord: int):
c = Card(col)
c.ord = ord
return c
def __init__(
self,
col: anki.storage._Collection,
card: Card,
note: Note,
fields: Dict[str, str],
qfmt: str,
afmt: str,
browser: bool = False,
template: Optional[Any] = None,
) -> None:
self._col = col
self._col = col.weakref()
self._card = card
self._note = note
self._fields = fields
self._qfmt = qfmt
self._afmt = afmt
self._browser = browser
self._template = template
self._note_type = note.model()
# if you need to store extra state to share amongst rendering
# hooks, you can insert it into this dictionary
@ -74,8 +88,9 @@ class TemplateRenderContext:
def col(self) -> anki.storage._Collection:
return self._col
# legacy
def fields(self) -> Dict[str, str]:
return self._fields
return fields_for_rendering(self.col(), self.card(), self.note())
def card(self) -> Card:
"""Returns the card being rendered.
@ -88,13 +103,53 @@ class TemplateRenderContext:
return self._note
def note_type(self) -> NoteType:
return self.card().note_type()
return self._note_type
# legacy
def qfmt(self) -> str:
return self._qfmt
return templates_for_card(self.card(), self._browser)[0]
# legacy
def afmt(self) -> str:
return self._afmt
return templates_for_card(self.card(), self._browser)[1]
def render(self) -> TemplateRenderOutput:
try:
partial = self._partially_render()
except anki.rsbackend.TemplateError as e:
return TemplateRenderOutput(
question_text=str(e),
answer_text=str(e),
question_av_tags=[],
answer_av_tags=[],
)
qtext = apply_custom_filters(partial.qnodes, self, front_side=None)
qtext, q_avtags = self.col().backend.extract_av_tags(qtext, True)
atext = apply_custom_filters(partial.anodes, self, front_side=qtext)
atext, a_avtags = self.col().backend.extract_av_tags(atext, False)
output = TemplateRenderOutput(
question_text=qtext,
answer_text=atext,
question_av_tags=q_avtags,
answer_av_tags=a_avtags,
css=self.note_type()["css"],
)
if not self._browser:
hooks.card_did_render(output, self)
return output
def _partially_render(self) -> PartiallyRenderedCard:
if self._template:
# card layout screen
raise Exception("nyi")
else:
# existing card (eg study mode)
return self._col.backend.render_existing_card(self._card.id, self._browser)
@dataclass
@ -104,35 +159,16 @@ class TemplateRenderOutput:
answer_text: str
question_av_tags: List[AVTag]
answer_av_tags: List[AVTag]
css: str = ""
def question_and_style(self) -> str:
return f"<style>{self.css}</style>{self.question_text}"
def answer_and_style(self) -> str:
return f"<style>{self.css}</style>{self.answer_text}"
def render_card(
col: anki.storage._Collection, card: Card, note: Note, browser: bool
) -> TemplateRenderOutput:
"Render a card."
# collect data
fields = fields_for_rendering(col, card, note)
qfmt, afmt = templates_for_card(card, browser)
ctx = TemplateRenderContext(
col=col, card=card, note=note, fields=fields, qfmt=qfmt, afmt=afmt
)
# render
try:
output = render_card_from_context(ctx)
except anki.rsbackend.TemplateError as e:
output = TemplateRenderOutput(
question_text=str(e),
answer_text=str(e),
question_av_tags=[],
answer_av_tags=[],
)
hooks.card_did_render(output, ctx)
return output
# legacy
def templates_for_card(card: Card, browser: bool) -> Tuple[str, str]:
template = card.template()
if browser:
@ -144,6 +180,7 @@ def templates_for_card(card: Card, browser: bool) -> Tuple[str, str]:
return q, a # type: ignore
# legacy
def fields_for_rendering(
col: anki.storage._Collection, card: Card, note: Note
) -> Dict[str, str]:
@ -163,30 +200,6 @@ def fields_for_rendering(
return fields
def render_card_from_context(ctx: TemplateRenderContext) -> TemplateRenderOutput:
"""Renders the provided templates, returning rendered output.
Will raise if the template is invalid."""
col = ctx.col()
(qnodes, anodes) = col.backend.render_card(
ctx.qfmt(), ctx.afmt(), ctx.fields(), ctx.card().ord
)
qtext = apply_custom_filters(qnodes, ctx, front_side=None)
qtext, q_avtags = col.backend.extract_av_tags(qtext, True)
atext = apply_custom_filters(anodes, ctx, front_side=qtext)
atext, a_avtags = col.backend.extract_av_tags(atext, False)
return TemplateRenderOutput(
question_text=qtext,
answer_text=atext,
question_av_tags=q_avtags,
answer_av_tags=a_avtags,
)
def apply_custom_filters(
rendered: TemplateReplacementList,
ctx: TemplateRenderContext,

View file

@ -115,7 +115,7 @@ def test_anki2_diffmodel_templates():
# the front template should contain the text added in the 2nd package
tcid = dst.findCards("")[0] # only 1 note in collection
tnote = dst.getCard(tcid).note()
assert "Changed Front Template" in dst.findTemplates(tnote)[0]["qfmt"]
assert "Changed Front Template" in tnote.cards()[0].template()["qfmt"]
def test_anki2_updates():

View file

@ -21,11 +21,11 @@ use crate::{
media::sync::MediaSyncProgress,
media::MediaManager,
notes::{Note, NoteID},
notetype::{all_stock_notetypes, NoteType, NoteTypeID, NoteTypeSchema11},
notetype::{all_stock_notetypes, NoteType, NoteTypeID, NoteTypeSchema11, RenderCardOutput},
sched::cutoff::local_minutes_west_for_stamp,
sched::timespan::{answer_button_time, learning_congrats, studied_today, time_span},
search::SortMode,
template::{render_card, RenderedNode},
template::RenderedNode,
text::{extract_av_tags, strip_av_tags, AVTag},
timestamp::TimestampSecs,
types::Usn,
@ -216,7 +216,9 @@ impl Backend {
Ok(match ival {
Value::SchedTimingToday(_) => OValue::SchedTimingToday(self.sched_timing_today()?),
Value::DeckTree(input) => OValue::DeckTree(self.deck_tree(input)?),
Value::RenderCard(input) => OValue::RenderCard(self.render_template(input)?),
Value::RenderExistingCard(input) => {
OValue::RenderExistingCard(self.render_existing_card(input)?)
}
Value::LocalMinutesWest(stamp) => {
OValue::LocalMinutesWest(local_minutes_west_for_stamp(stamp))
}
@ -447,27 +449,10 @@ impl Backend {
self.with_col(|col| col.deck_tree(input.include_counts))
}
fn render_template(&self, input: pb::RenderCardIn) -> Result<pb::RenderCardOut> {
// convert string map to &str
let fields: HashMap<_, _> = input
.fields
.iter()
.map(|(k, v)| (k.as_ref(), v.as_ref()))
.collect();
// render
let (qnodes, anodes) = render_card(
&input.question_template,
&input.answer_template,
&fields,
input.card_ordinal as u16,
&self.i18n,
)?;
// return
Ok(pb::RenderCardOut {
question_nodes: rendered_nodes_to_proto(qnodes),
answer_nodes: rendered_nodes_to_proto(anodes),
fn render_existing_card(&self, input: pb::RenderExistingCardIn) -> Result<pb::RenderCardOut> {
self.with_col(|col| {
col.render_existing_card(CardID(input.card_id), input.browser)
.map(Into::into)
})
}
@ -1169,6 +1154,15 @@ fn rendered_node_to_proto(node: RenderedNode) -> pb::rendered_template_node::Val
}
}
impl From<RenderCardOutput> for pb::RenderCardOut {
fn from(o: RenderCardOutput) -> Self {
pb::RenderCardOut {
question_nodes: rendered_nodes_to_proto(o.qnodes),
answer_nodes: rendered_nodes_to_proto(o.anodes),
}
}
}
fn progress_to_proto_bytes(progress: Progress, i18n: &I18n) -> Vec<u8> {
let proto = pb::Progress {
value: Some(match progress {

View file

@ -84,6 +84,10 @@ impl Deck {
}
}
pub fn human_name(&self) -> String {
self.name.replace("\x1f", "::")
}
pub(crate) fn prepare_for_update(&mut self) {
// fixme - we currently only do this when converting from human; should be done in pub methods instead

View file

@ -15,7 +15,11 @@ use crate::{
use itertools::Itertools;
use num_integer::Integer;
use regex::{Regex, Replacer};
use std::{borrow::Cow, collections::HashSet, convert::TryInto};
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
convert::TryInto,
};
define_newtype!(NoteID, i64);
@ -125,6 +129,22 @@ impl Note {
.collect()
}
pub(crate) fn fields_map<'a>(
&'a self,
fields: &'a [NoteField],
) -> HashMap<&'a str, Cow<'a, str>> {
self.fields
.iter()
.enumerate()
.map(|(ord, field_content)| {
(
fields.get(ord).map(|f| f.name.as_str()).unwrap_or(""),
field_content.as_str().into(),
)
})
.collect()
}
pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
let mut changed = false;
for tag in &mut self.tags {

View file

@ -4,6 +4,7 @@
mod cardgen;
mod emptycards;
mod fields;
mod render;
mod schema11;
mod schemachange;
mod stock;
@ -16,6 +17,7 @@ pub use crate::backend_proto::{
};
pub(crate) use cardgen::{AlreadyGeneratedCardInfo, CardGenContext};
pub use fields::NoteField;
pub(crate) use render::RenderCardOutput;
pub use schema11::{CardTemplateSchema11, NoteFieldSchema11, NoteTypeSchema11};
pub use stock::all_stock_notetypes;
pub use templates::CardTemplate;

View file

@ -0,0 +1,147 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{CardTemplate, NoteType, NoteTypeKind};
use crate::{
card::{Card, CardID},
collection::Collection,
err::{AnkiError, Result},
notes::{Note, NoteID},
template::{render_card, RenderedNode},
};
use std::{borrow::Cow, collections::HashMap};
pub struct RenderCardOutput {
pub qnodes: Vec<RenderedNode>,
pub anodes: Vec<RenderedNode>,
}
impl Collection {
/// Render an existing card saved in the database.
pub fn render_existing_card(&mut self, cid: CardID, browser: bool) -> Result<RenderCardOutput> {
let card = self
.storage
.get_card(cid)?
.ok_or_else(|| AnkiError::invalid_input("no such card"))?;
let note = self
.storage
.get_note(card.nid)?
.ok_or_else(|| AnkiError::invalid_input("no such note"))?;
let nt = self
.get_notetype(note.ntid)?
.ok_or_else(|| AnkiError::invalid_input("no such notetype"))?;
let template = match nt.config.kind() {
NoteTypeKind::Normal => nt.templates.get(card.ord as usize),
NoteTypeKind::Cloze => nt.templates.get(0),
}
.ok_or_else(|| AnkiError::invalid_input("missing template"))?;
self.render_card_inner(&note, &card, &nt, template, browser)
}
/// Render a card that may not yet have been added.
/// The provided ordinal will be used if the template has not yet been saved.
pub fn render_uncommitted_card(
&mut self,
note: &Note,
template: &CardTemplate,
card_ord: u16,
) -> Result<RenderCardOutput> {
let card = self.existing_or_synthesized_card(note.id, template.ord, card_ord)?;
let nt = self
.get_notetype(note.ntid)?
.ok_or_else(|| AnkiError::invalid_input("no such notetype"))?;
self.render_card_inner(note, &card, &nt, template, false)
}
fn existing_or_synthesized_card(
&self,
nid: NoteID,
template_ord: Option<u32>,
card_ord: u16,
) -> Result<Card> {
// fetch existing card
if let Some(ord) = template_ord {
if let Some(card) = self.storage.get_card_by_ordinal(nid, ord as u16)? {
return Ok(card);
}
}
// no existing card; synthesize one
let mut card = Card::default();
card.ord = card_ord;
Ok(card)
}
fn render_card_inner(
&mut self,
note: &Note,
card: &Card,
nt: &NoteType,
template: &CardTemplate,
browser: bool,
) -> Result<RenderCardOutput> {
let mut field_map = note.fields_map(&nt.fields);
let card_num;
self.add_special_fields(&mut field_map, note, card, &nt, template)?;
// due to lifetime restrictions we need to add card number here
card_num = format!("c{}", card.ord + 1);
field_map.entry(&card_num).or_insert_with(|| "1".into());
let (qfmt, afmt) = if browser {
(
template.question_format_for_browser(),
template.answer_format_for_browser(),
)
} else {
(
template.config.q_format.as_str(),
template.config.a_format.as_str(),
)
};
let (qnodes, anodes) = render_card(qfmt, afmt, &field_map, card.ord, &self.i18n)?;
Ok(RenderCardOutput { qnodes, anodes })
}
// Add special fields if they don't clobber note fields
fn add_special_fields(
&mut self,
map: &mut HashMap<&str, Cow<str>>,
note: &Note,
card: &Card,
nt: &NoteType,
template: &CardTemplate,
) -> Result<()> {
let tags = note.tags.join(" ");
map.entry("Tags").or_insert_with(|| tags.into());
map.entry("Type").or_insert_with(|| nt.name.clone().into());
let deck_name: Cow<str> = self
.get_deck(if card.odid.0 > 0 { card.odid } else { card.did })?
.map(|d| d.human_name().into())
.unwrap_or_else(|| "invalid deck".into());
let subdeck_name = deck_name.rsplit("::").next().unwrap();
map.entry("Subdeck")
.or_insert_with(|| subdeck_name.to_string().into());
map.entry("Deck")
.or_insert_with(|| deck_name.to_string().into());
map.entry("CardFlag")
.or_insert_with(|| flag_name(card.flags).into());
map.entry("Template")
.or_insert_with(|| template.name.clone().into());
Ok(())
}
}
fn flag_name(n: u8) -> &'static str {
match n {
1 => "flag1",
2 => "flag2",
3 => "flag3",
4 => "flag4",
_ => "",
}
}

View file

@ -27,6 +27,22 @@ impl CardTemplate {
ParsedTemplate::from_text(&self.config.a_format).ok()
}
pub(crate) fn question_format_for_browser(&self) -> &str {
if !self.config.q_format_browser.is_empty() {
&self.config.q_format_browser
} else {
&self.config.q_format
}
}
pub(crate) fn answer_format_for_browser(&self) -> &str {
if !self.config.a_format_browser.is_empty() {
&self.config.a_format_browser
} else {
&self.config.a_format
}
}
pub(crate) fn target_deck_id(&self) -> Option<DeckID> {
if self.config.target_deck_id > 0 {
Some(DeckID(self.config.target_deck_id))

View file

@ -1,4 +1,5 @@
select
id,
nid,
did,
ord,
@ -17,5 +18,3 @@ select
flags,
data
from cards
where
id = ?

View file

@ -5,15 +5,16 @@ use crate::{
card::{Card, CardID, CardQueue, CardType},
decks::DeckID,
err::Result,
notes::NoteID,
timestamp::{TimestampMillis, TimestampSecs},
types::Usn,
};
use rusqlite::params;
use rusqlite::{
types::{FromSql, FromSqlError, ValueRef},
OptionalExtension, NO_PARAMS,
OptionalExtension, Row, NO_PARAMS,
};
use std::convert::TryFrom;
use std::{convert::TryFrom, result};
impl FromSql for CardType {
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
@ -35,31 +36,34 @@ impl FromSql for CardQueue {
}
}
fn row_to_card(row: &Row) -> result::Result<Card, rusqlite::Error> {
Ok(Card {
id: row.get(0)?,
nid: row.get(1)?,
did: row.get(2)?,
ord: row.get(3)?,
mtime: row.get(4)?,
usn: row.get(5)?,
ctype: row.get(6)?,
queue: row.get(7)?,
due: row.get(8).ok().unwrap_or_default(),
ivl: row.get(9)?,
factor: row.get(10)?,
reps: row.get(11)?,
lapses: row.get(12)?,
left: row.get(13)?,
odue: row.get(14).ok().unwrap_or_default(),
odid: row.get(15)?,
flags: row.get(16)?,
data: row.get(17)?,
})
}
impl super::SqliteStorage {
pub fn get_card(&self, cid: CardID) -> Result<Option<Card>> {
let mut stmt = self.db.prepare_cached(include_str!("get_card.sql"))?;
stmt.query_row(params![cid], |row| {
Ok(Card {
id: cid,
nid: row.get(0)?,
did: row.get(1)?,
ord: row.get(2)?,
mtime: row.get(3)?,
usn: row.get(4)?,
ctype: row.get(5)?,
queue: row.get(6)?,
due: row.get(7).ok().unwrap_or_default(),
ivl: row.get(8)?,
factor: row.get(9)?,
reps: row.get(10)?,
lapses: row.get(11)?,
left: row.get(12)?,
odue: row.get(13).ok().unwrap_or_default(),
odid: row.get(14)?,
flags: row.get(15)?,
data: row.get(16)?,
})
})
self.db
.prepare_cached(concat!(include_str!("get_card.sql"), " where id = ?"))?
.query_row(params![cid], row_to_card)
.optional()
.map_err(Into::into)
}
@ -169,6 +173,17 @@ impl super::SqliteStorage {
.query_row(NO_PARAMS, |r| r.get(0))
.map_err(Into::into)
}
pub(crate) fn get_card_by_ordinal(&self, nid: NoteID, ord: u16) -> Result<Option<Card>> {
self.db
.prepare_cached(concat!(
include_str!("get_card.sql"),
" where nid = ? and ord = ?"
))?
.query_row(params![nid, ord], row_to_card)
.optional()
.map_err(Into::into)
}
}
#[cfg(test)]

View file

@ -14,7 +14,7 @@ use nom::{
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::iter;
use std::{borrow::Cow, iter};
pub type FieldMap<'a> = HashMap<&'a str, u16>;
type TemplateResult<T> = std::result::Result<T, TemplateError>;
@ -356,7 +356,7 @@ pub enum RenderedNode {
}
pub(crate) struct RenderContext<'a> {
pub fields: &'a HashMap<&'a str, &'a str>,
pub fields: &'a HashMap<&'a str, Cow<'a, str>>,
pub nonempty_fields: &'a HashSet<&'a str>,
pub question_side: bool,
pub card_ord: u16,
@ -496,11 +496,14 @@ fn field_is_empty(text: &str) -> bool {
RE.is_match(text)
}
fn nonempty_fields<'a>(fields: &'a HashMap<&str, &str>) -> HashSet<&'a str> {
fn nonempty_fields<'a, R>(fields: &'a HashMap<&str, R>) -> HashSet<&'a str>
where
R: AsRef<str>,
{
fields
.iter()
.filter_map(|(name, val)| {
if !field_is_empty(val) {
if !field_is_empty(val.as_ref()) {
Some(*name)
} else {
None
@ -516,7 +519,7 @@ fn nonempty_fields<'a>(fields: &'a HashMap<&str, &str>) -> HashSet<&'a str> {
pub fn render_card(
qfmt: &str,
afmt: &str,
field_map: &HashMap<&str, &str>,
field_map: &HashMap<&str, Cow<str>>,
card_ord: u16,
i18n: &I18n,
) -> Result<(Vec<RenderedNode>, Vec<RenderedNode>)> {
@ -905,6 +908,7 @@ mod test {
fn render_single() {
let map: HashMap<_, _> = vec![("F", "f"), ("B", "b"), ("E", " ")]
.into_iter()
.map(|r| (r.0, r.1.into()))
.collect();
let ctx = RenderContext {