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

View file

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

View file

@ -356,9 +356,6 @@ mod=?, scm=?, usn=?, ls=?""",
return all_cards 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( def _newCard(
self, self,
note: Note, note: Note,

View file

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

View file

@ -37,7 +37,7 @@ from anki.cards import Card
from anki.decks import DeckManager from anki.decks import DeckManager
from anki.models import NoteType from anki.models import NoteType
from anki.notes import Note from anki.notes import Note
from anki.rsbackend import TemplateReplacementList from anki.rsbackend import PartiallyRenderedCard, TemplateReplacementList
from anki.sound import AVTag from anki.sound import AVTag
CARD_BLANK_HELP = ( CARD_BLANK_HELP = (
@ -51,21 +51,35 @@ class TemplateRenderContext:
This may fetch information lazily in the future, so please avoid This may fetch information lazily in the future, so please avoid
using the _private fields directly.""" 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__( def __init__(
self, self,
col: anki.storage._Collection, col: anki.storage._Collection,
card: Card, card: Card,
note: Note, note: Note,
fields: Dict[str, str], browser: bool = False,
qfmt: str, template: Optional[Any] = None,
afmt: str,
) -> None: ) -> None:
self._col = col self._col = col.weakref()
self._card = card self._card = card
self._note = note self._note = note
self._fields = fields self._browser = browser
self._qfmt = qfmt self._template = template
self._afmt = afmt self._note_type = note.model()
# if you need to store extra state to share amongst rendering # if you need to store extra state to share amongst rendering
# hooks, you can insert it into this dictionary # hooks, you can insert it into this dictionary
@ -74,8 +88,9 @@ class TemplateRenderContext:
def col(self) -> anki.storage._Collection: def col(self) -> anki.storage._Collection:
return self._col return self._col
# legacy
def fields(self) -> Dict[str, str]: def fields(self) -> Dict[str, str]:
return self._fields return fields_for_rendering(self.col(), self.card(), self.note())
def card(self) -> Card: def card(self) -> Card:
"""Returns the card being rendered. """Returns the card being rendered.
@ -88,13 +103,53 @@ class TemplateRenderContext:
return self._note return self._note
def note_type(self) -> NoteType: def note_type(self) -> NoteType:
return self.card().note_type() return self._note_type
# legacy
def qfmt(self) -> str: def qfmt(self) -> str:
return self._qfmt return templates_for_card(self.card(), self._browser)[0]
# legacy
def afmt(self) -> str: 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 @dataclass
@ -104,35 +159,16 @@ class TemplateRenderOutput:
answer_text: str answer_text: str
question_av_tags: List[AVTag] question_av_tags: List[AVTag]
answer_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( # legacy
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
def templates_for_card(card: Card, browser: bool) -> Tuple[str, str]: def templates_for_card(card: Card, browser: bool) -> Tuple[str, str]:
template = card.template() template = card.template()
if browser: if browser:
@ -144,6 +180,7 @@ def templates_for_card(card: Card, browser: bool) -> Tuple[str, str]:
return q, a # type: ignore return q, a # type: ignore
# legacy
def fields_for_rendering( def fields_for_rendering(
col: anki.storage._Collection, card: Card, note: Note col: anki.storage._Collection, card: Card, note: Note
) -> Dict[str, str]: ) -> Dict[str, str]:
@ -163,30 +200,6 @@ def fields_for_rendering(
return fields 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( def apply_custom_filters(
rendered: TemplateReplacementList, rendered: TemplateReplacementList,
ctx: TemplateRenderContext, 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 # the front template should contain the text added in the 2nd package
tcid = dst.findCards("")[0] # only 1 note in collection tcid = dst.findCards("")[0] # only 1 note in collection
tnote = dst.getCard(tcid).note() 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(): def test_anki2_updates():

View file

@ -21,11 +21,11 @@ use crate::{
media::sync::MediaSyncProgress, media::sync::MediaSyncProgress,
media::MediaManager, media::MediaManager,
notes::{Note, NoteID}, 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::cutoff::local_minutes_west_for_stamp,
sched::timespan::{answer_button_time, learning_congrats, studied_today, time_span}, sched::timespan::{answer_button_time, learning_congrats, studied_today, time_span},
search::SortMode, search::SortMode,
template::{render_card, RenderedNode}, template::RenderedNode,
text::{extract_av_tags, strip_av_tags, AVTag}, text::{extract_av_tags, strip_av_tags, AVTag},
timestamp::TimestampSecs, timestamp::TimestampSecs,
types::Usn, types::Usn,
@ -216,7 +216,9 @@ impl Backend {
Ok(match ival { Ok(match ival {
Value::SchedTimingToday(_) => OValue::SchedTimingToday(self.sched_timing_today()?), Value::SchedTimingToday(_) => OValue::SchedTimingToday(self.sched_timing_today()?),
Value::DeckTree(input) => OValue::DeckTree(self.deck_tree(input)?), 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) => { Value::LocalMinutesWest(stamp) => {
OValue::LocalMinutesWest(local_minutes_west_for_stamp(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)) self.with_col(|col| col.deck_tree(input.include_counts))
} }
fn render_template(&self, input: pb::RenderCardIn) -> Result<pb::RenderCardOut> { fn render_existing_card(&self, input: pb::RenderExistingCardIn) -> Result<pb::RenderCardOut> {
// convert string map to &str self.with_col(|col| {
let fields: HashMap<_, _> = input col.render_existing_card(CardID(input.card_id), input.browser)
.fields .map(Into::into)
.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),
}) })
} }
@ -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> { fn progress_to_proto_bytes(progress: Progress, i18n: &I18n) -> Vec<u8> {
let proto = pb::Progress { let proto = pb::Progress {
value: Some(match 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) { 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 // 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 itertools::Itertools;
use num_integer::Integer; use num_integer::Integer;
use regex::{Regex, Replacer}; 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); define_newtype!(NoteID, i64);
@ -125,6 +129,22 @@ impl Note {
.collect() .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 { pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
let mut changed = false; let mut changed = false;
for tag in &mut self.tags { for tag in &mut self.tags {

View file

@ -4,6 +4,7 @@
mod cardgen; mod cardgen;
mod emptycards; mod emptycards;
mod fields; mod fields;
mod render;
mod schema11; mod schema11;
mod schemachange; mod schemachange;
mod stock; mod stock;
@ -16,6 +17,7 @@ pub use crate::backend_proto::{
}; };
pub(crate) use cardgen::{AlreadyGeneratedCardInfo, CardGenContext}; pub(crate) use cardgen::{AlreadyGeneratedCardInfo, CardGenContext};
pub use fields::NoteField; pub use fields::NoteField;
pub(crate) use render::RenderCardOutput;
pub use schema11::{CardTemplateSchema11, NoteFieldSchema11, NoteTypeSchema11}; pub use schema11::{CardTemplateSchema11, NoteFieldSchema11, NoteTypeSchema11};
pub use stock::all_stock_notetypes; pub use stock::all_stock_notetypes;
pub use templates::CardTemplate; 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() 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> { pub(crate) fn target_deck_id(&self) -> Option<DeckID> {
if self.config.target_deck_id > 0 { if self.config.target_deck_id > 0 {
Some(DeckID(self.config.target_deck_id)) Some(DeckID(self.config.target_deck_id))

View file

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

View file

@ -5,15 +5,16 @@ use crate::{
card::{Card, CardID, CardQueue, CardType}, card::{Card, CardID, CardQueue, CardType},
decks::DeckID, decks::DeckID,
err::Result, err::Result,
notes::NoteID,
timestamp::{TimestampMillis, TimestampSecs}, timestamp::{TimestampMillis, TimestampSecs},
types::Usn, types::Usn,
}; };
use rusqlite::params; use rusqlite::params;
use rusqlite::{ use rusqlite::{
types::{FromSql, FromSqlError, ValueRef}, types::{FromSql, FromSqlError, ValueRef},
OptionalExtension, NO_PARAMS, OptionalExtension, Row, NO_PARAMS,
}; };
use std::convert::TryFrom; use std::{convert::TryFrom, result};
impl FromSql for CardType { impl FromSql for CardType {
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> { fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
@ -35,33 +36,36 @@ 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 { impl super::SqliteStorage {
pub fn get_card(&self, cid: CardID) -> Result<Option<Card>> { pub fn get_card(&self, cid: CardID) -> Result<Option<Card>> {
let mut stmt = self.db.prepare_cached(include_str!("get_card.sql"))?; self.db
stmt.query_row(params![cid], |row| { .prepare_cached(concat!(include_str!("get_card.sql"), " where id = ?"))?
Ok(Card { .query_row(params![cid], row_to_card)
id: cid, .optional()
nid: row.get(0)?, .map_err(Into::into)
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)
} }
pub(crate) fn update_card(&self, card: &Card) -> Result<()> { pub(crate) fn update_card(&self, card: &Card) -> Result<()> {
@ -169,6 +173,17 @@ impl super::SqliteStorage {
.query_row(NO_PARAMS, |r| r.get(0)) .query_row(NO_PARAMS, |r| r.get(0))
.map_err(Into::into) .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)] #[cfg(test)]

View file

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