diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py index 2741bbc47..176002a3c 100644 --- a/pylib/anki/notes.py +++ b/pylib/anki/notes.py @@ -14,7 +14,7 @@ from anki.consts import MODEL_STD from anki.models import NotetypeDict, NotetypeId, TemplateDict from anki.utils import joinFields -DuplicateOrEmptyResult = _pb.NoteIsDuplicateOrEmptyOut.State +NoteFieldsCheckResult = DuplicateOrEmptyResult = _pb.NoteFieldsCheckOut.State # types NoteId = NewType("NoteId", int) @@ -190,12 +190,10 @@ class Note: addTag = add_tag delTag = remove_tag - # Unique/duplicate check + # Unique/duplicate/cloze check ################################################## - def duplicate_or_empty(self) -> DuplicateOrEmptyResult.V: - return self.col._backend.note_is_duplicate_or_empty( - self._to_backend_note() - ).state + def fields_check(self) -> NoteFieldsCheckResult.V: + return self.col._backend.note_fields_check(self._to_backend_note()).state - dupeOrEmpty = duplicate_or_empty + dupeOrEmpty = duplicate_or_empty = fields_check diff --git a/rslib/backend.proto b/rslib/backend.proto index 0ebfb68f3..521432dfe 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -174,7 +174,7 @@ service NotesService { rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChangesWithCount); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); - rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut); + rpc NoteFieldsCheck(Note) returns (NoteFieldsCheckOut); rpc CardsOfNote(NoteId) returns (CardIds); } @@ -1231,11 +1231,14 @@ message ReparentDecksIn { int64 new_parent = 2; } -message NoteIsDuplicateOrEmptyOut { +message NoteFieldsCheckOut { enum State { NORMAL = 0; EMPTY = 1; DUPLICATE = 2; + MISSING_CLOZE = 3; + NOTETYPE_NOT_CLOZE = 4; + FIELD_NOT_CLOZE = 5; } State state = 1; } diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index 673c32058..59025c1f8 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -120,11 +120,11 @@ impl NotesService for Backend { }) } - fn note_is_duplicate_or_empty(&self, input: pb::Note) -> Result { + fn note_fields_check(&self, input: pb::Note) -> Result { let note: Note = input.into(); self.with_col(|col| { - col.note_is_duplicate_or_empty(¬e) - .map(|r| pb::NoteIsDuplicateOrEmptyOut { state: r as i32 }) + col.note_fields_check(¬e) + .map(|r| pb::NoteFieldsCheckOut { state: r as i32 }) }) } diff --git a/rslib/src/cloze.rs b/rslib/src/cloze.rs index 01f219ed3..dcd064c2f 100644 --- a/rslib/src/cloze.rs +++ b/rslib/src/cloze.rs @@ -139,6 +139,10 @@ pub fn expand_clozes_to_reveal_latex(text: &str) -> String { buf } +pub(crate) fn contains_cloze(text: &str) -> bool { + CLOZE.is_match(text) +} + pub fn cloze_numbers_in_string(html: &str) -> HashSet { let mut set = HashSet::with_capacity(4); add_cloze_numbers_in_string(html, &mut set); diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 8ce7ef894..d1ddffbf6 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -14,7 +14,8 @@ use num_integer::Integer; use crate::{ 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, define_newtype, error::{AnkiError, Result}, @@ -529,36 +530,72 @@ impl Collection { Ok(changed_notes) } - pub(crate) fn note_is_duplicate_or_empty(&self, note: &Note) -> Result { - if let Some(field1) = note.fields.get(0) { + /// Check if the note's first field is empty or a duplicate. Then for cloze + /// 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 { + if let Some(text) = note.fields.get(0) { let field1 = if self.get_config_bool(BoolKey::NormalizeNoteText) { - normalize_to_nfc(field1) + normalize_to_nfc(text) } else { - field1.into() + text.into() }; let stripped = strip_html_preserving_media_filenames(&field1); if stripped.trim().is_empty() { - Ok(DuplicateState::Empty) + Ok(NoteFieldsState::Empty) + } else if self.is_duplicate(&stripped, note)? { + Ok(NoteFieldsState::Duplicate) } else { - let csum = field_checksum(&stripped); - let have_dupe = self - .storage - .note_fields_by_checksum(note.notetype_id, csum)? - .into_iter() - .any(|(nid, field)| { - nid != note.id && strip_html_preserving_media_filenames(&field) == stripped - }); - - if have_dupe { - Ok(DuplicateState::Duplicate) - } else { - Ok(DuplicateState::Normal) - } + self.field_cloze_check(note) } } else { - Ok(DuplicateState::Empty) + Ok(NoteFieldsState::Empty) } } + + fn is_duplicate(&self, first_field: &str, note: &Note) -> Result { + 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 { + 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 + }) + } } /// The existing note pulled from the DB will have sfld and csum set, but the diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index 13aa16cc6..ab5590a12 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -548,6 +548,22 @@ impl Notetype { pub(crate) fn is_cloze(&self) -> bool { 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 { + 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.