mirror of
https://github.com/ankitects/anki.git
synced 2025-09-25 01:06:35 -04:00
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:
parent
9874b19526
commit
826cbb0108
15 changed files with 363 additions and 151 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
147
rslib/src/notetype/render.rs
Normal file
147
rslib/src/notetype/render.rs
Normal 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(¬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<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",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
|
|
@ -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 = ?
|
|
|
@ -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,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 {
|
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,
|
|
||||||
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()
|
.optional()
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
@ -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)]
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue