From 782911471b13dd10168a26f9d1adc345a3b4ef52 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 14 May 2020 20:58:45 +1000 Subject: [PATCH] add "fill empty" checkbox --- proto/backend.proto | 1 + pylib/anki/rsbackend.py | 7 +- pylib/anki/template.py | 21 ++++- qt/aqt/clayout.py | 159 ++++++++++++++++++++--------------- qt/aqt/editor.py | 6 +- qt/aqt/models.py | 24 +----- qt/designer/preview.ui | 7 ++ rslib/ftl/card-templates.ftl | 2 + rslib/src/backend/mod.rs | 5 +- rslib/src/notetype/render.rs | 27 +++++- rslib/src/template.rs | 38 ++++++++- 11 files changed, 198 insertions(+), 99 deletions(-) diff --git a/proto/backend.proto b/proto/backend.proto index a150281db..9170f237c 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -275,6 +275,7 @@ message RenderUncommittedCardIn { Note note = 1; uint32 card_ord = 2; bytes template = 3; + bool fill_empty = 4; } message RenderCardOut { diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 12a0e1ade..abaaf9ca9 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -313,13 +313,16 @@ class RustBackend: return PartiallyRenderedCard(qnodes, anodes) def render_uncommitted_card( - self, note: BackendNote, card_ord: int, template: Dict + self, note: BackendNote, card_ord: int, template: Dict, fill_empty: bool ) -> PartiallyRenderedCard: template_json = orjson.dumps(template) out = self._run_command( pb.BackendInput( render_uncommitted_card=pb.RenderUncommittedCardIn( - note=note, template=template_json, card_ord=card_ord + note=note, + template=template_json, + card_ord=card_ord, + fill_empty=fill_empty, ) ) ).render_uncommitted_card diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 617d90d52..5568dfd9f 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -56,10 +56,20 @@ class TemplateRenderContext: @classmethod def from_card_layout( - cls, note: Note, card: Card, notetype: NoteType, template: Dict + cls, + note: Note, + card: Card, + notetype: NoteType, + template: Dict, + fill_empty: bool, ) -> TemplateRenderContext: return TemplateRenderContext( - note.col, card, note, notetype=notetype, template=template + note.col, + card, + note, + notetype=notetype, + template=template, + fill_empty=fill_empty, ) def __init__( @@ -70,12 +80,14 @@ class TemplateRenderContext: browser: bool = False, notetype: NoteType = None, template: Optional[Dict] = None, + fill_empty: bool = False, ) -> None: self._col = col.weakref() self._card = card self._note = note self._browser = browser self._template = template + self._fill_empty = fill_empty if not notetype: self._note_type = note.model() else: @@ -148,7 +160,10 @@ class TemplateRenderContext: if self._template: # card layout screen return self._col.backend.render_uncommitted_card( - self._note.to_backend_note(), self._card.ord, self._template + self._note.to_backend_note(), + self._card.ord, + self._template, + self._fill_empty, ) else: # existing card (eg study mode) diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 5d052d8e6..6157aa287 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -14,26 +14,26 @@ from anki.lang import _, ngettext from anki.notes import Note from anki.rsbackend import TemplateError from anki.template import TemplateRenderContext -from anki.utils import isMac, isWin from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.sound import av_player, play_clicked_audio from aqt.theme import theme_manager from aqt.utils import ( + TR, askUser, downArrow, getOnlyText, openHelp, restoreGeom, saveGeom, + shortcut, showInfo, showWarning, tooltip, - TR, tr, shortcut + tr, ) from aqt.webview import AnkiWebView -# fixme: previewing with empty fields # fixme: deck name on new cards # fixme: card count when removing # fixme: change tracking and tooltip in fields @@ -42,7 +42,12 @@ from aqt.webview import AnkiWebView class CardLayout(QDialog): def __init__( - self, mw: AnkiQt, note: Note, ord=0, parent: Optional[QWidget] = None, + self, + mw: AnkiQt, + note: Note, + ord=0, + parent: Optional[QWidget] = None, + fill_empty: bool = False, ): QDialog.__init__(self, parent or mw, Qt.Window) mw.setupDialogGC(self) @@ -53,6 +58,7 @@ class CardLayout(QDialog): self.mm = self.mw.col.models self.model = note.model() self.templates = self.model["tmpls"] + self._want_fill_empty_on = fill_empty self.mm._remove_from_cache(self.model["id"]) self.mw.checkpoint(_("Card Types")) self.changed = False @@ -160,17 +166,20 @@ class CardLayout(QDialog): QShortcut( QKeySequence("Ctrl+1"), self, - activated=lambda: self.tform.front_button.setChecked(True)) + activated=lambda: self.tform.front_button.setChecked(True), + ) # type: ignore QShortcut( QKeySequence("Ctrl+2"), self, - activated=lambda: self.tform.back_button.setChecked(True)) + activated=lambda: self.tform.back_button.setChecked(True), + ) # type: ignore QShortcut( QKeySequence("Ctrl+3"), self, - activated=lambda: self.tform.style_button.setChecked(True)) + activated=lambda: self.tform.style_button.setChecked(True), + ) # type: ignore - # Main area + # Main area setup ########################################################################## def setupMainArea(self): @@ -179,56 +188,42 @@ class CardLayout(QDialog): l.setContentsMargins(0, 0, 0, 0) l.setSpacing(3) left = QWidget() - # template area tform = self.tform = aqt.forms.template.Ui_Form() tform.setupUi(left) - qconnect(tform.edit_area.textChanged, self.write_edits_to_template_and_redraw) - tform.front_button.setText(tr(TR.CARD_TEMPLATES_FRONT_TEMPLATE)) - qconnect(tform.front_button.toggled, self.on_editor_toggled) - tform.back_button.setText(tr(TR.CARD_TEMPLATES_BACK_TEMPLATE)) - qconnect(tform.back_button.toggled, self.on_editor_toggled) - tform.style_button.setText(tr(TR.CARD_TEMPLATES_TEMPLATE_STYLING)) - qconnect(tform.style_button.toggled, self.on_editor_toggled) - tform.groupBox.setTitle(tr(TR.CARD_TEMPLATES_TEMPLATE_BOX)) - - cnt = self.mw.col.models.useCount(self.model) - self.tform.changes_affect_label.setText(self.col.tr( - TR.CARD_TEMPLATES_CHANGES_WILL_AFFECT_NOTES, count=cnt)) - l.addWidget(left, 5) - self.setup_edit_area() - - widg = tform.search_edit - widg.setPlaceholderText("Search") - qconnect(widg.textChanged, self.on_search_changed) - qconnect(widg.returnPressed, self.on_search_next) - # preview area right = QWidget() - self.pform: Any = aqt.forms.preview.Ui_Form() + self.pform = aqt.forms.preview.Ui_Form() pform = self.pform pform.setupUi(right) pform.preview_front.setText(tr(TR.CARD_TEMPLATES_FRONT_PREVIEW)) pform.preview_back.setText(tr(TR.CARD_TEMPLATES_BACK_PREVIEW)) pform.preview_box.setTitle(tr(TR.CARD_TEMPLATES_PREVIEW_BOX)) - if self._isCloze(): - nums = self.note.cloze_numbers_in_fields() - if self.ord + 1 not in nums: - # current card is empty - nums.append(self.ord + 1) - self.cloze_numbers = sorted(nums) - self.setup_cloze_number_box() - else: - self.cloze_numbers = [] - self.pform.cloze_number_combo.setHidden(True) - - self.setupWebviews() + self.setup_edit_area() + self.setup_preview() l.addWidget(right, 5) w.setLayout(l) def setup_edit_area(self): + tform = self.tform + + tform.front_button.setText(tr(TR.CARD_TEMPLATES_FRONT_TEMPLATE)) + tform.back_button.setText(tr(TR.CARD_TEMPLATES_BACK_TEMPLATE)) + tform.style_button.setText(tr(TR.CARD_TEMPLATES_TEMPLATE_STYLING)) + tform.groupBox.setTitle(tr(TR.CARD_TEMPLATES_TEMPLATE_BOX)) + + cnt = self.mw.col.models.useCount(self.model) + self.tform.changes_affect_label.setText( + self.col.tr(TR.CARD_TEMPLATES_CHANGES_WILL_AFFECT_NOTES, count=cnt) + ) + + qconnect(tform.edit_area.textChanged, self.write_edits_to_template_and_redraw) + qconnect(tform.front_button.toggled, self.on_editor_toggled) + qconnect(tform.back_button.toggled, self.on_editor_toggled) + qconnect(tform.style_button.toggled, self.on_editor_toggled) + self.current_editor_index = 0 self.tform.edit_area.setAcceptRichText(False) if qtminor < 10: @@ -237,6 +232,11 @@ class CardLayout(QDialog): tab_width = self.fontMetrics().width(" " * 4) self.tform.edit_area.setTabStopDistance(tab_width) + widg = tform.search_edit + widg.setPlaceholderText("Search") + qconnect(widg.textChanged, self.on_search_changed) + qconnect(widg.returnPressed, self.on_search_next) + def setup_cloze_number_box(self): names = (_("Cloze %d") % n for n in self.cloze_numbers) self.pform.cloze_number_combo.addItems(names) @@ -282,14 +282,20 @@ class CardLayout(QDialog): text = self.tform.search_edit.text() self.on_search_changed(text) - def setupWebviews(self): + def setup_preview(self): pform = self.pform - pform.frontWeb = AnkiWebView(title="card layout") - pform.verticalLayout.addWidget(pform.frontWeb) + self.preview_web = AnkiWebView(title="card layout") + pform.verticalLayout.addWidget(self.preview_web) pform.verticalLayout.setStretch(1, 99) pform.preview_front.isChecked() qconnect(pform.preview_front.toggled, self.on_preview_toggled) qconnect(pform.preview_back.toggled, self.on_preview_toggled) + if self._want_fill_empty_on: + pform.fill_empty.setChecked(True) + qconnect(pform.fill_empty.toggled, self.on_preview_toggled) + if not self.note_has_empty_field(): + pform.fill_empty.setHidden(True) + pform.fill_empty.setText(tr(TR.CARD_TEMPLATES_FILL_EMPTY)) jsinc = [ "jquery.js", "browsersel.js", @@ -297,10 +303,21 @@ class CardLayout(QDialog): "mathjax/MathJax.js", "reviewer.js", ] - pform.frontWeb.stdHtml( + self.preview_web.stdHtml( self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc, context=self, ) - pform.frontWeb.set_bridge_command(self._on_bridge_cmd, self) + self.preview_web.set_bridge_command(self._on_bridge_cmd, self) + + if self._isCloze(): + nums = self.note.cloze_numbers_in_fields() + if self.ord + 1 not in nums: + # current card is empty + nums.append(self.ord + 1) + self.cloze_numbers = sorted(nums) + self.setup_cloze_number_box() + else: + self.cloze_numbers = [] + self.pform.cloze_number_combo.setHidden(True) def on_preview_toggled(self): self._renderPreview() @@ -309,18 +326,12 @@ class CardLayout(QDialog): if cmd.startswith("play:"): play_clicked_audio(cmd, self.rendered_card) - def ephemeral_card_for_rendering(self) -> Card: - card = Card(self.col) - card.ord = self.ord - template = copy.copy(self.current_template()) - # may differ in cloze case - template["ord"] = card.ord - # this fetches notetype, we should pass it in - output = TemplateRenderContext.from_card_layout( - self.note, card, notetype=self.model, template=template - ).render() - card.set_render_output(output) - return card + def note_has_empty_field(self) -> bool: + for field in self.note.fields: + if not field.strip(): + # ignores HTML, but this should suffice + return True + return False # Buttons ########################################################################## @@ -383,9 +394,9 @@ class CardLayout(QDialog): text = self.tform.edit_area.toPlainText() if self.current_editor_index == 0: - self.current_template()['qfmt'] = text + self.current_template()["qfmt"] = text elif self.current_editor_index == 1: - self.current_template()['afmt'] = text + self.current_template()["afmt"] = text else: self.model["css"] = text @@ -427,9 +438,7 @@ class CardLayout(QDialog): audio = c.answer_av_tags() # use _showAnswer to avoid the longer delay - self.pform.frontWeb.eval( - "_showAnswer(%s,'%s');" % (json.dumps(text), bodyclass) - ) + self.preview_web.eval("_showAnswer(%s,'%s');" % (json.dumps(text), bodyclass)) if c.id not in self.playedAudio: av_player.play_tags(audio) @@ -459,6 +468,22 @@ class CardLayout(QDialog): repl = answerRepl return re.sub(r"\[\[type:.+?\]\]", repl, txt) + def ephemeral_card_for_rendering(self) -> Card: + card = Card(self.col) + card.ord = self.ord + template = copy.copy(self.current_template()) + # may differ in cloze case + template["ord"] = card.ord + output = TemplateRenderContext.from_card_layout( + self.note, + card, + notetype=self.model, + template=template, + fill_empty=self.pform.fill_empty.isChecked(), + ).render() + card.set_render_output(output) + return card + # Card operations ###################################################################### @@ -673,9 +698,7 @@ Enter deck to place new %s cards in, or leave blank:""" row = form.fields.currentIndex().row() if row >= 0: self._addField( - fields[row], - form.font.currentFont().family(), - form.size.value(), + fields[row], form.font.currentFont().family(), form.size.value(), ) def _addField(self, field, font, size): @@ -720,7 +743,7 @@ Enter deck to place new %s cards in, or leave blank:""" self.cancelPreviewTimer() av_player.stop_and_clear_queue() saveGeom(self, "CardLayout") - self.pform.frontWeb = None + self.preview_web = None self.model = None self.rendered_card = None self.mw = None diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index d8d71913f..3948ef105 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -353,7 +353,11 @@ class Editor: else: ord = 0 CardLayout( - self.mw, self.note, ord=ord, parent=self.parentWindow, + self.mw, + self.note, + ord=ord, + parent=self.parentWindow, + fill_empty=self.addMode, ) if isWin: self.parentWindow.activateWindow() diff --git a/qt/aqt/models.py b/qt/aqt/models.py index 4f1270cc5..f901da8f0 100644 --- a/qt/aqt/models.py +++ b/qt/aqt/models.py @@ -1,7 +1,7 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import collections -import re from operator import itemgetter from typing import List, Optional @@ -9,6 +9,7 @@ import aqt.clayout from anki import stdmodels from anki.lang import _, ngettext from anki.models import NoteType +from anki.notes import Note from anki.rsbackend import pb from aqt import AnkiQt, gui_hooks from aqt.qt import * @@ -160,24 +161,7 @@ class Models(QDialog): def _tmpNote(self): nt = self.current_notetype() - self.mm.setCurrent(nt) - n = self.col.newNote(forDeck=False) - field_names = list(n.keys()) - for name in field_names: - n[name] = "(" + name + ")" - - cloze_re = re.compile(r"{{(?:[^}:]*:)*cloze:(?:[^}:]*:)*([^}]+)}}") - q_template = nt["tmpls"][0]["qfmt"] - a_template = nt["tmpls"][0]["afmt"] - - used_cloze_fields = [] - used_cloze_fields.extend(cloze_re.findall(q_template)) - used_cloze_fields.extend(cloze_re.findall(a_template)) - for field in used_cloze_fields: - if field in field_names: - n[field] = f"{field}: " + _("This is a {{c1::sample}} cloze deletion.") - - return n + return Note(self.col, nt) def onFields(self): from aqt.fields import FieldDialog @@ -188,7 +172,7 @@ class Models(QDialog): from aqt.clayout import CardLayout n = self._tmpNote() - CardLayout(self.mw, n, ord=0, parent=self) + CardLayout(self.mw, n, ord=0, parent=self, fill_empty=True) # Cleanup ########################################################################## diff --git a/qt/designer/preview.ui b/qt/designer/preview.ui index a262bbf68..5d9ad877a 100644 --- a/qt/designer/preview.ui +++ b/qt/designer/preview.ui @@ -64,6 +64,13 @@ + + + + FILL_EMPTY + + + diff --git a/rslib/ftl/card-templates.ftl b/rslib/ftl/card-templates.ftl index 648456932..addf38156 100644 --- a/rslib/ftl/card-templates.ftl +++ b/rslib/ftl/card-templates.ftl @@ -11,3 +11,5 @@ card-templates-front-preview = Front Preview card-templates-back-preview = Back Preview card-templates-preview-box = Preview card-templates-template-box = Template +card-templates-sample-cloze = This is a {"{{c1::"}sample{"}}"} cloze deletion. +card-templates-fill-empty = Fill Empty Fields diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 383ac018a..8103148ae 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -472,13 +472,14 @@ impl Backend { ) -> Result { let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?; let template = schema11.into(); - let note = input + let mut note = input .note .ok_or_else(|| AnkiError::invalid_input("missing note"))? .into(); let ord = input.card_ord as u16; + let fill_empty = input.fill_empty; self.with_col(|col| { - col.render_uncommitted_card(¬e, &template, ord) + col.render_uncommitted_card(&mut note, &template, ord, fill_empty) .map(Into::into) }) } diff --git a/rslib/src/notetype/render.rs b/rslib/src/notetype/render.rs index c7bbc14a1..75b3f9bb7 100644 --- a/rslib/src/notetype/render.rs +++ b/rslib/src/notetype/render.rs @@ -6,8 +6,9 @@ use crate::{ card::{Card, CardID}, collection::Collection, err::{AnkiError, Result}, + i18n::{I18n, TR}, notes::{Note, NoteID}, - template::{render_card, RenderedNode}, + template::{field_is_empty, render_card, ParsedTemplate, RenderedNode}, }; use std::{borrow::Cow, collections::HashMap}; @@ -41,17 +42,23 @@ impl Collection { /// Render a card that may not yet have been added. /// The provided ordinal will be used if the template has not yet been saved. + /// If fill_empty is set, note will be mutated. pub fn render_uncommitted_card( &mut self, - note: &Note, + note: &mut Note, template: &CardTemplate, card_ord: u16, + fill_empty: bool, ) -> 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"))?; + if fill_empty { + fill_empty_fields(note, &template.config.q_format, &nt, &self.i18n); + } + self.render_card_inner(note, &card, &nt, template, false) } @@ -145,3 +152,19 @@ fn flag_name(n: u8) -> &'static str { _ => "", } } + +fn fill_empty_fields(note: &mut Note, qfmt: &str, nt: &NoteType, i18n: &I18n) { + if let Ok(tmpl) = ParsedTemplate::from_text(qfmt) { + let cloze_fields = tmpl.cloze_fields(); + + for (val, field) in note.fields.iter_mut().zip(nt.fields.iter()) { + if field_is_empty(val) { + if cloze_fields.contains(&field.name.as_str()) { + *val = i18n.tr(TR::CardTemplatesSampleCloze).into(); + } else { + *val = format!("({})", field.name); + } + } + } + } +} diff --git a/rslib/src/template.rs b/rslib/src/template.rs index c38be0bfa..19d173b55 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -480,7 +480,7 @@ fn append_str_to_nodes(nodes: &mut Vec, text: &str) { } /// True if provided text contains only whitespace and/or empty BR/DIV tags. -fn field_is_empty(text: &str) -> bool { +pub(crate) fn field_is_empty(text: &str) -> bool { lazy_static! { static ref RE: Regex = Regex::new( r#"(?xsi) @@ -721,6 +721,42 @@ fn nodes_to_string(buf: &mut String, nodes: &[ParsedNode]) { } } +// Detecting cloze fields +//---------------------------------------- + +impl ParsedTemplate { + /// A set of field names with a cloze filter attached. + /// Field names may not be valid. + pub(crate) fn cloze_fields(&self) -> HashSet<&str> { + let mut set = HashSet::new(); + find_fields_with_filter(&self.0, &mut set, "cloze"); + set + } +} + +fn find_fields_with_filter<'a>( + nodes: &'a [ParsedNode], + fields: &mut HashSet<&'a str>, + filter: &str, +) { + for node in nodes { + match node { + ParsedNode::Text(_) => {} + ParsedNode::Replacement { key, filters } => { + if filters.iter().any(|f| f == filter) { + fields.insert(key); + } + } + ParsedNode::Conditional { children, .. } => { + find_fields_with_filter(&children, fields, filter); + } + ParsedNode::NegatedConditional { children, .. } => { + find_fields_with_filter(&children, fields, filter); + } + } + } +} + // Tests //---------------------------------------