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
//---------------------------------------