Merge pull request #1230 from RumovZ/fields-check

Check for misplaced or missing clozes when adding and in the editor
This commit is contained in:
Damien Elmes 2021-06-17 21:26:16 +10:00 committed by GitHub
commit 2e53dc63c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 135 additions and 57 deletions

View file

@ -6,4 +6,6 @@ adding-history = History
adding-note-deleted = (Note deleted) adding-note-deleted = (Note deleted)
adding-shortcut = Shortcut: { $val } adding-shortcut = Shortcut: { $val }
adding-the-first-field-is-empty = The first field is empty. adding-the-first-field-is-empty = The first field is empty.
adding-you-have-a-cloze-deletion-note = You have a cloze deletion note type but have not made any cloze deletions. Proceed? adding-you-have-a-cloze-deletion-note = You have a cloze notetype but have not made any cloze deletions. Proceed?
adding-cloze-outside-cloze-notetype = Cloze deletion can only be used on cloze notetypes.
adding-cloze-outside-cloze-field = Cloze deletion can only be used in fields which use the 'cloze:' filter. This is typically the first field.

View file

@ -11,7 +11,7 @@ ignored-classes=
QueuedCards, QueuedCards,
UnburyDeckIn, UnburyDeckIn,
BuryOrSuspendCardsIn, BuryOrSuspendCardsIn,
NoteIsDuplicateOrEmptyOut, NoteFieldsCheckOut,
BackendError, BackendError,
SetDeckCollapsedIn, SetDeckCollapsedIn,

View file

@ -14,7 +14,8 @@ from anki.consts import MODEL_STD
from anki.models import NotetypeDict, NotetypeId, TemplateDict from anki.models import NotetypeDict, NotetypeId, TemplateDict
from anki.utils import joinFields from anki.utils import joinFields
DuplicateOrEmptyResult = _pb.NoteIsDuplicateOrEmptyOut.State DuplicateOrEmptyResult = _pb.NoteFieldsCheckOut.State
NoteFieldsCheckResult = _pb.NoteFieldsCheckOut.State
# types # types
NoteId = NewType("NoteId", int) NoteId = NewType("NoteId", int)
@ -190,12 +191,10 @@ class Note:
addTag = add_tag addTag = add_tag
delTag = remove_tag delTag = remove_tag
# Unique/duplicate check # Unique/duplicate/cloze check
################################################## ##################################################
def duplicate_or_empty(self) -> DuplicateOrEmptyResult.V: def fields_check(self) -> NoteFieldsCheckResult.V:
return self.col._backend.note_is_duplicate_or_empty( return self.col._backend.note_fields_check(self._to_backend_note()).state
self._to_backend_note()
).state
dupeOrEmpty = duplicate_or_empty dupeOrEmpty = duplicate_or_empty = fields_check

View file

@ -74,15 +74,15 @@ def test_noteAddDelete():
c0 = note.cards()[0] c0 = note.cards()[0]
assert "three" in c0.q() assert "three" in c0.q()
# it should not be a duplicate # it should not be a duplicate
assert not note.duplicate_or_empty() assert not note.fields_check()
# now let's make a duplicate # now let's make a duplicate
note2 = col.newNote() note2 = col.newNote()
note2["Front"] = "one" note2["Front"] = "one"
note2["Back"] = "" note2["Back"] = ""
assert note2.duplicate_or_empty() assert note2.fields_check()
# empty first field should not be permitted either # empty first field should not be permitted either
note2["Front"] = " " note2["Front"] = " "
assert note2.duplicate_or_empty() assert note2.fields_check()
def test_fieldChecksum(): def test_fieldChecksum():

View file

@ -6,10 +6,9 @@ from typing import Callable, List, Optional
import aqt.editor import aqt.editor
import aqt.forms import aqt.forms
from anki.collection import OpChanges, SearchNode from anki.collection import OpChanges, SearchNode
from anki.consts import MODEL_CLOZE
from anki.decks import DeckId from anki.decks import DeckId
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.notes import DuplicateOrEmptyResult, Note, NoteId from anki.notes import Note, NoteFieldsCheckResult, NoteId
from anki.utils import htmlToTextLine, isMac from anki.utils import htmlToTextLine, isMac
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.deckchooser import DeckChooser from aqt.deckchooser import DeckChooser
@ -231,9 +230,16 @@ class AddCards(QDialog):
).run_in_background() ).run_in_background()
def _note_can_be_added(self, note: Note) -> bool: def _note_can_be_added(self, note: Note) -> bool:
result = note.duplicate_or_empty() result = note.fields_check()
if result == DuplicateOrEmptyResult.EMPTY: if result == NoteFieldsCheckResult.EMPTY:
problem = tr.adding_the_first_field_is_empty() problem = tr.adding_the_first_field_is_empty()
elif result == NoteFieldsCheckResult.MISSING_CLOZE:
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
return False
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_notetype()
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
problem = tr.adding_cloze_outside_cloze_field()
else: else:
# duplicate entries are allowed these days # duplicate entries are allowed these days
problem = None problem = None
@ -244,12 +250,6 @@ class AddCards(QDialog):
showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE) showWarning(problem, help=HelpPage.ADDING_CARD_AND_NOTE)
return False return False
# missing cloze deletion?
if note.model()["type"] == MODEL_CLOZE:
if not note.cloze_numbers_in_fields():
if not askUser(tr.adding_you_have_a_cloze_deletion_note()):
return False
return True return True
def keyPressEvent(self, evt: QKeyEvent) -> None: def keyPressEvent(self, evt: QKeyEvent) -> None:

View file

@ -27,7 +27,7 @@ from anki.collection import Config, SearchNode
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.hooks import runFilter from anki.hooks import runFilter
from anki.httpclient import HttpClient from anki.httpclient import HttpClient
from anki.notes import DuplicateOrEmptyResult, Note from anki.notes import Note, NoteFieldsCheckResult
from anki.utils import checksum, isLin, isWin, namedtmp from anki.utils import checksum, isLin, isWin, namedtmp
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, colors, gui_hooks
from aqt.operations import QueryOp from aqt.operations import QueryOp
@ -79,8 +79,9 @@ audio = (
_html = """ _html = """
<div id="fields"></div> <div id="fields"></div>
<div id="dupes" class="is-inactive"> <div id="dupes" class="is-inactive">
<a href="#" onclick="pycmd('dupes');return false;">%s</a> <a href="#" onclick="pycmd('dupes');return false;">{}</a>
</div> </div>
<div id="cloze-hint"></div>
""" """
@ -129,7 +130,7 @@ class Editor:
# then load page # then load page
self.web.stdHtml( self.web.stdHtml(
_html % tr.editing_show_duplicates(), _html.format(tr.editing_show_duplicates()),
css=[ css=[
"css/editor.css", "css/editor.css",
], ],
@ -437,7 +438,7 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
self.widget.show() self.widget.show()
self.updateTags() self.updateTags()
dupe_status = self.note.duplicate_or_empty() note_fields_status = self.note.fields_check()
def oncallback(arg: Any) -> None: def oncallback(arg: Any) -> None:
if not self.note: if not self.note:
@ -445,7 +446,7 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
self.setupForegroundButton() self.setupForegroundButton()
# we currently do this synchronously to ensure we load before the # we currently do this synchronously to ensure we load before the
# sidebar on browser startup # sidebar on browser startup
self._update_duplicate_display(dupe_status) self._update_duplicate_display(note_fields_status)
if focusTo is not None: if focusTo is not None:
self.web.setFocus() self.web.setFocus()
gui_hooks.editor_did_load_note(self) gui_hooks.editor_did_load_note(self)
@ -494,25 +495,31 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
if not note: if not note:
return return
def on_done(result: DuplicateOrEmptyResult.V) -> None: def on_done(result: NoteFieldsCheckResult.V) -> None:
if self.note != note: if self.note != note:
return return
self._update_duplicate_display(result) self._update_duplicate_display(result)
QueryOp( QueryOp(
parent=self.parentWindow, parent=self.parentWindow,
op=lambda _: note.duplicate_or_empty(), op=lambda _: note.fields_check(),
success=on_done, success=on_done,
).run_in_background() ).run_in_background()
checkValid = _check_and_update_duplicate_display_async checkValid = _check_and_update_duplicate_display_async
def _update_duplicate_display(self, result: DuplicateOrEmptyResult.V) -> None: def _update_duplicate_display(self, result: NoteFieldsCheckResult.V) -> None:
cols = [""] * len(self.note.fields) cols = [""] * len(self.note.fields)
if result == DuplicateOrEmptyResult.DUPLICATE: cloze_hint = ""
if result == NoteFieldsCheckResult.DUPLICATE:
cols[0] = "dupe" cols[0] = "dupe"
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
cloze_hint = tr.adding_cloze_outside_cloze_notetype()
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
cloze_hint = tr.adding_cloze_outside_cloze_field()
self.web.eval(f"setBackgrounds({json.dumps(cols)});") self.web.eval(f"setBackgrounds({json.dumps(cols)});")
self.web.eval(f"setClozeHint({json.dumps(cloze_hint)});")
def showDupes(self) -> None: def showDupes(self) -> None:
aqt.dialogs.open( aqt.dialogs.open(

View file

@ -174,7 +174,7 @@ service NotesService {
rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut);
rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChangesWithCount); rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChangesWithCount);
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut); rpc NoteFieldsCheck(Note) returns (NoteFieldsCheckOut);
rpc CardsOfNote(NoteId) returns (CardIds); rpc CardsOfNote(NoteId) returns (CardIds);
} }
@ -1231,11 +1231,14 @@ message ReparentDecksIn {
int64 new_parent = 2; int64 new_parent = 2;
} }
message NoteIsDuplicateOrEmptyOut { message NoteFieldsCheckOut {
enum State { enum State {
NORMAL = 0; NORMAL = 0;
EMPTY = 1; EMPTY = 1;
DUPLICATE = 2; DUPLICATE = 2;
MISSING_CLOZE = 3;
NOTETYPE_NOT_CLOZE = 4;
FIELD_NOT_CLOZE = 5;
} }
State state = 1; State state = 1;
} }

View file

@ -120,11 +120,11 @@ impl NotesService for Backend {
}) })
} }
fn note_is_duplicate_or_empty(&self, input: pb::Note) -> Result<pb::NoteIsDuplicateOrEmptyOut> { fn note_fields_check(&self, input: pb::Note) -> Result<pb::NoteFieldsCheckOut> {
let note: Note = input.into(); let note: Note = input.into();
self.with_col(|col| { self.with_col(|col| {
col.note_is_duplicate_or_empty(&note) col.note_fields_check(&note)
.map(|r| pb::NoteIsDuplicateOrEmptyOut { state: r as i32 }) .map(|r| pb::NoteFieldsCheckOut { state: r as i32 })
}) })
} }

View file

@ -139,6 +139,10 @@ pub fn expand_clozes_to_reveal_latex(text: &str) -> String {
buf buf
} }
pub(crate) fn contains_cloze(text: &str) -> bool {
CLOZE.is_match(text)
}
pub fn cloze_numbers_in_string(html: &str) -> HashSet<u16> { pub fn cloze_numbers_in_string(html: &str) -> HashSet<u16> {
let mut set = HashSet::with_capacity(4); let mut set = HashSet::with_capacity(4);
add_cloze_numbers_in_string(html, &mut set); add_cloze_numbers_in_string(html, &mut set);

View file

@ -14,7 +14,8 @@ use num_integer::Integer;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState, backend_proto::note_fields_check_out::State as NoteFieldsState,
cloze::contains_cloze,
decks::DeckId, decks::DeckId,
define_newtype, define_newtype,
error::{AnkiError, Result}, error::{AnkiError, Result},
@ -529,35 +530,76 @@ impl Collection {
Ok(changed_notes) Ok(changed_notes)
} }
pub(crate) fn note_is_duplicate_or_empty(&self, note: &Note) -> Result<DuplicateState> { /// Check if the note's first field is empty or a duplicate. Then for cloze
if let Some(field1) = note.fields.get(0) { /// notetypes, check if there is a cloze in a non-cloze field or if there's
/// no cloze at all. For other notetypes, just check if there's a cloze.
pub(crate) fn note_fields_check(&mut self, note: &Note) -> Result<NoteFieldsState> {
Ok(if let Some(text) = note.fields.get(0) {
let field1 = if self.get_config_bool(BoolKey::NormalizeNoteText) { let field1 = if self.get_config_bool(BoolKey::NormalizeNoteText) {
normalize_to_nfc(field1) normalize_to_nfc(text)
} else { } else {
field1.into() text.into()
}; };
let stripped = strip_html_preserving_media_filenames(&field1); let stripped = strip_html_preserving_media_filenames(&field1);
if stripped.trim().is_empty() { if stripped.trim().is_empty() {
Ok(DuplicateState::Empty) NoteFieldsState::Empty
} else { } else {
let csum = field_checksum(&stripped); let cloze_state = self.field_cloze_check(note)?;
let have_dupe = self if cloze_state != NoteFieldsState::Normal {
.storage cloze_state
.note_fields_by_checksum(note.notetype_id, csum)? } else if self.is_duplicate(&stripped, note)? {
.into_iter() NoteFieldsState::Duplicate
.any(|(nid, field)| {
nid != note.id && strip_html_preserving_media_filenames(&field) == stripped
});
if have_dupe {
Ok(DuplicateState::Duplicate)
} else { } else {
Ok(DuplicateState::Normal) NoteFieldsState::Normal
} }
} }
} else { } else {
Ok(DuplicateState::Empty) NoteFieldsState::Empty
} })
}
fn is_duplicate(&self, first_field: &str, note: &Note) -> Result<bool> {
let csum = field_checksum(&first_field);
Ok(self
.storage
.note_fields_by_checksum(note.notetype_id, csum)?
.into_iter()
.any(|(nid, field)| {
nid != note.id && strip_html_preserving_media_filenames(&field) == first_field
}))
}
fn field_cloze_check(&mut self, note: &Note) -> Result<NoteFieldsState> {
let notetype = self
.get_notetype(note.notetype_id)?
.ok_or(AnkiError::NotFound)?;
let cloze_fields = notetype.cloze_fields();
let mut has_cloze = false;
let extraneous_cloze = note.fields.iter().enumerate().find_map(|(i, field)| {
if notetype.is_cloze() {
if contains_cloze(field) {
if cloze_fields.contains(&i) {
has_cloze = true;
None
} else {
Some(NoteFieldsState::FieldNotCloze)
}
} else {
None
}
} else if contains_cloze(field) {
Some(NoteFieldsState::NotetypeNotCloze)
} else {
None
}
});
Ok(if let Some(state) = extraneous_cloze {
state
} else if notetype.is_cloze() && !has_cloze {
NoteFieldsState::MissingCloze
} else {
NoteFieldsState::Normal
})
} }
} }

View file

@ -548,6 +548,22 @@ impl Notetype {
pub(crate) fn is_cloze(&self) -> bool { pub(crate) fn is_cloze(&self) -> bool {
matches!(self.config.kind(), NotetypeKind::Cloze) matches!(self.config.kind(), NotetypeKind::Cloze)
} }
/// Return all clozable fields. A field is clozable when it belongs to a cloze
/// notetype and a 'cloze' filter is applied to it in the template.
pub(crate) fn cloze_fields(&self) -> HashSet<usize> {
if !self.is_cloze() {
HashSet::new()
} else if let Some((Some(front), _)) = self.parsed_templates().get(0) {
front
.cloze_fields()
.iter()
.filter_map(|name| self.get_field_ord(name))
.collect()
} else {
HashSet::new()
}
}
} }
/// True if the slice is empty or either template of the first tuple doesn't have a cloze field. /// True if the slice is empty or either template of the first tuple doesn't have a cloze field.

View file

@ -28,7 +28,8 @@
padding: 0; padding: 0;
} }
#dupes { #dupes,
#cloze-hint {
position: sticky; position: sticky;
bottom: 0; bottom: 0;

View file

@ -144,6 +144,10 @@ export function setBackgrounds(cols: ("dupe" | "")[]): void {
.classList.toggle("is-inactive", !cols.includes("dupe")); .classList.toggle("is-inactive", !cols.includes("dupe"));
} }
export function setClozeHint(cloze_hint: string): void {
document.getElementById("cloze-hint")!.innerHTML = cloze_hint;
}
export function setFonts(fonts: [string, number, boolean][]): void { export function setFonts(fonts: [string, number, boolean][]): void {
forEditorField( forEditorField(
fonts, fonts,