diff --git a/proto/backend.proto b/proto/backend.proto index f48a7453e..32ac11817 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -92,6 +92,7 @@ message BackendInput { Empty deck_tree_legacy = 77; FieldNamesForNotesIn field_names_for_notes = 78; FindAndReplaceIn find_and_replace = 79; + AfterNoteUpdatesIn after_note_updates = 80; } } @@ -163,6 +164,7 @@ message BackendOutput { bytes deck_tree_legacy = 77; FieldNamesForNotesOut field_names_for_notes = 78; uint32 find_and_replace = 79; + Empty after_note_updates = 80; BackendError error = 2047; } @@ -732,3 +734,9 @@ message FindAndReplaceIn { bool match_case = 5; string field_name = 6; } + +message AfterNoteUpdatesIn { + repeated int64 nids = 1; + bool mark_notes_modified = 2; + bool generate_cards = 3; +} diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index b6c73234a..3ad11cd83 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -14,7 +14,7 @@ import time import traceback import unicodedata import weakref -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union +from typing import Any, Iterable, List, Optional, Sequence, Tuple, Union import anki.find import anki.latex # sets up hook @@ -34,16 +34,7 @@ from anki.rsbackend import TR, RustBackend from anki.sched import Scheduler as V1Scheduler from anki.schedv2 import Scheduler as V2Scheduler from anki.tags import TagManager -from anki.utils import ( - devMode, - fieldChecksum, - ids2str, - intTime, - joinFields, - maxID, - splitFields, - stripHTMLMedia, -) +from anki.utils import devMode, ids2str, intTime, joinFields # this is initialized by storage.Collection @@ -359,81 +350,6 @@ crt=?, mod=?, scm=?, dty=?, usn=?, ls=?""", ok.append(t) return ok - def genCards(self, nids: List[int]) -> List[int]: - "Generate cards for non-empty templates, return ids to remove." - # build map of (nid,ord) so we don't create dupes - snids = ids2str(nids) - have: Dict[int, Dict[int, int]] = {} - dids: Dict[int, Optional[int]] = {} - dues: Dict[int, int] = {} - for id, nid, ord, did, due, odue, odid, type in self.db.execute( - "select id, nid, ord, did, due, odue, odid, type from cards where nid in " - + snids - ): - # existing cards - if nid not in have: - have[nid] = {} - have[nid][ord] = id - # if in a filtered deck, add new cards to original deck - if odid != 0: - did = odid - # and their dids - if nid in dids: - if dids[nid] and dids[nid] != did: - # cards are in two or more different decks; revert to - # model default - dids[nid] = None - else: - # first card or multiple cards in same deck - dids[nid] = did - # save due - if odid != 0: - due = odue - if nid not in dues and type == 0: - # Add due to new card only if it's the due of a new sibling - dues[nid] = due - # build cards for each note - data = [] - ts = maxID(self.db) - now = intTime() - rem = [] - usn = self.usn() - for nid, mid, flds in self.db.execute( - "select id, mid, flds from notes where id in " + snids - ): - model = self.models.get(mid) - assert model - avail = self.models.availOrds(model, flds) - did = dids.get(nid) or model["did"] - due = dues.get(nid) - # add any missing cards - for t in self._tmplsFromOrds(model, avail): - doHave = nid in have and t["ord"] in have[nid] - if not doHave: - # check deck is not a cram deck - did = t["did"] or did - if self.decks.isDyn(did): - did = 1 - # if the deck doesn't exist, use default instead - did = self.decks.get(did)["id"] - # use sibling due# if there is one, else use a new id - if due is None: - due = self.nextID("pos") - data.append((ts, nid, did, t["ord"], now, usn, due)) - ts += 1 - # note any cards that need removing - if nid in have: - for ord, id in list(have[nid].items()): - if ord not in avail: - rem.append(id) - # bulk update - self.db.executemany( - """ -insert into cards values (?,?,?,?,?,?,0,0,?,0,0,0,0,0,0,0,0,"")""", - data, - ) - return rem - # type is no longer used def previewCards( self, note: Note, type: int = 0, did: Optional[int] = None @@ -535,31 +451,24 @@ select id from notes where id in %s and id not in (select nid from cards)""" print("emptyCids() will go away") return [] - # Field checksums and sorting fields + # Card generation & field checksums/sort fields ########################################################################## - def _fieldData(self, snids: str) -> Any: - return self.db.execute("select id, mid, flds from notes where id in " + snids) + def after_note_updates(self, nids: List[int], mark_modified: bool, generate_cards: bool = True) -> None: + self.backend.after_note_updates( + nids=nids, generate_cards=generate_cards, mark_notes_modified=mark_modified + ) + + # legacy def updateFieldCache(self, nids: List[int]) -> None: - "Update field checksums and sort cache, after find&replace, etc." - snids = ids2str(nids) - r = [] - for (nid, mid, flds) in self._fieldData(snids): - fields = splitFields(flds) - model = self.models.get(mid) - if not model: - # note points to invalid model - continue - r.append( - ( - stripHTMLMedia(fields[self.models.sortIdx(model)]), - fieldChecksum(fields[0]), - nid, - ) - ) - # apply, relying on calling code to bump usn+mod - self.db.executemany("update notes set sfld=?, csum=? where id=?", r) + self.after_note_updates(nids, mark_modified=False, generate_cards=False) + + # this also updates field cache + def genCards(self, nids: List[int]) -> List[int]: + self.after_note_updates(nids, mark_modified=False, generate_cards=True) + # previously returned empty cards, no longer does + return [] # Finding cards ########################################################################## @@ -909,7 +818,7 @@ select id from cards where odid > 0 and did in %s""" self.tags.registerNotes() # field cache for m in self.models.all(): - self.updateFieldCache(self.models.nids(m)) + self.after_note_updates(self.models.nids(m), mark_modified=False, generate_cards=False) # new cards can't have a due position > 32 bits, so wrap items over # 2 million back to 1 million self.db.execute( diff --git a/pylib/anki/importing/noteimp.py b/pylib/anki/importing/noteimp.py index 69d6a306a..13aabeddc 100644 --- a/pylib/anki/importing/noteimp.py +++ b/pylib/anki/importing/noteimp.py @@ -197,11 +197,8 @@ class NoteImporter(Importer): firsts[fld0] = True self.addNew(new) self.addUpdates(updates) - # make sure to update sflds, etc - self.col.updateFieldCache(self._ids) - # generate cards - if self.col.genCards(self._ids): - self.log.insert(0, _("Empty cards found. Please run Tools>Empty Cards.")) + # generate cards + update field cache + self.col.after_note_updates(self._ids, mark_modified=False) # apply scheduling updates self.updateCards() # we randomize or order here, to ensure that siblings diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 6d05552c1..f0500b717 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -448,7 +448,7 @@ class ModelManager: self._changeNotes(nids, newModel, fmap) if cmap: self._changeCards(nids, m, newModel, cmap) - self.col.genCards(nids) + self.col.after_note_updates(nids, mark_modified=True) def _changeNotes( self, nids: List[int], newModel: NoteType, map: Dict[int, Union[None, int]] @@ -470,7 +470,6 @@ class ModelManager: self.col.db.executemany( "update notes set flds=?,mid=?,mod=?,usn=? where id = ?", d ) - self.col.updateFieldCache(nids) def _changeCards( self, @@ -518,7 +517,7 @@ class ModelManager: # Required field/text cache ########################################################################## - # fixme: genCards(), clayout, importing, cards.isEmpty + # fixme: clayout, importing, cards.isEmpty def availOrds(self, m: NoteType, flds: str) -> List: "Given a joined field string, return available template ordinals." if m["type"] == MODEL_CLOZE: diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index ef964e8e7..cf33fda42 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -778,6 +778,20 @@ class RustBackend: release_gil=True, ).find_and_replace + def after_note_updates( + self, nids: List[int], generate_cards: bool, mark_notes_modified: bool + ) -> None: + self._run_command( + pb.BackendInput( + after_note_updates=pb.AfterNoteUpdatesIn( + nids=nids, + generate_cards=generate_cards, + mark_notes_modified=mark_notes_modified, + ) + ), + release_gil=True + ) + def translate_string_in( key: TR, **kwargs: Union[str, int, float] diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 3daed136d..f3c085c9f 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -366,6 +366,9 @@ impl Backend { OValue::FieldNamesForNotes(self.field_names_for_notes(input)?) } Value::FindAndReplace(input) => OValue::FindAndReplace(self.find_and_replace(input)?), + Value::AfterNoteUpdates(input) => { + OValue::AfterNoteUpdates(self.after_note_updates(input)?) + } }) } @@ -1094,6 +1097,21 @@ impl Backend { col.find_and_replace(FindReplaceContext::new(nids, &search, &repl, field_name)?) }) } + + fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result { + self.with_col(|col| { + col.transact(None, |col| { + let nids: Vec<_> = input.nids.into_iter().map(NoteID).collect(); + col.after_note_updates( + &nids, + col.usn()?, + input.generate_cards, + input.mark_notes_modified, + )?; + Ok(pb::Empty {}) + }) + }) + } } fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue { diff --git a/rslib/src/findreplace.rs b/rslib/src/findreplace.rs index cfa9cf50f..9e2e0126a 100644 --- a/rslib/src/findreplace.rs +++ b/rslib/src/findreplace.rs @@ -77,7 +77,7 @@ impl Collection { } } if changed { - self.update_note_inner(&genctx, &mut note)?; + self.update_note_inner_generating_cards(&genctx, &mut note, true)?; total_changed += 1; } } diff --git a/rslib/src/media/check.rs b/rslib/src/media/check.rs index 5f33471b3..d412cd36c 100644 --- a/rslib/src/media/check.rs +++ b/rslib/src/media/check.rs @@ -404,7 +404,8 @@ where &self.mgr.media_folder, )? { // note was modified, needs saving - note.prepare_for_update(nt, Some(usn))?; + note.prepare_for_update(nt)?; + note.set_modified(usn); self.ctx.storage.update_note(¬e)?; collection_modified = true; } diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index f80592a46..d8fcf2f68 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -12,6 +12,7 @@ use crate::{ timestamp::TimestampSecs, types::Usn, }; +use itertools::Itertools; use num_integer::Integer; use std::{collections::HashSet, convert::TryInto}; @@ -63,8 +64,8 @@ impl Note { Ok(()) } - /// Prepare note for saving to the database. If usn is provided, mtime will be bumped. - pub fn prepare_for_update(&mut self, nt: &NoteType, usn: Option) -> Result<()> { + /// Prepare note for saving to the database. Does not mark it as modified. + pub fn prepare_for_update(&mut self, nt: &NoteType) -> Result<()> { assert!(nt.id == self.ntid); if nt.fields.len() != self.fields.len() { return Err(AnkiError::invalid_input(format!( @@ -88,13 +89,14 @@ impl Note { }; self.sort_field = Some(sort_field.into()); self.checksum = Some(checksum); - if let Some(usn) = usn { - self.mtime = TimestampSecs::now(); - self.usn = usn; - } Ok(()) } + pub(crate) fn set_modified(&mut self, usn: Usn) { + self.mtime = TimestampSecs::now(); + self.usn = usn; + } + pub(crate) fn nonempty_fields<'a>(&self, fields: &'a [NoteField]) -> HashSet<&'a str> { self.fields .iter() @@ -193,7 +195,8 @@ impl Collection { did: DeckID, ) -> Result<()> { self.canonify_note_tags(note, ctx.usn)?; - note.prepare_for_update(&ctx.notetype, Some(ctx.usn))?; + note.prepare_for_update(&ctx.notetype)?; + note.set_modified(ctx.usn); self.storage.add_note(note)?; self.generate_cards_for_new_note(ctx, note, did) } @@ -204,21 +207,33 @@ impl Collection { .get_notetype(note.ntid)? .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; let ctx = CardGenContext::new(&nt, col.usn()?); - col.update_note_inner(&ctx, note) + col.update_note_inner_generating_cards(&ctx, note, true) }) } - pub(crate) fn update_note_inner( + pub(crate) fn update_note_inner_generating_cards( &mut self, ctx: &CardGenContext, note: &mut Note, + mark_note_modified: bool, ) -> Result<()> { - self.canonify_note_tags(note, ctx.usn)?; - note.prepare_for_update(ctx.notetype, Some(ctx.usn))?; - self.generate_cards_for_existing_note(ctx, note)?; - self.storage.update_note(note)?; + self.update_note_inner_without_cards(note, ctx.notetype, ctx.usn, mark_note_modified)?; + self.generate_cards_for_existing_note(ctx, note) + } - Ok(()) + pub(crate) fn update_note_inner_without_cards( + &mut self, + note: &mut Note, + nt: &NoteType, + usn: Usn, + mark_note_modified: bool, + ) -> Result<()> { + self.canonify_note_tags(note, usn)?; + note.prepare_for_update(nt)?; + if mark_note_modified { + note.set_modified(usn); + } + self.storage.update_note(note) } /// Remove a note. Cards must already have been deleted. @@ -230,6 +245,42 @@ impl Collection { } Ok(()) } + + /// Update cards and field cache after notes modified externally. + /// If gencards is false, skip card generation. + pub(crate) fn after_note_updates( + &mut self, + nids: &[NoteID], + usn: Usn, + generate_cards: bool, + mark_notes_modified: bool, + ) -> Result<()> { + let nids_by_notetype = self.storage.note_ids_by_notetype(nids)?; + for (ntid, group) in &nids_by_notetype.into_iter().group_by(|tup| tup.0) { + let nt = self + .get_notetype(ntid)? + .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; + let genctx = CardGenContext::new(&nt, usn); + for (_, nid) in group { + let mut note = self.storage.get_note(nid)?.unwrap(); + if generate_cards { + self.update_note_inner_generating_cards( + &genctx, + &mut note, + mark_notes_modified, + )?; + } else { + self.update_note_inner_without_cards( + &mut note, + &genctx.notetype, + usn, + mark_notes_modified, + )?; + } + } + } + Ok(()) + } } #[cfg(test)] diff --git a/rslib/src/notetype/schemachange.rs b/rslib/src/notetype/schemachange.rs index f073a2566..de8db24b8 100644 --- a/rslib/src/notetype/schemachange.rs +++ b/rslib/src/notetype/schemachange.rs @@ -63,7 +63,7 @@ impl Collection { let nids = self.search_notes_only(&format!("mid:{}", nt.id))?; for nid in nids { let mut note = self.storage.get_note(nid)?.unwrap(); - note.prepare_for_update(nt, None)?; + note.prepare_for_update(nt)?; self.storage.update_note(¬e)?; } } else { @@ -92,7 +92,8 @@ impl Collection { }) .map(Into::into) .collect(); - note.prepare_for_update(nt, Some(usn))?; + note.prepare_for_update(nt)?; + note.set_modified(usn); self.storage.update_note(¬e)?; } Ok(())