add "fill empty" checkbox

This commit is contained in:
Damien Elmes 2020-05-14 20:58:45 +10:00
parent 9a222e620a
commit 782911471b
11 changed files with 198 additions and 99 deletions

View file

@ -275,6 +275,7 @@ message RenderUncommittedCardIn {
Note note = 1;
uint32 card_ord = 2;
bytes template = 3;
bool fill_empty = 4;
}
message RenderCardOut {

View file

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

View file

@ -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)

View file

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

View file

@ -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()

View file

@ -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
##########################################################################

View file

@ -64,6 +64,13 @@
</property>
</spacer>
</item>
<item>
<widget class="QCheckBox" name="fill_empty">
<property name="text">
<string notr="true">FILL_EMPTY</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="cloze_number_combo"/>
</item>

View file

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

View file

@ -472,13 +472,14 @@ impl Backend {
) -> Result<pb::RenderCardOut> {
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(&note, &template, ord)
col.render_uncommitted_card(&mut note, &template, ord, fill_empty)
.map(Into::into)
})
}

View file

@ -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<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"))?;
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);
}
}
}
}
}

View file

@ -480,7 +480,7 @@ fn append_str_to_nodes(nodes: &mut Vec<RenderedNode>, 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
//---------------------------------------