diff --git a/proto/backend.proto b/proto/backend.proto index beef8824c..806929e66 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -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 fields = 3; - int32 card_ordinal = 4; +message RenderExistingCardIn { + int64 card_id = 1; + bool browser = 2; } message RenderCardOut { diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index be39d5bf7..b7f845e83 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -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 "" % self.model()["css"] + return "" % 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: diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 2a640ddca..92db03240 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -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, diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index c75e8897c..dc09b6ab0 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -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( diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 5b14d33f0..26124ecfa 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -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"{self.question_text}" + + def answer_and_style(self) -> str: + return f"{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, diff --git a/pylib/tests/test_importing.py b/pylib/tests/test_importing.py index b6773abed..b2c018307 100644 --- a/pylib/tests/test_importing.py +++ b/pylib/tests/test_importing.py @@ -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(): diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index dd2051d6a..0405ea500 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -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 { - // 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 { + 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 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 { let proto = pb::Progress { value: Some(match progress { diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 7053481de..aa58213cb 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -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 diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index f9db8eb2f..5261893e5 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -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(&mut self, re: &Regex, mut repl: T) -> bool { let mut changed = false; for tag in &mut self.tags { diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index 95559614b..9e659aeb2 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -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; diff --git a/rslib/src/notetype/render.rs b/rslib/src/notetype/render.rs new file mode 100644 index 000000000..c7bbc14a1 --- /dev/null +++ b/rslib/src/notetype/render.rs @@ -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, + pub anodes: Vec, +} + +impl Collection { + /// Render an existing card saved in the database. + pub fn render_existing_card(&mut self, cid: CardID, browser: bool) -> Result { + 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(¬e, &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 { + 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, + card_ord: u16, + ) -> Result { + // 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 { + 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>, + 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 = 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", + _ => "", + } +} diff --git a/rslib/src/notetype/templates.rs b/rslib/src/notetype/templates.rs index c2ef18d5d..d9f3dffdd 100644 --- a/rslib/src/notetype/templates.rs +++ b/rslib/src/notetype/templates.rs @@ -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 { if self.config.target_deck_id > 0 { Some(DeckID(self.config.target_deck_id)) diff --git a/rslib/src/storage/card/get_card.sql b/rslib/src/storage/card/get_card.sql index b62014e62..227c925ab 100644 --- a/rslib/src/storage/card/get_card.sql +++ b/rslib/src/storage/card/get_card.sql @@ -1,4 +1,5 @@ select + id, nid, did, ord, @@ -16,6 +17,4 @@ select odid, flags, data -from cards -where - id = ? \ No newline at end of file +from cards \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index d4e88278f..0a498cd84 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -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 { @@ -35,33 +36,36 @@ impl FromSql for CardQueue { } } +fn row_to_card(row: &Row) -> result::Result { + 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> { - 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)?, - }) - }) - .optional() - .map_err(Into::into) + self.db + .prepare_cached(concat!(include_str!("get_card.sql"), " where id = ?"))? + .query_row(params![cid], row_to_card) + .optional() + .map_err(Into::into) } pub(crate) fn update_card(&self, card: &Card) -> Result<()> { @@ -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> { + 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)] diff --git a/rslib/src/template.rs b/rslib/src/template.rs index 74045326c..7ec6d0a01 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -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 = std::result::Result; @@ -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, +{ 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>, card_ord: u16, i18n: &I18n, ) -> Result<(Vec, Vec)> { @@ -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 {