From 6f9e1d62ec86395ec5bbb36884c225f4d0139c40 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 13 Mar 2021 14:56:32 +1000 Subject: [PATCH 01/33] deck deletion in deck list was not resetting state --- qt/aqt/deckbrowser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index e80a7953f..d74b4b822 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -309,6 +309,7 @@ class DeckBrowser: return self.mw.col.decks.remove([did]) def on_done(fut: Future) -> None: + self.mw.reset() self.mw.update_undo_actions() self.show() tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result())) From e364b36dc462fa9aed14ff7cb420080f9de2afdd Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 13 Mar 2021 14:52:44 +1000 Subject: [PATCH 02/33] experiment with preserving search when resetting Up until now, we've been forcing a new search whenever reset is called. The primary reason was that the card list display routines did not expect a card or note to have been removed. By updating the model to show "(deleted)" when a card or note is missing, we no longer have to repeat the search. This has a few advantages: - Searches, especially complex ones, can be slow to execute. When we perform them after every operation like a delete, it can make Anki feel sluggish. - The fact that notes have been deleted becomes more obvious - some users found it easy to miss the "deleted" pop-up in the past. This change does not just affect deletions, as many other operations trigger a reset as well. In the past, when using 'set due date' in the review screen for example, it caused an ugly flicker in the browser screen, and could be slow when the current search couldn't be quickly redone. The disadvantage of this approach is that the displayed content may not reflect the specified search, which has the potential to be confusing. But if that turns out to be a problem, it could be (partly) alleviated by displaying a refresh button next to the search bar when the search may need to be refreshed. Feedback welcome! --- ftl/core/browsing.ftl | 1 + qt/aqt/browser.py | 52 +++++++++++++++++-------------------------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index cb676d53c..2a2e14b85 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -140,3 +140,4 @@ browsing-edited-today = Edited browsing-sidebar-due-today = Due browsing-sidebar-untagged = Untagged browsing-sidebar-overdue = Overdue +browsing-row-deleted = (deleted) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 0ff9db88f..4711e4732 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -14,7 +14,7 @@ import aqt.forms from anki.cards import Card from anki.collection import Collection, Config, SearchNode from anki.consts import * -from anki.errors import InvalidInput +from anki.errors import InvalidInput, NotFoundError from anki.lang import without_unicode_isolation from anki.models import NoteType from anki.notes import Note @@ -92,10 +92,15 @@ class DataModel(QAbstractTableModel): self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} - def getCard(self, index: QModelIndex) -> Card: + def getCard(self, index: QModelIndex) -> Optional[Card]: id = self.cards[index.row()] if not id in self.cardObjs: - self.cardObjs[id] = self.col.getCard(id) + try: + card = self.col.getCard(id) + except NotFoundError: + # deleted + card = None + self.cardObjs[id] = card return self.cardObjs[id] def refreshNote(self, note: Note) -> None: @@ -127,6 +132,8 @@ class DataModel(QAbstractTableModel): if self.activeCols[index.column()] not in ("question", "answer", "noteFld"): return c = self.getCard(index) + if not c: + return t = c.template() if not t.get("bfont"): return @@ -287,6 +294,8 @@ class DataModel(QAbstractTableModel): col = index.column() type = self.columnType(col) c = self.getCard(index) + if not c: + return tr(TR.BROWSING_ROW_DELETED) if type == "question": return self.question(c) elif type == "answer": @@ -399,12 +408,9 @@ class StatusDelegate(QItemDelegate): def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: - try: - c = self.model.getCard(index) - except: - # in the the middle of a reset; return nothing so this row is not - # rendered until we have a chance to reset the model - return + c = self.model.getCard(index) + if not c: + return QItemDelegate.paint(self, painter, option, index) if self.model.isRTL(index): option.direction = Qt.RightToLeft @@ -766,8 +772,7 @@ class Browser(QMainWindow): def onReset(self) -> None: self.sidebar.refresh() - self.editor.setNote(None) - self.search() + self.model.reset() # Table view & editor ###################################################################### @@ -831,10 +836,11 @@ QTableView {{ gridline-color: {grid} }} return update = self.updateTitle() show = self.model.cards and update == 1 - self.form.splitter.widget(1).setVisible(bool(show)) idx = self.form.tableView.selectionModel().currentIndex() if idx.isValid(): self.card = self.model.getCard(idx) + show = show and self.card is not None + self.form.splitter.widget(1).setVisible(bool(show)) if not show: self.editor.setNote(None) @@ -1172,32 +1178,14 @@ where id in %s""" if not nids: return - # figure out where to place the cursor after the deletion - current_row = self.form.tableView.selectionModel().currentIndex().row() - selected_rows = [ - i.row() for i in self.form.tableView.selectionModel().selectedRows() - ] - if min(selected_rows) < current_row < max(selected_rows): - # last selection in middle; place one below last selected item - move = sum(1 for i in selected_rows if i > current_row) - new_row = current_row - move - elif max(selected_rows) <= current_row: - # last selection at bottom; place one below bottommost selection - new_row = max(selected_rows) - len(nids) + 1 - else: - # last selection at top; place one above topmost selection - new_row = min(selected_rows) - 1 + # select the next card if there is one + self._onNextCard() def do_remove() -> None: self.col.remove_notes(nids) def on_done(fut: Future) -> None: fut.result() - self.search() - if len(self.model.cards): - row = min(new_row, len(self.model.cards) - 1) - row = max(row, 0) - self.model.focusedCard = self.model.cards[row] tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))) self.perform_op(do_remove, on_done, reset_model=True) From 8fc43956c2b011125ed3c1043d3428788a3c9ae5 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 13 Mar 2021 16:27:08 +1000 Subject: [PATCH 03/33] move collection mtime bump into backend Fixes the following issue: - some code directly modifies the database, causing modified_in_python to be set to true - an undoable operation is run, which calls autosave() at the end - autosave() notices there's an undoable operation, and commits immediately - because modified_in_python was true, col.mtime was bumped in Python - that invalidated the undo queue, preventing the operation from being undone --- pylib/anki/collection.py | 14 ++++---------- rslib/src/backend/dbproxy.rs | 12 ++++++++---- rslib/src/collection.rs | 5 ++++- rslib/src/storage/sqlite.rs | 2 +- rslib/src/undo/mod.rs | 5 +++++ 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 098b69854..c3113fa9b 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -195,23 +195,17 @@ class Collection: flush = setMod - def modified_after_begin(self) -> bool: + def modified_by_backend(self) -> bool: # Until we can move away from long-running transactions, the Python - # code needs to know if transaction should be committed, so we need + # code needs to know if the transaction should be committed, so we need # to check if the backend updated the modification time. return self.db.last_begin_at != self.mod def save(self, name: Optional[str] = None, trx: bool = True) -> None: "Flush, commit DB, and take out another write lock if trx=True." # commit needed? - if self.db.modified_in_python or self.modified_after_begin(): - if self.db.modified_in_python: - self.db.execute("update col set mod = ?", intTime(1000)) - self.db.modified_in_python = False - else: - # modifications made by the backend will have already bumped - # mtime - pass + if self.db.modified_in_python or self.modified_by_backend(): + self.db.modified_in_python = False self.db.commit() if trx: self.db.begin() diff --git a/rslib/src/backend/dbproxy.rs b/rslib/src/backend/dbproxy.rs index 49207d334..d6d2b6f41 100644 --- a/rslib/src/backend/dbproxy.rs +++ b/rslib/src/backend/dbproxy.rs @@ -75,7 +75,7 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result { - maybe_clear_undo(col, &sql); + update_state_after_modification(col, &sql); if first_row_only { db_query_row(&col.storage, &sql, &args)? } else { @@ -87,6 +87,10 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result { + if col.state.modified_by_dbproxy { + col.storage.set_modified()?; + col.state.modified_by_dbproxy = false; + } col.storage.commit_trx()?; DBResult::None } @@ -96,17 +100,17 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result { - maybe_clear_undo(col, &sql); + update_state_after_modification(col, &sql); db_execute_many(&col.storage, &sql, &args)? } }; Ok(serde_json::to_vec(&resp)?) } -fn maybe_clear_undo(col: &mut Collection, sql: &str) { +fn update_state_after_modification(col: &mut Collection, sql: &str) { if !is_dql(sql) { println!("clearing undo+study due to {}", sql); - col.discard_undo_and_study_queues(); + col.update_state_after_dbproxy_modification(); } } diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index bb701f9b6..2e664b087 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -65,6 +65,9 @@ pub struct CollectionState { pub(crate) notetype_cache: HashMap>, pub(crate) deck_cache: HashMap>, pub(crate) card_queues: Option, + /// True if legacy Python code has executed SQL that has modified the + /// database, requiring modification time to be bumped. + pub(crate) modified_by_dbproxy: bool, } pub struct Collection { @@ -92,7 +95,7 @@ impl Collection { let mut res = func(self); if res.is_ok() { - if let Err(e) = self.storage.mark_modified() { + if let Err(e) = self.storage.set_modified() { res = Err(e); } else if let Err(e) = self.storage.commit_rust_trx() { res = Err(e); diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index bbc0e0ff0..9d4d20c7e 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -257,7 +257,7 @@ impl SqliteStorage { ////////////////////////////////////////// - pub(crate) fn mark_modified(&self) -> Result<()> { + pub(crate) fn set_modified(&self) -> Result<()> { self.set_modified_time(TimestampMillis::now()) } diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 339db290a..55a8d2449 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -174,6 +174,11 @@ impl Collection { self.clear_study_queues(); } + pub(crate) fn update_state_after_dbproxy_modification(&mut self) { + self.discard_undo_and_study_queues(); + self.state.modified_by_dbproxy = true; + } + #[inline] pub(crate) fn save_undo(&mut self, item: impl Into) { self.state.undo.save(item.into()); From 90526c61cd2804fb1084c83c1948c9bc73a7067e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 13 Mar 2021 20:10:03 +1000 Subject: [PATCH 04/33] move ops.rs out of undo/ --- rslib/src/backend/card.rs | 2 +- rslib/src/backend/notes.rs | 2 +- rslib/src/card/mod.rs | 13 +++--- rslib/src/collection.rs | 2 +- rslib/src/config/undo.rs | 2 +- rslib/src/decks/mod.rs | 16 +++---- rslib/src/lib.rs | 1 + rslib/src/notes/mod.rs | 12 ++---- rslib/src/notes/undo.rs | 2 +- rslib/src/ops.rs | 57 +++++++++++++++++++++++++ rslib/src/preferences.rs | 8 ++-- rslib/src/prelude.rs | 2 +- rslib/src/scheduler/answering/mod.rs | 4 +- rslib/src/scheduler/bury_and_suspend.rs | 6 +-- rslib/src/scheduler/new.rs | 4 +- rslib/src/scheduler/reviews.rs | 5 +-- rslib/src/tags/mod.rs | 4 +- rslib/src/undo/mod.rs | 41 +++++++++--------- rslib/src/undo/ops.rs | 57 ------------------------- 19 files changed, 114 insertions(+), 126 deletions(-) create mode 100644 rslib/src/ops.rs delete mode 100644 rslib/src/undo/ops.rs diff --git a/rslib/src/backend/card.rs b/rslib/src/backend/card.rs index 8172f3f56..83c2d6eec 100644 --- a/rslib/src/backend/card.rs +++ b/rslib/src/backend/card.rs @@ -26,7 +26,7 @@ impl CardsService for Backend { let op = if input.skip_undo_entry { None } else { - Some(UndoableOpKind::UpdateCard) + Some(Op::UpdateCard) }; let mut card: Card = input.card.ok_or(AnkiError::NotFound)?.try_into()?; col.update_card_with_op(&mut card, op) diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index 5c82ad5a4..bb5331a69 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -51,7 +51,7 @@ impl NotesService for Backend { let op = if input.skip_undo_entry { None } else { - Some(UndoableOpKind::UpdateNote) + Some(Op::UpdateNote) }; let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into(); col.update_note_with_op(&mut note, op) diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index dec89213a..85b8ffc63 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -3,12 +3,13 @@ pub(crate) mod undo; +use crate::define_newtype; use crate::err::{AnkiError, Result}; use crate::notes::NoteID; use crate::{ - collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn, + collection::Collection, config::SchedulerVersion, prelude::*, timestamp::TimestampSecs, + types::Usn, }; -use crate::{define_newtype, undo::UndoableOpKind}; use crate::{deckconf::DeckConf, decks::DeckID}; use num_enum::TryFromPrimitive; @@ -139,11 +140,7 @@ impl Card { } impl Collection { - pub(crate) fn update_card_with_op( - &mut self, - card: &mut Card, - op: Option, - ) -> Result<()> { + pub(crate) fn update_card_with_op(&mut self, card: &mut Card, op: Option) -> Result<()> { let existing = self.storage.get_card(card.id)?.ok_or(AnkiError::NotFound)?; self.transact(op, |col| col.update_card_inner(card, existing, col.usn()?)) } @@ -211,7 +208,7 @@ impl Collection { self.storage.set_search_table_to_card_ids(cards, false)?; let sched = self.scheduler_version(); let usn = self.usn()?; - self.transact(Some(UndoableOpKind::SetDeck), |col| { + self.transact(Some(Op::SetDeck), |col| { for mut card in col.storage.all_searched_cards()? { if card.deck_id == deck_id { continue; diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 2e664b087..4f1c15692 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -85,7 +85,7 @@ pub struct Collection { impl Collection { /// Execute the provided closure in a transaction, rolling back if /// an error is returned. - pub(crate) fn transact(&mut self, op: Option, func: F) -> Result + pub(crate) fn transact(&mut self, op: Option, func: F) -> Result where F: FnOnce(&mut Collection) -> Result, { diff --git a/rslib/src/config/undo.rs b/rslib/src/config/undo.rs index 1aec6d075..3c11c300a 100644 --- a/rslib/src/config/undo.rs +++ b/rslib/src/config/undo.rs @@ -71,7 +71,7 @@ mod test { fn undo() -> Result<()> { let mut col = open_test_collection(); // the op kind doesn't matter, we just need undo enabled - let op = Some(UndoableOpKind::Bury); + let op = Some(Op::Bury); // test key let key = BoolKey::NormalizeNoteText; diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index f0e80a4d7..2a9581fda 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -10,16 +10,14 @@ pub use crate::backend_proto::{ deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto, DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck, }; -use crate::{ - backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images, - undo::UndoableOpKind, -}; +use crate::{backend_proto as pb, markdown::render_markdown, text::sanitize_html_no_images}; use crate::{ collection::Collection, deckconf::DeckConfID, define_newtype, err::{AnkiError, Result}, i18n::TR, + prelude::*, text::normalize_to_nfc, timestamp::TimestampSecs, types::Usn, @@ -283,7 +281,7 @@ impl Collection { return Err(AnkiError::invalid_input("deck to add must have id 0")); } - self.transact(Some(UndoableOpKind::AddDeck), |col| { + self.transact(Some(Op::AddDeck), |col| { let usn = col.usn()?; col.prepare_deck_for_update(deck, usn)?; deck.set_modified(usn); @@ -293,14 +291,14 @@ impl Collection { } pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> { - self.transact(Some(UndoableOpKind::UpdateDeck), |col| { + self.transact(Some(Op::UpdateDeck), |col| { let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?; col.update_deck_inner(deck, existing_deck, col.usn()?) }) } pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<()> { - self.transact(Some(UndoableOpKind::RenameDeck), |col| { + self.transact(Some(Op::RenameDeck), |col| { let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?; let mut deck = existing_deck.clone(); deck.name = human_deck_name_to_native(new_human_name); @@ -468,7 +466,7 @@ impl Collection { pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result { let mut card_count = 0; - self.transact(Some(UndoableOpKind::RemoveDeck), |col| { + self.transact(Some(Op::RemoveDeck), |col| { let usn = col.usn()?; for did in dids { if let Some(deck) = col.storage.get_deck(*did)? { @@ -627,7 +625,7 @@ impl Collection { target: Option, ) -> Result<()> { let usn = self.usn()?; - self.transact(Some(UndoableOpKind::RenameDeck), |col| { + self.transact(Some(Op::RenameDeck), |col| { let target_deck; let mut target_name = None; if let Some(target) = target { diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index c3a138ad0..bcbf847f2 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -24,6 +24,7 @@ mod markdown; pub mod media; pub mod notes; pub mod notetype; +pub mod ops; mod preferences; pub mod prelude; pub mod revlog; diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 18dee8f3f..900ccca52 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -306,7 +306,7 @@ impl Collection { } pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<()> { - self.transact(Some(UndoableOpKind::AddNote), |col| { + self.transact(Some(Op::AddNote), |col| { let nt = col .get_notetype(note.notetype_id)? .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; @@ -335,14 +335,10 @@ impl Collection { #[cfg(test)] pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<()> { - self.update_note_with_op(note, Some(UndoableOpKind::UpdateNote)) + self.update_note_with_op(note, Some(Op::UpdateNote)) } - pub(crate) fn update_note_with_op( - &mut self, - note: &mut Note, - op: Option, - ) -> Result<()> { + pub(crate) fn update_note_with_op(&mut self, note: &mut Note, op: Option) -> Result<()> { let mut existing_note = self.storage.get_note(note.id)?.ok_or(AnkiError::NotFound)?; if !note_differs_from_db(&mut existing_note, note) { // nothing to do @@ -398,7 +394,7 @@ impl Collection { /// Remove provided notes, and any cards that use them. pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result<()> { let usn = self.usn()?; - self.transact(Some(UndoableOpKind::RemoveNote), |col| { + self.transact(Some(Op::RemoveNote), |col| { for nid in nids { let nid = *nid; if let Some(_existing_note) = col.storage.get_note(nid)? { diff --git a/rslib/src/notes/undo.rs b/rslib/src/notes/undo.rs index 02bec7443..c7e42340f 100644 --- a/rslib/src/notes/undo.rs +++ b/rslib/src/notes/undo.rs @@ -96,7 +96,7 @@ impl Collection { op.changes.last() { note.id == before_change.id - && op.kind == UndoableOpKind::UpdateNote + && op.kind == Op::UpdateNote && op.timestamp.elapsed_secs() < 60 } else { false diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs new file mode 100644 index 000000000..47330406c --- /dev/null +++ b/rslib/src/ops.rs @@ -0,0 +1,57 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Op { + AddDeck, + AddNote, + AnswerCard, + Bury, + RemoveDeck, + RemoveNote, + RenameDeck, + ScheduleAsNew, + SetDueDate, + Suspend, + UnburyUnsuspend, + UpdateCard, + UpdateDeck, + UpdateNote, + UpdatePreferences, + UpdateTag, + SetDeck, +} + +impl Op { + pub(crate) fn needs_study_queue_reset(self) -> bool { + self != Op::AnswerCard + } +} + +impl Collection { + pub fn describe_op_kind(&self, op: Op) -> String { + let key = match op { + Op::AddDeck => TR::UndoAddDeck, + Op::AddNote => TR::UndoAddNote, + Op::AnswerCard => TR::UndoAnswerCard, + Op::Bury => TR::StudyingBury, + Op::RemoveDeck => TR::DecksDeleteDeck, + Op::RemoveNote => TR::StudyingDeleteNote, + Op::RenameDeck => TR::ActionsRenameDeck, + Op::ScheduleAsNew => TR::UndoForgetCard, + Op::SetDueDate => TR::ActionsSetDueDate, + Op::Suspend => TR::StudyingSuspend, + Op::UnburyUnsuspend => TR::UndoUnburyUnsuspend, + Op::UpdateCard => TR::UndoUpdateCard, + Op::UpdateDeck => TR::UndoUpdateDeck, + Op::UpdateNote => TR::UndoUpdateNote, + Op::UpdatePreferences => TR::PreferencesPreferences, + Op::UpdateTag => TR::UndoUpdateTag, + Op::SetDeck => TR::BrowsingChangeDeck, + }; + + self.i18n.tr(key).to_string() + } +} diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index e1b4eb80f..ada05b45b 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -10,6 +10,7 @@ use crate::{ collection::Collection, config::BoolKey, err::Result, + prelude::*, scheduler::timing::local_minutes_west_for_stamp, }; @@ -23,10 +24,9 @@ impl Collection { } pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> { - self.transact( - Some(crate::undo::UndoableOpKind::UpdatePreferences), - |col| col.set_preferences_inner(prefs), - ) + self.transact(Some(Op::UpdatePreferences), |col| { + col.set_preferences_inner(prefs) + }) } fn set_preferences_inner( diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs index 2e0d589ac..8b201112a 100644 --- a/rslib/src/prelude.rs +++ b/rslib/src/prelude.rs @@ -11,9 +11,9 @@ pub use crate::{ i18n::{tr_args, tr_strs, I18n, TR}, notes::{Note, NoteID}, notetype::{NoteType, NoteTypeID}, + ops::Op, revlog::RevlogID, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, - undo::UndoableOpKind, }; pub use slog::{debug, Logger}; diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 87b649250..ea9b4f3cd 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -241,9 +241,7 @@ impl Collection { /// Answer card, writing its new state to the database. pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<()> { - self.transact(Some(UndoableOpKind::AnswerCard), |col| { - col.answer_card_inner(answer) - }) + self.transact(Some(Op::AnswerCard), |col| col.answer_card_inner(answer)) } fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> { diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index 43f766bf3..69784344c 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -69,7 +69,7 @@ impl Collection { } pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result<()> { - self.transact(Some(UndoableOpKind::UnburyUnsuspend), |col| { + self.transact(Some(Op::UnburyUnsuspend), |col| { col.storage.set_search_table_to_card_ids(cids, false)?; col.unsuspend_or_unbury_searched_cards() }) @@ -126,8 +126,8 @@ impl Collection { mode: BuryOrSuspendMode, ) -> Result<()> { let op = match mode { - BuryOrSuspendMode::Suspend => UndoableOpKind::Suspend, - BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => UndoableOpKind::Bury, + BuryOrSuspendMode::Suspend => Op::Suspend, + BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury, }; self.transact(Some(op), |col| { col.storage.set_search_table_to_card_ids(cids, false)?; diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index 06691a421..c7cd1583d 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -7,9 +7,9 @@ use crate::{ decks::DeckID, err::Result, notes::NoteID, + prelude::*, search::SortMode, types::Usn, - undo::UndoableOpKind, }; use rand::seq::SliceRandom; use std::collections::{HashMap, HashSet}; @@ -106,7 +106,7 @@ impl Collection { pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result<()> { let usn = self.usn()?; let mut position = self.get_next_card_position(); - self.transact(Some(UndoableOpKind::ScheduleAsNew), |col| { + self.transact(Some(Op::ScheduleAsNew), |col| { col.storage.set_search_table_to_card_ids(cids, true)?; let cards = col.storage.all_searched_cards_in_search_order()?; for mut card in cards { diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index cec735a4e..ecfa8fe1b 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -7,8 +7,7 @@ use crate::{ config::StringKey, deckconf::INITIAL_EASE_FACTOR_THOUSANDS, err::Result, - prelude::AnkiError, - undo::UndoableOpKind, + prelude::*, }; use lazy_static::lazy_static; use rand::distributions::{Distribution, Uniform}; @@ -100,7 +99,7 @@ impl Collection { let today = self.timing_today()?.days_elapsed; let mut rng = rand::thread_rng(); let distribution = Uniform::from(spec.min..=spec.max); - self.transact(Some(UndoableOpKind::SetDueDate), |col| { + self.transact(Some(Op::SetDueDate), |col| { col.storage.set_search_table_to_card_ids(cids, false)?; for mut card in col.storage.all_searched_cards()? { let original = card.clone(); diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 39985dd72..de437b5a9 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -341,7 +341,7 @@ impl Collection { tags: &[Regex], mut repl: R, ) -> Result { - self.transact(Some(UndoableOpKind::UpdateTag), |col| { + self.transact(Some(Op::UpdateTag), |col| { col.transform_notes(nids, |note, _nt| { let mut changed = false; for re in tags { @@ -392,7 +392,7 @@ impl Collection { ) .map_err(|_| AnkiError::invalid_input("invalid regex"))?; - self.transact(Some(UndoableOpKind::UpdateTag), |col| { + self.transact(Some(Op::UpdateTag), |col| { col.transform_notes(nids, |note, _nt| { let mut need_to_add = true; let mut match_count = 0; diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 55a8d2449..917b10cad 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -2,10 +2,9 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod changes; -mod ops; +pub use crate::ops::Op; pub(crate) use changes::UndoableChange; -pub use ops::UndoableOpKind; use crate::backend_proto as pb; use crate::prelude::*; @@ -15,7 +14,7 @@ const UNDO_LIMIT: usize = 30; #[derive(Debug)] pub(crate) struct UndoableOp { - pub kind: UndoableOpKind, + pub kind: Op, pub timestamp: TimestampSecs, pub changes: Vec, } @@ -51,7 +50,7 @@ impl UndoManager { } } - fn begin_step(&mut self, op: Option) { + fn begin_step(&mut self, op: Option) { println!("begin: {:?}", op); if op.is_none() { self.undo_steps.clear(); @@ -88,11 +87,11 @@ impl UndoManager { .unwrap_or(true) } - fn can_undo(&self) -> Option { + fn can_undo(&self) -> Option { self.undo_steps.front().map(|s| s.kind) } - fn can_redo(&self) -> Option { + fn can_redo(&self) -> Option { self.redo_steps.last().map(|s| s.kind) } @@ -102,11 +101,11 @@ impl UndoManager { } impl Collection { - pub fn can_undo(&self) -> Option { + pub fn can_undo(&self) -> Option { self.state.undo.can_undo() } - pub fn can_redo(&self) -> Option { + pub fn can_redo(&self) -> Option { self.state.undo.can_redo() } @@ -156,7 +155,7 @@ impl Collection { } /// If op is None, clears the undo/redo queues. - pub(crate) fn begin_undoable_operation(&mut self, op: Option) { + pub(crate) fn begin_undoable_operation(&mut self, op: Option) { self.state.undo.begin_step(op); } @@ -219,7 +218,7 @@ mod test { // record a few undo steps for i in 3..=4 { - col.transact(Some(UndoableOpKind::UpdateCard), |col| { + col.transact(Some(Op::UpdateCard), |col| { col.get_and_update_card(cid, |card| { card.interval = i; Ok(()) @@ -231,41 +230,41 @@ mod test { } assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 4); - assert_eq!(col.can_undo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_undo(), Some(Op::UpdateCard)); assert_eq!(col.can_redo(), None); // undo a step col.undo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3); - assert_eq!(col.can_undo(), Some(UndoableOpKind::UpdateCard)); - assert_eq!(col.can_redo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_undo(), Some(Op::UpdateCard)); + assert_eq!(col.can_redo(), Some(Op::UpdateCard)); // and again col.undo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 2); assert_eq!(col.can_undo(), None); - assert_eq!(col.can_redo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_redo(), Some(Op::UpdateCard)); // redo a step col.redo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3); - assert_eq!(col.can_undo(), Some(UndoableOpKind::UpdateCard)); - assert_eq!(col.can_redo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_undo(), Some(Op::UpdateCard)); + assert_eq!(col.can_redo(), Some(Op::UpdateCard)); // and another col.redo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 4); - assert_eq!(col.can_undo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_undo(), Some(Op::UpdateCard)); assert_eq!(col.can_redo(), None); // and undo the redo col.undo().unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 3); - assert_eq!(col.can_undo(), Some(UndoableOpKind::UpdateCard)); - assert_eq!(col.can_redo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_undo(), Some(Op::UpdateCard)); + assert_eq!(col.can_redo(), Some(Op::UpdateCard)); // if any action is performed, it should clear the redo queue - col.transact(Some(UndoableOpKind::UpdateCard), |col| { + col.transact(Some(Op::UpdateCard), |col| { col.get_and_update_card(cid, |card| { card.interval = 5; Ok(()) @@ -275,7 +274,7 @@ mod test { }) .unwrap(); assert_eq!(col.storage.get_card(cid).unwrap().unwrap().interval, 5); - assert_eq!(col.can_undo(), Some(UndoableOpKind::UpdateCard)); + assert_eq!(col.can_undo(), Some(Op::UpdateCard)); assert_eq!(col.can_redo(), None); // and any action that doesn't support undoing will clear both queues diff --git a/rslib/src/undo/ops.rs b/rslib/src/undo/ops.rs deleted file mode 100644 index e505b2fa5..000000000 --- a/rslib/src/undo/ops.rs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use crate::prelude::*; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum UndoableOpKind { - AddDeck, - AddNote, - AnswerCard, - Bury, - RemoveDeck, - RemoveNote, - RenameDeck, - ScheduleAsNew, - SetDueDate, - Suspend, - UnburyUnsuspend, - UpdateCard, - UpdateDeck, - UpdateNote, - UpdatePreferences, - UpdateTag, - SetDeck, -} - -impl UndoableOpKind { - pub(crate) fn needs_study_queue_reset(self) -> bool { - self != UndoableOpKind::AnswerCard - } -} - -impl Collection { - pub fn describe_op_kind(&self, op: UndoableOpKind) -> String { - let key = match op { - UndoableOpKind::AddDeck => TR::UndoAddDeck, - UndoableOpKind::AddNote => TR::UndoAddNote, - UndoableOpKind::AnswerCard => TR::UndoAnswerCard, - UndoableOpKind::Bury => TR::StudyingBury, - UndoableOpKind::RemoveDeck => TR::DecksDeleteDeck, - UndoableOpKind::RemoveNote => TR::StudyingDeleteNote, - UndoableOpKind::RenameDeck => TR::ActionsRenameDeck, - UndoableOpKind::ScheduleAsNew => TR::UndoForgetCard, - UndoableOpKind::SetDueDate => TR::ActionsSetDueDate, - UndoableOpKind::Suspend => TR::StudyingSuspend, - UndoableOpKind::UnburyUnsuspend => TR::UndoUnburyUnsuspend, - UndoableOpKind::UpdateCard => TR::UndoUpdateCard, - UndoableOpKind::UpdateDeck => TR::UndoUpdateDeck, - UndoableOpKind::UpdateNote => TR::UndoUpdateNote, - UndoableOpKind::UpdatePreferences => TR::PreferencesPreferences, - UndoableOpKind::UpdateTag => TR::UndoUpdateTag, - UndoableOpKind::SetDeck => TR::BrowsingChangeDeck, - }; - - self.i18n.tr(key).to_string() - } -} From 7fab319dad21481dc1a2c3623ad641a579abc564 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 13 Mar 2021 20:45:24 +1000 Subject: [PATCH 05/33] derive reset scope from last undoable operation --- rslib/backend.proto | 13 +++++++ rslib/src/backend/mod.rs | 1 + rslib/src/backend/ops.rs | 20 ++++++++++ rslib/src/ops.rs | 83 ++++++++++++++++++++++++++++++++++++++++ rslib/src/undo/mod.rs | 6 +++ 5 files changed, 123 insertions(+) create mode 100644 rslib/src/backend/ops.rs diff --git a/rslib/backend.proto b/rslib/backend.proto index 7ffc67010..494cd10f2 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -1442,9 +1442,22 @@ message GetQueuedCardsOut { } } +message StateChanges { + bool card_added = 1; + bool card_modified = 2; + bool note_added = 3; + bool note_modified = 4; + bool deck_added = 5; + bool deck_modified = 6; + bool tag_modified = 7; + bool notetype_modified = 8; + bool preference_modified = 9; +} + message UndoStatus { string undo = 1; string redo = 2; + StateChanges changes = 3; } message DefaultsForAddingIn { diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index a13cb88f9..7c1521260 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -15,6 +15,7 @@ mod i18n; mod media; mod notes; mod notetypes; +mod ops; mod progress; mod scheduler; mod search; diff --git a/rslib/src/backend/ops.rs b/rslib/src/backend/ops.rs new file mode 100644 index 000000000..d1d99eaa1 --- /dev/null +++ b/rslib/src/backend/ops.rs @@ -0,0 +1,20 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, ops::StateChanges}; + +impl From for pb::StateChanges { + fn from(c: StateChanges) -> Self { + pb::StateChanges { + card_added: c.card_added, + card_modified: c.card_modified, + note_added: c.note_added, + note_modified: c.note_modified, + deck_added: c.deck_added, + deck_modified: c.deck_modified, + tag_modified: c.tag_modified, + notetype_modified: c.notetype_modified, + preference_modified: c.preference_modified, + } + } +} diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 47330406c..5d5b50416 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -25,8 +25,78 @@ pub enum Op { } impl Op { + /// Used internally to decide whether the study queues need to be invalidated. pub(crate) fn needs_study_queue_reset(self) -> bool { + let changes = self.state_changes(); self != Op::AnswerCard + && (changes.card_added + || changes.card_modified + || changes.deck_modified + || changes.preference_modified) + } + + pub fn state_changes(self) -> StateChanges { + let default = Default::default; + match self { + Op::ScheduleAsNew + | Op::SetDueDate + | Op::Suspend + | Op::UnburyUnsuspend + | Op::UpdateCard + | Op::SetDeck + | Op::Bury => StateChanges { + card_modified: true, + ..default() + }, + Op::AnswerCard => StateChanges { + card_modified: true, + // this also modifies the daily counts stored in the + // deck, but the UI does not care about that + ..default() + }, + Op::AddDeck => StateChanges { + deck_added: true, + ..default() + }, + Op::AddNote => StateChanges { + card_added: true, + note_added: true, + ..default() + }, + Op::RemoveDeck => StateChanges { + card_modified: true, + note_modified: true, + deck_modified: true, + ..default() + }, + Op::RemoveNote => StateChanges { + card_modified: true, + note_modified: true, + ..default() + }, + Op::RenameDeck => StateChanges { + deck_modified: true, + ..default() + }, + Op::UpdateDeck => StateChanges { + deck_modified: true, + ..default() + }, + Op::UpdateNote => StateChanges { + note_modified: true, + // edits may result in new cards being generated + card_added: true, + ..default() + }, + Op::UpdatePreferences => StateChanges { + preference_modified: true, + ..default() + }, + Op::UpdateTag => StateChanges { + tag_modified: true, + ..default() + }, + } } } @@ -55,3 +125,16 @@ impl Collection { self.i18n.tr(key).to_string() } } + +#[derive(Debug, Default, Clone, Copy)] +pub struct StateChanges { + pub card_added: bool, + pub card_modified: bool, + pub note_added: bool, + pub note_modified: bool, + pub deck_added: bool, + pub deck_modified: bool, + pub tag_modified: bool, + pub notetype_modified: bool, + pub preference_modified: bool, +} diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 917b10cad..75fd90208 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -151,6 +151,12 @@ impl Collection { .can_redo() .map(|op| self.describe_op_kind(op)) .unwrap_or_default(), + changes: Some( + self.can_undo() + .map(|op| op.state_changes()) + .unwrap_or_default() + .into(), + ), } } From 112cbe8b59d73fb10e2b1f7a7215f4c28f13635a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 13 Mar 2021 23:59:32 +1000 Subject: [PATCH 06/33] experiment with finer-scoped reset in perform_op() Basic proof of concept, where the 'delete note' operation in the reviewer has been updated to use mw.perform_op(). Instead of manually calling .reset() afterwards, a summary of the changes is returned as part of the undo status query, and various parts of the GUI can listen to gui_hooks.operation_did_execute and decide whether they want to redraw based on the scope of the changes. This should allow the sidebar to selectively redraw just the tags area in the future for example. Currently we're just listing out all possible areas that might be changed; in the future we could theoretically inspect the specific changes in the undo log to provide a more accurate report (avoiding refreshing the tags list when no tags were added for example). You can test it out by opening the browse screen while studying, and then deleting the current card - the browser should update to show (deleted) on the cards due the earlier change. If going ahead with this, aside from updating all the screens that currently listen for resets, some thought will be required on how we can integrate it with legacy code that expects to called when resets are made, and expects to call .reset() when it makes changes. Thoughts? --- pylib/anki/collection.py | 1 + qt/aqt/browser.py | 60 ++++++++++++---------------------- qt/aqt/main.py | 69 +++++++++++++++++++++++++++++++++++++++- qt/aqt/reviewer.py | 23 +++++++++++--- qt/tools/genhooks_gui.py | 19 ++++++++++- rslib/src/ops.rs | 3 ++ 6 files changed, 130 insertions(+), 45 deletions(-) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index c3113fa9b..dcd4d2fd6 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -54,6 +54,7 @@ GraphPreferences = _pb.GraphPreferences BuiltinSort = _pb.SortOrder.Builtin Preferences = _pb.Preferences UndoStatus = _pb.UndoStatus +StateChanges = _pb.StateChanges DefaultsForAdding = _pb.DeckAndNotetype diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 4711e4732..9c7332474 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, import aqt import aqt.forms from anki.cards import Card -from anki.collection import Collection, Config, SearchNode +from anki.collection import Collection, Config, SearchNode, StateChanges from anki.consts import * from anki.errors import InvalidInput, NotFoundError from anki.lang import without_unicode_isolation @@ -281,6 +281,15 @@ class DataModel(QAbstractTableModel): else: tv.selectRow(0) + def maybe_redraw_after_operation(self, changes: StateChanges) -> None: + if ( + changes.card_modified + or changes.note_modified + or changes.deck_modified + or changes.notetype_modified + ): + self.reset() + # Column data ###################################################################### @@ -434,8 +443,6 @@ class StatusDelegate(QItemDelegate): # Browser window ###################################################################### -# fixme: respond to reset+edit hooks - class Browser(QMainWindow): model: DataModel @@ -481,43 +488,14 @@ class Browser(QMainWindow): gui_hooks.browser_will_show(self) self.show() - def perform_op( - self, - op: Callable, - on_done: Callable[[Future], None], - *, - reset_model: bool = True, - ) -> None: - """Run the provided operation on a background thread. - - Ensures any changes in the editor have been saved. - - Shows progress popup for the duration of the op. - - Ensures the browser doesn't try to redraw during the operation, which can lead - to a frozen UI - - Updates undo state at the end of the operation - - If `reset_model` is true, calls beginReset()/endReset(), which will - refresh the displayed data, and update the editor's note. If the current search - has changed results, you will need to call .search() yourself in `on_done`. + def on_operation_will_execute(self) -> None: + # make sure the card list doesn't try to refresh itself during the operation, + # as that will block the UI + self.setUpdatesEnabled(False) - Caller must run fut.result() in the on_done() callback to check for errors; - if the operation returned a value, it will be returned by .result() - """ - - def wrapped_op() -> None: - if reset_model: - self.model.beginReset() - self.setUpdatesEnabled(False) - op() - - def wrapped_done(fut: Future) -> None: - self.setUpdatesEnabled(True) - on_done(fut) - if reset_model: - self.model.endReset() - self.mw.update_undo_actions() - - self.editor.saveNow( - lambda: self.mw.taskman.with_progress(wrapped_op, wrapped_done) - ) + def on_operation_did_execute(self, changes: StateChanges) -> None: + self.setUpdatesEnabled(True) + self.model.maybe_redraw_after_operation(changes) def setupMenus(self) -> None: # pylint: disable=unnecessary-lambda @@ -1466,6 +1444,8 @@ where id in %s""" gui_hooks.editor_did_unfocus_field.append(self.on_unfocus_field) gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) + gui_hooks.operation_will_execute.append(self.on_operation_will_execute) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def teardownHooks(self) -> None: gui_hooks.undo_state_did_change.remove(self.onUndoState) @@ -1475,6 +1455,8 @@ where id in %s""" gui_hooks.editor_did_unfocus_field.remove(self.on_unfocus_field) gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) + gui_hooks.operation_will_execute.remove(self.on_operation_will_execute) + gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) def on_unfocus_field(self, changed: bool, note: Note, field_idx: int) -> None: self.refreshCurrentCard(note) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index eef4e99d0..392802407 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -14,7 +14,18 @@ import zipfile from argparse import Namespace from concurrent.futures import Future from threading import Thread -from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple, cast +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Sequence, + TextIO, + Tuple, + TypeVar, + cast, +) import anki import aqt @@ -34,6 +45,7 @@ from anki.collection import ( Config, ReviewUndo, UndoResult, + UndoStatus, ) from anki.decks import Deck from anki.hooks import runHook @@ -74,6 +86,8 @@ from aqt.utils import ( tr, ) +T = TypeVar("T") + install_pylib_legacy() @@ -694,6 +708,44 @@ class AnkiQt(QMainWindow): # Resetting state ########################################################################## + def perform_op( + self, + op: Callable[[], T], + on_success: Optional[Callable[[T], None]] = None, + on_exception: Optional[Callable[[BaseException], None]] = None, + ) -> None: + """Run the provided operation on a background thread. + - Ensures any changes in the editor have been saved. + - Shows progress popup for the duration of the op. + - Ensures the browser doesn't try to redraw during the operation, which can lead + to a frozen UI + - Updates undo state at the end of the operation + + on_success() will be called with the return value of op() + if op() threw an exception, on_exception() will be called with it, + if it was provided + + """ + + gui_hooks.operation_will_execute() + + def wrapped_done(future: Future) -> None: + try: + if exception := future.exception(): + if on_exception: + on_exception(exception) + else: + showWarning(str(exception)) + else: + if on_success: + on_success(future.result()) + finally: + status = self.col.undo_status() + self._update_undo_actions_for_status(status) + gui_hooks.operation_did_execute(status.changes) + + self.taskman.with_progress(op, wrapped_done) + def reset(self, guiOnly: bool = False) -> None: "Called for non-trivial edits. Rebuilds queue and updates UI." if self.col: @@ -1108,6 +1160,21 @@ title="%s" %s>%s""" % ( self.form.actionUndo.setEnabled(False) gui_hooks.undo_state_did_change(False) + def _update_undo_actions_for_status(self, status: UndoStatus) -> None: + """Update menu text and enable/disable menu item as appropriate. + Plural as this may handle redo in the future too.""" + undo_action = status.undo + + if undo_action: + undo_action = tr(TR.UNDO_UNDO_ACTION, val=undo_action) + self.form.actionUndo.setText(undo_action) + self.form.actionUndo.setEnabled(True) + gui_hooks.undo_state_did_change(True) + else: + self.form.actionUndo.setText(tr(TR.UNDO_UNDO)) + self.form.actionUndo.setEnabled(False) + gui_hooks.undo_state_did_change(False) + def checkpoint(self, name: str) -> None: self.col.save(name) self.update_undo_actions() diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 420293ea3..21f009de1 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -13,7 +13,7 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card -from anki.collection import Config +from anki.collection import Config, StateChanges from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.profiles import VideoDriver @@ -63,6 +63,7 @@ class Reviewer: self.state: Optional[str] = None self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def show(self) -> None: self.mw.col.reset() @@ -86,6 +87,18 @@ class Reviewer: gui_hooks.reviewer_will_end() self.card = None + def on_operation_did_execute(self, changes: StateChanges) -> None: + need_queue_rebuild = ( + changes.card_added + or changes.card_modified + or changes.deck_modified + or changes.preference_modified + ) + + if need_queue_rebuild: + self.mw.col.reset() + self.nextCard() + # Fetching a card ########################################################################## @@ -839,9 +852,11 @@ time = %(time)d; if self.mw.state != "review" or not self.card: return cnt = len(self.card.note().cards()) - self.mw.col.remove_notes([self.card.note().id]) - self.mw.reset() - tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)) + + self.mw.perform_op( + lambda: self.mw.col.remove_notes([self.card.note().id]), + lambda _: tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)), + ) def onRecordVoice(self) -> None: def after_record(path: str) -> None: diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 4512dbbc9..d527e674c 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -365,8 +365,9 @@ hooks = [ args=["context: aqt.browser.SearchContext"], doc="""Allows you to modify the list of returned card ids from a search.""", ), - # States + # Main window states ################### + # these refer to things like deckbrowser, overview and reviewer state, Hook( name="state_will_change", args=["new_state: str", "old_state: str"], @@ -382,6 +383,8 @@ hooks = [ name="state_shortcuts_will_change", args=["state: str", "shortcuts: List[Tuple[str, Callable]]"], ), + # UI state/refreshing + ################### Hook( name="state_did_revert", args=["action: str"], @@ -393,6 +396,20 @@ hooks = [ legacy_hook="reset", doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.", ), + Hook( + name="operation_will_execute", + doc="""Called before an operation is executed with mw.perform_op(). + Subscribers can use this to ensure they don't try to access the collection until the operation completes, + as doing so on the main thread will temporarily freeze the UI.""", + ), + Hook( + name="operation_did_execute", + args=[ + "changes: anki.collection.StateChanges", + ], + doc="""Called after an operation completes. + Changes can be inspected to determine whether the UI needs updating.""", + ), # Webview ################### Hook( diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 5d5b50416..3326f402e 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -61,6 +61,7 @@ impl Op { Op::AddNote => StateChanges { card_added: true, note_added: true, + tag_modified: true, ..default() }, Op::RemoveDeck => StateChanges { @@ -86,6 +87,8 @@ impl Op { note_modified: true, // edits may result in new cards being generated card_added: true, + // and may result in new tags being added + tag_modified: true, ..default() }, Op::UpdatePreferences => StateChanges { From 1e849316beedc3406a27fbedb3f7abdf8c4a5733 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 14 Mar 2021 19:54:15 +1000 Subject: [PATCH 07/33] more reset refactoring 'card modified' covers the common case where we need to rebuild the study queue, but is also set when changing the card flags. We want to avoid a queue rebuild in that case, as it causes UI flicker, and may result in a different card being shown. Note marking doesn't trigger a queue build, but still causes flicker, and may return the user back to the front side when they were looking at the answer. I still think entity-based change tracking is the simplest in the common case, but to solve the above, I've introduced an enum describing the last operation that was taken. This currently is not trying to list out all possible operations, and just describes the ones we want to special-case. Other changes: - Fire the old 'state_did_reset' hook after an operation is performed, so legacy code can refresh itself after an operation is performed. - Fire the new `operation_did_execute` hook when mw.reset() is called, so that as the UI is updated to the use the new hook, it will still be able to refresh after legacy code calls mw.reset() - Update the deck browser, overview and review screens to listen to the new hook, instead of relying on the main window to call moveToState() - Add a 'set flag' backend action, so we can distinguish it from a normal card update. - Drop the separate added/modified entries in the change list in favour of a single entry per entity. - Add typing to mw.state - Tweak perform_op() - Convert a few more actions to use perform_op() --- ftl/core/undo.ftl | 1 + pylib/anki/cards.py | 3 +- pylib/anki/collection.py | 22 ++-- qt/.pylintrc | 1 + qt/aqt/browser.py | 53 +++------- qt/aqt/deckbrowser.py | 9 ++ qt/aqt/main.py | 82 +++++++++++---- qt/aqt/overview.py | 10 ++ qt/aqt/reviewer.py | 97 ++++++++++------- qt/aqt/scheduling.py | 53 +++------- qt/tools/genhooks_gui.py | 13 ++- rslib/backend.proto | 38 ++++--- rslib/src/backend/card.rs | 11 ++ rslib/src/backend/collection.rs | 6 +- rslib/src/backend/notes.rs | 8 +- rslib/src/backend/ops.rs | 52 +++++++--- rslib/src/card/mod.rs | 27 +++++ rslib/src/ops.rs | 179 ++++++++++++++++---------------- rslib/src/undo/mod.rs | 28 ++--- 19 files changed, 397 insertions(+), 296 deletions(-) diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index c2e95f1e9..61d60d945 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -18,3 +18,4 @@ undo-update-note = Update Note undo-update-card = Update Card undo-update-deck = Update Deck undo-forget-card = Forget Card +undo-set-flag = Set Flag diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index e08bb46f3..7d9a4a20a 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -196,7 +196,8 @@ class Card: return self.flags & 0b111 def set_user_flag(self, flag: int) -> None: - assert 0 <= flag <= 7 + print("use col.set_user_flag_for_cards() instead") + assert 0 <= flag <= 4 self.flags = (self.flags & ~0b111) | flag # legacy diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index dcd4d2fd6..e0d1b3ebe 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -54,7 +54,7 @@ GraphPreferences = _pb.GraphPreferences BuiltinSort = _pb.SortOrder.Builtin Preferences = _pb.Preferences UndoStatus = _pb.UndoStatus -StateChanges = _pb.StateChanges +OperationInfo = _pb.OperationInfo DefaultsForAdding = _pb.DeckAndNotetype @@ -783,8 +783,6 @@ table.review-log {{ {revlog_style} }} assert_exhaustive(self._undo) assert False - return status - def clear_python_undo(self) -> None: """Clear the Python undo state. The backend will automatically clear backend undo state when @@ -812,6 +810,11 @@ table.review-log {{ {revlog_style} }} assert_exhaustive(self._undo) assert False + def op_affects_study_queue(self, op: OperationInfo) -> bool: + if op.kind == op.SET_CARD_FLAG: + return False + return op.changes.card or op.changes.deck or op.changes.preference + def _check_backend_undo_status(self) -> Optional[UndoStatus]: """Return undo status if undo available on backend. If backend has undo available, clear the Python undo state.""" @@ -981,21 +984,10 @@ table.review-log {{ {revlog_style} }} self._logHnd.close() self._logHnd = None - # Card Flags ########################################################################## def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None: - assert 0 <= flag <= 7 - self.db.execute( - "update cards set flags = (flags & ~?) | ?, usn=?, mod=? where id in %s" - % ids2str(cids), - 0b111, - flag, - self.usn(), - intTime(), - ) - - ########################################################################## + self._backend.set_flag(card_ids=cids, flag=flag) def set_wants_abort(self) -> None: self._backend.set_wants_abort() diff --git a/qt/.pylintrc b/qt/.pylintrc index 3507ed84c..844ae633a 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -8,6 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-classes= SearchNode, Config, + OperationInfo [REPORTS] output-format=colorized diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 9c7332474..4d21fddbe 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, import aqt import aqt.forms from anki.cards import Card -from anki.collection import Collection, Config, SearchNode, StateChanges +from anki.collection import Collection, Config, OperationInfo, SearchNode from anki.consts import * from anki.errors import InvalidInput, NotFoundError from anki.lang import without_unicode_isolation @@ -281,13 +281,8 @@ class DataModel(QAbstractTableModel): else: tv.selectRow(0) - def maybe_redraw_after_operation(self, changes: StateChanges) -> None: - if ( - changes.card_modified - or changes.note_modified - or changes.deck_modified - or changes.notetype_modified - ): + def maybe_redraw_after_operation(self, op: OperationInfo) -> None: + if op.changes.card or op.changes.note or op.changes.deck or op.changes.notetype: self.reset() # Column data @@ -493,9 +488,9 @@ class Browser(QMainWindow): # as that will block the UI self.setUpdatesEnabled(False) - def on_operation_did_execute(self, changes: StateChanges) -> None: + def on_operation_did_execute(self, op: OperationInfo) -> None: self.setUpdatesEnabled(True) - self.model.maybe_redraw_after_operation(changes) + self.model.maybe_redraw_after_operation(op) def setupMenus(self) -> None: # pylint: disable=unnecessary-lambda @@ -1159,14 +1154,10 @@ where id in %s""" # select the next card if there is one self._onNextCard() - def do_remove() -> None: - self.col.remove_notes(nids) - - def on_done(fut: Future) -> None: - fut.result() - tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))) - - self.perform_op(do_remove, on_done, reset_model=True) + self.mw.perform_op( + lambda: self.col.remove_notes(nids), + success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))), + ) # legacy @@ -1196,14 +1187,7 @@ where id in %s""" return did = self.col.decks.id(ret.name) - def do_move() -> None: - self.col.set_deck(cids, did) - - def on_done(fut: Future) -> None: - fut.result() - self.mw.requireReset(reason=ResetReason.BrowserSetDeck, context=self) - - self.perform_op(do_move, on_done) + self.mw.perform_op(lambda: self.col.set_deck(cids, did)) # legacy @@ -1247,9 +1231,8 @@ where id in %s""" if not ok: return - self.model.beginReset() - func(self.selectedNotes(), tags) - self.model.endReset() + nids = self.selectedNotes() + self.mw.perform_op(lambda: func(nids, tags)) self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) def clearUnusedTags(self) -> None: @@ -1304,8 +1287,9 @@ where id in %s""" # flag needs toggling off? if n == self.card.user_flag(): n = 0 - self.col.set_user_flag_for_cards(n, self.selectedCards()) - self.model.reset() + + cids = self.selectedCards() + self.mw.perform_op(lambda: self.col.set_user_flag_for_cards(n, cids)) def _updateFlagsMenu(self) -> None: flag = self.card and self.card.user_flag() @@ -1382,11 +1366,6 @@ where id in %s""" # Scheduling ###################################################################### - def _after_schedule(self) -> None: - self.model.reset() - # updates undo status - self.mw.requireReset(reason=ResetReason.BrowserReschedule, context=self) - def set_due_date(self) -> None: self.editor.saveNow( lambda: set_due_date_dialog( @@ -1394,7 +1373,6 @@ where id in %s""" parent=self, card_ids=self.selectedCards(), config_key=Config.String.SET_DUE_BROWSER, - on_done=self._after_schedule, ) ) @@ -1404,7 +1382,6 @@ where id in %s""" mw=self.mw, parent=self, card_ids=self.selectedCards(), - on_done=self._after_schedule, ) ) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index d74b4b822..87aaaf6b7 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import Any import aqt +from anki.collection import OperationInfo from anki.decks import DeckTreeNode from anki.errors import DeckIsFilteredError from anki.utils import intTime @@ -61,6 +62,7 @@ class DeckBrowser: self.bottom = BottomBar(mw, mw.bottomWeb) self.scrollPos = QPoint(0, 0) self._v1_message_dismissed_at = 0 + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def show(self) -> None: av_player.stop_and_clear_queue() @@ -72,6 +74,13 @@ class DeckBrowser: def refresh(self) -> None: self._renderPage() + def on_operation_did_execute(self, op: OperationInfo) -> None: + if self.mw.state != "deckBrowser": + return + + if self.mw.col.op_affects_study_queue(op): + self.refresh() + # Event handlers ########################################################################## diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 392802407..8c86f6de6 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -19,6 +19,7 @@ from typing import ( Callable, Dict, List, + Literal, Optional, Sequence, TextIO, @@ -43,6 +44,7 @@ from anki.collection import ( Checkpoint, Collection, Config, + OperationInfo, ReviewUndo, UndoResult, UndoStatus, @@ -112,6 +114,11 @@ class ResetRequired: self.mw = mw +MainWindowState = Literal[ + "startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager" +] + + class AnkiQt(QMainWindow): col: Collection pm: ProfileManagerType @@ -128,7 +135,7 @@ class AnkiQt(QMainWindow): ) -> None: QMainWindow.__init__(self) self.backend = backend - self.state = "startup" + self.state: MainWindowState = "startup" self.opts = opts self.col: Optional[Collection] = None self.taskman = TaskManager(self) @@ -664,12 +671,12 @@ class AnkiQt(QMainWindow): self.pm.save() self.progress.finish() - # State machine + # Tracking main window state (deck browser, reviewer, etc) ########################################################################## - def moveToState(self, state: str, *args: Any) -> None: + def moveToState(self, state: MainWindowState, *args: Any) -> None: # print("-> move from", self.state, "to", state) - oldState = self.state or "dummy" + oldState = self.state cleanup = getattr(self, f"_{oldState}Cleanup", None) if cleanup: # pylint: disable=not-callable @@ -711,20 +718,27 @@ class AnkiQt(QMainWindow): def perform_op( self, op: Callable[[], T], - on_success: Optional[Callable[[T], None]] = None, - on_exception: Optional[Callable[[BaseException], None]] = None, + *, + success: Optional[Callable[[T], None]] = None, + failure: Optional[Callable[[BaseException], None]] = None, ) -> None: """Run the provided operation on a background thread. - - Ensures any changes in the editor have been saved. + - Shows progress popup for the duration of the op. - Ensures the browser doesn't try to redraw during the operation, which can lead to a frozen UI - Updates undo state at the end of the operation + - Commits changes + - Fires the `operation_(will|did)_reset` hooks + - Fires the legacy `state_did_reset` hook - on_success() will be called with the return value of op() - if op() threw an exception, on_exception() will be called with it, - if it was provided + Be careful not to call any UI routines in `op`, as that may crash Qt. + This includes things select .selectedCards() in the browse screen. + on_success() will be called with the return value of op(). + + If op() throws an exception, it will be shown in a popup, or + passed to on_exception() if it is provided. """ gui_hooks.operation_will_execute() @@ -732,28 +746,48 @@ class AnkiQt(QMainWindow): def wrapped_done(future: Future) -> None: try: if exception := future.exception(): - if on_exception: - on_exception(exception) + if failure: + failure(exception) else: showWarning(str(exception)) else: - if on_success: - on_success(future.result()) + if success: + success(future.result()) finally: status = self.col.undo_status() - self._update_undo_actions_for_status(status) - gui_hooks.operation_did_execute(status.changes) + self._update_undo_actions_for_status_and_save(status) + print("last op", status.last_op) + gui_hooks.operation_did_execute(status.last_op) + # fire legacy hook so old code notices changes + gui_hooks.state_did_reset() self.taskman.with_progress(op, wrapped_done) - def reset(self, guiOnly: bool = False) -> None: - "Called for non-trivial edits. Rebuilds queue and updates UI." + def _synthesize_op_did_execute_from_reset(self) -> None: + """Fire the `operation_did_execute` hook with everything marked as changed, + after legacy code has called .reset()""" + op = OperationInfo() + for field in op.changes.DESCRIPTOR.fields: + setattr(op.changes, field.name, True) + gui_hooks.operation_did_execute(op) + + def reset(self, unused_arg: bool = False) -> None: + """Legacy method of telling UI to refresh after changes made to DB. + + New code should use mw.perform_op() instead.""" + if self.col: - if not guiOnly: - self.col.reset() + # fire new `operation_did_execute` hook first. If the overview + # or review screen are currently open, they will rebuild the study + # queues (via mw.col.reset()) + self._synthesize_op_did_execute_from_reset() + # fire the old reset hook gui_hooks.state_did_reset() + self.update_undo_actions() - self.moveToState(self.state) + + # fixme: double-check + # self.moveToState(self.state) def requireReset( self, @@ -784,7 +818,7 @@ class AnkiQt(QMainWindow): # windows self.progress.timer(100, self.maybeReset, False) - def _resetRequiredState(self, oldState: str) -> None: + def _resetRequiredState(self, oldState: MainWindowState) -> None: if oldState != "resetRequired": self.returnState = oldState if self.resetModal: @@ -1160,7 +1194,7 @@ title="%s" %s>%s""" % ( self.form.actionUndo.setEnabled(False) gui_hooks.undo_state_did_change(False) - def _update_undo_actions_for_status(self, status: UndoStatus) -> None: + def _update_undo_actions_for_status_and_save(self, status: UndoStatus) -> None: """Update menu text and enable/disable menu item as appropriate. Plural as this may handle redo in the future too.""" undo_action = status.undo @@ -1175,6 +1209,8 @@ title="%s" %s>%s""" % ( self.form.actionUndo.setEnabled(False) gui_hooks.undo_state_did_change(False) + self.col.autosave() + def checkpoint(self, name: str) -> None: self.col.save(name) self.update_undo_actions() diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 723528602..5e2fc979c 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple import aqt +from anki.collection import OperationInfo from aqt import gui_hooks from aqt.sound import av_player from aqt.toolbar import BottomBar @@ -42,6 +43,7 @@ class Overview: self.mw = mw self.web = mw.web self.bottom = BottomBar(mw, mw.bottomWeb) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def show(self) -> None: av_player.stop_and_clear_queue() @@ -56,6 +58,14 @@ class Overview: self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) + def on_operation_did_execute(self, op: OperationInfo) -> None: + if self.mw.state != "overview": + return + + if self.mw.col.op_affects_study_queue(op): + # will also cover the deck description modified case + self.refresh() + # Handlers ############################################################ diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 21f009de1..5109b6ec4 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -13,7 +13,7 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card -from anki.collection import Config, StateChanges +from anki.collection import Config, OperationInfo from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.profiles import VideoDriver @@ -87,17 +87,26 @@ class Reviewer: gui_hooks.reviewer_will_end() self.card = None - def on_operation_did_execute(self, changes: StateChanges) -> None: - need_queue_rebuild = ( - changes.card_added - or changes.card_modified - or changes.deck_modified - or changes.preference_modified - ) + def on_operation_did_execute(self, op: OperationInfo) -> None: + if self.mw.state != "review": + return - if need_queue_rebuild: + if op.kind == OperationInfo.UPDATE_NOTE_TAGS: + self.card.load() + self._update_mark_icon() + elif op.kind == OperationInfo.SET_CARD_FLAG: + # fixme: v3 mtime check + self.card.load() + self._update_flag_icon() + elif self.mw.col.op_affects_study_queue(op): + # need queue rebuild self.mw.col.reset() self.nextCard() + return + elif op.changes.note or op.changes.notetype or op.changes.tag: + # need redraw of current card + self.card.load() + self._showQuestion() # Fetching a card ########################################################################## @@ -795,24 +804,27 @@ time = %(time)d; def onOptions(self) -> None: self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did)) - def set_flag_on_current_card(self, flag: int) -> None: - # need to toggle off? - if self.card.user_flag() == flag: - flag = 0 - self.card.set_user_flag(flag) - self.mw.col.update_card(self.card) - self.mw.update_undo_actions() - self._update_flag_icon() + def set_flag_on_current_card(self, desired_flag: int) -> None: + def op() -> None: + # need to toggle off? + if self.card.user_flag() == desired_flag: + flag = 0 + else: + flag = desired_flag + self.mw.col.set_user_flag_for_cards(flag, [self.card.id]) + + self.mw.perform_op(op) def toggle_mark_on_current_note(self) -> None: - note = self.card.note() - if note.has_tag("marked"): - note.remove_tag("marked") - else: - note.add_tag("marked") - self.mw.col.update_note(note) - self.mw.update_undo_actions() - self._update_mark_icon() + def op() -> None: + tag = "marked" + note = self.card.note() + if note.has_tag(tag): + self.mw.col.tags.bulk_remove([note.id], tag) + else: + self.mw.col.tags.bulk_add([note.id], tag) + + self.mw.perform_op(op) def on_set_due(self) -> None: if self.mw.state != "review" or not self.card: @@ -823,28 +835,33 @@ time = %(time)d; parent=self.mw, card_ids=[self.card.id], config_key=Config.String.SET_DUE_REVIEWER, - on_done=self.mw.reset, ) def suspend_current_note(self) -> None: - self.mw.col.sched.suspend_cards([c.id for c in self.card.note().cards()]) - self.mw.reset() - tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)) + self.mw.perform_op( + lambda: self.mw.col.sched.suspend_cards( + [c.id for c in self.card.note().cards()] + ), + success=lambda _: tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)), + ) def suspend_current_card(self) -> None: - self.mw.col.sched.suspend_cards([self.card.id]) - self.mw.reset() - tooltip(tr(TR.STUDYING_CARD_SUSPENDED)) + self.mw.perform_op( + lambda: self.mw.col.sched.suspend_cards([self.card.id]), + success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)), + ) def bury_current_card(self) -> None: - self.mw.col.sched.bury_cards([self.card.id]) - self.mw.reset() - tooltip(tr(TR.STUDYING_CARD_BURIED)) + self.mw.perform_op( + lambda: self.mw.col.sched.bury_cards([self.card.id]), + success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)), + ) def bury_current_note(self) -> None: - self.mw.col.sched.bury_note(self.card.note()) - self.mw.reset() - tooltip(tr(TR.STUDYING_NOTE_BURIED)) + self.mw.perform_op( + lambda: self.mw.col.sched.bury_note(self.card.note()), + success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)), + ) def delete_current_note(self) -> None: # need to check state because the shortcut is global to the main @@ -855,7 +872,9 @@ time = %(time)d; self.mw.perform_op( lambda: self.mw.col.remove_notes([self.card.note().id]), - lambda _: tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)), + success=lambda _: tooltip( + tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt) + ), ) def onRecordVoice(self) -> None: diff --git a/qt/aqt/scheduling.py b/qt/aqt/scheduling.py index f16d49b1a..c6302b194 100644 --- a/qt/aqt/scheduling.py +++ b/qt/aqt/scheduling.py @@ -3,14 +3,13 @@ from __future__ import annotations -from concurrent.futures import Future from typing import List, Optional import aqt from anki.collection import Config from anki.lang import TR from aqt.qt import * -from aqt.utils import getText, showWarning, tooltip, tr +from aqt.utils import getText, tooltip, tr def set_due_date_dialog( @@ -19,12 +18,13 @@ def set_due_date_dialog( parent: QDialog, card_ids: List[int], config_key: Optional[Config.String.Key.V], - on_done: Callable[[], None], ) -> None: if not card_ids: return - default = mw.col.get_config_string(config_key) if config_key is not None else "" + default_text = ( + mw.col.get_config_string(config_key) if config_key is not None else "" + ) prompt = "\n".join( [ tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)), @@ -34,49 +34,28 @@ def set_due_date_dialog( (days, success) = getText( prompt=prompt, parent=parent, - default=default, + default=default_text, title=tr(TR.ACTIONS_SET_DUE_DATE), ) if not success or not days.strip(): return - def set_due() -> None: - mw.col.sched.set_due_date(card_ids, days, config_key) - - def after_set(fut: Future) -> None: - try: - fut.result() - except Exception as e: - showWarning(str(e)) - on_done() - return - - tooltip( + mw.perform_op( + lambda: mw.col.sched.set_due_date(card_ids, days, config_key), + success=lambda _: tooltip( tr(TR.SCHEDULING_SET_DUE_DATE_DONE, cards=len(card_ids)), parent=parent, - ) - - on_done() - - mw.taskman.with_progress(set_due, after_set) + ), + ) -def forget_cards( - *, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int], on_done: Callable[[], None] -) -> None: +def forget_cards(*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int]) -> None: if not card_ids: return - def on_done_wrapper(fut: Future) -> None: - try: - fut.result() - except Exception as e: - showWarning(str(e)) - else: - tooltip(tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent) - - on_done() - - mw.taskman.with_progress( - lambda: mw.col.sched.schedule_cards_as_new(card_ids), on_done_wrapper + mw.perform_op( + lambda: mw.col.sched.schedule_cards_as_new(card_ids), + success=lambda _: tooltip( + tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent + ), ) diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index d527e674c..e04b78c1d 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -394,7 +394,10 @@ hooks = [ Hook( name="state_did_reset", legacy_hook="reset", - doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.", + doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI. + + New code should use `operation_did_execute` instead. + """, ), Hook( name="operation_will_execute", @@ -405,10 +408,14 @@ hooks = [ Hook( name="operation_did_execute", args=[ - "changes: anki.collection.StateChanges", + "op: anki.collection.OperationInfo", ], doc="""Called after an operation completes. - Changes can be inspected to determine whether the UI needs updating.""", + Changes can be inspected to determine whether the UI needs updating. + + This will also be called when the legacy mw.reset() is used. When called via + mw.reset(), `operation_will_execute` will not be called. + """, ), # Webview ################### diff --git a/rslib/backend.proto b/rslib/backend.proto index 494cd10f2..759f3eb65 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -267,6 +267,7 @@ service CardsService { rpc UpdateCard(UpdateCardIn) returns (Empty); rpc RemoveCards(RemoveCardsIn) returns (Empty); rpc SetDeck(SetDeckIn) returns (Empty); + rpc SetFlag(SetFlagIn) returns (Empty); } // Protobuf stored in .anki2 files @@ -1442,22 +1443,30 @@ message GetQueuedCardsOut { } } -message StateChanges { - bool card_added = 1; - bool card_modified = 2; - bool note_added = 3; - bool note_modified = 4; - bool deck_added = 5; - bool deck_modified = 6; - bool tag_modified = 7; - bool notetype_modified = 8; - bool preference_modified = 9; +message OperationInfo { + message Changes { + bool card = 1; + bool note = 2; + bool deck = 3; + bool tag = 4; + bool notetype = 5; + bool preference = 6; + } + // this is not an exhaustive list; we can add more cases as we need them + enum Kind { + OTHER = 0; + UPDATE_NOTE_TAGS = 1; + SET_CARD_FLAG = 2; + } + + Kind kind = 1; + Changes changes = 2; } message UndoStatus { string undo = 1; string redo = 2; - StateChanges changes = 3; + OperationInfo last_op = 3; } message DefaultsForAddingIn { @@ -1472,4 +1481,9 @@ message DeckAndNotetype { message RenameDeckIn { int64 deck_id = 1; string new_name = 2; -} \ No newline at end of file +} + +message SetFlagIn { + repeated int64 card_ids = 1; + uint32 flag = 2; +} diff --git a/rslib/src/backend/card.rs b/rslib/src/backend/card.rs index 83c2d6eec..4125cec33 100644 --- a/rslib/src/backend/card.rs +++ b/rslib/src/backend/card.rs @@ -54,6 +54,13 @@ impl CardsService for Backend { let deck_id = input.deck_id.into(); self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into)) } + + fn set_flag(&self, input: pb::SetFlagIn) -> Result { + self.with_col(|col| { + col.set_card_flag(&to_card_ids(input.card_ids), input.flag) + .map(Into::into) + }) + } } impl TryFrom for Card { @@ -111,3 +118,7 @@ impl From for pb::Card { } } } + +fn to_card_ids(v: Vec) -> Vec { + v.into_iter().map(CardID).collect() +} diff --git a/rslib/src/backend/collection.rs b/rslib/src/backend/collection.rs index 6153d1f9d..83df57dda 100644 --- a/rslib/src/backend/collection.rs +++ b/rslib/src/backend/collection.rs @@ -85,20 +85,20 @@ impl CollectionService for Backend { } fn get_undo_status(&self, _input: pb::Empty) -> Result { - self.with_col(|col| Ok(col.undo_status())) + self.with_col(|col| Ok(col.undo_status().into_protobuf(&col.i18n))) } fn undo(&self, _input: pb::Empty) -> Result { self.with_col(|col| { col.undo()?; - Ok(col.undo_status()) + Ok(col.undo_status().into_protobuf(&col.i18n)) }) } fn redo(&self, _input: pb::Empty) -> Result { self.with_col(|col| { col.redo()?; - Ok(col.undo_status()) + Ok(col.undo_status().into_protobuf(&col.i18n)) }) } } diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index bb5331a69..335a858b2 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -95,7 +95,7 @@ impl NotesService for Backend { fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result { self.with_col(|col| { - col.add_tags_to_notes(&to_nids(input.nids), &input.tags) + col.add_tags_to_notes(&to_note_ids(input.nids), &input.tags) .map(|n| n as u32) }) .map(Into::into) @@ -104,7 +104,7 @@ impl NotesService for Backend { fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { self.with_col(|col| { col.replace_tags_for_notes( - &to_nids(input.nids), + &to_note_ids(input.nids), &input.tags, &input.replacement, input.regex, @@ -127,7 +127,7 @@ impl NotesService for Backend { self.with_col(|col| { col.transact(None, |col| { col.after_note_updates( - &to_nids(input.nids), + &to_note_ids(input.nids), input.generate_cards, input.mark_notes_modified, )?; @@ -167,6 +167,6 @@ impl NotesService for Backend { } } -fn to_nids(ids: Vec) -> Vec { +fn to_note_ids(ids: Vec) -> Vec { ids.into_iter().map(NoteID).collect() } diff --git a/rslib/src/backend/ops.rs b/rslib/src/backend/ops.rs index d1d99eaa1..4d1447215 100644 --- a/rslib/src/backend/ops.rs +++ b/rslib/src/backend/ops.rs @@ -1,20 +1,48 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::{backend_proto as pb, ops::StateChanges}; +use pb::operation_info::{Changes, Kind}; -impl From for pb::StateChanges { +use crate::{backend_proto as pb, ops::StateChanges, prelude::*, undo::UndoStatus}; + +impl From for Changes { fn from(c: StateChanges) -> Self { - pb::StateChanges { - card_added: c.card_added, - card_modified: c.card_modified, - note_added: c.note_added, - note_modified: c.note_modified, - deck_added: c.deck_added, - deck_modified: c.deck_modified, - tag_modified: c.tag_modified, - notetype_modified: c.notetype_modified, - preference_modified: c.preference_modified, + Changes { + card: c.card, + note: c.note, + deck: c.deck, + tag: c.tag, + notetype: c.notetype, + preference: c.preference, + } + } +} + +impl From for Kind { + fn from(o: Op) -> Self { + match o { + Op::SetFlag => Kind::SetCardFlag, + Op::UpdateTag => Kind::UpdateNoteTags, + _ => Kind::Other, + } + } +} + +impl From for pb::OperationInfo { + fn from(op: Op) -> Self { + pb::OperationInfo { + changes: Some(op.state_changes().into()), + kind: Kind::from(op) as i32, + } + } +} + +impl UndoStatus { + pub(crate) fn into_protobuf(self, i18n: &I18n) -> pb::UndoStatus { + pb::UndoStatus { + undo: self.undo.map(|op| op.describe(i18n)).unwrap_or_default(), + redo: self.redo.map(|op| op.describe(i18n)).unwrap_or_default(), + last_op: self.undo.map(Into::into), } } } diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 85b8ffc63..490933290 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -111,6 +111,15 @@ impl Card { self.deck_id = deck; } + fn set_flag(&mut self, flag: u8) { + // we currently only allow 4 flags + assert!(flag < 5); + + // but reserve space for 7, preserving the rest of + // the flags (up to a byte) + self.flags = (self.flags & !0b111) | flag + } + /// Return the total number of steps left to do, ignoring the /// "steps today" number packed into the DB representation. pub fn remaining_steps(&self) -> u32 { @@ -221,6 +230,24 @@ impl Collection { }) } + pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result<()> { + if flag > 4 { + return Err(AnkiError::invalid_input("invalid flag")); + } + let flag = flag as u8; + + self.storage.set_search_table_to_card_ids(cards, false)?; + let usn = self.usn()?; + self.transact(Some(Op::SetFlag), |col| { + for mut card in col.storage.all_searched_cards()? { + let original = card.clone(); + card.set_flag(flag); + col.update_card_inner(&mut card, original, usn)?; + } + Ok(()) + }) + } + /// Get deck config for the given card. If missing, return default values. #[allow(dead_code)] pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result { diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 3326f402e..3c8ff1c78 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -13,7 +13,9 @@ pub enum Op { RemoveNote, RenameDeck, ScheduleAsNew, + SetDeck, SetDueDate, + SetFlag, Suspend, UnburyUnsuspend, UpdateCard, @@ -21,91 +23,11 @@ pub enum Op { UpdateNote, UpdatePreferences, UpdateTag, - SetDeck, } impl Op { - /// Used internally to decide whether the study queues need to be invalidated. - pub(crate) fn needs_study_queue_reset(self) -> bool { - let changes = self.state_changes(); - self != Op::AnswerCard - && (changes.card_added - || changes.card_modified - || changes.deck_modified - || changes.preference_modified) - } - - pub fn state_changes(self) -> StateChanges { - let default = Default::default; - match self { - Op::ScheduleAsNew - | Op::SetDueDate - | Op::Suspend - | Op::UnburyUnsuspend - | Op::UpdateCard - | Op::SetDeck - | Op::Bury => StateChanges { - card_modified: true, - ..default() - }, - Op::AnswerCard => StateChanges { - card_modified: true, - // this also modifies the daily counts stored in the - // deck, but the UI does not care about that - ..default() - }, - Op::AddDeck => StateChanges { - deck_added: true, - ..default() - }, - Op::AddNote => StateChanges { - card_added: true, - note_added: true, - tag_modified: true, - ..default() - }, - Op::RemoveDeck => StateChanges { - card_modified: true, - note_modified: true, - deck_modified: true, - ..default() - }, - Op::RemoveNote => StateChanges { - card_modified: true, - note_modified: true, - ..default() - }, - Op::RenameDeck => StateChanges { - deck_modified: true, - ..default() - }, - Op::UpdateDeck => StateChanges { - deck_modified: true, - ..default() - }, - Op::UpdateNote => StateChanges { - note_modified: true, - // edits may result in new cards being generated - card_added: true, - // and may result in new tags being added - tag_modified: true, - ..default() - }, - Op::UpdatePreferences => StateChanges { - preference_modified: true, - ..default() - }, - Op::UpdateTag => StateChanges { - tag_modified: true, - ..default() - }, - } - } -} - -impl Collection { - pub fn describe_op_kind(&self, op: Op) -> String { - let key = match op { + pub fn describe(self, i18n: &I18n) -> String { + let key = match self { Op::AddDeck => TR::UndoAddDeck, Op::AddNote => TR::UndoAddNote, Op::AnswerCard => TR::UndoAnswerCard, @@ -123,21 +45,94 @@ impl Collection { Op::UpdatePreferences => TR::PreferencesPreferences, Op::UpdateTag => TR::UndoUpdateTag, Op::SetDeck => TR::BrowsingChangeDeck, + Op::SetFlag => TR::UndoSetFlag, }; - self.i18n.tr(key).to_string() + i18n.tr(key).to_string() + } + + /// Used internally to decide whether the study queues need to be invalidated. + pub(crate) fn needs_study_queue_reset(self) -> bool { + let changes = self.state_changes(); + self != Op::AnswerCard && (changes.card || changes.deck || changes.preference) + } + + pub fn state_changes(self) -> StateChanges { + let default = Default::default; + match self { + Op::ScheduleAsNew + | Op::SetDueDate + | Op::Suspend + | Op::UnburyUnsuspend + | Op::UpdateCard + | Op::SetDeck + | Op::Bury + | Op::SetFlag => StateChanges { + card: true, + ..default() + }, + Op::AnswerCard => StateChanges { + card: true, + // this also modifies the daily counts stored in the + // deck, but the UI does not care about that + ..default() + }, + Op::AddDeck => StateChanges { + deck: true, + ..default() + }, + Op::AddNote => StateChanges { + card: true, + note: true, + tag: true, + ..default() + }, + Op::RemoveDeck => StateChanges { + card: true, + note: true, + deck: true, + ..default() + }, + Op::RemoveNote => StateChanges { + card: true, + note: true, + ..default() + }, + Op::RenameDeck => StateChanges { + deck: true, + ..default() + }, + Op::UpdateDeck => StateChanges { + deck: true, + ..default() + }, + Op::UpdateNote => StateChanges { + note: true, + // edits may result in new cards being generated + card: true, + // and may result in new tags being added + tag: true, + ..default() + }, + Op::UpdatePreferences => StateChanges { + preference: true, + ..default() + }, + Op::UpdateTag => StateChanges { + note: true, + tag: true, + ..default() + }, + } } } #[derive(Debug, Default, Clone, Copy)] pub struct StateChanges { - pub card_added: bool, - pub card_modified: bool, - pub note_added: bool, - pub note_modified: bool, - pub deck_added: bool, - pub deck_modified: bool, - pub tag_modified: bool, - pub notetype_modified: bool, - pub preference_modified: bool, + pub card: bool, + pub note: bool, + pub deck: bool, + pub tag: bool, + pub notetype: bool, + pub preference: bool, } diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 75fd90208..73986776d 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -6,7 +6,6 @@ mod changes; pub use crate::ops::Op; pub(crate) use changes::UndoableChange; -use crate::backend_proto as pb; use crate::prelude::*; use std::collections::VecDeque; @@ -32,6 +31,11 @@ impl Default for UndoMode { } } +pub struct UndoStatus { + pub undo: Option, + pub redo: Option, +} + #[derive(Debug, Default)] pub(crate) struct UndoManager { // undo steps are added to the front of a double-ended queue, so we can @@ -75,6 +79,8 @@ impl UndoManager { self.undo_steps.truncate(UNDO_LIMIT - 1); self.undo_steps.push_front(step); } + } else { + println!("no undo changes, discarding step"); } } println!("ended, undo steps count now {}", self.undo_steps.len()); @@ -141,22 +147,10 @@ impl Collection { Ok(()) } - pub fn undo_status(&self) -> pb::UndoStatus { - pb::UndoStatus { - undo: self - .can_undo() - .map(|op| self.describe_op_kind(op)) - .unwrap_or_default(), - redo: self - .can_redo() - .map(|op| self.describe_op_kind(op)) - .unwrap_or_default(), - changes: Some( - self.can_undo() - .map(|op| op.state_changes()) - .unwrap_or_default() - .into(), - ), + pub fn undo_status(&self) -> UndoStatus { + UndoStatus { + undo: self.can_undo(), + redo: self.can_redo(), } } From 0a5be6543e6d347f8e580433a083669e2cc97c44 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 14 Mar 2021 22:08:37 +1000 Subject: [PATCH 08/33] experiment with replacing requireReset with updates on focus-in - This avoids the need for a separate screen, though we may want to slightly fade out the display when information is stale. - Means the browser can delay updates just like the main window does. --- ftl/qt/qt-misc.ftl | 2 - qt/aqt/browser.py | 21 +++++-- qt/aqt/deckbrowser.py | 18 ++++-- qt/aqt/main.py | 128 +++++++++++++++++++-------------------- qt/aqt/overview.py | 18 +++--- qt/aqt/reviewer.py | 51 ++++++++++++---- qt/aqt/utils.py | 14 +++++ qt/tools/genhooks_gui.py | 11 +++- 8 files changed, 163 insertions(+), 100 deletions(-) diff --git a/ftl/qt/qt-misc.ftl b/ftl/qt/qt-misc.ftl index e1de29fbe..1da297bb2 100644 --- a/ftl/qt/qt-misc.ftl +++ b/ftl/qt/qt-misc.ftl @@ -36,7 +36,6 @@ qt-misc-please-select-a-deck = Please select a deck. qt-misc-please-use-fileimport-to-import-this = Please use File>Import to import this file. qt-misc-processing = Processing... qt-misc-replace-your-collection-with-an-earlier = Replace your collection with an earlier backup? -qt-misc-resume-now = Resume Now qt-misc-revert-to-backup = Revert to backup qt-misc-reverted-to-state-prior-to = Reverted to state prior to '{ $val }'. qt-misc-segoe-ui = "Segoe UI" @@ -56,7 +55,6 @@ qt-misc-unable-to-move-existing-file-to = Unable to move existing file to trash qt-misc-undo = Undo qt-misc-undo2 = Undo { $val } qt-misc-unexpected-response-code = Unexpected response code: { $val } -qt-misc-waiting-for-editing-to-finish = Waiting for editing to finish. qt-misc-would-you-like-to-download-it = Would you like to download it now? qt-misc-your-collection-file-appears-to-be = Your collection file appears to be corrupt. This can happen when the file is copied or moved while Anki is open, or when the collection is stored on a network or cloud drive. If problems persist after restarting your computer, please open an automatic backup from the profile screen. qt-misc-your-computers-storage-may-be-full = Your computer's storage may be full. Please delete some unneeded files, then try again. diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 4d21fddbe..a1e4b425d 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -34,6 +34,7 @@ from aqt.utils import ( TR, HelpPage, askUser, + current_top_level_widget, disable_help_button, getTag, openHelp, @@ -91,6 +92,7 @@ class DataModel(QAbstractTableModel): ) self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} + self.refresh_needed = False def getCard(self, index: QModelIndex) -> Optional[Card]: id = self.cards[index.row()] @@ -203,6 +205,7 @@ class DataModel(QAbstractTableModel): def reset(self) -> None: self.beginReset() self.endReset() + self.refresh_needed = False # caller must have called editor.saveNow() before calling this or .reset() def beginReset(self) -> None: @@ -281,8 +284,14 @@ class DataModel(QAbstractTableModel): else: tv.selectRow(0) - def maybe_redraw_after_operation(self, op: OperationInfo) -> None: + def op_executed(self, op: OperationInfo, focused: bool) -> None: if op.changes.card or op.changes.note or op.changes.deck or op.changes.notetype: + self.refresh_needed = True + if focused: + self.refresh_if_needed() + + def refresh_if_needed(self) -> None: + if self.refresh_needed: self.reset() # Column data @@ -490,7 +499,11 @@ class Browser(QMainWindow): def on_operation_did_execute(self, op: OperationInfo) -> None: self.setUpdatesEnabled(True) - self.model.maybe_redraw_after_operation(op) + self.model.op_executed(op, current_top_level_widget() == self) + + def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: + if current_top_level_widget() == self: + self.model.refresh_if_needed() def setupMenus(self) -> None: # pylint: disable=unnecessary-lambda @@ -1415,7 +1428,6 @@ where id in %s""" def setupHooks(self) -> None: gui_hooks.undo_state_did_change.append(self.onUndoState) - gui_hooks.state_did_reset.append(self.onReset) gui_hooks.editor_did_fire_typing_timer.append(self.refreshCurrentCard) gui_hooks.editor_did_load_note.append(self.onLoadNote) gui_hooks.editor_did_unfocus_field.append(self.on_unfocus_field) @@ -1423,10 +1435,10 @@ where id in %s""" gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) gui_hooks.operation_will_execute.append(self.on_operation_will_execute) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + gui_hooks.focus_did_change.append(self.on_focus_change) def teardownHooks(self) -> None: gui_hooks.undo_state_did_change.remove(self.onUndoState) - gui_hooks.state_did_reset.remove(self.onReset) gui_hooks.editor_did_fire_typing_timer.remove(self.refreshCurrentCard) gui_hooks.editor_did_load_note.remove(self.onLoadNote) gui_hooks.editor_did_unfocus_field.remove(self.on_unfocus_field) @@ -1434,6 +1446,7 @@ where id in %s""" gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) gui_hooks.operation_will_execute.remove(self.on_operation_will_execute) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) + gui_hooks.focus_did_change.remove(self.on_focus_change) def on_unfocus_field(self, changed: bool, note: Note, field_idx: int) -> None: self.refreshCurrentCard(note) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 87aaaf6b7..a25b5e4f0 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -62,7 +62,7 @@ class DeckBrowser: self.bottom = BottomBar(mw, mw.bottomWeb) self.scrollPos = QPoint(0, 0) self._v1_message_dismissed_at = 0 - gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + self.refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -70,17 +70,23 @@ class DeckBrowser: self._renderPage() # redraw top bar for theme change self.mw.toolbar.redraw() + self.refresh() def refresh(self) -> None: self._renderPage() + self.refresh_needed = False - def on_operation_did_execute(self, op: OperationInfo) -> None: - if self.mw.state != "deckBrowser": - return - - if self.mw.col.op_affects_study_queue(op): + def refresh_if_needed(self) -> None: + if self.refresh_needed: self.refresh() + def op_executed(self, op: OperationInfo, focused: bool) -> None: + if self.mw.col.op_affects_study_queue(op): + self.refresh_needed = True + + if focused: + self.refresh_if_needed() + # Event handlers ########################################################################## diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 8c86f6de6..59f133800 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -72,6 +72,7 @@ from aqt.utils import ( HelpPage, askUser, checkInvalidFilename, + current_top_level_widget, disable_help_button, getFile, getOnlyText, @@ -85,6 +86,7 @@ from aqt.utils import ( showInfo, showWarning, tooltip, + top_level_widget, tr, ) @@ -92,28 +94,6 @@ T = TypeVar("T") install_pylib_legacy() - -class ResetReason(enum.Enum): - Unknown = "unknown" - AddCardsAddNote = "addCardsAddNote" - EditCurrentInit = "editCurrentInit" - EditorBridgeCmd = "editorBridgeCmd" - BrowserSetDeck = "browserSetDeck" - BrowserAddTags = "browserAddTags" - BrowserRemoveTags = "browserRemoveTags" - BrowserSuspend = "browserSuspend" - BrowserReposition = "browserReposition" - BrowserReschedule = "browserReschedule" - BrowserFindReplace = "browserFindReplace" - BrowserTagDupes = "browserTagDupes" - BrowserDeleteDeck = "browserDeleteDeck" - - -class ResetRequired: - def __init__(self, mw: AnkiQt) -> None: - self.mw = mw - - MainWindowState = Literal[ "startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager" ] @@ -194,6 +174,7 @@ class AnkiQt(QMainWindow): self.setupHooks() self.setup_timers() self.updateTitleBar() + self.setup_focus() # screens self.setupDeckBrowser() self.setupOverview() @@ -222,6 +203,12 @@ class AnkiQt(QMainWindow): "Shortcut to create a weak reference that doesn't break code completion." return weakref.proxy(self) # type: ignore + def setup_focus(self) -> None: + qconnect(self.app.focusChanged, self.on_focus_changed) + + def on_focus_changed(self, old: QWidget, new: QWidget) -> None: + gui_hooks.focus_did_change(new, old) + # Profiles ########################################################################## @@ -771,11 +758,32 @@ class AnkiQt(QMainWindow): setattr(op.changes, field.name, True) gui_hooks.operation_did_execute(op) + def on_operation_did_execute(self, op: OperationInfo) -> None: + "Notify current screen of changes." + focused = current_top_level_widget() == self + if self.state == "review": + self.reviewer.op_executed(op, focused) + elif self.state == "overview": + self.overview.op_executed(op, focused) + elif self.state == "deckBrowser": + self.deckBrowser.op_executed(op, focused) + + def on_focus_did_change( + self, new_focus: Optional[QWidget], _old: Optional[QWidget] + ) -> None: + "If main window has received focus, ensure current UI state is updated." + if new_focus and top_level_widget(new_focus) == self: + if self.state == "review": + self.reviewer.refresh_if_needed() + elif self.state == "overview": + self.overview.refresh_if_needed() + elif self.state == "deckBrowser": + self.deckBrowser.refresh_if_needed() + def reset(self, unused_arg: bool = False) -> None: """Legacy method of telling UI to refresh after changes made to DB. New code should use mw.perform_op() instead.""" - if self.col: # fire new `operation_did_execute` hook first. If the overview # or review screen are currently open, they will rebuild the study @@ -783,63 +791,26 @@ class AnkiQt(QMainWindow): self._synthesize_op_did_execute_from_reset() # fire the old reset hook gui_hooks.state_did_reset() - self.update_undo_actions() - # fixme: double-check - # self.moveToState(self.state) + # legacy def requireReset( self, modal: bool = False, - reason: ResetReason = ResetReason.Unknown, + reason: Any = None, context: Any = None, ) -> None: - "Signal queue needs to be rebuilt when edits are finished or by user." - self.autosave() - self.resetModal = modal - if gui_hooks.main_window_should_require_reset( - self.interactiveState(), reason, context - ): - self.moveToState("resetRequired") - - def interactiveState(self) -> bool: - "True if not in profile manager, syncing, etc." - return self.state in ("overview", "review", "deckBrowser") + self.reset() def maybeReset(self) -> None: - self.autosave() - if self.state == "resetRequired": - self.state = self.returnState - self.reset() + pass def delayedMaybeReset(self) -> None: - # if we redraw the page in a button click event it will often crash on - # windows - self.progress.timer(100, self.maybeReset, False) + pass def _resetRequiredState(self, oldState: MainWindowState) -> None: - if oldState != "resetRequired": - self.returnState = oldState - if self.resetModal: - # we don't have to change the webview, as we have a covering window - return - web_context = ResetRequired(self) - self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context) - i = tr(TR.QT_MISC_WAITING_FOR_EDITING_TO_FINISH) - b = self.button("refresh", tr(TR.QT_MISC_RESUME_NOW), id="resume") - self.web.stdHtml( - f""" -
-
-{i}

-{b}
- -""", - context=web_context, - ) - self.bottomWeb.hide() - self.web.setFocus() + pass # HTML helpers ########################################################################## @@ -1403,7 +1374,7 @@ title="%s" %s>%s""" % ( if elap > minutes * 60: self.maybe_auto_sync_media() - # Permanent libanki hooks + # Permanent hooks ########################################################################## def setupHooks(self) -> None: @@ -1413,6 +1384,8 @@ title="%s" %s>%s""" % ( gui_hooks.av_player_will_play.append(self.on_av_player_will_play) gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + gui_hooks.focus_did_change.append(self.on_focus_did_change) self._activeWindowOnPlay: Optional[QWidget] = None @@ -1748,6 +1721,10 @@ title="%s" %s>%s""" % ( def _isAddon(self, buf: str) -> bool: return buf.endswith(self.addonManager.ext) + def interactiveState(self) -> bool: + "True if not in profile manager, syncing, etc." + return self.state in ("overview", "review", "deckBrowser") + # GC ########################################################################## # The default Python garbage collection can trigger on any thread. This can @@ -1803,3 +1780,20 @@ title="%s" %s>%s""" % ( def serverURL(self) -> str: return "http://127.0.0.1:%d/" % self.mediaServer.getPort() + + +# legacy +class ResetReason(enum.Enum): + Unknown = "unknown" + AddCardsAddNote = "addCardsAddNote" + EditCurrentInit = "editCurrentInit" + EditorBridgeCmd = "editorBridgeCmd" + BrowserSetDeck = "browserSetDeck" + BrowserAddTags = "browserAddTags" + BrowserRemoveTags = "browserRemoveTags" + BrowserSuspend = "browserSuspend" + BrowserReposition = "browserReposition" + BrowserReschedule = "browserReschedule" + BrowserFindReplace = "browserFindReplace" + BrowserTagDupes = "browserTagDupes" + BrowserDeleteDeck = "browserDeleteDeck" diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 5e2fc979c..b3239313a 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -43,7 +43,7 @@ class Overview: self.mw = mw self.web = mw.web self.bottom = BottomBar(mw, mw.bottomWeb) - gui_hooks.operation_did_execute.append(self.on_operation_did_execute) + self.refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -57,15 +57,19 @@ class Overview: self._renderBottom() self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) + self.refresh_needed = False - def on_operation_did_execute(self, op: OperationInfo) -> None: - if self.mw.state != "overview": - return - - if self.mw.col.op_affects_study_queue(op): - # will also cover the deck description modified case + def refresh_if_needed(self) -> None: + if self.refresh_needed: self.refresh() + def op_executed(self, op: OperationInfo, focused: bool) -> None: + if self.mw.col.op_affects_study_queue(op): + self.refresh_needed = True + + if focused: + self.refresh_if_needed() + # Handlers ############################################################ diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 5109b6ec4..c0b87a2b6 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -7,6 +7,7 @@ import html import json import re import unicodedata as ucd +from enum import Enum, auto from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union from PyQt5.QtCore import Qt @@ -14,6 +15,7 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card from anki.collection import Config, OperationInfo +from anki.types import assert_exhaustive from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.profiles import VideoDriver @@ -38,6 +40,14 @@ class ReviewerBottomBar: self.reviewer = reviewer +class RefreshNeeded(Enum): + NO = auto() + NOTE_MARK = auto() + CARD_FLAG = auto() + QUEUE = auto() + CARD = auto() + + def replay_audio(card: Card, question_side: bool) -> None: if question_side: av_player.play_tags(card.question_av_tags()) @@ -61,17 +71,17 @@ class Reviewer: self._recordedAudio: Optional[str] = None self.typeCorrect: str = None # web init happens before this is set self.state: Optional[str] = None + self.refresh_needed = RefreshNeeded.NO self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) - gui_hooks.operation_did_execute.append(self.on_operation_did_execute) def show(self) -> None: - self.mw.col.reset() self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.web.set_bridge_command(self._linkHandler, self) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self._reps: int = None - self.nextCard() + self.refresh_needed = RefreshNeeded.QUEUE + self.refresh_if_needed() def lastCard(self) -> Optional[Card]: if self._answeredIds: @@ -87,26 +97,41 @@ class Reviewer: gui_hooks.reviewer_will_end() self.card = None - def on_operation_did_execute(self, op: OperationInfo) -> None: - if self.mw.state != "review": + def refresh_if_needed(self) -> None: + if self.refresh_needed is RefreshNeeded.NO: return - - if op.kind == OperationInfo.UPDATE_NOTE_TAGS: + elif self.refresh_needed is RefreshNeeded.NOTE_MARK: self.card.load() self._update_mark_icon() - elif op.kind == OperationInfo.SET_CARD_FLAG: + elif self.refresh_needed is RefreshNeeded.CARD_FLAG: # fixme: v3 mtime check self.card.load() self._update_flag_icon() - elif self.mw.col.op_affects_study_queue(op): - # need queue rebuild + elif self.refresh_needed is RefreshNeeded.QUEUE: self.mw.col.reset() self.nextCard() - return - elif op.changes.note or op.changes.notetype or op.changes.tag: - # need redraw of current card + elif self.refresh_needed is RefreshNeeded.CARD: self.card.load() self._showQuestion() + else: + assert_exhaustive(self.refresh_needed) + + self.refresh_needed = RefreshNeeded.NO + + def op_executed(self, op: OperationInfo, focused: bool) -> None: + if op.kind == OperationInfo.UPDATE_NOTE_TAGS: + self.refresh_needed = RefreshNeeded.NOTE_MARK + elif op.kind == OperationInfo.SET_CARD_FLAG: + self.refresh_needed = RefreshNeeded.CARD_FLAG + elif self.mw.col.op_affects_study_queue(op): + self.refresh_needed = RefreshNeeded.QUEUE + elif op.changes.note or op.changes.notetype or op.changes.tag: + self.refresh_needed = RefreshNeeded.CARD + else: + self.refresh_needed = RefreshNeeded.NO + + if focused: + self.refresh_if_needed() # Fetching a card ########################################################################## diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 8d94aa5c7..870d7b444 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -729,6 +729,20 @@ def downArrow() -> str: return "â–¾" +def top_level_widget(widget: QWidget) -> QWidget: + window = None + while widget := widget.parent(): + window = widget + return window + + +def current_top_level_widget() -> Optional[QWidget]: + if widget := QApplication.focusWidget(): + return top_level_widget(widget) + else: + return None + + # Tooltips ###################################################################### diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index e04b78c1d..aca968811 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -24,7 +24,7 @@ from anki.cards import Card from anki.decks import Deck, DeckConfig from anki.hooks import runFilter, runHook from anki.models import NoteType -from aqt.qt import QDialog, QEvent, QMenu +from aqt.qt import QDialog, QEvent, QMenu, QWidget from aqt.tagedit import TagEdit """ @@ -417,6 +417,15 @@ hooks = [ mw.reset(), `operation_will_execute` will not be called. """, ), + Hook( + name="focus_did_change", + args=[ + "new: Optional[QWidget]", + "old: Optional[QWidget]", + ], + doc="""Called each time the focus changes. Can be used to defer updates from + `operation_did_execute` until a window is brought to the front.""", + ), # Webview ################### Hook( From 30c7cf1fddc13ab011274a56bdf651c77c2e9485 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 15 Mar 2021 00:03:41 +1000 Subject: [PATCH 09/33] fade out webview when pending updates; do some reviewer updates immediately Issues that need fixing: - when the editor saves the note with perform_op(), if it isn't modified, no new undo entry is created, and perform_op then returns the changes made by the previous operation instead - the approach of fetching the last action in a subsequent backend method is unsound, as another queued operation may sneak in first before we have a chance to query the result - it would be better if it were returned in a single atomic action - redrawing the current card while editing is likely to make sound autoplay annoyingly, and it has an unpleasant redraw. We may be better off fading it out instead Side note: the editor cursor moves to the start of the field when the note is updated in another window - it might be nicer to have it move the cursor to the end instead. --- qt/aqt/browser.py | 17 ++++++----- qt/aqt/deckbrowser.py | 12 ++++---- qt/aqt/editor.py | 5 ++-- qt/aqt/main.py | 17 +++++++++-- qt/aqt/overview.py | 12 ++++---- qt/aqt/reviewer.py | 63 +++++++++++++++++----------------------- rslib/backend.proto | 1 + rslib/src/backend/ops.rs | 1 + ts/sass/core.scss | 1 + 9 files changed, 68 insertions(+), 61 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index a1e4b425d..75b247b19 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1246,7 +1246,6 @@ where id in %s""" nids = self.selectedNotes() self.mw.perform_op(lambda: func(nids, tags)) - self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) def clearUnusedTags(self) -> None: self.editor.saveNow(self._clearUnusedTags) @@ -1272,13 +1271,15 @@ where id in %s""" def _suspend_selected_cards(self) -> None: want_suspend = not self.current_card_is_suspended() - c = self.selectedCards() - if want_suspend: - self.col.sched.suspend_cards(c) - else: - self.col.sched.unsuspend_cards(c) - self.model.reset() - self.mw.requireReset(reason=ResetReason.BrowserSuspend, context=self) + + def op() -> None: + if want_suspend: + self.col.sched.suspend_cards(cids) + else: + self.col.sched.unsuspend_cards(cids) + + cids = self.selectedCards() + self.mw.perform_op(op) # Exporting ###################################################################### diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index a25b5e4f0..759866f0a 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -62,7 +62,7 @@ class DeckBrowser: self.bottom = BottomBar(mw, mw.bottomWeb) self.scrollPos = QPoint(0, 0) self._v1_message_dismissed_at = 0 - self.refresh_needed = False + self._refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -74,19 +74,21 @@ class DeckBrowser: def refresh(self) -> None: self._renderPage() - self.refresh_needed = False + self._refresh_needed = False def refresh_if_needed(self) -> None: - if self.refresh_needed: + if self._refresh_needed: self.refresh() - def op_executed(self, op: OperationInfo, focused: bool) -> None: + def op_executed(self, op: OperationInfo, focused: bool) -> bool: if self.mw.col.op_affects_study_queue(op): - self.refresh_needed = True + self._refresh_needed = True if focused: self.refresh_if_needed() + return self._refresh_needed + # Event handlers ########################################################################## diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index ec2d69dd8..979e61aed 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -27,7 +27,6 @@ from anki.httpclient import HttpClient from anki.notes import Note from anki.utils import checksum, isLin, isWin, namedtmp from aqt import AnkiQt, colors, gui_hooks -from aqt.main import ResetReason from aqt.qt import * from aqt.sound import av_player from aqt.theme import theme_manager @@ -450,7 +449,6 @@ class Editor: if not self.addMode: self._save_current_note() - self.mw.requireReset(reason=ResetReason.EditorBridgeCmd, context=self) if type == "blur": self.currentField = None # run any filters @@ -544,7 +542,8 @@ class Editor: def _save_current_note(self) -> None: "Call after note is updated with data from webview." - self.mw.col.update_note(self.note) + note = self.note + self.mw.perform_op(lambda: self.mw.col.update_note(note)) def fonts(self) -> List[Tuple[str, int, bool]]: return [ diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 59f133800..b77701fe9 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -762,11 +762,16 @@ class AnkiQt(QMainWindow): "Notify current screen of changes." focused = current_top_level_widget() == self if self.state == "review": - self.reviewer.op_executed(op, focused) + dirty = self.reviewer.op_executed(op, focused) elif self.state == "overview": - self.overview.op_executed(op, focused) + dirty = self.overview.op_executed(op, focused) elif self.state == "deckBrowser": - self.deckBrowser.op_executed(op, focused) + dirty = self.deckBrowser.op_executed(op, focused) + else: + dirty = False + + if not focused and dirty: + self.fade_out_webview() def on_focus_did_change( self, new_focus: Optional[QWidget], _old: Optional[QWidget] @@ -780,6 +785,12 @@ class AnkiQt(QMainWindow): elif self.state == "deckBrowser": self.deckBrowser.refresh_if_needed() + def fade_out_webview(self) -> None: + self.web.eval("document.body.style.opacity = 0.3") + + def fade_in_webview(self) -> None: + self.web.eval("document.body.style.opacity = 1") + def reset(self, unused_arg: bool = False) -> None: """Legacy method of telling UI to refresh after changes made to DB. diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index b3239313a..ac62de45c 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -43,7 +43,7 @@ class Overview: self.mw = mw self.web = mw.web self.bottom = BottomBar(mw, mw.bottomWeb) - self.refresh_needed = False + self._refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -57,19 +57,21 @@ class Overview: self._renderBottom() self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) - self.refresh_needed = False + self._refresh_needed = False def refresh_if_needed(self) -> None: - if self.refresh_needed: + if self._refresh_needed: self.refresh() - def op_executed(self, op: OperationInfo, focused: bool) -> None: + def op_executed(self, op: OperationInfo, focused: bool) -> bool: if self.mw.col.op_affects_study_queue(op): - self.refresh_needed = True + self._refresh_needed = True if focused: self.refresh_if_needed() + return self._refresh_needed + # Handlers ############################################################ diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index c0b87a2b6..db96b0852 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -7,7 +7,6 @@ import html import json import re import unicodedata as ucd -from enum import Enum, auto from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union from PyQt5.QtCore import Qt @@ -15,7 +14,6 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card from anki.collection import Config, OperationInfo -from anki.types import assert_exhaustive from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.profiles import VideoDriver @@ -40,14 +38,6 @@ class ReviewerBottomBar: self.reviewer = reviewer -class RefreshNeeded(Enum): - NO = auto() - NOTE_MARK = auto() - CARD_FLAG = auto() - QUEUE = auto() - CARD = auto() - - def replay_audio(card: Card, question_side: bool) -> None: if question_side: av_player.play_tags(card.question_av_tags()) @@ -71,7 +61,7 @@ class Reviewer: self._recordedAudio: Optional[str] = None self.typeCorrect: str = None # web init happens before this is set self.state: Optional[str] = None - self.refresh_needed = RefreshNeeded.NO + self._refresh_needed = False self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) @@ -80,7 +70,7 @@ class Reviewer: self.web.set_bridge_command(self._linkHandler, self) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self._reps: int = None - self.refresh_needed = RefreshNeeded.QUEUE + self._refresh_needed = True self.refresh_if_needed() def lastCard(self) -> Optional[Card]: @@ -98,41 +88,40 @@ class Reviewer: self.card = None def refresh_if_needed(self) -> None: - if self.refresh_needed is RefreshNeeded.NO: - return - elif self.refresh_needed is RefreshNeeded.NOTE_MARK: + if self._refresh_needed: + self.mw.col.reset() + self.nextCard() + self._refresh_needed = False + self.mw.fade_in_webview() + + def op_executed(self, op: OperationInfo, focused: bool) -> bool: + + if op.kind == OperationInfo.UPDATE_NOTE_TAGS: self.card.load() self._update_mark_icon() - elif self.refresh_needed is RefreshNeeded.CARD_FLAG: + elif op.kind == OperationInfo.SET_CARD_FLAG: # fixme: v3 mtime check self.card.load() self._update_flag_icon() - elif self.refresh_needed is RefreshNeeded.QUEUE: - self.mw.col.reset() - self.nextCard() - elif self.refresh_needed is RefreshNeeded.CARD: - self.card.load() - self._showQuestion() - else: - assert_exhaustive(self.refresh_needed) - - self.refresh_needed = RefreshNeeded.NO - - def op_executed(self, op: OperationInfo, focused: bool) -> None: - if op.kind == OperationInfo.UPDATE_NOTE_TAGS: - self.refresh_needed = RefreshNeeded.NOTE_MARK - elif op.kind == OperationInfo.SET_CARD_FLAG: - self.refresh_needed = RefreshNeeded.CARD_FLAG + elif op.kind == OperationInfo.UPDATE_NOTE: + self._redraw_current_card() elif self.mw.col.op_affects_study_queue(op): - self.refresh_needed = RefreshNeeded.QUEUE + self._refresh_needed = True elif op.changes.note or op.changes.notetype or op.changes.tag: - self.refresh_needed = RefreshNeeded.CARD - else: - self.refresh_needed = RefreshNeeded.NO + self._redraw_current_card() - if focused: + if focused and self._refresh_needed: self.refresh_if_needed() + return self._refresh_needed + + def _redraw_current_card(self) -> None: + self.card.load() + if self.state == "answer": + self._showAnswer() + else: + self._showQuestion() + # Fetching a card ########################################################################## diff --git a/rslib/backend.proto b/rslib/backend.proto index 759f3eb65..1f6daed11 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -1457,6 +1457,7 @@ message OperationInfo { OTHER = 0; UPDATE_NOTE_TAGS = 1; SET_CARD_FLAG = 2; + UPDATE_NOTE = 3; } Kind kind = 1; diff --git a/rslib/src/backend/ops.rs b/rslib/src/backend/ops.rs index 4d1447215..71fb2fa11 100644 --- a/rslib/src/backend/ops.rs +++ b/rslib/src/backend/ops.rs @@ -23,6 +23,7 @@ impl From for Kind { match o { Op::SetFlag => Kind::SetCardFlag, Op::UpdateTag => Kind::UpdateNoteTags, + Op::UpdateNote => Kind::UpdateNote, _ => Kind::Other, } } diff --git a/ts/sass/core.scss b/ts/sass/core.scss index f0df18217..47fd71c7c 100644 --- a/ts/sass/core.scss +++ b/ts/sass/core.scss @@ -12,6 +12,7 @@ body { color: var(--text-fg); background: var(--window-bg); margin: 1em; + transition: opacity 0.5s ease-out; } a { From 6b0fe4b3810aded191c7d0833ed9558d6f0bebe7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 14:26:42 +1000 Subject: [PATCH 10/33] undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead --- pylib/anki/collection.py | 97 ++++++++----- pylib/anki/decks.py | 3 +- pylib/anki/find.py | 7 +- pylib/anki/latex.py | 3 +- pylib/anki/scheduler/base.py | 27 ++-- pylib/anki/tags.py | 11 +- pylib/tests/test_find.py | 26 +++- qt/.pylintrc | 2 +- qt/aqt/addcards.py | 29 ++-- qt/aqt/browser.py | 143 ++++++++++---------- qt/aqt/card_ops.py | 16 +++ qt/aqt/deck_ops.py | 24 ++++ qt/aqt/deckbrowser.py | 19 +-- qt/aqt/editor.py | 4 +- qt/aqt/main.py | 78 ++++++++--- qt/aqt/note_ops.py | 74 ++++++++++ qt/aqt/overview.py | 6 +- qt/aqt/reviewer.py | 91 +++++++------ qt/aqt/{scheduling.py => scheduling_ops.py} | 50 ++++++- qt/aqt/sidebar.py | 20 +-- qt/aqt/utils.py | 4 +- qt/mypy.ini | 9 ++ qt/tools/genhooks_gui.py | 2 +- rslib/backend.proto | 66 +++++---- rslib/src/backend/card.rs | 15 +- rslib/src/backend/config.rs | 14 +- rslib/src/backend/deckconfig.rs | 4 +- rslib/src/backend/decks.rs | 8 +- rslib/src/backend/generic.rs | 9 ++ rslib/src/backend/media.rs | 6 +- rslib/src/backend/notes.rs | 57 ++++---- rslib/src/backend/ops.rs | 39 +++--- rslib/src/backend/scheduler/mod.rs | 16 +-- rslib/src/backend/search.rs | 4 +- rslib/src/backend/tags.rs | 6 +- rslib/src/card/mod.rs | 36 ++++- rslib/src/collection.rs | 55 ++++++-- rslib/src/config/undo.rs | 2 +- rslib/src/dbcheck.rs | 2 +- rslib/src/decks/mod.rs | 29 ++-- rslib/src/filtered.rs | 4 +- rslib/src/findreplace.rs | 12 +- rslib/src/media/check.rs | 10 +- rslib/src/notes/mod.rs | 81 +++++++---- rslib/src/notes/undo.rs | 58 ++++---- rslib/src/notetype/mod.rs | 6 +- rslib/src/ops.rs | 88 ++---------- rslib/src/preferences.rs | 9 +- rslib/src/prelude.rs | 2 +- rslib/src/scheduler/answering/mod.rs | 8 +- rslib/src/scheduler/bury_and_suspend.rs | 10 +- rslib/src/scheduler/new.rs | 9 +- rslib/src/scheduler/queue/mod.rs | 7 + rslib/src/scheduler/reviews.rs | 4 +- rslib/src/sync/server.rs | 3 +- rslib/src/tags/mod.rs | 22 +-- rslib/src/undo/mod.rs | 91 +++++++++---- 57 files changed, 918 insertions(+), 619 deletions(-) create mode 100644 qt/aqt/card_ops.py create mode 100644 qt/aqt/deck_ops.py create mode 100644 qt/aqt/note_ops.py rename qt/aqt/{scheduling.py => scheduling_ops.py} (54%) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index e0d1b3ebe..3bdf63574 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -3,6 +3,22 @@ from __future__ import annotations +from typing import Any, List, Literal, Optional, Sequence, Tuple, Union + +import anki._backend.backend_pb2 as _pb + +# protobuf we publicly export - listed first to avoid circular imports +SearchNode = _pb.SearchNode +Progress = _pb.Progress +EmptyCardsReport = _pb.EmptyCardsReport +GraphPreferences = _pb.GraphPreferences +BuiltinSort = _pb.SortOrder.Builtin +Preferences = _pb.Preferences +UndoStatus = _pb.UndoStatus +OpChanges = _pb.OpChanges +OpChangesWithCount = _pb.OpChangesWithCount +DefaultsForAdding = _pb.DeckAndNotetype + import copy import os import pprint @@ -12,12 +28,8 @@ import time import traceback import weakref from dataclasses import dataclass, field -from typing import Any, List, Literal, Optional, Sequence, Tuple, Union -import anki._backend.backend_pb2 as _pb -import anki.find -import anki.latex # sets up hook -import anki.template +import anki.latex from anki import hooks from anki._backend import RustBackend from anki.cards import Card @@ -45,17 +57,10 @@ from anki.utils import ( stripHTMLMedia, ) -# public exports -SearchNode = _pb.SearchNode +anki.latex.setup_hook() + + SearchJoiner = Literal["AND", "OR"] -Progress = _pb.Progress -EmptyCardsReport = _pb.EmptyCardsReport -GraphPreferences = _pb.GraphPreferences -BuiltinSort = _pb.SortOrder.Builtin -Preferences = _pb.Preferences -UndoStatus = _pb.UndoStatus -OperationInfo = _pb.OperationInfo -DefaultsForAdding = _pb.DeckAndNotetype @dataclass @@ -323,10 +328,12 @@ class Collection: def get_note(self, id: int) -> Note: return Note(self, id=id) - def update_note(self, note: Note) -> None: + def update_note(self, note: Note) -> OpChanges: """Save note changes to database, and add an undo entry. Unlike note.flush(), this will invalidate any current checkpoint.""" - self._backend.update_note(note=note._to_backend_note(), skip_undo_entry=False) + return self._backend.update_note( + note=note._to_backend_note(), skip_undo_entry=False + ) getCard = get_card getNote = get_note @@ -361,12 +368,14 @@ class Collection: def new_note(self, notetype: NoteType) -> Note: return Note(self, notetype) - def add_note(self, note: Note, deck_id: int) -> None: - note.id = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id) + def add_note(self, note: Note, deck_id: int) -> OpChanges: + out = self._backend.add_note(note=note._to_backend_note(), deck_id=deck_id) + note.id = out.note_id + return out.changes - def remove_notes(self, note_ids: Sequence[int]) -> None: + def remove_notes(self, note_ids: Sequence[int]) -> OpChanges: hooks.notes_will_be_deleted(self, note_ids) - self._backend.remove_notes(note_ids=note_ids, card_ids=[]) + return self._backend.remove_notes(note_ids=note_ids, card_ids=[]) def remove_notes_by_card(self, card_ids: List[int]) -> None: if hooks.notes_will_be_deleted.count(): @@ -440,8 +449,8 @@ class Collection: "You probably want .remove_notes_by_card() instead." self._backend.remove_cards(card_ids=card_ids) - def set_deck(self, card_ids: List[int], deck_id: int) -> None: - self._backend.set_deck(card_ids=card_ids, deck_id=deck_id) + def set_deck(self, card_ids: Sequence[int], deck_id: int) -> OpChanges: + return self._backend.set_deck(card_ids=card_ids, deck_id=deck_id) def get_empty_cards(self) -> EmptyCardsReport: return self._backend.get_empty_cards() @@ -531,14 +540,23 @@ class Collection: def find_and_replace( self, - nids: List[int], - src: str, - dst: str, - regex: Optional[bool] = None, - field: Optional[str] = None, - fold: bool = True, - ) -> int: - return anki.find.findReplace(self, nids, src, dst, regex, field, fold) + *, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool = False, + field_name: Optional[str] = None, + match_case: bool = False, + ) -> OpChangesWithCount: + "Find and replace fields in a note. Returns changed note count." + return self._backend.find_and_replace( + nids=note_ids, + search=search, + replacement=replacement, + regex=regex, + match_case=match_case, + field_name=field_name or "", + ) # returns array of ("dupestr", [nids]) def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: @@ -810,10 +828,17 @@ table.review-log {{ {revlog_style} }} assert_exhaustive(self._undo) assert False - def op_affects_study_queue(self, op: OperationInfo) -> bool: - if op.kind == op.SET_CARD_FLAG: + def op_affects_study_queue(self, changes: OpChanges) -> bool: + if changes.kind == changes.SET_CARD_FLAG: return False - return op.changes.card or op.changes.deck or op.changes.preference + return changes.card or changes.deck or changes.preference + + def op_made_changes(self, changes: OpChanges) -> bool: + for field in changes.DESCRIPTOR.fields: + if field.name != "kind": + if getattr(changes, field.name, False): + return True + return False def _check_backend_undo_status(self) -> Optional[UndoStatus]: """Return undo status if undo available on backend. @@ -986,8 +1011,8 @@ table.review-log {{ {revlog_style} }} ########################################################################## - def set_user_flag_for_cards(self, flag: int, cids: List[int]) -> None: - self._backend.set_flag(card_ids=cids, flag=flag) + def set_user_flag_for_cards(self, flag: int, cids: Sequence[int]) -> OpChanges: + return self._backend.set_flag(card_ids=cids, flag=flag) def set_wants_abort(self) -> None: self._backend.set_wants_abort() diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 853ba991a..16a1a60c1 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -11,6 +11,7 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union import anki # pylint: disable=unused-import import anki._backend.backend_pb2 as _pb +from anki.collection import OpChangesWithCount from anki.consts import * from anki.errors import NotFoundError from anki.utils import from_json_bytes, ids2str, intTime, legacy_func, to_json_bytes @@ -138,7 +139,7 @@ class DeckManager: assert cardsToo and childrenToo self.remove([did]) - def remove(self, dids: List[int]) -> int: + def remove(self, dids: Sequence[int]) -> OpChangesWithCount: return self.col._backend.remove_decks(dids) def all_names_and_ids( diff --git a/pylib/anki/find.py b/pylib/anki/find.py index af829cb7d..346eff311 100644 --- a/pylib/anki/find.py +++ b/pylib/anki/find.py @@ -37,14 +37,15 @@ def findReplace( fold: bool = True, ) -> int: "Find and replace fields in a note. Returns changed note count." - return col._backend.find_and_replace( - nids=nids, + print("use col.find_and_replace() instead of findReplace()") + return col.find_and_replace( + note_ids=nids, search=src, replacement=dst, regex=regex, match_case=not fold, field_name=field, - ) + ).count def fieldNamesForNotes(col: Collection, nids: List[int]) -> List[str]: diff --git a/pylib/anki/latex.py b/pylib/anki/latex.py index cfbf6734b..e7bab9dce 100644 --- a/pylib/anki/latex.py +++ b/pylib/anki/latex.py @@ -178,4 +178,5 @@ def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any: return msg -hooks.card_did_render.append(on_card_did_render) +def setup_hook() -> None: + hooks.card_did_render.append(on_card_did_render) diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index f8c05392b..06f2dd61b 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -5,6 +5,7 @@ from __future__ import annotations import anki import anki._backend.backend_pb2 as _pb +from anki.collection import OpChanges from anki.config import Config SchedTimingToday = _pb.SchedTimingTodayOut @@ -96,11 +97,11 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Suspending & burying ########################################################################## - def unsuspend_cards(self, ids: List[int]) -> None: - self.col._backend.restore_buried_and_suspended_cards(ids) + def unsuspend_cards(self, ids: Sequence[int]) -> OpChanges: + return self.col._backend.restore_buried_and_suspended_cards(ids) - def unbury_cards(self, ids: List[int]) -> None: - self.col._backend.restore_buried_and_suspended_cards(ids) + def unbury_cards(self, ids: List[int]) -> OpChanges: + return self.col._backend.restore_buried_and_suspended_cards(ids) def unbury_cards_in_current_deck( self, @@ -108,17 +109,17 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l ) -> None: self.col._backend.unbury_cards_in_current_deck(mode) - def suspend_cards(self, ids: Sequence[int]) -> None: - self.col._backend.bury_or_suspend_cards( + def suspend_cards(self, ids: Sequence[int]) -> OpChanges: + return self.col._backend.bury_or_suspend_cards( card_ids=ids, mode=BuryOrSuspend.SUSPEND ) - def bury_cards(self, ids: Sequence[int], manual: bool = True) -> None: + def bury_cards(self, ids: Sequence[int], manual: bool = True) -> OpChanges: if manual: mode = BuryOrSuspend.BURY_USER else: mode = BuryOrSuspend.BURY_SCHED - self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode) + return self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode) def bury_note(self, note: Note) -> None: self.bury_cards(note.card_ids()) @@ -126,16 +127,16 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Resetting/rescheduling ########################################################################## - def schedule_cards_as_new(self, card_ids: List[int]) -> None: + def schedule_cards_as_new(self, card_ids: List[int]) -> OpChanges: "Put cards at the end of the new queue." - self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True) + return self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True) def set_due_date( self, card_ids: List[int], days: str, config_key: Optional[Config.String.Key.V] = None, - ) -> None: + ) -> OpChanges: """Set cards to be due in `days`, turning them into review cards if necessary. `days` can be of the form '5' or '5..7' If `config_key` is provided, provided days will be remembered in config.""" @@ -143,7 +144,9 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l key = Config.String(key=config_key) else: key = None - self.col._backend.set_due_date(card_ids=card_ids, days=days, config_key=key) + return self.col._backend.set_due_date( + card_ids=card_ids, days=days, config_key=key + ) def resetCards(self, ids: List[int]) -> None: "Completely reset cards for export." diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 5e2ae27ce..ffd9da9fc 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -18,6 +18,7 @@ from typing import Collection, List, Match, Optional, Sequence import anki # pylint: disable=unused-import import anki._backend.backend_pb2 as _pb import anki.collection +from anki.collection import OpChangesWithCount from anki.utils import ids2str # public exports @@ -75,27 +76,27 @@ class TagManager: # Bulk addition/removal from notes ############################################################# - def bulk_add(self, nids: List[int], tags: str) -> int: + def bulk_add(self, nids: Sequence[int], tags: str) -> OpChangesWithCount: """Add space-separate tags to provided notes, returning changed count.""" return self.col._backend.add_note_tags(nids=nids, tags=tags) def bulk_update( self, nids: Sequence[int], tags: str, replacement: str, regex: bool - ) -> int: + ) -> OpChangesWithCount: """Replace space-separated tags, returning changed count. Tags replaced with an empty string will be removed.""" return self.col._backend.update_note_tags( nids=nids, tags=tags, replacement=replacement, regex=regex ) - def bulk_remove(self, nids: Sequence[int], tags: str) -> int: + def bulk_remove(self, nids: Sequence[int], tags: str) -> OpChangesWithCount: return self.bulk_update(nids, tags, "", False) - def rename(self, old: str, new: str) -> int: + def rename(self, old: str, new: str) -> OpChangesWithCount: "Rename provided tag, returning number of changed notes." nids = self.col.find_notes(anki.collection.SearchNode(tag=old)) if not nids: - return 0 + return OpChangesWithCount() escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old) return self.bulk_update(nids, escaped_name, new, False) diff --git a/pylib/tests/test_find.py b/pylib/tests/test_find.py index eb5eca906..91d7f1bb0 100644 --- a/pylib/tests/test_find.py +++ b/pylib/tests/test_find.py @@ -243,24 +243,40 @@ def test_findReplace(): col.addNote(note2) nids = [note.id, note2.id] # should do nothing - assert col.findReplace(nids, "abc", "123") == 0 + assert ( + col.find_and_replace(note_ids=nids, search="abc", replacement="123").count == 0 + ) # global replace - assert col.findReplace(nids, "foo", "qux") == 2 + assert ( + col.find_and_replace(note_ids=nids, search="foo", replacement="qux").count == 2 + ) note.load() assert note["Front"] == "qux" note2.load() assert note2["Back"] == "qux" # single field replace - assert col.findReplace(nids, "qux", "foo", field="Front") == 1 + assert ( + col.find_and_replace( + note_ids=nids, search="qux", replacement="foo", field_name="Front" + ).count + == 1 + ) note.load() assert note["Front"] == "foo" note2.load() assert note2["Back"] == "qux" # regex replace - assert col.findReplace(nids, "B.r", "reg") == 0 + assert ( + col.find_and_replace(note_ids=nids, search="B.r", replacement="reg").count == 0 + ) note.load() assert note["Back"] != "reg" - assert col.findReplace(nids, "B.r", "reg", regex=True) == 1 + assert ( + col.find_and_replace( + note_ids=nids, search="B.r", replacement="reg", regex=True + ).count + == 1 + ) note.load() assert note["Back"] == "reg" diff --git a/qt/.pylintrc b/qt/.pylintrc index 844ae633a..4a701207b 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -8,7 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-classes= SearchNode, Config, - OperationInfo + OpChanges [REPORTS] output-format=colorized diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 6c38f16e3..b91609504 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -6,12 +6,12 @@ from typing import Callable, List, Optional import aqt.deckchooser import aqt.editor import aqt.forms -from anki.collection import SearchNode +from anki.collection import OpChanges, SearchNode from anki.consts import MODEL_CLOZE from anki.notes import DuplicateOrEmptyResult, Note from anki.utils import htmlToTextLine, isMac from aqt import AnkiQt, gui_hooks -from aqt.main import ResetReason +from aqt.note_ops import add_note from aqt.notetypechooser import NoteTypeChooser from aqt.qt import * from aqt.sound import av_player @@ -191,23 +191,24 @@ class AddCards(QDialog): return target_deck_id = self.deck_chooser.selected_deck_id - self.mw.col.add_note(note, target_deck_id) - # only used for detecting changed sticky fields on close - self._last_added_note = note + def on_success(changes: OpChanges) -> None: + # only used for detecting changed sticky fields on close + self._last_added_note = note - self.addHistory(note) - self.mw.requireReset(reason=ResetReason.AddCardsAddNote, context=self) + self.addHistory(note) - # workaround for PyQt focus bug - self.editor.hideCompleters() + # workaround for PyQt focus bug + self.editor.hideCompleters() - tooltip(tr(TR.ADDING_ADDED), period=500) - av_player.stop_and_clear_queue() - self._load_new_note(sticky_fields_from=note) - self.mw.col.autosave() # fixme: + tooltip(tr(TR.ADDING_ADDED), period=500) + av_player.stop_and_clear_queue() + self._load_new_note(sticky_fields_from=note) + gui_hooks.add_cards_did_add_note(note) - gui_hooks.add_cards_did_add_note(note) + add_note( + mw=self.mw, note=note, target_deck_id=target_deck_id, success=on_success + ) def _note_can_be_added(self, note: Note) -> bool: result = note.duplicate_or_empty() diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 75b247b19..089528c8a 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, import aqt import aqt.forms from anki.cards import Card -from anki.collection import Collection, Config, OperationInfo, SearchNode +from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.errors import InvalidInput, NotFoundError from anki.lang import without_unicode_isolation @@ -21,13 +21,20 @@ from anki.notes import Note from anki.stats import CardStats from anki.utils import htmlToTextLine, ids2str, isMac, isWin from aqt import AnkiQt, colors, gui_hooks +from aqt.card_ops import set_card_deck, set_card_flag from aqt.editor import Editor from aqt.exporting import ExportDialog from aqt.main import ResetReason +from aqt.note_ops import add_tags, find_and_replace, remove_notes, remove_tags from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer from aqt.qt import * -from aqt.scheduling import forget_cards, set_due_date_dialog +from aqt.scheduling_ops import ( + forget_cards, + set_due_date_dialog, + suspend_cards, + unsuspend_cards, +) from aqt.sidebar import SidebarSearchBar, SidebarToolbar, SidebarTreeView from aqt.theme import theme_manager from aqt.utils import ( @@ -284,8 +291,8 @@ class DataModel(QAbstractTableModel): else: tv.selectRow(0) - def op_executed(self, op: OperationInfo, focused: bool) -> None: - if op.changes.card or op.changes.note or op.changes.deck or op.changes.notetype: + def op_executed(self, op: OpChanges, focused: bool) -> None: + if op.card or op.note or op.deck or op.notetype: self.refresh_needed = True if focused: self.refresh_if_needed() @@ -497,9 +504,9 @@ class Browser(QMainWindow): # as that will block the UI self.setUpdatesEnabled(False) - def on_operation_did_execute(self, op: OperationInfo) -> None: + def on_operation_did_execute(self, changes: OpChanges) -> None: self.setUpdatesEnabled(True) - self.model.op_executed(op, current_top_level_widget() == self) + self.model.op_executed(changes, current_top_level_widget() == self) def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: if current_top_level_widget() == self: @@ -1167,8 +1174,9 @@ where id in %s""" # select the next card if there is one self._onNextCard() - self.mw.perform_op( - lambda: self.col.remove_notes(nids), + remove_notes( + mw=self.mw, + note_ids=nids, success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))), ) @@ -1200,7 +1208,7 @@ where id in %s""" return did = self.col.decks.id(ret.name) - self.mw.perform_op(lambda: self.col.set_deck(cids, did)) + set_card_deck(mw=self.mw, card_ids=cids, deck_id=did) # legacy @@ -1214,38 +1222,43 @@ where id in %s""" tags: Optional[str] = None, ) -> None: "Shows prompt if tags not provided." - self.editor.saveNow( - lambda: self._update_tags_of_selected_notes( - func=self.col.tags.bulk_add, - tags=tags, - prompt=tr(TR.BROWSING_ENTER_TAGS_TO_ADD), - ) - ) + + def op() -> None: + if not ( + tags2 := self.maybe_prompt_for_tags( + tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD) + ) + ): + return + nids = self.selectedNotes() + add_tags(mw=self.mw, note_ids=nids, space_separated_tags=tags2) + + self.editor.saveNow(op) def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: "Shows prompt if tags not provided." - self.editor.saveNow( - lambda: self._update_tags_of_selected_notes( - func=self.col.tags.bulk_remove, - tags=tags, - prompt=tr(TR.BROWSING_ENTER_TAGS_TO_DELETE), - ) - ) - def _update_tags_of_selected_notes( - self, - func: Callable[[List[int], str], int], - tags: Optional[str], - prompt: Optional[str], - ) -> None: - "If tags provided, prompt skipped. If tags not provided, prompt must be." - if tags is None: - (tags, ok) = getTag(self, self.col, prompt) - if not ok: + def op() -> None: + if not ( + tags2 := self.maybe_prompt_for_tags( + tags, tr(TR.BROWSING_ENTER_TAGS_TO_DELETE) + ) + ): return + nids = self.selectedNotes() + remove_tags(mw=self.mw, note_ids=nids, space_separated_tags=tags2) - nids = self.selectedNotes() - self.mw.perform_op(lambda: func(nids, tags)) + self.editor.saveNow(op) + + def _maybe_prompt_for_tags(self, tags: Optional[str], prompt: str) -> Optional[str]: + if tags is not None: + return tags + + (tags, ok) = getTag(self, self.col, prompt) + if not ok: + return None + else: + return tags def clearUnusedTags(self) -> None: self.editor.saveNow(self._clearUnusedTags) @@ -1271,15 +1284,12 @@ where id in %s""" def _suspend_selected_cards(self) -> None: want_suspend = not self.current_card_is_suspended() - - def op() -> None: - if want_suspend: - self.col.sched.suspend_cards(cids) - else: - self.col.sched.unsuspend_cards(cids) - cids = self.selectedCards() - self.mw.perform_op(op) + + if want_suspend: + suspend_cards(mw=self.mw, card_ids=cids) + else: + unsuspend_cards(mw=self.mw, card_ids=cids) # Exporting ###################################################################### @@ -1297,13 +1307,13 @@ where id in %s""" return self.editor.saveNow(lambda: self._on_set_flag(n)) - def _on_set_flag(self, n: int) -> None: + def _on_set_flag(self, flag: int) -> None: # flag needs toggling off? - if n == self.card.user_flag(): - n = 0 + if flag == self.card.user_flag(): + flag = 0 cids = self.selectedCards() - self.mw.perform_op(lambda: self.col.set_user_flag_for_cards(n, cids)) + set_card_flag(mw=self.mw, card_ids=cids, flag=flag) def _updateFlagsMenu(self) -> None: flag = self.card and self.card.user_flag() @@ -1531,38 +1541,21 @@ where id in %s""" replace = save_combo_history(frm.replace, replacehistory, combo + "Replace") regex = frm.re.isChecked() - nocase = frm.ignoreCase.isChecked() + match_case = not frm.ignoreCase.isChecked() save_is_checked(frm.re, combo + "Regex") save_is_checked(frm.ignoreCase, combo + "ignoreCase") - self.mw.checkpoint(tr(TR.BROWSING_FIND_AND_REPLACE)) - # starts progress dialog as well - self.model.beginReset() - - def do_search() -> int: - return self.col.find_and_replace( - nids, search, replace, regex, field, nocase - ) - - def on_done(fut: Future) -> None: - self.search() - self.mw.requireReset(reason=ResetReason.BrowserFindReplace, context=self) - self.model.endReset() - - total = len(nids) - try: - changed = fut.result() - except InvalidInput as e: - show_invalid_search_error(e) - return - - showInfo( - tr(TR.FINDREPLACE_NOTES_UPDATED, changed=changed, total=total), - parent=self, - ) - - self.mw.taskman.run_in_background(do_search, on_done) + find_and_replace( + mw=self.mw, + parent=self, + note_ids=nids, + search=search, + replacement=replace, + regex=regex, + field_name=field, + match_case=match_case, + ) def onFindReplaceHelp(self) -> None: openHelp(HelpPage.BROWSING_FIND_AND_REPLACE) diff --git a/qt/aqt/card_ops.py b/qt/aqt/card_ops.py new file mode 100644 index 000000000..d7553527a --- /dev/null +++ b/qt/aqt/card_ops.py @@ -0,0 +1,16 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from typing import Sequence + +from aqt import AnkiQt + + +def set_card_deck(*, mw: AnkiQt, card_ids: Sequence[int], deck_id: int) -> None: + mw.perform_op(lambda: mw.col.set_deck(card_ids, deck_id)) + + +def set_card_flag(*, mw: AnkiQt, card_ids: Sequence[int], flag: int) -> None: + mw.perform_op(lambda: mw.col.set_user_flag_for_cards(flag, card_ids)) diff --git a/qt/aqt/deck_ops.py b/qt/aqt/deck_ops.py new file mode 100644 index 000000000..ff7e3ba98 --- /dev/null +++ b/qt/aqt/deck_ops.py @@ -0,0 +1,24 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from typing import Sequence + +from anki.lang import TR +from aqt import AnkiQt, QDialog +from aqt.utils import tooltip, tr + + +def remove_decks( + *, + mw: AnkiQt, + parent: QDialog, + deck_ids: Sequence[int], +) -> None: + mw.perform_op( + lambda: mw.col.decks.remove(deck_ids), + success=lambda out: tooltip( + tr(TR.BROWSING_CARDS_DELETED, count=out.count), parent=parent + ), + ) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 759866f0a..2795e7a46 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -8,11 +8,12 @@ from dataclasses import dataclass from typing import Any import aqt -from anki.collection import OperationInfo +from anki.collection import OpChanges from anki.decks import DeckTreeNode from anki.errors import DeckIsFilteredError from anki.utils import intTime from aqt import AnkiQt, gui_hooks +from aqt.deck_ops import remove_decks from aqt.qt import * from aqt.sound import av_player from aqt.toolbar import BottomBar @@ -24,7 +25,6 @@ from aqt.utils import ( shortcut, showInfo, showWarning, - tooltip, tr, ) @@ -80,8 +80,8 @@ class DeckBrowser: if self._refresh_needed: self.refresh() - def op_executed(self, op: OperationInfo, focused: bool) -> bool: - if self.mw.col.op_affects_study_queue(op): + def op_executed(self, changes: OpChanges, focused: bool) -> bool: + if self.mw.col.op_affects_study_queue(changes): self._refresh_needed = True if focused: @@ -322,16 +322,7 @@ class DeckBrowser: self.mw.taskman.with_progress(process, on_done) def _delete(self, did: int) -> None: - def do_delete() -> int: - return self.mw.col.decks.remove([did]) - - def on_done(fut: Future) -> None: - self.mw.reset() - self.mw.update_undo_actions() - self.show() - tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result())) - - self.mw.taskman.with_progress(do_delete, on_done) + remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did]) # Top buttons ###################################################################### diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 979e61aed..9386a6bd6 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -27,6 +27,7 @@ from anki.httpclient import HttpClient from anki.notes import Note from anki.utils import checksum, isLin, isWin, namedtmp from aqt import AnkiQt, colors, gui_hooks +from aqt.note_ops import update_note from aqt.qt import * from aqt.sound import av_player from aqt.theme import theme_manager @@ -542,8 +543,7 @@ class Editor: def _save_current_note(self) -> None: "Call after note is updated with data from webview." - note = self.note - self.mw.perform_op(lambda: self.mw.col.update_note(note)) + update_note(mw=self.mw, note=self.note) def fonts(self) -> List[Tuple[str, int, bool]]: return [ diff --git a/qt/aqt/main.py b/qt/aqt/main.py index b77701fe9..cb1d0a77c 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -21,6 +21,7 @@ from typing import ( List, Literal, Optional, + Protocol, Sequence, TextIO, Tuple, @@ -44,7 +45,8 @@ from anki.collection import ( Checkpoint, Collection, Config, - OperationInfo, + OpChanges, + OpChangesWithCount, ReviewUndo, UndoResult, UndoStatus, @@ -90,7 +92,19 @@ from aqt.utils import ( tr, ) -T = TypeVar("T") + +class HasChangesProperty(Protocol): + changes: OpChanges + + +# either an OpChanges object, or an object with .changes on it. This bound +# doesn't actually work for protobuf objects, so new protobuf objects will +# either need to be added here, or cast at call time +ResultWithChanges = TypeVar( + "ResultWithChanges", bound=Union[OpChanges, OpChangesWithCount, HasChangesProperty] +) + +PerformOpOptionalSuccessCallback = Optional[Callable[[ResultWithChanges], Any]] install_pylib_legacy() @@ -704,13 +718,17 @@ class AnkiQt(QMainWindow): def perform_op( self, - op: Callable[[], T], + op: Callable[[], ResultWithChanges], *, - success: Optional[Callable[[T], None]] = None, - failure: Optional[Callable[[BaseException], None]] = None, + success: PerformOpOptionalSuccessCallback = None, + failure: Optional[Callable[[Exception], Any]] = None, ) -> None: """Run the provided operation on a background thread. + op() should either return OpChanges, or an object with a 'changes' + property. The changes will be passed to `operation_did_execute` so that + the UI can decide whether it needs to update itself. + - Shows progress popup for the duration of the op. - Ensures the browser doesn't try to redraw during the operation, which can lead to a frozen UI @@ -731,42 +749,62 @@ class AnkiQt(QMainWindow): gui_hooks.operation_will_execute() def wrapped_done(future: Future) -> None: - try: - if exception := future.exception(): + # did something go wrong? + if exception := future.exception(): + if isinstance(exception, Exception): if failure: failure(exception) else: showWarning(str(exception)) + return else: - if success: - success(future.result()) + # BaseException like SystemExit; rethrow it + future.result() + try: + result = future.result() + if success: + success(result) finally: + # update undo status status = self.col.undo_status() self._update_undo_actions_for_status_and_save(status) - print("last op", status.last_op) - gui_hooks.operation_did_execute(status.last_op) - # fire legacy hook so old code notices changes - gui_hooks.state_did_reset() + # fire change hooks + self._fire_change_hooks_after_op_performed(result) self.taskman.with_progress(op, wrapped_done) + def _fire_change_hooks_after_op_performed(self, result: ResultWithChanges) -> None: + if isinstance(result, OpChanges): + changes = result + else: + changes = result.changes + + # fire new hook + print("op changes:") + print(changes) + gui_hooks.operation_did_execute(changes) + # fire legacy hook so old code notices changes + if self.col.op_made_changes(changes): + gui_hooks.state_did_reset() + def _synthesize_op_did_execute_from_reset(self) -> None: """Fire the `operation_did_execute` hook with everything marked as changed, after legacy code has called .reset()""" - op = OperationInfo() - for field in op.changes.DESCRIPTOR.fields: - setattr(op.changes, field.name, True) + op = OpChanges() + for field in op.DESCRIPTOR.fields: + if field.name != "kind": + setattr(op, field.name, True) gui_hooks.operation_did_execute(op) - def on_operation_did_execute(self, op: OperationInfo) -> None: + def on_operation_did_execute(self, changes: OpChanges) -> None: "Notify current screen of changes." focused = current_top_level_widget() == self if self.state == "review": - dirty = self.reviewer.op_executed(op, focused) + dirty = self.reviewer.op_executed(changes, focused) elif self.state == "overview": - dirty = self.overview.op_executed(op, focused) + dirty = self.overview.op_executed(changes, focused) elif self.state == "deckBrowser": - dirty = self.deckBrowser.op_executed(op, focused) + dirty = self.deckBrowser.op_executed(changes, focused) else: dirty = False diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py new file mode 100644 index 000000000..f5438fc7a --- /dev/null +++ b/qt/aqt/note_ops.py @@ -0,0 +1,74 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from typing import Optional, Sequence + +from anki.lang import TR +from anki.notes import Note +from aqt import AnkiQt +from aqt.main import PerformOpOptionalSuccessCallback +from aqt.qt import QDialog +from aqt.utils import show_invalid_search_error, showInfo, tr + + +def add_note( + *, + mw: AnkiQt, + note: Note, + target_deck_id: int, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success) + + +def update_note(*, mw: AnkiQt, note: Note) -> None: + mw.perform_op(lambda: mw.col.update_note(note)) + + +def remove_notes( + *, + mw: AnkiQt, + note_ids: Sequence[int], + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) + + +def add_tags(*, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str) -> None: + mw.perform_op(lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags)) + + +def remove_tags( + *, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str +) -> None: + mw.perform_op(lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags)) + + +def find_and_replace( + *, + mw: AnkiQt, + parent: QDialog, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool, + field_name: Optional[str], + match_case: bool, +) -> None: + mw.perform_op( + lambda: mw.col.find_and_replace( + note_ids=note_ids, + search=search, + replacement=replacement, + regex=regex, + field_name=field_name, + match_case=match_case, + ), + success=lambda out: showInfo( + tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)), + parent=parent, + ), + failure=lambda exc: show_invalid_search_error(exc, parent=parent), + ) diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index ac62de45c..9bb908a48 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional, Tuple import aqt -from anki.collection import OperationInfo +from anki.collection import OpChanges from aqt import gui_hooks from aqt.sound import av_player from aqt.toolbar import BottomBar @@ -63,8 +63,8 @@ class Overview: if self._refresh_needed: self.refresh() - def op_executed(self, op: OperationInfo, focused: bool) -> bool: - if self.mw.col.op_affects_study_queue(op): + def op_executed(self, changes: OpChanges, focused: bool) -> bool: + if self.mw.col.op_affects_study_queue(changes): self._refresh_needed = True if focused: diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index db96b0852..1fd27a3bd 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -13,12 +13,20 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card -from anki.collection import Config, OperationInfo +from anki.collection import Config, OpChanges from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks +from aqt.card_ops import set_card_flag +from aqt.note_ops import add_tags, remove_notes, remove_tags from aqt.profiles import VideoDriver from aqt.qt import * -from aqt.scheduling import set_due_date_dialog +from aqt.scheduling_ops import ( + bury_cards, + bury_note, + set_due_date_dialog, + suspend_cards, + suspend_note, +) from aqt.sound import av_player, play_clicked_audio, record_audio from aqt.theme import theme_manager from aqt.toolbar import BottomBar @@ -94,20 +102,19 @@ class Reviewer: self._refresh_needed = False self.mw.fade_in_webview() - def op_executed(self, op: OperationInfo, focused: bool) -> bool: - - if op.kind == OperationInfo.UPDATE_NOTE_TAGS: + def op_executed(self, changes: OpChanges, focused: bool) -> bool: + if changes.note and changes.kind == OpChanges.UPDATE_NOTE_TAGS: self.card.load() self._update_mark_icon() - elif op.kind == OperationInfo.SET_CARD_FLAG: + elif changes.card and changes.kind == OpChanges.SET_CARD_FLAG: # fixme: v3 mtime check self.card.load() self._update_flag_icon() - elif op.kind == OperationInfo.UPDATE_NOTE: + elif changes.note and changes.kind == OpChanges.UPDATE_NOTE: self._redraw_current_card() - elif self.mw.col.op_affects_study_queue(op): + elif self.mw.col.op_affects_study_queue(changes): self._refresh_needed = True - elif op.changes.note or op.changes.notetype or op.changes.tag: + elif changes.note or changes.notetype or changes.tag: self._redraw_current_card() if focused and self._refresh_needed: @@ -819,26 +826,21 @@ time = %(time)d; self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did)) def set_flag_on_current_card(self, desired_flag: int) -> None: - def op() -> None: - # need to toggle off? - if self.card.user_flag() == desired_flag: - flag = 0 - else: - flag = desired_flag - self.mw.col.set_user_flag_for_cards(flag, [self.card.id]) + # need to toggle off? + if self.card.user_flag() == desired_flag: + flag = 0 + else: + flag = desired_flag - self.mw.perform_op(op) + set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag) def toggle_mark_on_current_note(self) -> None: - def op() -> None: - tag = "marked" - note = self.card.note() - if note.has_tag(tag): - self.mw.col.tags.bulk_remove([note.id], tag) - else: - self.mw.col.tags.bulk_add([note.id], tag) - - self.mw.perform_op(op) + tag = "marked" + note = self.card.note() + if note.has_tag(tag): + remove_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag) + else: + add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag) def on_set_due(self) -> None: if self.mw.state != "review" or not self.card: @@ -852,29 +854,31 @@ time = %(time)d; ) def suspend_current_note(self) -> None: - self.mw.perform_op( - lambda: self.mw.col.sched.suspend_cards( - [c.id for c in self.card.note().cards()] - ), + suspend_note( + mw=self.mw, + note_id=self.card.nid, success=lambda _: tooltip(tr(TR.STUDYING_NOTE_SUSPENDED)), ) def suspend_current_card(self) -> None: - self.mw.perform_op( - lambda: self.mw.col.sched.suspend_cards([self.card.id]), + suspend_cards( + mw=self.mw, + card_ids=[self.card.id], success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)), ) - def bury_current_card(self) -> None: - self.mw.perform_op( - lambda: self.mw.col.sched.bury_cards([self.card.id]), - success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)), + def bury_current_note(self) -> None: + bury_note( + mw=self.mw, + note_id=self.card.nid, + success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)), ) - def bury_current_note(self) -> None: - self.mw.perform_op( - lambda: self.mw.col.sched.bury_note(self.card.note()), - success=lambda _: tooltip(tr(TR.STUDYING_NOTE_BURIED)), + def bury_current_card(self) -> None: + bury_cards( + mw=self.mw, + card_ids=[self.card.id], + success=lambda _: tooltip(tr(TR.STUDYING_CARD_BURIED)), ) def delete_current_note(self) -> None: @@ -882,10 +886,13 @@ time = %(time)d; # window if self.mw.state != "review" or not self.card: return + + # fixme: pass this back from the backend method instead cnt = len(self.card.note().cards()) - self.mw.perform_op( - lambda: self.mw.col.remove_notes([self.card.note().id]), + remove_notes( + mw=self.mw, + note_ids=[self.card.nid], success=lambda _: tooltip( tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt) ), diff --git a/qt/aqt/scheduling.py b/qt/aqt/scheduling_ops.py similarity index 54% rename from qt/aqt/scheduling.py rename to qt/aqt/scheduling_ops.py index c6302b194..f379cd42b 100644 --- a/qt/aqt/scheduling.py +++ b/qt/aqt/scheduling_ops.py @@ -3,11 +3,13 @@ from __future__ import annotations -from typing import List, Optional +from typing import List, Optional, Sequence import aqt from anki.collection import Config from anki.lang import TR +from aqt import AnkiQt +from aqt.main import PerformOpOptionalSuccessCallback from aqt.qt import * from aqt.utils import getText, tooltip, tr @@ -59,3 +61,49 @@ def forget_cards(*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int]) -> Non tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent ), ) + + +def suspend_cards( + *, + mw: AnkiQt, + card_ids: Sequence[int], + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op(lambda: mw.col.sched.suspend_cards(card_ids), success=success) + + +def suspend_note( + *, + mw: AnkiQt, + note_id: int, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.taskman.run_in_background( + lambda: mw.col.card_ids_of_note(note_id), + lambda future: suspend_cards(mw=mw, card_ids=future.result(), success=success), + ) + + +def unsuspend_cards(*, mw: AnkiQt, card_ids: Sequence[int]) -> None: + mw.perform_op(lambda: mw.col.sched.unsuspend_cards(card_ids)) + + +def bury_cards( + *, + mw: AnkiQt, + card_ids: Sequence[int], + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op(lambda: mw.col.sched.bury_cards(card_ids), success=success) + + +def bury_note( + *, + mw: AnkiQt, + note_id: int, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.taskman.run_in_background( + lambda: mw.col.card_ids_of_note(note_id), + lambda future: bury_cards(mw=mw, card_ids=future.result(), success=success), + ) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 03d903152..5f8c0e011 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -16,6 +16,7 @@ from anki.tags import TagTreeNode from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.clayout import CardLayout +from aqt.deck_ops import remove_decks from aqt.main import ResetReason from aqt.models import Models from aqt.qt import * @@ -1166,22 +1167,7 @@ class SidebarTreeView(QTreeView): self.mw.update_undo_actions() def delete_decks(self, _item: SidebarItem) -> None: - self.browser.editor.saveNow(self._delete_decks) - - def _delete_decks(self) -> None: - def do_delete() -> int: - return self.mw.col.decks.remove(dids) - - def on_done(fut: Future) -> None: - self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self) - self.browser.search() - self.browser.model.endReset() - tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()), parent=self) - self.refresh() - - dids = self._selected_decks() - self.browser.model.beginReset() - self.mw.taskman.with_progress(do_delete, on_done) + remove_decks(mw=self.mw, parent=self.browser, deck_ids=self._selected_decks()) # Tags ########################### @@ -1218,7 +1204,7 @@ class SidebarTreeView(QTreeView): def do_rename() -> int: self.mw.col.tags.remove(old_name) - return self.col.tags.rename(old_name, new_name) + return self.col.tags.rename(old_name, new_name).count def on_done(fut: Future) -> None: self.setUpdatesEnabled(True) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 870d7b444..f4d71aa6b 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -138,12 +138,12 @@ def showCritical( return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat) -def show_invalid_search_error(err: Exception) -> None: +def show_invalid_search_error(err: Exception, parent: Optional[QDialog] = None) -> None: "Render search errors in markdown, then display a warning." text = str(err) if isinstance(err, InvalidInput): text = markdown(text) - showWarning(text) + showWarning(text, parent=parent) def showInfo( diff --git a/qt/mypy.ini b/qt/mypy.ini index 63ed611ac..3df1f2a6a 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -9,6 +9,15 @@ check_untyped_defs = true disallow_untyped_defs = True strict_equality = true +[mypy-aqt.scheduling_ops] +no_strict_optional = false +[mypy-aqt.note_ops] +no_strict_optional = false +[mypy-aqt.card_ops] +no_strict_optional = false +[mypy-aqt.deck_ops] +no_strict_optional = false + [mypy-aqt.winpaths] disallow_untyped_defs=false [mypy-aqt.mpv] diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index aca968811..49dd359d4 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -408,7 +408,7 @@ hooks = [ Hook( name="operation_did_execute", args=[ - "op: anki.collection.OperationInfo", + "changes: anki.collection.OpChanges", ], doc="""Called after an operation completes. Changes can be inspected to determine whether the UI needs updating. diff --git a/rslib/backend.proto b/rslib/backend.proto index 1f6daed11..1fa44011e 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -45,6 +45,11 @@ message StringList { repeated string vals = 1; } +message OpChangesWithCount { + uint32 count = 1; + OpChanges changes = 2; +} + // IDs used in RPC calls /////////////////////////////////////////////////////////// @@ -108,19 +113,19 @@ service SchedulingService { rpc ExtendLimits(ExtendLimitsIn) returns (Empty); rpc CountsForDeckToday(DeckID) returns (CountsForDeckTodayOut); rpc CongratsInfo(Empty) returns (CongratsInfoOut); - rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (Empty); + rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (OpChanges); rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty); - rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (Empty); + rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges); rpc EmptyFilteredDeck(DeckID) returns (Empty); rpc RebuildFilteredDeck(DeckID) returns (UInt32); - rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (Empty); - rpc SetDueDate(SetDueDateIn) returns (Empty); + rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); + rpc SetDueDate(SetDueDateIn) returns (OpChanges); rpc SortCards(SortCardsIn) returns (Empty); rpc SortDeck(SortDeckIn) returns (Empty); rpc GetNextCardStates(CardID) returns (NextCardStates); rpc DescribeNextStates(NextCardStates) returns (StringList); rpc StateIsLeech(SchedulingState) returns (Bool); - rpc AnswerCard(AnswerCardIn) returns (Empty); + rpc AnswerCard(AnswerCardIn) returns (OpChanges); rpc UpgradeScheduler(Empty) returns (Empty); rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); } @@ -134,23 +139,23 @@ service DecksService { rpc GetDeckLegacy(DeckID) returns (Json); rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames); rpc NewDeckLegacy(Bool) returns (Json); - rpc RemoveDecks(DeckIDs) returns (UInt32); - rpc DragDropDecks(DragDropDecksIn) returns (Empty); - rpc RenameDeck(RenameDeckIn) returns (Empty); + rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount); + rpc DragDropDecks(DragDropDecksIn) returns (OpChanges); + rpc RenameDeck(RenameDeckIn) returns (OpChanges); } service NotesService { rpc NewNote(NoteTypeID) returns (Note); - rpc AddNote(AddNoteIn) returns (NoteID); + rpc AddNote(AddNoteIn) returns (AddNoteOut); rpc DefaultsForAdding(DefaultsForAddingIn) returns (DeckAndNotetype); rpc DefaultDeckForNotetype(NoteTypeID) returns (DeckID); - rpc UpdateNote(UpdateNoteIn) returns (Empty); + rpc UpdateNote(UpdateNoteIn) returns (OpChanges); rpc GetNote(NoteID) returns (Note); - rpc RemoveNotes(RemoveNotesIn) returns (Empty); - rpc AddNoteTags(AddNoteTagsIn) returns (UInt32); - rpc UpdateNoteTags(UpdateNoteTagsIn) returns (UInt32); + rpc RemoveNotes(RemoveNotesIn) returns (OpChanges); + rpc AddNoteTags(AddNoteTagsIn) returns (OpChangesWithCount); + rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); - rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (Empty); + rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut); rpc CardsOfNote(NoteID) returns (CardIDs); @@ -179,7 +184,7 @@ service ConfigService { rpc GetConfigString(Config.String) returns (String); rpc SetConfigString(SetConfigStringIn) returns (Empty); rpc GetPreferences(Empty) returns (Preferences); - rpc SetPreferences(Preferences) returns (Empty); + rpc SetPreferences(Preferences) returns (OpChanges); } service NoteTypesService { @@ -227,7 +232,7 @@ service SearchService { rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); - rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); + rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount); } service StatsService { @@ -264,10 +269,10 @@ service CollectionService { service CardsService { rpc GetCard(CardID) returns (Card); - rpc UpdateCard(UpdateCardIn) returns (Empty); + rpc UpdateCard(UpdateCardIn) returns (OpChanges); rpc RemoveCards(RemoveCardsIn) returns (Empty); - rpc SetDeck(SetDeckIn) returns (Empty); - rpc SetFlag(SetFlagIn) returns (Empty); + rpc SetDeck(SetDeckIn) returns (OpChanges); + rpc SetFlag(SetFlagIn) returns (OpChanges); } // Protobuf stored in .anki2 files @@ -971,6 +976,11 @@ message AddNoteIn { int64 deck_id = 2; } +message AddNoteOut { + int64 note_id = 1; + OpChanges changes = 2; +} + message UpdateNoteIn { Note note = 1; bool skip_undo_entry = 2; @@ -1443,15 +1453,7 @@ message GetQueuedCardsOut { } } -message OperationInfo { - message Changes { - bool card = 1; - bool note = 2; - bool deck = 3; - bool tag = 4; - bool notetype = 5; - bool preference = 6; - } +message OpChanges { // this is not an exhaustive list; we can add more cases as we need them enum Kind { OTHER = 0; @@ -1461,13 +1463,17 @@ message OperationInfo { } Kind kind = 1; - Changes changes = 2; + bool card = 2; + bool note = 3; + bool deck = 4; + bool tag = 5; + bool notetype = 6; + bool preference = 7; } message UndoStatus { string undo = 1; string redo = 2; - OperationInfo last_op = 3; } message DefaultsForAddingIn { diff --git a/rslib/src/backend/card.rs b/rslib/src/backend/card.rs index 4125cec33..53e286ea8 100644 --- a/rslib/src/backend/card.rs +++ b/rslib/src/backend/card.rs @@ -21,22 +21,17 @@ impl CardsService for Backend { }) } - fn update_card(&self, input: pb::UpdateCardIn) -> Result { + fn update_card(&self, input: pb::UpdateCardIn) -> Result { self.with_col(|col| { - let op = if input.skip_undo_entry { - None - } else { - Some(Op::UpdateCard) - }; let mut card: Card = input.card.ok_or(AnkiError::NotFound)?.try_into()?; - col.update_card_with_op(&mut card, op) + col.update_card_maybe_undoable(&mut card, !input.skip_undo_entry) }) .map(Into::into) } fn remove_cards(&self, input: pb::RemoveCardsIn) -> Result { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { col.remove_cards_and_orphaned_notes( &input .card_ids @@ -49,13 +44,13 @@ impl CardsService for Backend { }) } - fn set_deck(&self, input: pb::SetDeckIn) -> Result { + fn set_deck(&self, input: pb::SetDeckIn) -> Result { let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let deck_id = input.deck_id.into(); self.with_col(|col| col.set_deck(&cids, deck_id).map(Into::into)) } - fn set_flag(&self, input: pb::SetFlagIn) -> Result { + fn set_flag(&self, input: pb::SetFlagIn) -> Result { self.with_col(|col| { col.set_card_flag(&to_card_ids(input.card_ids), input.flag) .map(Into::into) diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index 58ae16984..9e8d59610 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -61,7 +61,7 @@ impl ConfigService for Backend { fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { // ensure it's a well-formed object let val: Value = serde_json::from_slice(&input.value_json)?; col.set_config(input.key.as_str(), &val) @@ -71,7 +71,7 @@ impl ConfigService for Backend { } fn remove_config(&self, input: pb::String) -> Result { - self.with_col(|col| col.transact(None, |col| col.remove_config(input.val.as_str()))) + self.with_col(|col| col.transact_no_undo(|col| col.remove_config(input.val.as_str()))) .map(Into::into) } @@ -92,8 +92,10 @@ impl ConfigService for Backend { } fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result { - self.with_col(|col| col.transact(None, |col| col.set_bool(input.key().into(), input.value))) - .map(Into::into) + self.with_col(|col| { + col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value)) + }) + .map(Into::into) } fn get_config_string(&self, input: pb::config::String) -> Result { @@ -106,7 +108,7 @@ impl ConfigService for Backend { fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result { self.with_col(|col| { - col.transact(None, |col| col.set_string(input.key().into(), &input.value)) + col.transact_no_undo(|col| col.set_string(input.key().into(), &input.value)) }) .map(Into::into) } @@ -115,7 +117,7 @@ impl ConfigService for Backend { self.with_col(|col| col.get_preferences()) } - fn set_preferences(&self, input: pb::Preferences) -> Result { + fn set_preferences(&self, input: pb::Preferences) -> Result { self.with_col(|col| col.set_preferences(input)) .map(Into::into) } diff --git a/rslib/src/backend/deckconfig.rs b/rslib/src/backend/deckconfig.rs index 9b20fe8f7..4737959e8 100644 --- a/rslib/src/backend/deckconfig.rs +++ b/rslib/src/backend/deckconfig.rs @@ -17,7 +17,7 @@ impl DeckConfigService for Backend { let conf: DeckConfSchema11 = serde_json::from_slice(&input.config)?; let mut conf: DeckConf = conf.into(); self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { col.add_or_update_deck_config(&mut conf, input.preserve_usn_and_mtime)?; Ok(pb::DeckConfigId { dcid: conf.id.0 }) }) @@ -54,7 +54,7 @@ impl DeckConfigService for Backend { } fn remove_deck_config(&self, input: pb::DeckConfigId) -> Result { - self.with_col(|col| col.transact(None, |col| col.remove_deck_config(input.into()))) + self.with_col(|col| col.transact_no_undo(|col| col.remove_deck_config(input.into()))) .map(Into::into) } } diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs index ab1fae037..9cfb908f0 100644 --- a/rslib/src/backend/decks.rs +++ b/rslib/src/backend/decks.rs @@ -15,7 +15,7 @@ impl DecksService for Backend { let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?; let mut deck: Deck = schema11.into(); if input.preserve_usn_and_mtime { - col.transact(None, |col| { + col.transact_no_undo(|col| { let usn = col.usn()?; col.add_or_update_single_deck_with_existing_id(&mut deck, usn) })?; @@ -109,12 +109,12 @@ impl DecksService for Backend { .map(Into::into) } - fn remove_decks(&self, input: pb::DeckIDs) -> Result { + fn remove_decks(&self, input: pb::DeckIDs) -> Result { self.with_col(|col| col.remove_decks_and_child_decks(&Into::>::into(input))) .map(Into::into) } - fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result { + fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result { let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect(); let target_did = if input.target_deck_id == 0 { None @@ -125,7 +125,7 @@ impl DecksService for Backend { .map(Into::into) } - fn rename_deck(&self, input: pb::RenameDeckIn) -> Result { + fn rename_deck(&self, input: pb::RenameDeckIn) -> Result { self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name)) .map(Into::into) } diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs index 3aae3b721..4595e279f 100644 --- a/rslib/src/backend/generic.rs +++ b/rslib/src/backend/generic.rs @@ -80,3 +80,12 @@ impl From> for pb::StringList { pb::StringList { vals } } } + +impl From> for pb::OpChangesWithCount { + fn from(out: OpOutput) -> Self { + pb::OpChangesWithCount { + count: out.output as u32, + changes: Some(out.changes.into()), + } + } +} diff --git a/rslib/src/backend/media.rs b/rslib/src/backend/media.rs index f36a92c60..9c8f7ba7f 100644 --- a/rslib/src/backend/media.rs +++ b/rslib/src/backend/media.rs @@ -19,7 +19,7 @@ impl MediaService for Backend { move |progress| handler.update(Progress::MediaCheck(progress as u32), true); self.with_col(|col| { let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - col.transact(None, |ctx| { + col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); let mut output = checker.check()?; @@ -62,7 +62,7 @@ impl MediaService for Backend { self.with_col(|col| { let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - col.transact(None, |ctx| { + col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); checker.empty_trash() @@ -78,7 +78,7 @@ impl MediaService for Backend { self.with_col(|col| { let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - col.transact(None, |ctx| { + col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); checker.restore_trash() diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index 335a858b2..0f4522f37 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -12,9 +12,6 @@ use crate::{ pub(super) use pb::notes_service::Service as NotesService; impl NotesService for Backend { - // notes - //------------------------------------------------------------------- - fn new_note(&self, input: pb::NoteTypeId) -> Result { self.with_col(|col| { let nt = col.get_notetype(input.into())?.ok_or(AnkiError::NotFound)?; @@ -22,11 +19,14 @@ impl NotesService for Backend { }) } - fn add_note(&self, input: pb::AddNoteIn) -> Result { + fn add_note(&self, input: pb::AddNoteIn) -> Result { self.with_col(|col| { let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into(); - col.add_note(&mut note, DeckID(input.deck_id)) - .map(|_| pb::NoteId { nid: note.id.0 }) + let changes = col.add_note(&mut note, DeckID(input.deck_id))?; + Ok(pb::AddNoteOut { + note_id: note.id.0, + changes: Some(changes.into()), + }) }) } @@ -46,15 +46,10 @@ impl NotesService for Backend { }) } - fn update_note(&self, input: pb::UpdateNoteIn) -> Result { + fn update_note(&self, input: pb::UpdateNoteIn) -> Result { self.with_col(|col| { - let op = if input.skip_undo_entry { - None - } else { - Some(Op::UpdateNote) - }; let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into(); - col.update_note_with_op(&mut note, op) + col.update_note_maybe_undoable(&mut note, !input.skip_undo_entry) }) .map(Into::into) } @@ -68,7 +63,7 @@ impl NotesService for Backend { }) } - fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result { + fn remove_notes(&self, input: pb::RemoveNotesIn) -> Result { self.with_col(|col| { if !input.note_ids.is_empty() { col.remove_notes( @@ -77,9 +72,8 @@ impl NotesService for Backend { .into_iter() .map(Into::into) .collect::>(), - )?; - } - if !input.card_ids.is_empty() { + ) + } else { let nids = col.storage.note_ids_of_cards( &input .card_ids @@ -87,21 +81,20 @@ impl NotesService for Backend { .map(Into::into) .collect::>(), )?; - col.remove_notes(&nids.into_iter().collect::>())? + col.remove_notes(&nids.into_iter().collect::>()) } - Ok(().into()) + .map(Into::into) }) } - fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result { + fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result { self.with_col(|col| { col.add_tags_to_notes(&to_note_ids(input.nids), &input.tags) - .map(|n| n as u32) + .map(Into::into) }) - .map(Into::into) } - fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { + fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { self.with_col(|col| { col.replace_tags_for_notes( &to_note_ids(input.nids), @@ -109,7 +102,7 @@ impl NotesService for Backend { &input.replacement, input.regex, ) - .map(|n| (n as u32).into()) + .map(Into::into) }) } @@ -123,16 +116,14 @@ impl NotesService for Backend { }) } - fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result { + fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> Result { self.with_col(|col| { - col.transact(None, |col| { - col.after_note_updates( - &to_note_ids(input.nids), - input.generate_cards, - input.mark_notes_modified, - )?; - Ok(pb::Empty {}) - }) + col.after_note_updates( + &to_note_ids(input.nids), + input.generate_cards, + input.mark_notes_modified, + ) + .map(Into::into) }) } diff --git a/rslib/src/backend/ops.rs b/rslib/src/backend/ops.rs index 71fb2fa11..b25c51de5 100644 --- a/rslib/src/backend/ops.rs +++ b/rslib/src/backend/ops.rs @@ -1,22 +1,9 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use pb::operation_info::{Changes, Kind}; +use pb::op_changes::Kind; -use crate::{backend_proto as pb, ops::StateChanges, prelude::*, undo::UndoStatus}; - -impl From for Changes { - fn from(c: StateChanges) -> Self { - Changes { - card: c.card, - note: c.note, - deck: c.deck, - tag: c.tag, - notetype: c.notetype, - preference: c.preference, - } - } -} +use crate::{backend_proto as pb, ops::OpChanges, prelude::*, undo::UndoStatus}; impl From for Kind { fn from(o: Op) -> Self { @@ -29,11 +16,16 @@ impl From for Kind { } } -impl From for pb::OperationInfo { - fn from(op: Op) -> Self { - pb::OperationInfo { - changes: Some(op.state_changes().into()), - kind: Kind::from(op) as i32, +impl From for pb::OpChanges { + fn from(c: OpChanges) -> Self { + pb::OpChanges { + kind: Kind::from(c.op) as i32, + card: c.changes.card, + note: c.changes.note, + deck: c.changes.deck, + tag: c.changes.tag, + notetype: c.changes.notetype, + preference: c.changes.preference, } } } @@ -43,7 +35,12 @@ impl UndoStatus { pb::UndoStatus { undo: self.undo.map(|op| op.describe(i18n)).unwrap_or_default(), redo: self.redo.map(|op| op.describe(i18n)).unwrap_or_default(), - last_op: self.undo.map(Into::into), } } } + +impl From> for pb::OpChanges { + fn from(o: OpOutput<()>) -> Self { + o.changes.into() + } +} diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index be9405edd..521b8aed4 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -39,7 +39,7 @@ impl SchedulingService for Backend { fn update_stats(&self, input: pb::UpdateStatsIn) -> Result { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { let today = col.current_due_day(0)?; let usn = col.usn()?; col.update_deck_stats(today, usn, input).map(Into::into) @@ -49,7 +49,7 @@ impl SchedulingService for Backend { fn extend_limits(&self, input: pb::ExtendLimitsIn) -> Result { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { let today = col.current_due_day(0)?; let usn = col.usn()?; col.extend_limits( @@ -72,7 +72,7 @@ impl SchedulingService for Backend { self.with_col(|col| col.congrats_info()) } - fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result { + fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> Result { let cids: Vec<_> = input.into(); self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into)) } @@ -87,7 +87,7 @@ impl SchedulingService for Backend { }) } - fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result { + fn bury_or_suspend_cards(&self, input: pb::BuryOrSuspendCardsIn) -> Result { self.with_col(|col| { let mode = input.mode(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); @@ -103,7 +103,7 @@ impl SchedulingService for Backend { self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) } - fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result { + fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> Result { self.with_col(|col| { let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let log = input.log; @@ -111,7 +111,7 @@ impl SchedulingService for Backend { }) } - fn set_due_date(&self, input: pb::SetDueDateIn) -> Result { + fn set_due_date(&self, input: pb::SetDueDateIn) -> Result { let config = input.config_key.map(Into::into); let days = input.days; let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); @@ -161,13 +161,13 @@ impl SchedulingService for Backend { Ok(state.leeched().into()) } - fn answer_card(&self, input: pb::AnswerCardIn) -> Result { + fn answer_card(&self, input: pb::AnswerCardIn) -> Result { self.with_col(|col| col.answer_card(&input.into())) .map(Into::into) } fn upgrade_scheduler(&self, _input: pb::Empty) -> Result { - self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler())) + self.with_col(|col| col.transact_no_undo(|col| col.upgrade_to_v2_scheduler())) .map(Into::into) } diff --git a/rslib/src/backend/search.rs b/rslib/src/backend/search.rs index 6bda494ac..ed4c7d497 100644 --- a/rslib/src/backend/search.rs +++ b/rslib/src/backend/search.rs @@ -68,7 +68,7 @@ impl SearchService for Backend { Ok(replace_search_node(existing, replacement).into()) } - fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result { + fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result { let mut search = if input.regex { input.search } else { @@ -86,7 +86,7 @@ impl SearchService for Backend { let repl = input.replacement; self.with_col(|col| { col.find_and_replace(nids, &search, &repl, field_name) - .map(|cnt| pb::UInt32 { val: cnt as u32 }) + .map(Into::into) }) } } diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 3d08e0f0d..26a15d72f 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -7,7 +7,7 @@ pub(super) use pb::tags_service::Service as TagsService; impl TagsService for Backend { fn clear_unused_tags(&self, _input: pb::Empty) -> Result { - self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into))) + self.with_col(|col| col.transact_no_undo(|col| col.clear_unused_tags().map(Into::into))) } fn all_tags(&self, _input: pb::Empty) -> Result { @@ -29,7 +29,7 @@ impl TagsService for Backend { fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { col.set_tag_expanded(&input.name, input.expanded)?; Ok(().into()) }) @@ -38,7 +38,7 @@ impl TagsService for Backend { fn clear_tag(&self, tag: pb::String) -> Result { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { col.storage.clear_tag_and_children(tag.val.as_str())?; Ok(().into()) }) diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 490933290..d6fa34a85 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -3,13 +3,13 @@ pub(crate) mod undo; -use crate::define_newtype; use crate::err::{AnkiError, Result}; use crate::notes::NoteID; use crate::{ collection::Collection, config::SchedulerVersion, prelude::*, timestamp::TimestampSecs, types::Usn, }; +use crate::{define_newtype, ops::StateChanges}; use crate::{deckconf::DeckConf, decks::DeckID}; use num_enum::TryFromPrimitive; @@ -149,9 +149,31 @@ impl Card { } impl Collection { - pub(crate) fn update_card_with_op(&mut self, card: &mut Card, op: Option) -> Result<()> { + pub(crate) fn update_card_maybe_undoable( + &mut self, + card: &mut Card, + undoable: bool, + ) -> Result> { let existing = self.storage.get_card(card.id)?.ok_or(AnkiError::NotFound)?; - self.transact(op, |col| col.update_card_inner(card, existing, col.usn()?)) + if undoable { + self.transact(Op::UpdateCard, |col| { + col.update_card_inner(card, existing, col.usn()?) + }) + } else { + self.transact_no_undo(|col| { + col.update_card_inner(card, existing, col.usn()?)?; + Ok(OpOutput { + output: (), + changes: OpChanges { + op: Op::UpdateCard, + changes: StateChanges { + card: true, + ..Default::default() + }, + }, + }) + }) + } } #[cfg(test)] @@ -209,7 +231,7 @@ impl Collection { Ok(()) } - pub fn set_deck(&mut self, cards: &[CardID], deck_id: DeckID) -> Result<()> { + pub fn set_deck(&mut self, cards: &[CardID], deck_id: DeckID) -> Result> { let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?; if deck.is_filtered() { return Err(AnkiError::DeckIsFiltered); @@ -217,7 +239,7 @@ impl Collection { self.storage.set_search_table_to_card_ids(cards, false)?; let sched = self.scheduler_version(); let usn = self.usn()?; - self.transact(Some(Op::SetDeck), |col| { + self.transact(Op::SetDeck, |col| { for mut card in col.storage.all_searched_cards()? { if card.deck_id == deck_id { continue; @@ -230,7 +252,7 @@ impl Collection { }) } - pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result<()> { + pub fn set_card_flag(&mut self, cards: &[CardID], flag: u32) -> Result> { if flag > 4 { return Err(AnkiError::invalid_input("invalid flag")); } @@ -238,7 +260,7 @@ impl Collection { self.storage.set_search_table_to_card_ids(cards, false)?; let usn = self.usn()?; - self.transact(Some(Op::SetFlag), |col| { + self.transact(Op::SetFlag, |col| { for mut card in col.storage.all_searched_cards()? { let original = card.clone(); card.set_flag(flag); diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 4f1c15692..8dd635a9c 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -1,7 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::i18n::I18n; use crate::log::Logger; use crate::types::Usn; use crate::{ @@ -12,6 +11,7 @@ use crate::{ undo::UndoManager, }; use crate::{err::Result, scheduler::queue::CardQueues}; +use crate::{i18n::I18n, ops::StateChanges}; use std::{collections::HashMap, path::PathBuf, sync::Arc}; pub fn open_collection>( @@ -83,9 +83,7 @@ pub struct Collection { } impl Collection { - /// Execute the provided closure in a transaction, rolling back if - /// an error is returned. - pub(crate) fn transact(&mut self, op: Option, func: F) -> Result + fn transact_inner(&mut self, op: Option, func: F) -> Result> where F: FnOnce(&mut Collection) -> Result, { @@ -102,14 +100,49 @@ impl Collection { } } - if res.is_err() { - self.discard_undo_and_study_queues(); - self.storage.rollback_rust_trx()?; - } else { - self.end_undoable_operation(); + match res { + Ok(output) => { + let changes = if op.is_some() { + let changes = self.op_changes()?; + self.maybe_clear_study_queues_after_op(changes); + self.maybe_coalesce_note_undo_entry(changes); + changes + } else { + self.clear_study_queues(); + // dummy value, not used by transact_no_undo(). only required + // until we can migrate all the code to undoable ops + OpChanges { + op: Op::SetFlag, + changes: StateChanges::default(), + } + }; + self.end_undoable_operation(); + Ok(OpOutput { output, changes }) + } + Err(err) => { + self.discard_undo_and_study_queues(); + self.storage.rollback_rust_trx()?; + Err(err) + } } + } - res + /// Execute the provided closure in a transaction, rolling back if + /// an error is returned. Records undo state, and returns changes. + pub(crate) fn transact(&mut self, op: Op, func: F) -> Result> + where + F: FnOnce(&mut Collection) -> Result, + { + self.transact_inner(Some(op), func) + } + + /// Execute the provided closure in a transaction, rolling back if + /// an error is returned. + pub(crate) fn transact_no_undo(&mut self, func: F) -> Result + where + F: FnOnce(&mut Collection) -> Result, + { + self.transact_inner(None, func).map(|out| out.output) } pub(crate) fn close(self, downgrade: bool) -> Result<()> { @@ -123,7 +156,7 @@ impl Collection { /// Prepare for upload. Caller should not create transaction. pub(crate) fn before_upload(&mut self) -> Result<()> { - self.transact(None, |col| { + self.transact_no_undo(|col| { col.storage.clear_all_graves()?; col.storage.clear_pending_note_usns()?; col.storage.clear_pending_card_usns()?; diff --git a/rslib/src/config/undo.rs b/rslib/src/config/undo.rs index 3c11c300a..55e14f895 100644 --- a/rslib/src/config/undo.rs +++ b/rslib/src/config/undo.rs @@ -71,7 +71,7 @@ mod test { fn undo() -> Result<()> { let mut col = open_test_collection(); // the op kind doesn't matter, we just need undo enabled - let op = Some(Op::Bury); + let op = Op::Bury; // test key let key = BoolKey::NormalizeNoteText; diff --git a/rslib/src/dbcheck.rs b/rslib/src/dbcheck.rs index d5668d3a0..01a900026 100644 --- a/rslib/src/dbcheck.rs +++ b/rslib/src/dbcheck.rs @@ -129,7 +129,7 @@ impl Collection { debug!(self.log, "optimize"); self.storage.optimize()?; - self.transact(None, |col| col.check_database_inner(progress_fn)) + self.transact_no_undo(|col| col.check_database_inner(progress_fn)) } fn check_database_inner(&mut self, mut progress_fn: F) -> Result diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 2a9581fda..7b12ea5bc 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -267,7 +267,7 @@ impl Collection { /// or rename children as required. Prefer add_deck() or update_deck() to /// be explicit about your intentions; this function mainly exists so we /// can integrate with older Python code that behaved this way. - pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result<()> { + pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck) -> Result> { if deck.id.0 == 0 { self.add_deck(deck) } else { @@ -276,12 +276,12 @@ impl Collection { } /// Add a new deck. The id must be 0, as it will be automatically assigned. - pub fn add_deck(&mut self, deck: &mut Deck) -> Result<()> { + pub fn add_deck(&mut self, deck: &mut Deck) -> Result> { if deck.id.0 != 0 { return Err(AnkiError::invalid_input("deck to add must have id 0")); } - self.transact(Some(Op::AddDeck), |col| { + self.transact(Op::AddDeck, |col| { let usn = col.usn()?; col.prepare_deck_for_update(deck, usn)?; deck.set_modified(usn); @@ -290,15 +290,15 @@ impl Collection { }) } - pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> { - self.transact(Some(Op::UpdateDeck), |col| { + pub fn update_deck(&mut self, deck: &mut Deck) -> Result> { + self.transact(Op::UpdateDeck, |col| { let existing_deck = col.storage.get_deck(deck.id)?.ok_or(AnkiError::NotFound)?; col.update_deck_inner(deck, existing_deck, col.usn()?) }) } - pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result<()> { - self.transact(Some(Op::RenameDeck), |col| { + pub fn rename_deck(&mut self, did: DeckID, new_human_name: &str) -> Result> { + self.transact(Op::RenameDeck, |col| { let existing_deck = col.storage.get_deck(did)?.ok_or(AnkiError::NotFound)?; let mut deck = existing_deck.clone(); deck.name = human_deck_name_to_native(new_human_name); @@ -464,9 +464,9 @@ impl Collection { self.storage.get_deck_id(&machine_name) } - pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result { - let mut card_count = 0; - self.transact(Some(Op::RemoveDeck), |col| { + pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result> { + self.transact(Op::RemoveDeck, |col| { + let mut card_count = 0; let usn = col.usn()?; for did in dids { if let Some(deck) = col.storage.get_deck(*did)? { @@ -481,9 +481,8 @@ impl Collection { } } } - Ok(()) - })?; - Ok(card_count) + Ok(card_count) + }) } pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result { @@ -623,9 +622,9 @@ impl Collection { &mut self, source_decks: &[DeckID], target: Option, - ) -> Result<()> { + ) -> Result> { let usn = self.usn()?; - self.transact(Some(Op::RenameDeck), |col| { + self.transact(Op::RenameDeck, |col| { let target_deck; let mut target_name = None; if let Some(target) = target { diff --git a/rslib/src/filtered.rs b/rslib/src/filtered.rs index 101fb86cb..0fae27f07 100644 --- a/rslib/src/filtered.rs +++ b/rslib/src/filtered.rs @@ -169,7 +169,7 @@ pub(crate) struct DeckFilterContext<'a> { impl Collection { pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result<()> { - self.transact(None, |col| col.return_all_cards_in_filtered_deck(did)) + self.transact_no_undo(|col| col.return_all_cards_in_filtered_deck(did)) } pub(super) fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> { let cids = self.storage.all_cards_in_single_deck(did)?; @@ -206,7 +206,7 @@ impl Collection { today: self.timing_today()?.days_elapsed, }; - self.transact(None, |col| { + self.transact_no_undo(|col| { col.return_all_cards_in_filtered_deck(did)?; col.build_filtered_deck(ctx) }) diff --git a/rslib/src/findreplace.rs b/rslib/src/findreplace.rs index 8d97eb1ec..a52ef2307 100644 --- a/rslib/src/findreplace.rs +++ b/rslib/src/findreplace.rs @@ -45,8 +45,8 @@ impl Collection { search_re: &str, repl: &str, field_name: Option, - ) -> Result { - self.transact(None, |col| { + ) -> Result> { + self.transact(Op::FindAndReplace, |col| { let norm = col.get_bool(BoolKey::NormalizeNoteText); let search = if norm { normalize_to_nfc(search_re) @@ -119,8 +119,8 @@ mod test { col.add_note(&mut note2, DeckID(1))?; let nids = col.search_notes("")?; - let cnt = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?; - assert_eq!(cnt, 2); + let out = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?; + assert_eq!(out.output, 2); let note = col.storage.get_note(note.id)?.unwrap(); // but the update should be limited to the specified field when it was available @@ -138,10 +138,10 @@ mod test { "Text".into() ] ); - let cnt = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?; + let out = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?; // still 2, as the caller is expected to provide only note ids that have // that field, and if we can't find the field we fall back on all fields - assert_eq!(cnt, 2); + assert_eq!(out.output, 2); let note = col.storage.get_note(note.id)?.unwrap(); // but the update should be limited to the specified field when it was available diff --git a/rslib/src/media/check.rs b/rslib/src/media/check.rs index a6018c1dd..6e8107e83 100644 --- a/rslib/src/media/check.rs +++ b/rslib/src/media/check.rs @@ -572,7 +572,7 @@ pub(crate) mod test { let progress = |_n| true; - let (output, report) = col.transact(None, |ctx| { + let (output, report) = col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress); let output = checker.check()?; let summary = checker.summarize_output(&mut output.clone()); @@ -642,7 +642,7 @@ Unused: unused.jpg let progress = |_n| true; - col.transact(None, |ctx| { + col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress); checker.restore_trash() })?; @@ -656,7 +656,7 @@ Unused: unused.jpg // if we repeat the process, restoring should do the same thing if the contents are equal fs::write(trash_folder.join("test.jpg"), "test")?; - col.transact(None, |ctx| { + col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress); checker.restore_trash() })?; @@ -668,7 +668,7 @@ Unused: unused.jpg // but rename if required fs::write(trash_folder.join("test.jpg"), "test2")?; - col.transact(None, |ctx| { + col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress); checker.restore_trash() })?; @@ -692,7 +692,7 @@ Unused: unused.jpg let progress = |_n| true; - let mut output = col.transact(None, |ctx| { + let mut output = col.transact_no_undo(|ctx| { let mut checker = MediaChecker::new(ctx, &mgr, progress); checker.check() })?; diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 900ccca52..f0a58a1a3 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod undo; -use crate::backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState; use crate::{ backend_proto as pb, decks::DeckID, @@ -16,6 +15,9 @@ use crate::{ timestamp::TimestampSecs, types::Usn, }; +use crate::{ + backend_proto::note_is_duplicate_or_empty_out::State as DuplicateState, ops::StateChanges, +}; use itertools::Itertools; use num_integer::Integer; use regex::{Regex, Replacer}; @@ -305,8 +307,8 @@ impl Collection { Ok(()) } - pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<()> { - self.transact(Some(Op::AddNote), |col| { + pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result> { + self.transact(Op::AddNote, |col| { let nt = col .get_notetype(note.notetype_id)? .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; @@ -334,25 +336,49 @@ impl Collection { } #[cfg(test)] - pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<()> { - self.update_note_with_op(note, Some(Op::UpdateNote)) + pub(crate) fn update_note(&mut self, note: &mut Note) -> Result> { + self.update_note_maybe_undoable(note, true) } - pub(crate) fn update_note_with_op(&mut self, note: &mut Note, op: Option) -> Result<()> { + pub(crate) fn update_note_maybe_undoable( + &mut self, + note: &mut Note, + undoable: bool, + ) -> Result> { + if undoable { + self.transact(Op::UpdateNote, |col| col.update_note_inner(note)) + } else { + self.transact_no_undo(|col| { + col.update_note_inner(note)?; + Ok(OpOutput { + output: (), + changes: OpChanges { + op: Op::UpdateNote, + changes: StateChanges { + note: true, + tag: true, + card: true, + ..Default::default() + }, + }, + }) + }) + } + } + + pub(crate) fn update_note_inner(&mut self, note: &mut Note) -> Result<()> { let mut existing_note = self.storage.get_note(note.id)?.ok_or(AnkiError::NotFound)?; if !note_differs_from_db(&mut existing_note, note) { // nothing to do return Ok(()); } - - self.transact(op, |col| { - let nt = col - .get_notetype(note.notetype_id)? - .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; - let ctx = CardGenContext::new(&nt, col.usn()?); - let norm = col.get_bool(BoolKey::NormalizeNoteText); - col.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm) - }) + let nt = self + .get_notetype(note.notetype_id)? + .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; + let ctx = CardGenContext::new(&nt, self.usn()?); + let norm = self.get_bool(BoolKey::NormalizeNoteText); + self.update_note_inner_generating_cards(&ctx, note, &existing_note, true, norm)?; + Ok(()) } pub(crate) fn update_note_inner_generating_cards( @@ -388,13 +414,13 @@ impl Collection { if mark_note_modified { note.set_modified(usn); } - self.update_note_undoable(note, original, true) + self.update_note_undoable(note, original) } /// Remove provided notes, and any cards that use them. - pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result<()> { + pub(crate) fn remove_notes(&mut self, nids: &[NoteID]) -> Result> { let usn = self.usn()?; - self.transact(Some(Op::RemoveNote), |col| { + self.transact(Op::RemoveNote, |col| { for nid in nids { let nid = *nid; if let Some(_existing_note) = col.storage.get_note(nid)? { @@ -404,27 +430,28 @@ impl Collection { col.remove_note_only_undoable(nid, usn)?; } } - Ok(()) }) } /// Update cards and field cache after notes modified externally. /// If gencards is false, skip card generation. - pub(crate) fn after_note_updates( + pub fn after_note_updates( &mut self, nids: &[NoteID], generate_cards: bool, mark_notes_modified: bool, - ) -> Result<()> { - self.transform_notes(nids, |_note, _nt| { - Ok(TransformNoteOutput { - changed: true, - generate_cards, - mark_modified: mark_notes_modified, + ) -> Result> { + self.transact(Op::UpdateNote, |col| { + col.transform_notes(nids, |_note, _nt| { + Ok(TransformNoteOutput { + changed: true, + generate_cards, + mark_modified: mark_notes_modified, + }) }) + .map(|_| ()) }) - .map(|_| ()) } pub(crate) fn transform_notes( diff --git a/rslib/src/notes/undo.rs b/rslib/src/notes/undo.rs index c7e42340f..a0e176009 100644 --- a/rslib/src/notes/undo.rs +++ b/rslib/src/notes/undo.rs @@ -21,7 +21,7 @@ impl Collection { .storage .get_note(note.id)? .ok_or_else(|| AnkiError::invalid_input("note disappeared"))?; - self.update_note_undoable(¬e, ¤t, false) + self.update_note_undoable(¬e, ¤t) } UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note), UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1), @@ -31,17 +31,8 @@ impl Collection { /// Saves in the undo queue, and commits to DB. /// No validation, card generation or normalization is done. - /// If `coalesce_updates` is true, successive updates within a 1 minute - /// period will not result in further undo entries. - pub(super) fn update_note_undoable( - &mut self, - note: &Note, - original: &Note, - coalesce_updates: bool, - ) -> Result<()> { - if !coalesce_updates || !self.note_was_just_updated(note) { - self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone()))); - } + pub(super) fn update_note_undoable(&mut self, note: &Note, original: &Note) -> Result<()> { + self.save_undo(UndoableNoteChange::Updated(Box::new(original.clone()))); self.storage.update_note(note)?; Ok(()) @@ -57,6 +48,31 @@ impl Collection { Ok(()) } + /// If note is edited multiple times in quick succession, avoid creating extra undo entries. + pub(crate) fn maybe_coalesce_note_undo_entry(&mut self, changes: OpChanges) { + if changes.op != Op::UpdateNote { + return; + } + + if let Some(previous_op) = self.previous_undo_op() { + if previous_op.kind != Op::UpdateNote { + return; + } + + if let ( + Some(UndoableChange::Note(UndoableNoteChange::Updated(previous))), + Some(UndoableChange::Note(UndoableNoteChange::Updated(current))), + ) = ( + previous_op.changes.last(), + self.current_undo_op().and_then(|op| op.changes.last()), + ) { + if previous.id == current.id && previous_op.timestamp.elapsed_secs() < 60 { + self.pop_last_change(); + } + } + } + } + /// Add a note, not adding any cards. pub(super) fn add_note_only_undoable(&mut self, note: &mut Note) -> Result<(), AnkiError> { self.storage.add_note(note)?; @@ -86,22 +102,4 @@ impl Collection { self.save_undo(UndoableNoteChange::GraveRemoved(Box::new((nid, usn)))); self.storage.remove_note_grave(nid) } - - /// True only if the last operation was UpdateNote, and the same note was just updated less than - /// a minute ago. - fn note_was_just_updated(&self, before_change: &Note) -> bool { - self.previous_undo_op() - .map(|op| { - if let Some(UndoableChange::Note(UndoableNoteChange::Updated(note))) = - op.changes.last() - { - note.id == before_change.id - && op.kind == Op::UpdateNote - && op.timestamp.elapsed_secs() < 60 - } else { - false - } - }) - .unwrap_or(false) - } } diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index 089ae52e5..526ae7849 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -376,7 +376,7 @@ impl From for NoteTypeProto { impl Collection { /// Add a new notetype, and allocate it an ID. pub fn add_notetype(&mut self, nt: &mut NoteType) -> Result<()> { - self.transact(None, |col| { + self.transact_no_undo(|col| { let usn = col.usn()?; nt.set_modified(usn); col.add_notetype_inner(nt, usn) @@ -415,7 +415,7 @@ impl Collection { let existing = self.get_notetype(nt.id)?; let norm = self.get_bool(BoolKey::NormalizeNoteText); nt.prepare_for_update(existing.as_ref().map(AsRef::as_ref))?; - self.transact(None, |col| { + self.transact_no_undo(|col| { if let Some(existing_notetype) = existing { if existing_notetype.mtime_secs > nt.mtime_secs { return Err(AnkiError::invalid_input("attempt to save stale notetype")); @@ -484,7 +484,7 @@ impl Collection { pub fn remove_notetype(&mut self, ntid: NoteTypeID) -> Result<()> { // fixme: currently the storage layer is taking care of removing the notes and cards, // but we need to do it in this layer in the future for undo handling - self.transact(None, |col| { + self.transact_no_undo(|col| { col.storage.set_schema_modified()?; col.state.notetype_cache.remove(&ntid); col.clear_aux_config_for_notetype(ntid)?; diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 3c8ff1c78..4807719f4 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -9,6 +9,7 @@ pub enum Op { AddNote, AnswerCard, Bury, + FindAndReplace, RemoveDeck, RemoveNote, RenameDeck, @@ -46,85 +47,11 @@ impl Op { Op::UpdateTag => TR::UndoUpdateTag, Op::SetDeck => TR::BrowsingChangeDeck, Op::SetFlag => TR::UndoSetFlag, + Op::FindAndReplace => TR::BrowsingFindAndReplace, }; i18n.tr(key).to_string() } - - /// Used internally to decide whether the study queues need to be invalidated. - pub(crate) fn needs_study_queue_reset(self) -> bool { - let changes = self.state_changes(); - self != Op::AnswerCard && (changes.card || changes.deck || changes.preference) - } - - pub fn state_changes(self) -> StateChanges { - let default = Default::default; - match self { - Op::ScheduleAsNew - | Op::SetDueDate - | Op::Suspend - | Op::UnburyUnsuspend - | Op::UpdateCard - | Op::SetDeck - | Op::Bury - | Op::SetFlag => StateChanges { - card: true, - ..default() - }, - Op::AnswerCard => StateChanges { - card: true, - // this also modifies the daily counts stored in the - // deck, but the UI does not care about that - ..default() - }, - Op::AddDeck => StateChanges { - deck: true, - ..default() - }, - Op::AddNote => StateChanges { - card: true, - note: true, - tag: true, - ..default() - }, - Op::RemoveDeck => StateChanges { - card: true, - note: true, - deck: true, - ..default() - }, - Op::RemoveNote => StateChanges { - card: true, - note: true, - ..default() - }, - Op::RenameDeck => StateChanges { - deck: true, - ..default() - }, - Op::UpdateDeck => StateChanges { - deck: true, - ..default() - }, - Op::UpdateNote => StateChanges { - note: true, - // edits may result in new cards being generated - card: true, - // and may result in new tags being added - tag: true, - ..default() - }, - Op::UpdatePreferences => StateChanges { - preference: true, - ..default() - }, - Op::UpdateTag => StateChanges { - note: true, - tag: true, - ..default() - }, - } - } } #[derive(Debug, Default, Clone, Copy)] @@ -136,3 +63,14 @@ pub struct StateChanges { pub notetype: bool, pub preference: bool, } + +#[derive(Debug, Clone, Copy)] +pub struct OpChanges { + pub op: Op, + pub changes: StateChanges, +} + +pub struct OpOutput { + pub output: T, + pub changes: OpChanges, +} diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index ada05b45b..5f607a5b4 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -23,16 +23,13 @@ impl Collection { }) } - pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> { - self.transact(Some(Op::UpdatePreferences), |col| { + pub fn set_preferences(&mut self, prefs: Preferences) -> Result> { + self.transact(Op::UpdatePreferences, |col| { col.set_preferences_inner(prefs) }) } - fn set_preferences_inner( - &mut self, - prefs: Preferences, - ) -> Result<(), crate::prelude::AnkiError> { + fn set_preferences_inner(&mut self, prefs: Preferences) -> Result<()> { if let Some(sched) = prefs.scheduling { self.set_scheduling_preferences(sched)?; } diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs index 8b201112a..949188e98 100644 --- a/rslib/src/prelude.rs +++ b/rslib/src/prelude.rs @@ -11,7 +11,7 @@ pub use crate::{ i18n::{tr_args, tr_strs, I18n, TR}, notes::{Note, NoteID}, notetype::{NoteType, NoteTypeID}, - ops::Op, + ops::{Op, OpChanges, OpOutput}, revlog::RevlogID, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index ea9b4f3cd..762d38687 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -240,8 +240,8 @@ impl Collection { } /// Answer card, writing its new state to the database. - pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<()> { - self.transact(Some(Op::AnswerCard), |col| col.answer_card_inner(answer)) + pub fn answer_card(&mut self, answer: &CardAnswer) -> Result> { + self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer)) } fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> { @@ -272,9 +272,7 @@ impl Collection { self.add_leech_tag(card.note_id)?; } - self.update_queues_after_answering_card(&card, timing)?; - - Ok(()) + self.update_queues_after_answering_card(&card, timing) } fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConf) -> Result<()> { diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index 69784344c..011589caa 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -68,8 +68,8 @@ impl Collection { self.storage.clear_searched_cards_table() } - pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result<()> { - self.transact(Some(Op::UnburyUnsuspend), |col| { + pub fn unbury_or_unsuspend_cards(&mut self, cids: &[CardID]) -> Result> { + self.transact(Op::UnburyUnsuspend, |col| { col.storage.set_search_table_to_card_ids(cids, false)?; col.unsuspend_or_unbury_searched_cards() }) @@ -81,7 +81,7 @@ impl Collection { UnburyDeckMode::UserOnly => "is:buried-manually", UnburyDeckMode::SchedOnly => "is:buried-sibling", }; - self.transact(None, |col| { + self.transact_no_undo(|col| { col.search_cards_into_table(&format!("deck:current {}", search), SortMode::NoOrder)?; col.unsuspend_or_unbury_searched_cards() }) @@ -124,12 +124,12 @@ impl Collection { &mut self, cids: &[CardID], mode: BuryOrSuspendMode, - ) -> Result<()> { + ) -> Result> { let op = match mode { BuryOrSuspendMode::Suspend => Op::Suspend, BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury, }; - self.transact(Some(op), |col| { + self.transact(op, |col| { col.storage.set_search_table_to_card_ids(cids, false)?; col.bury_or_suspend_searched_cards(mode) }) diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index c7cd1583d..2e7b3de47 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -103,10 +103,10 @@ fn nids_in_preserved_order(cards: &[Card]) -> Vec { } impl Collection { - pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result<()> { + pub fn reschedule_cards_as_new(&mut self, cids: &[CardID], log: bool) -> Result> { let usn = self.usn()?; let mut position = self.get_next_card_position(); - self.transact(Some(Op::ScheduleAsNew), |col| { + self.transact(Op::ScheduleAsNew, |col| { col.storage.set_search_table_to_card_ids(cids, true)?; let cards = col.storage.all_searched_cards_in_search_order()?; for mut card in cards { @@ -119,8 +119,7 @@ impl Collection { position += 1; } col.set_next_card_position(position)?; - col.storage.clear_searched_cards_table()?; - Ok(()) + col.storage.clear_searched_cards_table() }) } @@ -133,7 +132,7 @@ impl Collection { shift: bool, ) -> Result<()> { let usn = self.usn()?; - self.transact(None, |col| { + self.transact_no_undo(|col| { col.sort_cards_inner(cids, starting_from, step, order, shift, usn) }) } diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs index 64d38d1e0..717120748 100644 --- a/rslib/src/scheduler/queue/mod.rs +++ b/rslib/src/scheduler/queue/mod.rs @@ -139,6 +139,13 @@ impl Collection { self.state.card_queues = None; } + pub(crate) fn maybe_clear_study_queues_after_op(&mut self, op: OpChanges) { + if op.op != Op::AnswerCard && (op.changes.card || op.changes.deck || op.changes.preference) + { + self.state.card_queues = None; + } + } + pub(crate) fn update_queues_after_answering_card( &mut self, card: &Card, diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index ecfa8fe1b..fdb929438 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -93,13 +93,13 @@ impl Collection { cids: &[CardID], days: &str, context: Option, - ) -> Result<()> { + ) -> Result> { let spec = parse_due_date_str(days)?; let usn = self.usn()?; let today = self.timing_today()?.days_elapsed; let mut rng = rand::thread_rng(); let distribution = Uniform::from(spec.min..=spec.max); - self.transact(Some(Op::SetDueDate), |col| { + self.transact(Op::SetDueDate, |col| { col.storage.set_search_table_to_card_ids(cids, false)?; for mut card in col.storage.all_searched_cards()? { let original = card.clone(); diff --git a/rslib/src/sync/server.rs b/rslib/src/sync/server.rs index 842f0d37c..9406ed78a 100644 --- a/rslib/src/sync/server.rs +++ b/rslib/src/sync/server.rs @@ -210,7 +210,8 @@ impl SyncServer for LocalServer { _col_folder: Option<&Path>, ) -> Result { // bump usn/mod & close - self.col.transact(None, |col| col.storage.increment_usn())?; + self.col + .transact_no_undo(|col| col.storage.increment_usn())?; let col_path = self.col.col_path.clone(); self.col.close(true)?; diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index de437b5a9..ff2ed3cb4 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -297,7 +297,7 @@ impl Collection { let tag_group = format!("({})", regex::escape(tags.trim()).replace(' ', "|")); let nids = self.nids_for_tags(&tag_group)?; let re = Regex::new(&format!("(?i)^{}(::.*)?$", tag_group))?; - self.transact(None, |col| { + self.transact_no_undo(|col| { col.storage.clear_tag_group(&tag_group)?; col.transform_notes(&nids, |note, _nt| { Ok(TransformNoteOutput { @@ -340,8 +340,8 @@ impl Collection { nids: &[NoteID], tags: &[Regex], mut repl: R, - ) -> Result { - self.transact(Some(Op::UpdateTag), |col| { + ) -> Result> { + self.transact(Op::UpdateTag, |col| { col.transform_notes(nids, |note, _nt| { let mut changed = false; for re in tags { @@ -367,7 +367,7 @@ impl Collection { tags: &str, repl: &str, regex: bool, - ) -> Result { + ) -> Result> { // generate regexps let tags = split_tags(tags) .map(|tag| { @@ -383,7 +383,7 @@ impl Collection { } } - pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result { + pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result> { let tags: Vec<_> = split_tags(tags).collect(); let matcher = regex::RegexSet::new( tags.iter() @@ -392,7 +392,7 @@ impl Collection { ) .map_err(|_| AnkiError::invalid_input("invalid regex"))?; - self.transact(Some(Op::UpdateTag), |col| { + self.transact(Op::UpdateTag, |col| { col.transform_notes(nids, |note, _nt| { let mut need_to_add = true; let mut match_count = 0; @@ -476,7 +476,7 @@ impl Collection { } // update notes - self.transact(None, |col| { + self.transact_no_undo(|col| { // clear the existing original tags for (source_tag, _) in &source_tags_and_outputs { col.storage.clear_tag_and_children(source_tag)?; @@ -578,14 +578,14 @@ mod test { let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(note.tags[0], "baz"); - let cnt = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(cnt, 1); + let out = col.add_tags_to_notes(&[note.id], "cee aye")?; + assert_eq!(out.output, 1); let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(¬e.tags, &["aye", "baz", "cee"]); // if all tags already on note, it doesn't get updated - let cnt = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(cnt, 0); + let out = col.add_tags_to_notes(&[note.id], "cee aye")?; + assert_eq!(out.output, 0); // empty replacement deletes tag col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?; diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 73986776d..c2f8eaa9f 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -6,7 +6,10 @@ mod changes; pub use crate::ops::Op; pub(crate) use changes::UndoableChange; -use crate::prelude::*; +use crate::{ + ops::{OpChanges, StateChanges}, + prelude::*, +}; use std::collections::VecDeque; const UNDO_LIMIT: usize = 30; @@ -86,13 +89,6 @@ impl UndoManager { println!("ended, undo steps count now {}", self.undo_steps.len()); } - fn current_step_requires_study_queue_reset(&self) -> bool { - self.current_step - .as_ref() - .map(|s| s.kind.needs_study_queue_reset()) - .unwrap_or(true) - } - fn can_undo(&self) -> Option { self.undo_steps.front().map(|s| s.kind) } @@ -101,9 +97,38 @@ impl UndoManager { self.redo_steps.last().map(|s| s.kind) } - pub(crate) fn previous_op(&self) -> Option<&UndoableOp> { + fn previous_op(&self) -> Option<&UndoableOp> { self.undo_steps.front() } + + fn current_op(&self) -> Option<&UndoableOp> { + self.current_step.as_ref() + } + + fn op_changes(&self) -> OpChanges { + let current_op = self + .current_step + .as_ref() + .expect("current_changes() called when no op set"); + + let mut changes = StateChanges::default(); + for change in ¤t_op.changes { + match change { + UndoableChange::Card(_) => changes.card = true, + UndoableChange::Note(_) => changes.note = true, + UndoableChange::Deck(_) => changes.deck = true, + UndoableChange::Tag(_) => changes.tag = true, + UndoableChange::Revlog(_) => {} + UndoableChange::Queue(_) => {} + UndoableChange::Config(_) => {} // fixme: preferences? + } + } + + OpChanges { + op: current_op.kind, + changes, + } + } } impl Collection { @@ -115,36 +140,38 @@ impl Collection { self.state.undo.can_redo() } - pub fn undo(&mut self) -> Result<()> { + pub fn undo(&mut self) -> Result> { if let Some(step) = self.state.undo.undo_steps.pop_front() { let changes = step.changes; self.state.undo.mode = UndoMode::Undoing; - let res = self.transact(Some(step.kind), |col| { + let res = self.transact(step.kind, |col| { for change in changes.into_iter().rev() { change.undo(col)?; } Ok(()) }); self.state.undo.mode = UndoMode::NormalOp; - res?; + res + } else { + Err(AnkiError::invalid_input("no undo available")) } - Ok(()) } - pub fn redo(&mut self) -> Result<()> { + pub fn redo(&mut self) -> Result> { if let Some(step) = self.state.undo.redo_steps.pop() { let changes = step.changes; self.state.undo.mode = UndoMode::Redoing; - let res = self.transact(Some(step.kind), |col| { + let res = self.transact(step.kind, |col| { for change in changes.into_iter().rev() { change.undo(col)?; } Ok(()) }); self.state.undo.mode = UndoMode::NormalOp; - res?; + res + } else { + Err(AnkiError::invalid_input("no redo available")) } - Ok(()) } pub fn undo_status(&self) -> UndoStatus { @@ -162,9 +189,6 @@ impl Collection { /// Called at the end of a successful transaction. /// In most instances, this will also clear the study queues. pub(crate) fn end_undoable_operation(&mut self) { - if self.state.undo.current_step_requires_study_queue_reset() { - self.clear_study_queues(); - } self.state.undo.end_step(); } @@ -183,9 +207,30 @@ impl Collection { self.state.undo.save(item.into()); } + pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> { + self.state.undo.current_op() + } + pub(crate) fn previous_undo_op(&self) -> Option<&UndoableOp> { self.state.undo.previous_op() } + + /// Used for coalescing successive note updates. + pub(crate) fn pop_last_change(&mut self) -> Option { + self.state + .undo + .current_step + .as_mut() + .expect("no operation active") + .changes + .pop() + } + + /// Return changes made by the current op. Must only be called in a transaction, + /// when an operation was passed to transact(). + pub(crate) fn op_changes(&self) -> Result { + Ok(self.state.undo.op_changes()) + } } #[cfg(test)] @@ -218,7 +263,7 @@ mod test { // record a few undo steps for i in 3..=4 { - col.transact(Some(Op::UpdateCard), |col| { + col.transact(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = i; Ok(()) @@ -264,7 +309,7 @@ mod test { assert_eq!(col.can_redo(), Some(Op::UpdateCard)); // if any action is performed, it should clear the redo queue - col.transact(Some(Op::UpdateCard), |col| { + col.transact(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = 5; Ok(()) @@ -278,7 +323,7 @@ mod test { assert_eq!(col.can_redo(), None); // and any action that doesn't support undoing will clear both queues - col.transact(None, |_col| Ok(())).unwrap(); + col.transact_no_undo(|_col| Ok(())).unwrap(); assert_eq!(col.can_undo(), None); assert_eq!(col.can_redo(), None); } From 3ad86f18526764c0cfd57984797ff57eb46e6634 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 16:39:41 +1000 Subject: [PATCH 11/33] prevent editor from refreshing itself after a save - add after_hooks arg to perform_op() - when refreshing browse screen, just redraws cells, and handle editor update in Browser instead of the model --- qt/aqt/addcards.py | 2 +- qt/aqt/browser.py | 23 ++++++++++---- qt/aqt/editcurrent.py | 70 ++++++++++++++++++++----------------------- qt/aqt/editor.py | 28 ++++++++++++++--- qt/aqt/main.py | 17 ++++++++--- qt/aqt/note_ops.py | 9 ++++-- 6 files changed, 95 insertions(+), 54 deletions(-) diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index b91609504..5d5e82446 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -104,7 +104,7 @@ class AddCards(QDialog): self.historyButton = b def setAndFocusNote(self, note: Note) -> None: - self.editor.setNote(note, focusTo=0) + self.editor.set_note(note, focusTo=0) def show_notetype_selector(self) -> None: self.editor.saveNow(self.notetype_chooser.choose_notetype) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 089528c8a..c5477944e 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -209,14 +209,21 @@ class DataModel(QAbstractTableModel): finally: self.endReset() + def redraw_cells(self) -> None: + "Update cell contents, without changing search count/columns/sorting." + if not self.cards: + return + top_left = self.index(0, 0) + bottom_right = self.index(len(self.cards)-1, len(self.activeCols)-1) + self.dataChanged.emit(top_left, bottom_right) + def reset(self) -> None: self.beginReset() self.endReset() - self.refresh_needed = False # caller must have called editor.saveNow() before calling this or .reset() def beginReset(self) -> None: - self.browser.editor.setNote(None, hide=False) + self.browser.editor.set_note(None, hide=False) self.browser.mw.progress.start() self.saveSelection() self.beginResetModel() @@ -299,7 +306,8 @@ class DataModel(QAbstractTableModel): def refresh_if_needed(self) -> None: if self.refresh_needed: - self.reset() + self.redraw_cells() + self.refresh_needed = False # Column data ###################################################################### @@ -507,6 +515,11 @@ class Browser(QMainWindow): def on_operation_did_execute(self, changes: OpChanges) -> None: self.setUpdatesEnabled(True) self.model.op_executed(changes, current_top_level_widget() == self) + if (changes.note or changes.notetype) and not self.editor.is_updating_note(): + note = self.editor.note + if note: + note.load() + self.editor.set_note(note) def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: if current_top_level_widget() == self: @@ -836,11 +849,11 @@ QTableView {{ gridline-color: {grid} }} self.form.splitter.widget(1).setVisible(bool(show)) if not show: - self.editor.setNote(None) + self.editor.set_note(None) self.singleCard = False self._renderPreview() else: - self.editor.setNote(self.card.note(reload=True), focusTo=self.focusTo) + self.editor.set_note(self.card.note(reload=True), focusTo=self.focusTo) self.focusTo = None self.editor.card = self.card self.singleCard = True diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py index 4a540d76b..92c2867c4 100644 --- a/qt/aqt/editcurrent.py +++ b/qt/aqt/editcurrent.py @@ -1,10 +1,12 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import aqt.editor +from anki.collection import OpChanges +from anki.errors import NotFoundError from aqt import gui_hooks -from aqt.main import ResetReason from aqt.qt import * -from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tooltip, tr +from aqt.utils import TR, disable_help_button, restoreGeom, saveGeom, tr class EditCurrent(QDialog): @@ -23,33 +25,38 @@ class EditCurrent(QDialog): ) self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self) self.editor.card = self.mw.reviewer.card - self.editor.setNote(self.mw.reviewer.card.note(), focusTo=0) + self.editor.set_note(self.mw.reviewer.card.note(), focusTo=0) restoreGeom(self, "editcurrent") - gui_hooks.state_did_reset.append(self.onReset) - self.mw.requireReset(reason=ResetReason.EditCurrentInit, context=self) + gui_hooks.operation_did_execute.append(self.on_operation_did_execute) self.show() - # reset focus after open, taking care not to retain webview - # pylint: disable=unnecessary-lambda - self.mw.progress.timer(100, lambda: self.editor.web.setFocus(), False) - def onReset(self) -> None: - # lazy approach for now: throw away edits - try: - n = self.editor.note - n.load() # reload in case the model changed - except: - # card's been deleted - gui_hooks.state_did_reset.remove(self.onReset) - self.editor.setNote(None) - self.mw.reset() - aqt.dialogs.markClosed("EditCurrent") - self.close() + def on_operation_did_execute(self, changes: OpChanges) -> None: + if not (changes.note or changes.notetype): return - self.editor.setNote(n) + if self.editor.is_updating_note(): + return + + # reload note + note = self.editor.note + try: + note.load() + except NotFoundError: + # note's been deleted + self.cleanup_and_close() + return + + self.editor.set_note(note) + + def cleanup_and_close(self) -> None: + gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) + self.editor.cleanup() + saveGeom(self, "editcurrent") + aqt.dialogs.markClosed("EditCurrent") + QDialog.reject(self) def reopen(self, mw: aqt.AnkiQt) -> None: - tooltip("Please finish editing the existing card first.") - self.onReset() + if card := self.mw.reviewer.card: + self.editor.set_note(card.note()) def reject(self) -> None: self.saveAndClose() @@ -58,20 +65,7 @@ class EditCurrent(QDialog): self.editor.saveNow(self._saveAndClose) def _saveAndClose(self) -> None: - gui_hooks.state_did_reset.remove(self.onReset) - r = self.mw.reviewer - try: - r.card.load() - except: - # card was removed by clayout - pass - else: - self.mw.reviewer.cardQueue.append(self.mw.reviewer.card) - self.editor.cleanup() - self.mw.moveToState("review") - saveGeom(self, "editcurrent") - aqt.dialogs.markClosed("EditCurrent") - QDialog.reject(self) + self.cleanup_and_close() def closeWithCallback(self, onsuccess: Callable[[], None]) -> None: def callback() -> None: @@ -79,3 +73,5 @@ class EditCurrent(QDialog): onsuccess() self.editor.saveNow(callback) + + onReset = on_operation_did_execute diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 9386a6bd6..5a6da7b6a 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -90,8 +90,16 @@ _html = """ """ -# caller is responsible for resetting note on reset class Editor: + """The screen that embeds an editing widget should listen for changes via + the `operation_did_execute` hook, and call set_note() when the editor needs + redrawing. + + The editor will cause that hook to be fired when it saves changes. To avoid + an unwanted refresh, the parent widget should call editor.is_updating_note(), + and avoid re-setting the note if it returns true. + """ + def __init__( self, mw: AnkiQt, widget: QWidget, parentWindow: QWidget, addMode: bool = False ) -> None: @@ -101,6 +109,7 @@ class Editor: self.note: Optional[Note] = None self.addMode = addMode self.currentField: Optional[int] = None + self._is_updating_note = False # current card, for card layout self.card: Optional[Card] = None self.setupOuter() @@ -491,7 +500,7 @@ class Editor: # Setting/unsetting the current note ###################################################################### - def setNote( + def set_note( self, note: Optional[Note], hide: bool = True, focusTo: Optional[int] = None ) -> None: "Make NOTE the current note." @@ -543,7 +552,14 @@ class Editor: def _save_current_note(self) -> None: "Call after note is updated with data from webview." - update_note(mw=self.mw, note=self.note) + self._is_updating_note = True + update_note(mw=self.mw, note=self.note, after_hooks=self._after_updating_note) + + def _after_updating_note(self) -> None: + self._is_updating_note = False + + def is_updating_note(self) -> bool: + return self._is_updating_note def fonts(self) -> List[Tuple[str, int, bool]]: return [ @@ -596,10 +612,14 @@ class Editor: return True def cleanup(self) -> None: - self.setNote(None) + self.set_note(None) # prevent any remaining evalWithCallback() events from firing after C++ object deleted self.web = None + # legacy + + setNote = set_note + # HTML editing ###################################################################### diff --git a/qt/aqt/main.py b/qt/aqt/main.py index cb1d0a77c..8c8ff9cd4 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -722,6 +722,7 @@ class AnkiQt(QMainWindow): *, success: PerformOpOptionalSuccessCallback = None, failure: Optional[Callable[[Exception], Any]] = None, + after_hooks: Optional[Callable[[], None]] = None, ) -> None: """Run the provided operation on a background thread. @@ -740,10 +741,14 @@ class AnkiQt(QMainWindow): Be careful not to call any UI routines in `op`, as that may crash Qt. This includes things select .selectedCards() in the browse screen. - on_success() will be called with the return value of op(). + success() will be called with the return value of op(). If op() throws an exception, it will be shown in a popup, or - passed to on_exception() if it is provided. + passed to failure() if it is provided. + + after_hooks() will be called after hooks are fired, if it is provided. + Components can use this to ignore change notices generated by operations + they invoke themselves. """ gui_hooks.operation_will_execute() @@ -769,11 +774,13 @@ class AnkiQt(QMainWindow): status = self.col.undo_status() self._update_undo_actions_for_status_and_save(status) # fire change hooks - self._fire_change_hooks_after_op_performed(result) + self._fire_change_hooks_after_op_performed(result, after_hooks) self.taskman.with_progress(op, wrapped_done) - def _fire_change_hooks_after_op_performed(self, result: ResultWithChanges) -> None: + def _fire_change_hooks_after_op_performed( + self, result: ResultWithChanges, after_hooks: Optional[Callable[[], None]] + ) -> None: if isinstance(result, OpChanges): changes = result else: @@ -786,6 +793,8 @@ class AnkiQt(QMainWindow): # fire legacy hook so old code notices changes if self.col.op_made_changes(changes): gui_hooks.state_did_reset() + if after_hooks: + after_hooks() def _synthesize_op_did_execute_from_reset(self) -> None: """Fire the `operation_did_execute` hook with everything marked as changed, diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index f5438fc7a..d9861dccd 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Optional, Sequence +from typing import Callable, Optional, Sequence from anki.lang import TR from anki.notes import Note @@ -23,8 +23,11 @@ def add_note( mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success) -def update_note(*, mw: AnkiQt, note: Note) -> None: - mw.perform_op(lambda: mw.col.update_note(note)) +def update_note(*, mw: AnkiQt, note: Note, after_hooks: Callable[[], None]) -> None: + mw.perform_op( + lambda: mw.col.update_note(note), + after_hooks=after_hooks, + ) def remove_notes( From 3f87f7bf5c3d498b387ab922e468dc6f940988a6 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 16:43:47 +1000 Subject: [PATCH 12/33] don't update review screen immediately on note changes The redraw causes an ugly flash, and it will result in audio being replayed over and over as the user types. --- qt/aqt/reviewer.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 1fd27a3bd..8b5aeb0c8 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -110,25 +110,16 @@ class Reviewer: # fixme: v3 mtime check self.card.load() self._update_flag_icon() - elif changes.note and changes.kind == OpChanges.UPDATE_NOTE: - self._redraw_current_card() elif self.mw.col.op_affects_study_queue(changes): self._refresh_needed = True elif changes.note or changes.notetype or changes.tag: - self._redraw_current_card() + self._refresh_needed = True if focused and self._refresh_needed: self.refresh_if_needed() return self._refresh_needed - def _redraw_current_card(self) -> None: - self.card.load() - if self.state == "answer": - self._showAnswer() - else: - self._showQuestion() - # Fetching a card ########################################################################## From 017005a4f8765c0db5e8219a62347f0e811289c9 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 18:30:54 +1000 Subject: [PATCH 13/33] various redraw fixes - need to drop cardObjs cache when updating cells - stop listening on editor_did_* hooks. unfocus_field and typing_timer are covered by operation_did_execute on note save already, and the user potentially has editors open in other windows as well - distinguish between card queue refresh and note text redraw in review screen again - update preview window when note updated - defer setUpdatesEnabled(True) until we receive focus again, as it causes cells to redraw. We might want to use our own flag to prevent updating in the model instead of using Qt for this --- qt/aqt/browser.py | 51 +++++++++++++++------------------------------- qt/aqt/editor.py | 1 + qt/aqt/reviewer.py | 34 +++++++++++++++++++++++-------- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index c5477944e..09e409e2a 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -17,7 +17,6 @@ from anki.consts import * from anki.errors import InvalidInput, NotFoundError from anki.lang import without_unicode_isolation from anki.models import NoteType -from anki.notes import Note from anki.stats import CardStats from anki.utils import htmlToTextLine, ids2str, isMac, isWin from aqt import AnkiQt, colors, gui_hooks @@ -112,15 +111,6 @@ class DataModel(QAbstractTableModel): self.cardObjs[id] = card return self.cardObjs[id] - def refreshNote(self, note: Note) -> None: - refresh = False - for c in note.cards(): - if c.id in self.cardObjs: - del self.cardObjs[c.id] - refresh = True - if refresh: - self.layoutChanged.emit() # type: ignore - # Model interface ###################################################################### @@ -214,8 +204,9 @@ class DataModel(QAbstractTableModel): if not self.cards: return top_left = self.index(0, 0) - bottom_right = self.index(len(self.cards)-1, len(self.activeCols)-1) - self.dataChanged.emit(top_left, bottom_right) + bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1) + self.cardObjs = {} + self.dataChanged.emit(top_left, bottom_right) # type: ignore def reset(self) -> None: self.beginReset() @@ -513,16 +504,22 @@ class Browser(QMainWindow): self.setUpdatesEnabled(False) def on_operation_did_execute(self, changes: OpChanges) -> None: - self.setUpdatesEnabled(True) - self.model.op_executed(changes, current_top_level_widget() == self) - if (changes.note or changes.notetype) and not self.editor.is_updating_note(): - note = self.editor.note - if note: - note.load() - self.editor.set_note(note) + focused = current_top_level_widget() == self + if focused: + self.setUpdatesEnabled(True) + self.model.op_executed(changes, focused) + if changes.note or changes.notetype: + if not self.editor.is_updating_note(): + note = self.editor.note + if note: + note.load() + self.editor.set_note(note) + + self._renderPreview() def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: if current_top_level_widget() == self: + self.setUpdatesEnabled(True) self.model.refresh_if_needed() def setupMenus(self) -> None: @@ -860,13 +857,6 @@ QTableView {{ gridline-color: {grid} }} self._updateFlagsMenu() gui_hooks.browser_did_change_row(self) - def refreshCurrentCard(self, note: Note) -> None: - self.model.refreshNote(note) - self._renderPreview() - - def onLoadNote(self, editor: Editor) -> None: - self.refreshCurrentCard(editor.note) - def currentRow(self) -> int: idx = self.form.tableView.selectionModel().currentIndex() return idx.row() @@ -1452,9 +1442,6 @@ where id in %s""" def setupHooks(self) -> None: gui_hooks.undo_state_did_change.append(self.onUndoState) - gui_hooks.editor_did_fire_typing_timer.append(self.refreshCurrentCard) - gui_hooks.editor_did_load_note.append(self.onLoadNote) - gui_hooks.editor_did_unfocus_field.append(self.on_unfocus_field) gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) gui_hooks.operation_will_execute.append(self.on_operation_will_execute) @@ -1463,18 +1450,12 @@ where id in %s""" def teardownHooks(self) -> None: gui_hooks.undo_state_did_change.remove(self.onUndoState) - gui_hooks.editor_did_fire_typing_timer.remove(self.refreshCurrentCard) - gui_hooks.editor_did_load_note.remove(self.onLoadNote) - gui_hooks.editor_did_unfocus_field.remove(self.on_unfocus_field) gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) gui_hooks.operation_will_execute.remove(self.on_operation_will_execute) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.focus_did_change.remove(self.on_focus_change) - def on_unfocus_field(self, changed: bool, note: Note, field_idx: int) -> None: - self.refreshCurrentCard(note) - # covers the tag, note and deck case def on_item_added(self, item: Any = None) -> None: self.sidebar.refresh() diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 5a6da7b6a..4b8c0f9ca 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -90,6 +90,7 @@ _html = """ """ + class Editor: """The screen that embeds an editing widget should listen for changes via the `operation_did_execute` hook, and call set_note() when the editor needs diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 8b5aeb0c8..f8154ab1d 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -7,6 +7,7 @@ import html import json import re import unicodedata as ucd +from enum import Enum, auto from typing import Any, Callable, List, Match, Optional, Sequence, Tuple, Union from PyQt5.QtCore import Qt @@ -41,6 +42,12 @@ from aqt.utils import ( from aqt.webview import AnkiWebView +class RefreshNeeded(Enum): + NO = auto() + NOTE_TEXT = auto() + QUEUES = auto() + + class ReviewerBottomBar: def __init__(self, reviewer: Reviewer) -> None: self.reviewer = reviewer @@ -69,7 +76,7 @@ class Reviewer: self._recordedAudio: Optional[str] = None self.typeCorrect: str = None # web init happens before this is set self.state: Optional[str] = None - self._refresh_needed = False + self._refresh_needed = RefreshNeeded.NO self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) @@ -78,7 +85,7 @@ class Reviewer: self.web.set_bridge_command(self._linkHandler, self) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self._reps: int = None - self._refresh_needed = True + self._refresh_needed = RefreshNeeded.QUEUES self.refresh_if_needed() def lastCard(self) -> Optional[Card]: @@ -96,11 +103,15 @@ class Reviewer: self.card = None def refresh_if_needed(self) -> None: - if self._refresh_needed: + if self._refresh_needed is RefreshNeeded.QUEUES: self.mw.col.reset() self.nextCard() - self._refresh_needed = False self.mw.fade_in_webview() + self._refresh_needed = RefreshNeeded.NO + elif self._refresh_needed is RefreshNeeded.NOTE_TEXT: + self._redraw_current_card() + self.mw.fade_in_webview() + self._refresh_needed = RefreshNeeded.NO def op_executed(self, changes: OpChanges, focused: bool) -> bool: if changes.note and changes.kind == OpChanges.UPDATE_NOTE_TAGS: @@ -111,14 +122,21 @@ class Reviewer: self.card.load() self._update_flag_icon() elif self.mw.col.op_affects_study_queue(changes): - self._refresh_needed = True + self._refresh_needed = RefreshNeeded.QUEUES elif changes.note or changes.notetype or changes.tag: - self._refresh_needed = True + self._refresh_needed = RefreshNeeded.NOTE_TEXT - if focused and self._refresh_needed: + if focused and self._refresh_needed is not RefreshNeeded.NO: self.refresh_if_needed() - return self._refresh_needed + return self._refresh_needed is not RefreshNeeded.NO + + def _redraw_current_card(self) -> None: + self.card.load() + if self.state == "answer": + self._showAnswer() + else: + self._showQuestion() # Fetching a card ########################################################################## From 7171a24e160d42a09949e27400f7c1b278470719 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 19:21:18 +1000 Subject: [PATCH 14/33] redraw sidebar in response to perform_op() changes --- qt/aqt/browser.py | 11 +++++++---- qt/aqt/sidebar.py | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 09e409e2a..c5c727048 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -98,7 +98,7 @@ class DataModel(QAbstractTableModel): ) self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} - self.refresh_needed = False + self._refresh_needed = False def getCard(self, index: QModelIndex) -> Optional[Card]: id = self.cards[index.row()] @@ -291,14 +291,14 @@ class DataModel(QAbstractTableModel): def op_executed(self, op: OpChanges, focused: bool) -> None: if op.card or op.note or op.deck or op.notetype: - self.refresh_needed = True + self._refresh_needed = True if focused: self.refresh_if_needed() def refresh_if_needed(self) -> None: - if self.refresh_needed: + if self._refresh_needed: self.redraw_cells() - self.refresh_needed = False + self._refresh_needed = False # Column data ###################################################################### @@ -508,6 +508,7 @@ class Browser(QMainWindow): if focused: self.setUpdatesEnabled(True) self.model.op_executed(changes, focused) + self.sidebar.op_executed(changes, focused) if changes.note or changes.notetype: if not self.editor.is_updating_note(): note = self.editor.note @@ -521,6 +522,7 @@ class Browser(QMainWindow): if current_top_level_widget() == self: self.setUpdatesEnabled(True) self.model.refresh_if_needed() + self.sidebar.refresh_if_needed() def setupMenus(self) -> None: # pylint: disable=unnecessary-lambda @@ -1442,6 +1444,7 @@ where id in %s""" def setupHooks(self) -> None: gui_hooks.undo_state_did_change.append(self.onUndoState) + # fixme: remove these once all items are using `operation_did_execute` gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) gui_hooks.operation_will_execute.append(self.on_operation_will_execute) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 5f8c0e011..65ba2e0f5 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -8,7 +8,7 @@ from enum import Enum, auto from typing import Dict, Iterable, List, Optional, Tuple, cast import aqt -from anki.collection import Config, SearchJoiner, SearchNode +from anki.collection import Config, OpChanges, SearchJoiner, SearchNode from anki.decks import DeckTreeNode from anki.errors import DeckIsFilteredError, InvalidInput from anki.notes import Note @@ -362,6 +362,7 @@ class SidebarTreeView(QTreeView): self.col = self.mw.col self.current_search: Optional[str] = None self.valid_drop_types: Tuple[SidebarItemType, ...] = () + self._refresh_needed = False self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore @@ -411,6 +412,20 @@ class SidebarTreeView(QTreeView): def model(self) -> SidebarModel: return super().model() + # Refreshing + ########################### + + def op_executed(self, op: OpChanges, focused: bool) -> None: + if op.tag or op.notetype or op.deck: + self._refresh_needed = True + if focused: + self.refresh_if_needed() + + def refresh_if_needed(self) -> None: + if self._refresh_needed: + self.refresh() + self._refresh_needed = False + def refresh( self, is_current: Optional[Callable[[SidebarItem], bool]] = None ) -> None: From 949584d3fa916db9a39129666013043bbcb1f576 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 19:33:26 +1000 Subject: [PATCH 15/33] don't show busy cursor immediately Setting it straight away causes the cursor to flash on quick operations, like saving the current note. Delay it for 300ms, which should hopefully be long enough to not get in the way, but short enough to give indication that long-running requests are being processed. --- qt/aqt/progress.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index d5002208e..6f99524e7 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -20,6 +20,7 @@ class ProgressManager: self.inDB = False self.blockUpdates = False self._show_timer: Optional[QTimer] = None + self._busy_cursor_timer: Optional[QTimer] = None self._win: Optional[ProgressDialog] = None self._levels = 0 @@ -94,7 +95,10 @@ class ProgressManager: self._win.setWindowTitle("Anki") self._win.setWindowModality(Qt.ApplicationModal) self._win.setMinimumWidth(300) - self._setBusy() + self._busy_cursor_timer = QTimer(self.mw) + self._busy_cursor_timer.setSingleShot(True) + self._busy_cursor_timer.start(300) + qconnect(self._busy_cursor_timer.timeout, self._set_busy_cursor) self._shown: float = 0 self._counter = min self._min = min @@ -148,7 +152,10 @@ class ProgressManager: if self._levels == 0: if self._win: self._closeWin() - self._unsetBusy() + if self._busy_cursor_timer: + self._busy_cursor_timer.stop() + self._busy_cursor_timer = None + self._restore_cursor() if self._show_timer: self._show_timer.stop() self._show_timer = None @@ -187,10 +194,10 @@ class ProgressManager: self._win = None self._shown = 0 - def _setBusy(self) -> None: + def _set_busy_cursor(self) -> None: self.mw.app.setOverrideCursor(QCursor(Qt.WaitCursor)) - def _unsetBusy(self) -> None: + def _restore_cursor(self) -> None: self.app.restoreOverrideCursor() def busy(self) -> int: From 71456b0825f2fcea4ffbe467213e550783d1449f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 19:41:40 +1000 Subject: [PATCH 16/33] remove the processEvents() call in progress window Relic from when we were processing UI events via the sqlite progress handler. --- qt/aqt/progress.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index 6f99524e7..947ffc993 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -104,8 +104,6 @@ class ProgressManager: self._min = min self._max = max self._firstTime = time.time() - self._lastUpdate = time.time() - self._updating = False self._show_timer = QTimer(self.mw) self._show_timer.setSingleShot(True) self._show_timer.start(immediate and 100 or 600) @@ -124,13 +122,10 @@ class ProgressManager: if not self.mw.inMainThread(): print("progress.update() called on wrong thread") return - if self._updating: - return if maybeShow: self._maybeShow() if not self._shown: return - elapsed = time.time() - self._lastUpdate if label: self._win.form.label.setText(label) @@ -140,12 +135,6 @@ class ProgressManager: self._counter = value or (self._counter + 1) self._win.form.progressBar.setValue(self._counter) - if process and elapsed >= 0.2: - self._updating = True - self.app.processEvents() # type: ignore #possibly related to https://github.com/python/mypy/issues/6910 - self._updating = False - self._lastUpdate = time.time() - def finish(self) -> None: self._levels -= 1 self._levels = max(0, self._levels) From f71446ddf53f83d96a9a827ad883e9f3dc528baa Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 16 Mar 2021 22:40:37 +1000 Subject: [PATCH 17/33] decorator for saveNow(), mkII Mostly @RumovZ's work from https://github.com/ankitects/anki/pull/1066, with a workaround for the issue encountered on https://github.com/ankitects/anki/commit/6e0e17b2b9c28405db9f8644e0d445a662f84b13 Fix is to use pyqtSlot() to specify the slot signature, as described on https://stackoverflow.com/questions/44371451/python-pyqt-qt-qmenu-qaction-syntax Also renamed saveNow() for PEP8, but have not updated all the existing calls to use the decorator yet - might be easiest to do at the same time as perform_op() calls are added. --- qt/aqt/addcards.py | 6 +- qt/aqt/browser.py | 136 ++++++++++++++++-------------------------- qt/aqt/editcurrent.py | 4 +- qt/aqt/editor.py | 14 +++-- qt/aqt/previewer.py | 4 +- qt/aqt/sidebar.py | 13 ++-- qt/aqt/utils.py | 27 +++++++++ 7 files changed, 102 insertions(+), 102 deletions(-) diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 5d5e82446..0b1c7f3c2 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -107,7 +107,7 @@ class AddCards(QDialog): self.editor.set_note(note, focusTo=0) def show_notetype_selector(self) -> None: - self.editor.saveNow(self.notetype_chooser.choose_notetype) + self.editor.call_after_note_saved(self.notetype_chooser.choose_notetype) def on_notetype_change(self, notetype_id: int) -> None: # need to adjust current deck? @@ -182,7 +182,7 @@ class AddCards(QDialog): aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),)) def add_current_note(self) -> None: - self.editor.saveNow(self._add_current_note) + self.editor.call_after_note_saved(self._add_current_note) def _add_current_note(self) -> None: note = self.editor.note @@ -259,7 +259,7 @@ class AddCards(QDialog): if ok: onOk() - self.editor.saveNow(afterSave) + self.editor.call_after_note_saved(afterSave) def closeWithCallback(self, cb: Callable[[], None]) -> None: def doClose() -> None: diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index c5c727048..0b0d7cd3a 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1,5 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + from __future__ import annotations import html @@ -42,6 +43,8 @@ from aqt.utils import ( askUser, current_top_level_widget, disable_help_button, + ensure_editor_saved, + ensure_editor_saved_on_trigger, getTag, openHelp, qtMenuShortcutWorkaround, @@ -226,7 +229,7 @@ class DataModel(QAbstractTableModel): self.browser.mw.progress.finish() def reverse(self) -> None: - self.browser.editor.saveNow(self._reverse) + self.browser.editor.call_after_note_saved(self._reverse) def _reverse(self) -> None: self.beginReset() @@ -612,7 +615,7 @@ class Browser(QMainWindow): if self._closeEventHasCleanedUp: evt.accept() return - self.editor.saveNow(self._closeWindow) + self.editor.call_after_note_saved(self._closeWindow) evt.ignore() def _closeWindow(self) -> None: @@ -629,12 +632,10 @@ class Browser(QMainWindow): self.mw.deferred_delete_and_garbage_collect(self) self.close() + @ensure_editor_saved def closeWithCallback(self, onsuccess: Callable) -> None: - def callback() -> None: - self._closeWindow() - onsuccess() - - self.editor.saveNow(callback) + self._closeWindow() + onsuccess() def keyPressEvent(self, evt: QKeyEvent) -> None: if evt.key() == Qt.Key_Escape: @@ -700,10 +701,8 @@ class Browser(QMainWindow): self.form.searchEdit.setFocus() # search triggered by user + @ensure_editor_saved def onSearchActivated(self) -> None: - self.editor.saveNow(self._onSearchActivated) - - def _onSearchActivated(self) -> None: text = self.form.searchEdit.lineEdit().text() try: normed = self.col.build_search_string(text) @@ -773,7 +772,7 @@ class Browser(QMainWindow): self.search_for(search, "") self.focusCid(card.id) - self.editor.saveNow(on_show_single_card) + self.editor.call_after_note_saved(on_show_single_card) def onReset(self) -> None: self.sidebar.refresh() @@ -832,11 +831,9 @@ QTableView {{ gridline-color: {grid} }} self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self) gui_hooks.editor_did_init_left_buttons.remove(add_preview_button) + @ensure_editor_saved def onRowChanged(self, current: QItemSelection, previous: QItemSelection) -> None: - "Update current note and hide/show editor." - self.editor.saveNow(lambda: self._onRowChanged(current, previous)) - - def _onRowChanged(self, current: QItemSelection, previous: QItemSelection) -> None: + """Update current note and hide/show editor.""" if self._closeEventHasCleanedUp: return update = self.updateTitle() @@ -883,11 +880,9 @@ QTableView {{ gridline-color: {grid} }} qconnect(hh.sortIndicatorChanged, self.onSortChanged) qconnect(hh.sectionMoved, self.onColumnMoved) + @ensure_editor_saved def onSortChanged(self, idx: int, ord: int) -> None: - ord_bool = bool(ord) - self.editor.saveNow(lambda: self._onSortChanged(idx, ord_bool)) - - def _onSortChanged(self, idx: int, ord: bool) -> None: + ord = bool(ord) type = self.model.activeCols[idx] noSort = ("question", "answer") if type in noSort: @@ -935,10 +930,8 @@ QTableView {{ gridline-color: {grid} }} gui_hooks.browser_header_will_show_context_menu(self, m) m.exec_(gpos) + @ensure_editor_saved_on_trigger def toggleField(self, type: str) -> None: - self.editor.saveNow(lambda: self._toggleField(type)) - - def _toggleField(self, type: str) -> None: self.model.beginReset() if type in self.model.activeCols: if len(self.model.activeCols) < 2: @@ -1115,10 +1108,8 @@ where id in %s""" # Misc menu options ###################################################################### + @ensure_editor_saved_on_trigger def onChangeModel(self) -> None: - self.editor.saveNow(self._onChangeModel) - - def _onChangeModel(self) -> None: nids = self.oneModelNotes() if nids: ChangeModel(self, nids) @@ -1192,6 +1183,7 @@ where id in %s""" # Deck change ###################################################################### + @ensure_editor_saved_on_trigger def set_deck_of_selected_cards(self) -> None: from aqt.studydeck import StudyDeck @@ -1222,38 +1214,30 @@ where id in %s""" # Tags ###################################################################### + @ensure_editor_saved_on_trigger def add_tags_to_selected_notes( self, tags: Optional[str] = None, ) -> None: "Shows prompt if tags not provided." + if not ( + tags := self.maybe_prompt_for_tags(tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) + ): + return + add_tags(mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags) - def op() -> None: - if not ( - tags2 := self.maybe_prompt_for_tags( - tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD) - ) - ): - return - nids = self.selectedNotes() - add_tags(mw=self.mw, note_ids=nids, space_separated_tags=tags2) - - self.editor.saveNow(op) - + @ensure_editor_saved_on_trigger def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: "Shows prompt if tags not provided." - - def op() -> None: - if not ( - tags2 := self.maybe_prompt_for_tags( - tags, tr(TR.BROWSING_ENTER_TAGS_TO_DELETE) - ) - ): - return - nids = self.selectedNotes() - remove_tags(mw=self.mw, note_ids=nids, space_separated_tags=tags2) - - self.editor.saveNow(op) + if not ( + tags := self.maybe_prompt_for_tags( + tags, tr(TR.BROWSING_ENTER_TAGS_TO_DELETE) + ) + ): + return + remove_tags( + mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags + ) def _maybe_prompt_for_tags(self, tags: Optional[str], prompt: str) -> Optional[str]: if tags is not None: @@ -1265,10 +1249,8 @@ where id in %s""" else: return tags + @ensure_editor_saved_on_trigger def clearUnusedTags(self) -> None: - self.editor.saveNow(self._clearUnusedTags) - - def _clearUnusedTags(self) -> None: def on_done(fut: Future) -> None: fut.result() self.on_tag_list_update() @@ -1284,10 +1266,8 @@ where id in %s""" def current_card_is_suspended(self) -> bool: return bool(self.card and self.card.queue == QUEUE_TYPE_SUSPENDED) + @ensure_editor_saved_on_trigger def suspend_selected_cards(self) -> None: - self.editor.saveNow(self._suspend_selected_cards) - - def _suspend_selected_cards(self) -> None: want_suspend = not self.current_card_is_suspended() cids = self.selectedCards() @@ -1310,7 +1290,7 @@ where id in %s""" def onSetFlag(self, n: int) -> None: if not self.card: return - self.editor.saveNow(lambda: self._on_set_flag(n)) + self.editor.call_after_note_saved(lambda: self._on_set_flag(n)) def _on_set_flag(self, flag: int) -> None: # flag needs toggling off? @@ -1351,10 +1331,8 @@ where id in %s""" # Repositioning ###################################################################### + @ensure_editor_saved_on_trigger def reposition(self) -> None: - self.editor.saveNow(self._reposition) - - def _reposition(self) -> None: cids = self.selectedCards() cids2 = self.col.db.list( f"select id from cards where type = {CARD_TYPE_NEW} and id in " @@ -1395,32 +1373,28 @@ where id in %s""" # Scheduling ###################################################################### + @ensure_editor_saved_on_trigger def set_due_date(self) -> None: - self.editor.saveNow( - lambda: set_due_date_dialog( - mw=self.mw, - parent=self, - card_ids=self.selectedCards(), - config_key=Config.String.SET_DUE_BROWSER, - ) + set_due_date_dialog( + mw=self.mw, + parent=self, + card_ids=self.selectedCards(), + config_key=Config.String.SET_DUE_BROWSER, ) + @ensure_editor_saved_on_trigger def forget_cards(self) -> None: - self.editor.saveNow( - lambda: forget_cards( - mw=self.mw, - parent=self, - card_ids=self.selectedCards(), - ) + forget_cards( + mw=self.mw, + parent=self, + card_ids=self.selectedCards(), ) # Edit: selection ###################################################################### + @ensure_editor_saved_on_trigger def selectNotes(self) -> None: - self.editor.saveNow(self._selectNotes) - - def _selectNotes(self) -> None: nids = self.selectedNotes() # clear the selection so we don't waste energy preserving it tv = self.form.tableView @@ -1484,10 +1458,8 @@ where id in %s""" # Edit: replacing ###################################################################### + @ensure_editor_saved_on_trigger def onFindReplace(self) -> None: - self.editor.saveNow(self._onFindReplace) - - def _onFindReplace(self) -> None: nids = self.selectedNotes() if not nids: return @@ -1560,10 +1532,8 @@ where id in %s""" # Edit: finding dupes ###################################################################### + @ensure_editor_saved def onFindDupes(self) -> None: - self.editor.saveNow(self._onFindDupes) - - def _onFindDupes(self) -> None: d = QDialog(self) self.mw.garbage_collect_on_dialog_finish(d) frm = aqt.forms.finddupes.Ui_Dialog() @@ -1682,14 +1652,14 @@ where id in %s""" def onPreviousCard(self) -> None: self.focusTo = self.editor.currentField - self.editor.saveNow(self._onPreviousCard) + self.editor.call_after_note_saved(self._onPreviousCard) def _onPreviousCard(self) -> None: self._moveCur(QAbstractItemView.MoveUp) def onNextCard(self) -> None: self.focusTo = self.editor.currentField - self.editor.saveNow(self._onNextCard) + self.editor.call_after_note_saved(self._onNextCard) def _onNextCard(self) -> None: self._moveCur(QAbstractItemView.MoveDown) diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py index 92c2867c4..79ebd8e83 100644 --- a/qt/aqt/editcurrent.py +++ b/qt/aqt/editcurrent.py @@ -62,7 +62,7 @@ class EditCurrent(QDialog): self.saveAndClose() def saveAndClose(self) -> None: - self.editor.saveNow(self._saveAndClose) + self.editor.call_after_note_saved(self._saveAndClose) def _saveAndClose(self) -> None: self.cleanup_and_close() @@ -72,6 +72,6 @@ class EditCurrent(QDialog): self._saveAndClose() onsuccess() - self.editor.saveNow(callback) + self.editor.call_after_note_saved(callback) onReset = on_operation_did_execute diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 4b8c0f9ca..b484e91ec 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -409,7 +409,7 @@ class Editor: return checkFocus def onFields(self) -> None: - self.saveNow(self._onFields) + self.call_after_note_saved(self._onFields) def _onFields(self) -> None: from aqt.fields import FieldDialog @@ -417,7 +417,7 @@ class Editor: FieldDialog(self.mw, self.note.model(), parent=self.parentWindow) def onCardLayout(self) -> None: - self.saveNow(self._onCardLayout) + self.call_after_note_saved(self._onCardLayout) def _onCardLayout(self) -> None: from aqt.clayout import CardLayout @@ -568,7 +568,9 @@ class Editor: for f in self.note.model()["flds"] ] - def saveNow(self, callback: Callable, keepFocus: bool = False) -> None: + def call_after_note_saved( + self, callback: Callable, keepFocus: bool = False + ) -> None: "Save unsaved edits then call callback()." if not self.note: # calling code may not expect the callback to fire immediately @@ -577,6 +579,8 @@ class Editor: self.saveTags() self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) + saveNow = call_after_note_saved + def checkValid(self) -> None: cols = [""] * len(self.note.fields) err = self.note.duplicate_or_empty() @@ -626,7 +630,7 @@ class Editor: def onHtmlEdit(self) -> None: field = self.currentField - self.saveNow(lambda: self._onHtmlEdit(field)) + self.call_after_note_saved(lambda: self._onHtmlEdit(field)) def _onHtmlEdit(self, field: int) -> None: d = QDialog(self.widget, Qt.Window) @@ -732,7 +736,7 @@ class Editor: self.web.eval("setFormat('removeFormat');") def onCloze(self) -> None: - self.saveNow(self._onCloze, keepFocus=True) + self.call_after_note_saved(self._onCloze, keepFocus=True) def _onCloze(self) -> None: # check that the model is set up for cloze deletion diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py index 26a55ff77..966b56d32 100644 --- a/qt/aqt/previewer.py +++ b/qt/aqt/previewer.py @@ -317,12 +317,12 @@ class BrowserPreviewer(MultiCardPreviewer): return changed def _on_prev_card(self) -> None: - self._parent.editor.saveNow( + self._parent.editor.call_after_note_saved( lambda: self._parent._moveCur(QAbstractItemView.MoveUp) ) def _on_next_card(self) -> None: - self._parent.editor.saveNow( + self._parent.editor.call_after_note_saved( lambda: self._parent._moveCur(QAbstractItemView.MoveDown) ) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 65ba2e0f5..8017055b5 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -618,7 +618,7 @@ class SidebarTreeView(QTreeView): lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done ) - self.browser.editor.saveNow(on_save) + self.browser.editor.call_after_note_saved(on_save) return True def _handle_drag_drop_tags( @@ -650,7 +650,7 @@ class SidebarTreeView(QTreeView): lambda: self.col.tags.drag_drop(source_ids, target_name), on_done ) - self.browser.editor.saveNow(on_save) + self.browser.editor.call_after_note_saved(on_save) return True def _on_search(self, index: QModelIndex) -> None: @@ -1187,10 +1187,7 @@ class SidebarTreeView(QTreeView): # Tags ########################### - def remove_tags(self, item: SidebarItem) -> None: - self.browser.editor.saveNow(lambda: self._remove_tags(item)) - - def _remove_tags(self, _item: SidebarItem) -> None: + def remove_tags(self, _item: SidebarItem) -> None: tags = self._selected_tags() def do_remove() -> int: @@ -1211,7 +1208,9 @@ class SidebarTreeView(QTreeView): if new_name and new_name != item.name: # block repainting until collection is updated self.setUpdatesEnabled(False) - self.browser.editor.saveNow(lambda: self._rename_tag(item, new_name)) + self.browser.editor.call_after_note_saved( + lambda: self._rename_tag(item, new_name) + ) def _rename_tag(self, item: SidebarItem, new_name: str) -> None: old_name = item.full_name diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index f4d71aa6b..cc393f165 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -7,6 +7,7 @@ import re import subprocess import sys from enum import Enum +from functools import wraps from typing import ( TYPE_CHECKING, Any, @@ -988,3 +989,29 @@ def startup_info() -> Any: si = subprocess.STARTUPINFO() # pytype: disable=module-attr si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr return si + + +def ensure_editor_saved(func: Callable) -> Callable: + """Ensure the current editor's note is saved before running the wrapped function. + + Must be used on functions that may be invoked from a shortcut key while the + editor has focus. For functions that can't be activated while the editor has + focus, you don't need this. + + Will look for the editor as self.editor. + """ + + @wraps(func) + def decorated(self: Any, *args: Any, **kwargs: Any) -> None: + self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs)) + + return decorated + + +def ensure_editor_saved_on_trigger(func: Callable) -> Callable: + """Like ensure_editor_saved(), but tells Qt this function takes no args. + + This ensures PyQt doesn't attempt to pass a `toggled` arg + into functions connected to a `triggered` signal. + """ + return pyqtSlot()(ensure_editor_saved(func)) # type: ignore From 75a0f165c6a8e97d033d11fd32e96b1632dcebfc Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 17 Mar 2021 10:13:26 +1000 Subject: [PATCH 18/33] fix opening the browser in an empty collection case _onRowChanged() no longer exists, and super-frustratingly mypy doesn't seem to notice references to missing properties on mw or mw.browser --- qt/aqt/browser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 0b0d7cd3a..8ded9eaf3 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -735,7 +735,7 @@ class Browser(QMainWindow): show_invalid_search_error(err) if not self.model.cards: # no row change will fire - self._onRowChanged(None, None) + self.onRowChanged(None, None) def update_history(self) -> None: sh = self.mw.pm.profile["searchHistory"] @@ -832,7 +832,7 @@ QTableView {{ gridline-color: {grid} }} gui_hooks.editor_did_init_left_buttons.remove(add_preview_button) @ensure_editor_saved - def onRowChanged(self, current: QItemSelection, previous: QItemSelection) -> None: + def onRowChanged(self, current: Optional[QItemSelection], previous: Optional[QItemSelection]) -> None: """Update current note and hide/show editor.""" if self._closeEventHasCleanedUp: return From 0c59c8b591947adacf55872213aba68f35496c0f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 17 Mar 2021 14:51:59 +1000 Subject: [PATCH 19/33] fix a bunch of qt typing issues uncovered by the following commit --- qt/aqt/__init__.py | 4 +-- qt/aqt/addons.py | 14 ++++---- qt/aqt/browser.py | 29 +++++++-------- qt/aqt/clayout.py | 2 +- qt/aqt/deck_ops.py | 4 +-- qt/aqt/deckbrowser.py | 1 - qt/aqt/editor.py | 5 +-- qt/aqt/errors.py | 4 +-- qt/aqt/fields.py | 2 +- qt/aqt/main.py | 26 +++++++------- qt/aqt/models.py | 2 +- qt/aqt/note_ops.py | 5 ++- qt/aqt/previewer.py | 11 +++++- qt/aqt/progress.py | 4 +-- qt/aqt/scheduling_ops.py | 4 +-- qt/aqt/sidebar.py | 25 ++++++++----- qt/aqt/sound.py | 4 +-- qt/aqt/studydeck.py | 17 +++++---- qt/aqt/tagedit.py | 50 +++++++++++++------------- qt/aqt/taglimit.py | 4 +-- qt/aqt/theme.py | 8 ++--- qt/aqt/utils.py | 78 ++++++++++++++++++++++++++-------------- qt/aqt/webview.py | 34 ++++++++++++------ 23 files changed, 198 insertions(+), 139 deletions(-) diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index e503c712b..95fd829c4 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -10,7 +10,7 @@ import os import sys import tempfile import traceback -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast import anki.lang from anki import version as _version @@ -299,7 +299,7 @@ class AnkiApp(QApplication): if not sock.waitForReadyRead(self.TMOUT): sys.stderr.write(sock.errorString()) return - path = bytes(sock.readAll()).decode("utf8") + path = bytes(cast(bytes, sock.readAll())).decode("utf8") self.appMsg.emit(path) # type: ignore sock.disconnectFromServer() diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index e50ac35db..258227996 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -715,7 +715,7 @@ class AddonsDialog(QDialog): gui_hooks.addons_dialog_will_show(self) self.show() - def dragEnterEvent(self, event: QEvent) -> None: + def dragEnterEvent(self, event: QDragEnterEvent) -> None: mime = event.mimeData() if not mime.hasUrls(): return None @@ -724,7 +724,7 @@ class AddonsDialog(QDialog): if all(url.toLocalFile().endswith(ext) for url in urls): event.acceptProposedAction() - def dropEvent(self, event: QEvent) -> None: + def dropEvent(self, event: QDropEvent) -> None: mime = event.mimeData() paths = [] for url in mime.urls(): @@ -908,7 +908,7 @@ class AddonsDialog(QDialog): class GetAddons(QDialog): - def __init__(self, dlg: QDialog) -> None: + def __init__(self, dlg: AddonsDialog) -> None: QDialog.__init__(self, dlg) self.addonsDlg = dlg self.mgr = dlg.mgr @@ -1079,7 +1079,9 @@ class DownloaderInstaller(QObject): self.on_done = on_done - self.mgr.mw.progress.start(immediate=True, parent=self.parent()) + parent = self.parent() + assert isinstance(parent, QWidget) + self.mgr.mw.progress.start(immediate=True, parent=parent) self.mgr.mw.taskman.run_in_background(self._download_all, self._download_done) def _progress_callback(self, up: int, down: int) -> None: @@ -1438,7 +1440,7 @@ def prompt_to_update( class ConfigEditor(QDialog): - def __init__(self, dlg: QDialog, addon: str, conf: Dict) -> None: + def __init__(self, dlg: AddonsDialog, addon: str, conf: Dict) -> None: super().__init__(dlg) self.addon = addon self.conf = conf @@ -1506,7 +1508,7 @@ class ConfigEditor(QDialog): txt = gui_hooks.addon_config_editor_will_save_json(txt) try: new_conf = json.loads(txt) - jsonschema.validate(new_conf, self.parent().mgr._addon_schema(self.addon)) + jsonschema.validate(new_conf, self.mgr._addon_schema(self.addon)) except ValidationError as e: # The user did edit the configuration and entered a value # which can not be interpreted. diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 8ded9eaf3..acd7aa26a 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -35,11 +35,12 @@ from aqt.scheduling_ops import ( suspend_cards, unsuspend_cards, ) -from aqt.sidebar import SidebarSearchBar, SidebarToolbar, SidebarTreeView +from aqt.sidebar import SidebarTreeView from aqt.theme import theme_manager from aqt.utils import ( TR, HelpPage, + KeyboardModifiersPressed, askUser, current_top_level_widget, disable_help_button, @@ -832,7 +833,9 @@ QTableView {{ gridline-color: {grid} }} gui_hooks.editor_did_init_left_buttons.remove(add_preview_button) @ensure_editor_saved - def onRowChanged(self, current: Optional[QItemSelection], previous: Optional[QItemSelection]) -> None: + def onRowChanged( + self, current: Optional[QItemSelection], previous: Optional[QItemSelection] + ) -> None: """Update current note and hide/show editor.""" if self._closeEventHasCleanedUp: return @@ -975,15 +978,13 @@ QTableView {{ gridline-color: {grid} }} self.sidebar = SidebarTreeView(self) self.sidebarTree = self.sidebar # legacy alias dw.setWidget(self.sidebar) - self.sidebar.toolbar = toolbar = SidebarToolbar(self.sidebar) - self.sidebar.searchBar = searchBar = SidebarSearchBar(self.sidebar) qconnect( self.form.actionSidebarFilter.triggered, self.focusSidebarSearchBar, ) grid = QGridLayout() - grid.addWidget(searchBar, 0, 0) - grid.addWidget(toolbar, 0, 1) + grid.addWidget(self.sidebar.searchBar, 0, 0) + grid.addWidget(self.sidebar.toolbar, 0, 1) grid.addWidget(self.sidebar, 1, 0, 1, 2) grid.setContentsMargins(0, 0, 0, 0) grid.setSpacing(0) @@ -1116,10 +1117,7 @@ where id in %s""" def createFilteredDeck(self) -> None: search = self.form.searchEdit.lineEdit().text() - if ( - self.mw.col.schedVer() != 1 - and self.mw.app.keyboardModifiers() & Qt.AltModifier - ): + if self.mw.col.schedVer() != 1 and KeyboardModifiersPressed().alt: aqt.dialogs.open("DynDeckConfDialog", self.mw, search_2=search) else: aqt.dialogs.open("DynDeckConfDialog", self.mw, search=search) @@ -1482,9 +1480,9 @@ where id in %s""" combo = "BrowserFindAndReplace" findhistory = restore_combo_history(frm.find, combo + "Find") - frm.find.completer().setCaseSensitivity(True) + frm.find._completer().setCaseSensitivity(True) replacehistory = restore_combo_history(frm.replace, combo + "Replace") - frm.replace.completer().setCaseSensitivity(True) + frm.replace._completer().setCaseSensitivity(True) restore_is_checked(frm.re, combo + "Regex") restore_is_checked(frm.ignoreCase, combo + "ignoreCase") @@ -1668,7 +1666,7 @@ where id in %s""" sm = self.form.tableView.selectionModel() idx = sm.currentIndex() self._moveCur(None, self.model.index(0, 0)) - if not self.mw.app.keyboardModifiers() & Qt.ShiftModifier: + if not KeyboardModifiersPressed().shift: return idx2 = sm.currentIndex() item = QItemSelection(idx2, idx) @@ -1678,7 +1676,7 @@ where id in %s""" sm = self.form.tableView.selectionModel() idx = sm.currentIndex() self._moveCur(None, self.model.index(len(self.model.cards) - 1, 0)) - if not self.mw.app.keyboardModifiers() & Qt.ShiftModifier: + if not KeyboardModifiersPressed().shift: return idx2 = sm.currentIndex() item = QItemSelection(idx, idx2) @@ -1728,6 +1726,9 @@ class ChangeModel(QDialog): restoreGeom(self, "changeModel") gui_hooks.state_did_reset.append(self.onReset) gui_hooks.current_note_type_did_change.append(self.on_note_type_change) + # ugh - these are set dynamically by rebuildTemplateMap() + self.tcombos: List[QComboBox] = [] + self.fcombos: List[QComboBox] = [] self.exec_() def on_note_type_change(self, notetype: NoteType) -> None: diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 01474cd21..318b942b0 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -795,7 +795,7 @@ class CardLayout(QDialog): showWarning(str(e)) return self.mw.reset() - tooltip(tr(TR.CARD_TEMPLATES_CHANGES_SAVED), parent=self.parent()) + tooltip(tr(TR.CARD_TEMPLATES_CHANGES_SAVED), parent=self.parentWidget()) self.cleanup() gui_hooks.sidebar_should_refresh_notetypes() return QDialog.accept(self) diff --git a/qt/aqt/deck_ops.py b/qt/aqt/deck_ops.py index ff7e3ba98..60a70a49a 100644 --- a/qt/aqt/deck_ops.py +++ b/qt/aqt/deck_ops.py @@ -6,14 +6,14 @@ from __future__ import annotations from typing import Sequence from anki.lang import TR -from aqt import AnkiQt, QDialog +from aqt import AnkiQt, QWidget from aqt.utils import tooltip, tr def remove_decks( *, mw: AnkiQt, - parent: QDialog, + parent: QWidget, deck_ids: Sequence[int], ) -> None: mw.perform_op( diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 2795e7a46..af6ca9cb4 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -162,7 +162,6 @@ class DeckBrowser: ], context=self, ) - self.web.key = "deckBrowser" self._drawButtons() if offset is not None: self._scrollToOffset(offset) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index b484e91ec..130cb96bd 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -34,6 +34,7 @@ from aqt.theme import theme_manager from aqt.utils import ( TR, HelpPage, + KeyboardModifiersPressed, disable_help_button, getFile, openHelp, @@ -753,7 +754,7 @@ class Editor: if m: highest = max(highest, sorted([int(x) for x in m])[-1]) # reuse last? - if not self.mw.app.keyboardModifiers() & Qt.AltModifier: + if not KeyboardModifiersPressed().alt: highest += 1 # must start at 1 highest = max(1, highest) @@ -1130,7 +1131,7 @@ class EditorWebView(AnkiWebView): strip_html = self.editor.mw.col.get_config_bool( Config.Bool.PASTE_STRIPS_FORMATTING ) - if self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier: + if KeyboardModifiersPressed().shift: strip_html = not strip_html return strip_html diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py index 51e5ca14b..8ed9e6273 100644 --- a/qt/aqt/errors.py +++ b/qt/aqt/errors.py @@ -4,7 +4,7 @@ import html import re import sys import traceback -from typing import Optional +from typing import Optional, TextIO, cast from markdown import markdown @@ -37,7 +37,7 @@ class ErrorHandler(QObject): qconnect(self.errorTimer, self._setTimer) self.pool = "" self._oldstderr = sys.stderr - sys.stderr = self + sys.stderr = cast(TextIO, self) def unload(self) -> None: sys.stderr = self._oldstderr diff --git a/qt/aqt/fields.py b/qt/aqt/fields.py index e19668842..5fb326351 100644 --- a/qt/aqt/fields.py +++ b/qt/aqt/fields.py @@ -26,7 +26,7 @@ from aqt.utils import ( class FieldDialog(QDialog): def __init__( - self, mw: AnkiQt, nt: NoteType, parent: Optional[QDialog] = None + self, mw: AnkiQt, nt: NoteType, parent: Optional[QWidget] = None ) -> None: QDialog.__init__(self, parent or mw) self.mw = mw diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 8c8ff9cd4..d1c0e8bdb 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -72,6 +72,7 @@ from aqt.theme import theme_manager from aqt.utils import ( TR, HelpPage, + KeyboardModifiersPressed, askUser, checkInvalidFilename, current_top_level_widget, @@ -121,7 +122,7 @@ class AnkiQt(QMainWindow): def __init__( self, - app: QApplication, + app: aqt.AnkiApp, profileManager: ProfileManagerType, backend: _RustBackend, opts: Namespace, @@ -138,9 +139,7 @@ class AnkiQt(QMainWindow): self.app = app self.pm = profileManager # init rest of app - self.safeMode = ( - self.app.queryKeyboardModifiers() & Qt.ShiftModifier - ) or self.opts.safemode + self.safeMode = (KeyboardModifiersPressed().shift) or self.opts.safemode try: self.setupUI() self.setupAddons(args) @@ -927,10 +926,8 @@ title="%s" %s>%s""" % ( # force webengine processes to load before cwd is changed if isWin: - for o in self.web, self.bottomWeb: - o.requiresCol = False - o._domReady = False - o._page.setContent(bytes("", "ascii")) + for webview in self.web, self.bottomWeb: + webview.force_load_hack() def closeAllWindows(self, onsuccess: Callable) -> None: aqt.dialogs.closeAll(onsuccess) @@ -1103,8 +1100,7 @@ title="%s" %s>%s""" % ( ("y", self.on_sync_button_clicked), ] self.applyShortcuts(globalShortcuts) - - self.stateShortcuts: Sequence[Tuple[str, Callable]] = [] + self.stateShortcuts: List[QShortcut] = [] def applyShortcuts( self, shortcuts: Sequence[Tuple[str, Callable]] @@ -1281,7 +1277,7 @@ title="%s" %s>%s""" % ( deck = self._selectedDeck() if not deck: return - want_old = self.app.queryKeyboardModifiers() & Qt.ShiftModifier + want_old = KeyboardModifiersPressed().shift if want_old: aqt.dialogs.open("DeckStats", self) else: @@ -1538,13 +1534,14 @@ title="%s" %s>%s""" % ( frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog() class DebugDialog(QDialog): + silentlyClose = True + def reject(self) -> None: super().reject() saveSplitter(frm.splitter, "DebugConsoleWindow") saveGeom(self, "DebugConsoleWindow") d = self.debugDiag = DebugDialog() - d.silentlyClose = True disable_help_button(d) frm.setupUi(d) restoreGeom(d, "DebugConsoleWindow") @@ -1708,7 +1705,8 @@ title="%s" %s>%s""" % ( if not self.hideMenuAccels: return tgt = tgt or self - for action in tgt.findChildren(QAction): + for action_ in tgt.findChildren(QAction): + action = cast(QAction, action_) txt = str(action.text()) m = re.match(r"^(.+)\(&.+\)(.+)?", txt) if m: @@ -1716,7 +1714,7 @@ title="%s" %s>%s""" % ( def hideStatusTips(self) -> None: for action in self.findChildren(QAction): - action.setStatusTip("") + cast(QAction, action).setStatusTip("") def onMacMinimize(self) -> None: self.setWindowState(self.windowState() | Qt.WindowMinimized) # type: ignore diff --git a/qt/aqt/models.py b/qt/aqt/models.py index e03a08fe3..a893178cc 100644 --- a/qt/aqt/models.py +++ b/qt/aqt/models.py @@ -31,7 +31,7 @@ class Models(QDialog): def __init__( self, mw: AnkiQt, - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, fromMain: bool = False, selected_notetype_id: Optional[int] = None, ): diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index d9861dccd..36592537c 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -7,9 +7,8 @@ from typing import Callable, Optional, Sequence from anki.lang import TR from anki.notes import Note -from aqt import AnkiQt +from aqt import AnkiQt, QWidget from aqt.main import PerformOpOptionalSuccessCallback -from aqt.qt import QDialog from aqt.utils import show_invalid_search_error, showInfo, tr @@ -52,7 +51,7 @@ def remove_tags( def find_and_replace( *, mw: AnkiQt, - parent: QDialog, + parent: QWidget, note_ids: Sequence[int], search: str, replacement: str, diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py index 966b56d32..9cfaed271 100644 --- a/qt/aqt/previewer.py +++ b/qt/aqt/previewer.py @@ -1,11 +1,14 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# mypy: check-untyped-defs + +from __future__ import annotations + import json import re import time from typing import Any, Callable, Optional, Tuple, Union +import aqt.browser from anki.cards import Card from anki.collection import Config from aqt import AnkiQt, gui_hooks @@ -300,6 +303,12 @@ class MultiCardPreviewer(Previewer): class BrowserPreviewer(MultiCardPreviewer): _last_card_id = 0 + _parent: Optional[aqt.browser.Browser] + + def __init__( + self, parent: aqt.browser.Browser, mw: AnkiQt, on_close: Callable[[], None] + ) -> None: + super().__init__(parent=parent, mw=mw, on_close=on_close) def card(self) -> Optional[Card]: if self._parent.singleCard: diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index 947ffc993..6f3eeab70 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -16,7 +16,7 @@ from aqt.utils import TR, disable_help_button, tr class ProgressManager: def __init__(self, mw: aqt.AnkiQt) -> None: self.mw = mw - self.app = QApplication.instance() + self.app = mw.app self.inDB = False self.blockUpdates = False self._show_timer: Optional[QTimer] = None @@ -75,7 +75,7 @@ class ProgressManager: max: int = 0, min: int = 0, label: Optional[str] = None, - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, immediate: bool = False, ) -> Optional[ProgressDialog]: self._levels += 1 diff --git a/qt/aqt/scheduling_ops.py b/qt/aqt/scheduling_ops.py index f379cd42b..4d351405c 100644 --- a/qt/aqt/scheduling_ops.py +++ b/qt/aqt/scheduling_ops.py @@ -17,7 +17,7 @@ from aqt.utils import getText, tooltip, tr def set_due_date_dialog( *, mw: aqt.AnkiQt, - parent: QDialog, + parent: QWidget, card_ids: List[int], config_key: Optional[Config.String.Key.V], ) -> None: @@ -51,7 +51,7 @@ def set_due_date_dialog( ) -def forget_cards(*, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int]) -> None: +def forget_cards(*, mw: aqt.AnkiQt, parent: QWidget, card_ids: List[int]) -> None: if not card_ids: return diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 8017055b5..b4311c736 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -23,6 +23,7 @@ from aqt.qt import * from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( TR, + KeyboardModifiersPressed, askUser, getOnlyText, show_invalid_search_error, @@ -254,9 +255,7 @@ class SidebarModel(QAbstractItemModel): return QVariant(item.tooltip) return QVariant(theme_manager.icon_from_resources(item.icon)) - def setData( - self, index: QModelIndex, text: QVariant, _role: int = Qt.EditRole - ) -> bool: + def setData(self, index: QModelIndex, text: str, _role: int = Qt.EditRole) -> bool: return self.sidebar._on_rename(index.internalPointer(), text) def supportedDropActions(self) -> Qt.DropActions: @@ -354,6 +353,10 @@ def _want_right_border() -> bool: return not isMac or theme_manager.night_mode +# fixme: we should have a top-level Sidebar class inheriting from QWidget that +# handles the treeview, search bar and so on. Currently the treeview embeds the +# search bar which is wrong, and the layout code is handled in browser.py instead +# of here class SidebarTreeView(QTreeView): def __init__(self, browser: aqt.browser.Browser) -> None: super().__init__() @@ -390,6 +393,10 @@ class SidebarTreeView(QTreeView): self.setStyleSheet("QTreeView { %s }" % ";".join(styles)) + # these do not really belong here, they should be in a higher-level class + self.toolbar = SidebarToolbar(self) + self.searchBar = SidebarSearchBar(self) + @property def tool(self) -> SidebarTool: return self._tool @@ -410,7 +417,7 @@ class SidebarTreeView(QTreeView): self.setExpandsOnDoubleClick(double_click_expands) def model(self) -> SidebarModel: - return super().model() + return cast(SidebarModel, super().model()) # Refreshing ########################### @@ -512,22 +519,22 @@ class SidebarTreeView(QTreeView): joiner: SearchJoiner = "AND", ) -> None: """Modify the current search string based on modifier keys, then refresh.""" - mods = self.mw.app.keyboardModifiers() + mods = KeyboardModifiersPressed() previous = SearchNode(parsable_text=self.browser.current_search()) current = self.mw.col.group_searches(*terms, joiner=joiner) # if Alt pressed, invert - if mods & Qt.AltModifier: + if mods.alt: current = SearchNode(negated=current) try: - if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: + if mods.control and mods.shift: # If Ctrl+Shift, replace searches nodes of the same type. search = self.col.replace_in_search_node(previous, current) - elif mods & Qt.ControlModifier: + elif mods.control: # If Ctrl, AND with previous search = self.col.join_searches(previous, current, "AND") - elif mods & Qt.ShiftModifier: + elif mods.shift: # If Shift, OR with previous search = self.col.join_searches(previous, current, "OR") else: diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index c4532754a..0e6a24a31 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -15,7 +15,7 @@ import wave from abc import ABC, abstractmethod from concurrent.futures import Future from operator import itemgetter -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, cast import aqt from anki import hooks @@ -568,7 +568,7 @@ class QtAudioInputRecorder(Recorder): super().start(on_done) def _on_read_ready(self) -> None: - self._buffer += self._iodevice.readAll() + self._buffer += cast(bytes, self._iodevice.readAll()) def stop(self, on_done: Callable[[str], None]) -> None: def on_stop_timer() -> None: diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py index cedac958c..6f5df7161 100644 --- a/qt/aqt/studydeck.py +++ b/qt/aqt/studydeck.py @@ -33,9 +33,9 @@ class StudyDeck(QDialog): help: HelpPageArgument = HelpPage.KEYBOARD_SHORTCUTS, current: Optional[str] = None, cancel: bool = True, - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, dyn: bool = False, - buttons: Optional[List[str]] = None, + buttons: Optional[List[Union[str, QPushButton]]] = None, geomKey: str = "default", ) -> None: QDialog.__init__(self, parent or mw) @@ -53,8 +53,10 @@ class StudyDeck(QDialog): self.form.buttonBox.button(QDialogButtonBox.Cancel) ) if buttons is not None: - for b in buttons: - self.form.buttonBox.addButton(b, QDialogButtonBox.ActionRole) + for button_or_label in buttons: + self.form.buttonBox.addButton( + button_or_label, QDialogButtonBox.ActionRole + ) else: b = QPushButton(tr(TR.ACTIONS_ADD)) b.setShortcut(QKeySequence("Ctrl+N")) @@ -89,7 +91,7 @@ class StudyDeck(QDialog): self.exec_() def eventFilter(self, obj: QObject, evt: QEvent) -> bool: - if evt.type() == QEvent.KeyPress: + if isinstance(evt, QKeyEvent) and evt.type() == QEvent.KeyPress: new_row = current_row = self.form.list.currentRow() rows_count = self.form.list.count() key = evt.key() @@ -98,7 +100,10 @@ class StudyDeck(QDialog): new_row = current_row - 1 elif key == Qt.Key_Down: new_row = current_row + 1 - elif evt.modifiers() & Qt.ControlModifier and Qt.Key_1 <= key <= Qt.Key_9: + elif ( + int(evt.modifiers()) & Qt.ControlModifier + and Qt.Key_1 <= key <= Qt.Key_9 + ): row_index = key - Qt.Key_1 if row_index < rows_count: new_row = row_index diff --git a/qt/aqt/tagedit.py b/qt/aqt/tagedit.py index 10ac52027..df01f3acf 100644 --- a/qt/aqt/tagedit.py +++ b/qt/aqt/tagedit.py @@ -12,24 +12,24 @@ from aqt.qt import * class TagEdit(QLineEdit): - completer: Union[QCompleter, TagCompleter] + _completer: Union[QCompleter, TagCompleter] lostFocus = pyqtSignal() # 0 = tags, 1 = decks - def __init__(self, parent: QDialog, type: int = 0) -> None: + def __init__(self, parent: QWidget, type: int = 0) -> None: QLineEdit.__init__(self, parent) self.col: Optional[Collection] = None self.model = QStringListModel() self.type = type if type == 0: - self.completer = TagCompleter(self.model, parent, self) + self._completer = TagCompleter(self.model, parent, self) else: - self.completer = QCompleter(self.model, parent) - self.completer.setCompletionMode(QCompleter.PopupCompletion) - self.completer.setCaseSensitivity(Qt.CaseInsensitive) - self.completer.setFilterMode(Qt.MatchContains) - self.setCompleter(self.completer) + self._completer = QCompleter(self.model, parent) + self._completer.setCompletionMode(QCompleter.PopupCompletion) + self._completer.setCaseSensitivity(Qt.CaseInsensitive) + self._completer.setFilterMode(Qt.MatchContains) + self.setCompleter(self._completer) def setCol(self, col: Collection) -> None: "Set the current col, updating list of available tags." @@ -47,29 +47,29 @@ class TagEdit(QLineEdit): def keyPressEvent(self, evt: QKeyEvent) -> None: if evt.key() in (Qt.Key_Up, Qt.Key_Down): # show completer on arrow key up/down - if not self.completer.popup().isVisible(): + if not self._completer.popup().isVisible(): self.showCompleter() return - if evt.key() == Qt.Key_Tab and evt.modifiers() & Qt.ControlModifier: + if evt.key() == Qt.Key_Tab and int(evt.modifiers()) & Qt.ControlModifier: # select next completion - if not self.completer.popup().isVisible(): + if not self._completer.popup().isVisible(): self.showCompleter() - index = self.completer.currentIndex() - self.completer.popup().setCurrentIndex(index) + index = self._completer.currentIndex() + self._completer.popup().setCurrentIndex(index) cur_row = index.row() - if not self.completer.setCurrentRow(cur_row + 1): - self.completer.setCurrentRow(0) + if not self._completer.setCurrentRow(cur_row + 1): + self._completer.setCurrentRow(0) return if ( evt.key() in (Qt.Key_Enter, Qt.Key_Return) - and self.completer.popup().isVisible() + and self._completer.popup().isVisible() ): # apply first completion if no suggestion selected - selected_row = self.completer.popup().currentIndex().row() + selected_row = self._completer.popup().currentIndex().row() if selected_row == -1: - self.completer.setCurrentRow(0) - index = self.completer.currentIndex() - self.completer.popup().setCurrentIndex(index) + self._completer.setCurrentRow(0) + index = self._completer.currentIndex() + self._completer.popup().setCurrentIndex(index) self.hideCompleter() QWidget.keyPressEvent(self, evt) return @@ -90,18 +90,18 @@ class TagEdit(QLineEdit): gui_hooks.tag_editor_did_process_key(self, evt) def showCompleter(self) -> None: - self.completer.setCompletionPrefix(self.text()) - self.completer.complete() + self._completer.setCompletionPrefix(self.text()) + self._completer.complete() def focusOutEvent(self, evt: QFocusEvent) -> None: QLineEdit.focusOutEvent(self, evt) self.lostFocus.emit() # type: ignore - self.completer.popup().hide() + self._completer.popup().hide() def hideCompleter(self) -> None: - if sip.isdeleted(self.completer): + if sip.isdeleted(self._completer): return - self.completer.popup().hide() + self._completer.popup().hide() class TagCompleter(QCompleter): diff --git a/qt/aqt/taglimit.py b/qt/aqt/taglimit.py index 15c2e9bab..64459a7f1 100644 --- a/qt/aqt/taglimit.py +++ b/qt/aqt/taglimit.py @@ -15,8 +15,8 @@ class TagLimit(QDialog): self.tags: str = "" self.tags_list: List[str] = [] self.mw = mw - self.parent: Optional[QWidget] = parent - self.deck = self.parent.deck + self.parent_: Optional[CustomStudy] = parent + self.deck = self.parent_.deck self.dialog = aqt.forms.taglimit.Ui_Dialog() self.dialog.setupUi(self) disable_help_button(self) diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py index 7362f6b7c..11b30d011 100644 --- a/qt/aqt/theme.py +++ b/qt/aqt/theme.py @@ -86,12 +86,12 @@ class ThemeManager: else: # specified colours icon = QIcon(path.path) - img = icon.pixmap(16) - painter = QPainter(img) + pixmap = icon.pixmap(16) + painter = QPainter(pixmap) painter.setCompositionMode(QPainter.CompositionMode_SourceIn) - painter.fillRect(img.rect(), QColor(path.current_color(self.night_mode))) + painter.fillRect(pixmap.rect(), QColor(path.current_color(self.night_mode))) painter.end() - icon = QIcon(img) + icon = QIcon(pixmap) return icon return cache.setdefault(path, icon) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index cc393f165..ceb4db073 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -111,7 +111,7 @@ def openHelp(section: HelpPageArgument) -> None: openLink(link) -def openLink(link: str) -> None: +def openLink(link: Union[str, QUrl]) -> None: tooltip(tr(TR.QT_MISC_LOADING), period=1000) with noBundledLibs(): QDesktopServices.openUrl(QUrl(link)) @@ -119,7 +119,7 @@ def openLink(link: str) -> None: def showWarning( text: str, - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, help: HelpPageArgument = "", title: str = "Anki", textFormat: Optional[TextFormat] = None, @@ -139,7 +139,7 @@ def showCritical( return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat) -def show_invalid_search_error(err: Exception, parent: Optional[QDialog] = None) -> None: +def show_invalid_search_error(err: Exception, parent: Optional[QWidget] = None) -> None: "Render search errors in markdown, then display a warning." text = str(err) if isinstance(err, InvalidInput): @@ -149,7 +149,7 @@ def show_invalid_search_error(err: Exception, parent: Optional[QDialog] = None) def showInfo( text: str, - parent: Union[Literal[False], QDialog] = False, + parent: Optional[QWidget] = None, help: HelpPageArgument = "", type: str = "info", title: str = "Anki", @@ -158,7 +158,7 @@ def showInfo( ) -> int: "Show a small info window with an OK button." parent_widget: QWidget - if parent is False: + if parent is None: parent_widget = aqt.mw.app.activeWindow() or aqt.mw else: parent_widget = parent @@ -214,6 +214,7 @@ def showText( disable_help_button(diag) layout = QVBoxLayout(diag) diag.setLayout(layout) + text: Union[QPlainTextEdit, QTextBrowser] if plain_text_edit: # used by the importer text = QPlainTextEdit() @@ -222,10 +223,10 @@ def showText( else: text = QTextBrowser() text.setOpenExternalLinks(True) - if type == "text": - text.setPlainText(txt) - else: - text.setHtml(txt) + if type == "text": + text.setPlainText(txt) + else: + text.setHtml(txt) layout.addWidget(text) box = QDialogButtonBox(QDialogButtonBox.Close) layout.addWidget(box) @@ -263,7 +264,7 @@ def showText( def askUser( text: str, - parent: QDialog = None, + parent: QWidget = None, help: HelpPageArgument = None, defaultno: bool = False, msgfunc: Optional[Callable] = None, @@ -296,7 +297,7 @@ class ButtonedDialog(QMessageBox): self, text: str, buttons: List[str], - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, help: HelpPageArgument = None, title: str = "Anki", ): @@ -329,7 +330,7 @@ class ButtonedDialog(QMessageBox): def askUserDialog( text: str, buttons: List[str], - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, help: HelpPageArgument = None, title: str = "Anki", ) -> ButtonedDialog: @@ -342,7 +343,7 @@ def askUserDialog( class GetTextDialog(QDialog): def __init__( self, - parent: Optional[QDialog], + parent: Optional[QWidget], question: str, help: HelpPageArgument = None, edit: Optional[QLineEdit] = None, @@ -389,7 +390,7 @@ class GetTextDialog(QDialog): def getText( prompt: str, - parent: Optional[QDialog] = None, + parent: Optional[QWidget] = None, help: HelpPageArgument = None, edit: Optional[QLineEdit] = None, default: str = "", @@ -446,7 +447,7 @@ def chooseList( def getTag( - parent: QDialog, deck: Collection, question: str, **kwargs: Any + parent: QWidget, deck: Collection, question: str, **kwargs: Any ) -> Tuple[str, int]: from aqt.tagedit import TagEdit @@ -459,7 +460,8 @@ def getTag( def disable_help_button(widget: QWidget) -> None: "Disable the help button in the window titlebar." - flags = cast(Qt.WindowType, widget.windowFlags() & ~Qt.WindowContextHelpButtonHint) + flags_int = int(widget.windowFlags()) & ~Qt.WindowContextHelpButtonHint + flags = Qt.WindowFlags(flags_int) # type: ignore widget.setWindowFlags(flags) @@ -468,7 +470,7 @@ def disable_help_button(widget: QWidget) -> None: def getFile( - parent: QDialog, + parent: QWidget, title: str, # single file returned unless multi=True cb: Optional[Callable[[Union[str, Sequence[str]]], None]], @@ -548,9 +550,9 @@ def getSaveFile( return file -def saveGeom(widget: QDialog, key: str) -> None: +def saveGeom(widget: QWidget, key: str) -> None: key += "Geom" - if isMac and widget.windowState() & Qt.WindowFullScreen: + if isMac and int(widget.windowState()) & Qt.WindowFullScreen: geom = None else: geom = widget.saveGeometry() @@ -600,12 +602,12 @@ def ensureWidgetInScreenBoundaries(widget: QWidget) -> None: widget.move(x, y) -def saveState(widget: QFileDialog, key: str) -> None: +def saveState(widget: Union[QFileDialog, QMainWindow], key: str) -> None: key += "State" aqt.mw.pm.profile[key] = widget.saveState() -def restoreState(widget: Union[aqt.AnkiQt, QFileDialog], key: str) -> None: +def restoreState(widget: Union[QFileDialog, QMainWindow], key: str) -> None: key += "State" if aqt.mw.pm.profile.get(key): widget.restoreState(aqt.mw.pm.profile[key]) @@ -633,12 +635,12 @@ def restoreHeader(widget: QHeaderView, key: str) -> None: widget.restoreState(aqt.mw.pm.profile[key]) -def save_is_checked(widget: QWidget, key: str) -> None: +def save_is_checked(widget: QCheckBox, key: str) -> None: key += "IsChecked" aqt.mw.pm.profile[key] = widget.isChecked() -def restore_is_checked(widget: QWidget, key: str) -> None: +def restore_is_checked(widget: QCheckBox, key: str) -> None: key += "IsChecked" if aqt.mw.pm.profile.get(key) is not None: widget.setChecked(aqt.mw.pm.profile[key]) @@ -719,8 +721,9 @@ def maybeHideClose(bbox: QDialogButtonBox) -> None: def addCloseShortcut(widg: QDialog) -> None: if not isMac: return - widg._closeShortcut = QShortcut(QKeySequence("Ctrl+W"), widg) - qconnect(widg._closeShortcut.activated, widg.reject) + shortcut = QShortcut(QKeySequence("Ctrl+W"), widg) + qconnect(shortcut.activated, widg.reject) + setattr(widg, "_closeShortcut", shortcut) def downArrow() -> str: @@ -732,7 +735,7 @@ def downArrow() -> str: def top_level_widget(widget: QWidget) -> QWidget: window = None - while widget := widget.parent(): + while widget := widget.parentWidget(): window = widget return window @@ -754,7 +757,7 @@ _tooltipLabel: Optional[QLabel] = None def tooltip( msg: str, period: int = 3000, - parent: Optional[aqt.AnkiQt] = None, + parent: Optional[QWidget] = None, x_offset: int = 0, y_offset: int = 100, ) -> None: @@ -1015,3 +1018,24 @@ def ensure_editor_saved_on_trigger(func: Callable) -> Callable: into functions connected to a `triggered` signal. """ return pyqtSlot()(ensure_editor_saved(func)) # type: ignore + + +class KeyboardModifiersPressed: + "Util for type-safe checks of currently-pressed modifier keys." + + def __init__(self) -> None: + from aqt import mw + + self._modifiers = int(mw.app.keyboardModifiers()) + + @property + def shift(self) -> bool: + return bool(self._modifiers & Qt.ShiftModifier) + + @property + def control(self) -> bool: + return bool(self._modifiers & Qt.ControlModifier) + + @property + def alt(self) -> bool: + return bool(self._modifiers & Qt.AltModifier) diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index f5543eaeb..642033976 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -1,5 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + import dataclasses import json import re @@ -31,12 +32,15 @@ class AnkiWebPage(QWebEnginePage): def _setupBridge(self) -> None: class Bridge(QObject): + def __init__(self, bridge_handler: Callable[[str], Any]) -> None: + super().__init__() + self.onCmd = bridge_handler + @pyqtSlot(str, result=str) # type: ignore def cmd(self, str: str) -> Any: return json.dumps(self.onCmd(str)) - self._bridge = Bridge() - self._bridge.onCmd = self._onCmd + self._bridge = Bridge(self._onCmd) self._channel = QWebChannel(self) self._channel.registerObject("py", self._bridge) @@ -46,7 +50,7 @@ class AnkiWebPage(QWebEnginePage): jsfile = QFile(qwebchannel) if not jsfile.open(QIODevice.ReadOnly): print(f"Error opening '{qwebchannel}': {jsfile.error()}", file=sys.stderr) - jstext = bytes(jsfile.readAll()).decode("utf-8") + jstext = bytes(cast(bytes, jsfile.readAll())).decode("utf-8") jsfile.close() script = QWebEngineScript() @@ -131,7 +135,7 @@ class AnkiWebPage(QWebEnginePage): openLink(url) return False - def _onCmd(self, str: str) -> None: + def _onCmd(self, str: str) -> Any: return self._onBridgeCmd(str) def javaScriptAlert(self, url: QUrl, text: str) -> None: @@ -252,7 +256,7 @@ class AnkiWebView(QWebEngineView): # disable pinch to zoom gesture if isinstance(evt, QNativeGestureEvent): return True - elif evt.type() == QEvent.MouseButtonRelease: + elif isinstance(evt, QMouseEvent) and evt.type() == QEvent.MouseButtonRelease: if evt.button() == Qt.MidButton and isLin: self.onMiddleClickPaste() return True @@ -273,7 +277,9 @@ class AnkiWebView(QWebEngineView): w.close() else: # in the main window, removes focus from type in area - self.parent().setFocus() + parent = self.parent() + assert isinstance(parent, QWidget) + parent.setFocus() break w = w.parent() @@ -315,15 +321,16 @@ class AnkiWebView(QWebEngineView): self.set_open_links_externally(True) def _setHtml(self, html: str) -> None: - app = QApplication.instance() - oldFocus = app.focusWidget() + from aqt import mw + + oldFocus = mw.app.focusWidget() self._domDone = False self._page.setHtml(html) # work around webengine stealing focus on setHtml() if oldFocus: oldFocus.setFocus() - def load(self, url: QUrl) -> None: + def load_url(self, url: QUrl) -> None: # allow queuing actions when loading url directly self._domDone = False super().load(url) @@ -641,5 +648,12 @@ document.head.appendChild(style); else: extra = "" self.hide_while_preserving_layout() - self.load(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}")) + self.load_url(QUrl(f"{mw.serverURL()}_anki/pages/{name}.html{extra}")) self.inject_dynamic_style_and_show() + + def force_load_hack(self) -> None: + """Force process to initialize. + Must be done on Windows prior to changing current working directory.""" + self.requiresCol = False + self._domReady = False + self._page.setContent(bytes("", "ascii")) From 7d6fd48a6fb8f3e2fb2f2374c2279e3e5b997e7b Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 17 Mar 2021 14:54:06 +1000 Subject: [PATCH 20/33] fix mypy treating Qt objects as inheriting from Any Before this change, mypy would fail to catch mistakes like mw.does_not_exist(). Also fix a couple of bugs this has uncovered. --- pip/pyqt5/install_pyqt5.py | 10 ++++++++++ qt/aqt/browser.py | 4 ++-- qt/aqt/sidebar.py | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pip/pyqt5/install_pyqt5.py b/pip/pyqt5/install_pyqt5.py index 13f3234af..05186160f 100644 --- a/pip/pyqt5/install_pyqt5.py +++ b/pip/pyqt5/install_pyqt5.py @@ -91,7 +91,17 @@ def copy_and_fix_pyi(source, dest): with open(source) as input_file: with open(dest, "w") as output_file: for line in input_file.readlines(): + # assigning to None is a syntax error line = fix_none.sub(r"\1_ =", line) + # inheriting from the missing sip.sipwrapper definition + # causes missing attributes not to be detected, as it's treating + # the class as inheriting from Any + line = line.replace("sip.simplewrapper", "object") + line = line.replace("sip.wrapper", "object") + # remove blanket getattr in QObject which also causes missing + # attributes not to be detected + if "def __getattr__(self, name: str) -> typing.Any" in line: + continue output_file.write(line) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index acd7aa26a..d6e8ec212 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1219,7 +1219,7 @@ where id in %s""" ) -> None: "Shows prompt if tags not provided." if not ( - tags := self.maybe_prompt_for_tags(tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) + tags := self._maybe_prompt_for_tags(tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) ): return add_tags(mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags) @@ -1228,7 +1228,7 @@ where id in %s""" def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: "Shows prompt if tags not provided." if not ( - tags := self.maybe_prompt_for_tags( + tags := self._maybe_prompt_for_tags( tags, tr(TR.BROWSING_ENTER_TAGS_TO_DELETE) ) ): diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index b4311c736..7aa53479b 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -716,7 +716,9 @@ class SidebarTreeView(QTreeView): for stage in SidebarStage: if stage == SidebarStage.ROOT: root = SidebarItem("", "", item_type=SidebarItemType.ROOT) - handled = gui_hooks.browser_will_build_tree(False, root, stage, self) + handled = gui_hooks.browser_will_build_tree( + False, root, stage, self.browser + ) if not handled: self._build_stage(root, stage) From de668441b5ca9e089150d8ed7cab57c680c14f74 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 17 Mar 2021 21:27:42 +1000 Subject: [PATCH 21/33] clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading --- ftl/core/browsing.ftl | 5 ++++ pylib/anki/tags.py | 24 +++++++++------- qt/aqt/browser.py | 51 +++++++++++++++++++-------------- qt/aqt/editor.py | 51 ++++++++++++++++++++++++--------- qt/aqt/main.py | 60 +++++++++++++++++++++++++++++++++++++-- qt/aqt/note_ops.py | 11 ++++++- qt/aqt/progress.py | 5 +++- qt/aqt/sidebar.py | 26 ++++++++++------- qt/aqt/taskman.py | 10 +++++++ qt/tools/genhooks_gui.py | 17 ++++++++--- rslib/backend.proto | 2 +- rslib/src/backend/tags.rs | 2 +- rslib/src/ops.rs | 2 ++ rslib/src/tags/mod.rs | 29 +++++++++++-------- rslib/src/tags/undo.rs | 9 +++--- 15 files changed, 221 insertions(+), 83 deletions(-) diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 2a2e14b85..46b317627 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -141,3 +141,8 @@ browsing-sidebar-due-today = Due browsing-sidebar-untagged = Untagged browsing-sidebar-overdue = Overdue browsing-row-deleted = (deleted) +browsing-removed-unused-tags-count = + { $count -> + [one] Removed { $count } unused tag. + *[other] Removed { $count } unused tags. + } diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index ffd9da9fc..253383ed3 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -44,17 +44,8 @@ class TagManager: # Registering and fetching tags ############################################################# - def register( - self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False - ) -> None: - print("tags.register() is deprecated and no longer works") - - def registerNotes(self, nids: Optional[List[int]] = None) -> None: - "Clear unused tags and add any missing tags from notes to the tag list." - self.clear_unused_tags() - - def clear_unused_tags(self) -> None: - self.col._backend.clear_unused_tags() + def clear_unused_tags(self) -> OpChangesWithCount: + return self.col._backend.clear_unused_tags() def byDeck(self, did: int, children: bool = False) -> List[str]: basequery = "select n.tags from cards c, notes n WHERE c.nid = n.id" @@ -170,3 +161,14 @@ class TagManager: def inList(self, tag: str, tags: List[str]) -> bool: "True if TAG is in TAGS. Ignore case." return tag.lower() in [t.lower() for t in tags] + + # legacy + ########################################################################## + + def registerNotes(self, nids: Optional[List[int]] = None) -> None: + self.clear_unused_tags() + + def register( + self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False + ) -> None: + print("tags.register() is deprecated and no longer works") diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index d6e8ec212..437a83377 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -25,7 +25,13 @@ from aqt.card_ops import set_card_deck, set_card_flag from aqt.editor import Editor from aqt.exporting import ExportDialog from aqt.main import ResetReason -from aqt.note_ops import add_tags, find_and_replace, remove_notes, remove_tags +from aqt.note_ops import ( + add_tags, + clear_unused_tags, + find_and_replace, + remove_notes, + remove_tags, +) from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer from aqt.qt import * @@ -103,6 +109,7 @@ class DataModel(QAbstractTableModel): self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} self._refresh_needed = False + self.block_updates = False def getCard(self, index: QModelIndex) -> Optional[Card]: id = self.cards[index.row()] @@ -129,6 +136,8 @@ class DataModel(QAbstractTableModel): return len(self.activeCols) def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: + if self.block_updates: + return if not index.isValid(): return if role == Qt.FontRole: @@ -431,6 +440,9 @@ class StatusDelegate(QItemDelegate): def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: + if self.model.block_updates: + return QItemDelegate.paint(self, painter, option, index) + c = self.model.getCard(index) if not c: return QItemDelegate.paint(self, painter, option, index) @@ -502,15 +514,16 @@ class Browser(QMainWindow): gui_hooks.browser_will_show(self) self.show() - def on_operation_will_execute(self) -> None: + def on_operations_will_execute(self) -> None: # make sure the card list doesn't try to refresh itself during the operation, # as that will block the UI - self.setUpdatesEnabled(False) + self.model.block_updates = True + + def on_operations_did_execute(self) -> None: + self.model.block_updates = False def on_operation_did_execute(self, changes: OpChanges) -> None: focused = current_top_level_widget() == self - if focused: - self.setUpdatesEnabled(True) self.model.op_executed(changes, focused) self.sidebar.op_executed(changes, focused) if changes.note or changes.notetype: @@ -547,7 +560,7 @@ class Browser(QMainWindow): f.actionRemove_Tags.triggered, lambda: self.remove_tags_from_selected_notes(), ) - qconnect(f.actionClear_Unused_Tags.triggered, self.clearUnusedTags) + qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags) qconnect(f.actionToggle_Mark.triggered, lambda: self.onMark()) qconnect(f.actionChangeModel.triggered, self.onChangeModel) qconnect(f.actionFindDuplicates.triggered, self.onFindDupes) @@ -1219,7 +1232,7 @@ where id in %s""" ) -> None: "Shows prompt if tags not provided." if not ( - tags := self._maybe_prompt_for_tags(tags, tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) + tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) ): return add_tags(mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags) @@ -1228,19 +1241,14 @@ where id in %s""" def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: "Shows prompt if tags not provided." if not ( - tags := self._maybe_prompt_for_tags( - tags, tr(TR.BROWSING_ENTER_TAGS_TO_DELETE) - ) + tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_DELETE)) ): return remove_tags( mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags ) - def _maybe_prompt_for_tags(self, tags: Optional[str], prompt: str) -> Optional[str]: - if tags is not None: - return tags - + def _prompt_for_tags(self, prompt: str) -> Optional[str]: (tags, ok) = getTag(self, self.col, prompt) if not ok: return None @@ -1248,15 +1256,12 @@ where id in %s""" return tags @ensure_editor_saved_on_trigger - def clearUnusedTags(self) -> None: - def on_done(fut: Future) -> None: - fut.result() - self.on_tag_list_update() - - self.mw.taskman.run_in_background(self.col.tags.registerNotes, on_done) + def clear_unused_tags(self) -> None: + clear_unused_tags(mw=self.mw, parent=self) addTags = add_tags_to_selected_notes deleteTags = remove_tags_from_selected_notes + clearUnusedTags = clear_unused_tags # Suspending ###################################################################### @@ -1419,7 +1424,8 @@ where id in %s""" # fixme: remove these once all items are using `operation_did_execute` gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) - gui_hooks.operation_will_execute.append(self.on_operation_will_execute) + gui_hooks.operations_will_execute.append(self.on_operations_will_execute) + gui_hooks.operations_did_execute.append(self.on_operations_did_execute) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.focus_did_change.append(self.on_focus_change) @@ -1427,7 +1433,8 @@ where id in %s""" gui_hooks.undo_state_did_change.remove(self.onUndoState) gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) - gui_hooks.operation_will_execute.remove(self.on_operation_will_execute) + gui_hooks.operations_will_execute.remove(self.on_operations_will_execute) + gui_hooks.operations_did_execute.remove(self.on_operations_will_execute) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.focus_did_change.remove(self.on_focus_change) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 130cb96bd..b18e99537 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -1,5 +1,8 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + import base64 import html import itertools @@ -24,7 +27,7 @@ from anki.collection import Config, SearchNode from anki.consts import MODEL_CLOZE from anki.hooks import runFilter from anki.httpclient import HttpClient -from anki.notes import Note +from anki.notes import DuplicateOrEmptyResult, Note from anki.utils import checksum, isLin, isWin, namedtmp from aqt import AnkiQt, colors, gui_hooks from aqt.note_ops import update_note @@ -469,10 +472,10 @@ class Editor: # event has had time to fire self.mw.progress.timer(100, self.loadNoteKeepingFocus, False) else: - self.checkValid() + self._check_and_update_duplicate_display_async() else: gui_hooks.editor_did_fire_typing_timer(self.note) - self.checkValid() + self._check_and_update_duplicate_display_async() # focused into field? elif cmd.startswith("focus"): @@ -529,11 +532,15 @@ class Editor: self.widget.show() self.updateTags() + dupe_status = self.note.duplicate_or_empty() + def oncallback(arg: Any) -> None: if not self.note: return self.setupForegroundButton() - self.checkValid() + # we currently do this synchronously to ensure we load before the + # sidebar on browser startup + self._update_duplicate_display(dupe_status) if focusTo is not None: self.web.setFocus() gui_hooks.editor_did_load_note(self) @@ -577,15 +584,26 @@ class Editor: # calling code may not expect the callback to fire immediately self.mw.progress.timer(10, callback, False) return - self.saveTags() + self.blur_tags_if_focused() self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) saveNow = call_after_note_saved - def checkValid(self) -> None: + def _check_and_update_duplicate_display_async(self) -> None: + note = self.note + + def on_done(result: DuplicateOrEmptyResult.V) -> None: + if self.note != note: + return + self._update_duplicate_display(result) + + self.mw.query_op(self.note.duplicate_or_empty, success=on_done) + + checkValid = _check_and_update_duplicate_display_async + + def _update_duplicate_display(self, result: DuplicateOrEmptyResult.V) -> None: cols = [""] * len(self.note.fields) - err = self.note.duplicate_or_empty() - if err == 2: + if result == DuplicateOrEmptyResult.DUPLICATE: cols[0] = "dupe" self.web.eval(f"setBackgrounds({json.dumps(cols)});") @@ -681,7 +699,7 @@ class Editor: l = QLabel(tr(TR.EDITING_TAGS)) tb.addWidget(l, 1, 0) self.tags = aqt.tagedit.TagEdit(self.widget) - qconnect(self.tags.lostFocus, self.saveTags) + qconnect(self.tags.lostFocus, self.on_tag_focus_lost) self.tags.setToolTip( shortcut(tr(TR.EDITING_JUMP_TO_TAGS_WITH_CTRLANDSHIFTANDT)) ) @@ -697,13 +715,17 @@ class Editor: if not self.tags.text() or not self.addMode: self.tags.setText(self.note.stringTags().strip()) - def saveTags(self) -> None: - if not self.note: - return + def on_tag_focus_lost(self) -> None: self.note.tags = self.mw.col.tags.split(self.tags.text()) + gui_hooks.editor_did_update_tags(self.note) if not self.addMode: self._save_current_note() - gui_hooks.editor_did_update_tags(self.note) + + def blur_tags_if_focused(self) -> None: + if not self.note: + return + if self.tags.hasFocus(): + self.widget.setFocus() def hideCompleters(self) -> None: self.tags.hideCompleter() @@ -712,9 +734,12 @@ class Editor: self.tags.setFocus() # legacy + def saveAddModeVars(self) -> None: pass + saveTags = blur_tags_if_focused + # Format buttons ###################################################################### diff --git a/qt/aqt/main.py b/qt/aqt/main.py index d1c0e8bdb..6667e6048 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -715,6 +715,48 @@ class AnkiQt(QMainWindow): # Resetting state ########################################################################## + def query_op( + self, + op: Callable[[], Any], + *, + success: Callable[[Any], Any] = None, + failure: Optional[Callable[[Exception], Any]] = None, + ) -> None: + """Run an operation that queries the DB on a background thread. + + Similar interface to perform_op(), but intended to be used for operations + that do not change collection state. Undo status will not be changed, + and `operation_did_execute` will not fire. No progress window will + be shown either. + + `operations_will|did_execute` will still fire, so the UI can defer + updates during a background task. + """ + + def wrapped_done(future: Future) -> None: + self._decrease_background_ops() + # did something go wrong? + if exception := future.exception(): + if isinstance(exception, Exception): + if failure: + failure(exception) + else: + showWarning(str(exception)) + return + else: + # BaseException like SystemExit; rethrow it + future.result() + + result = future.result() + if success: + success(result) + + self._increase_background_ops() + self.taskman.run_in_background(op, wrapped_done) + + # Resetting state + ########################################################################## + def perform_op( self, op: Callable[[], ResultWithChanges], @@ -750,9 +792,10 @@ class AnkiQt(QMainWindow): they invoke themselves. """ - gui_hooks.operation_will_execute() + self._increase_background_ops() def wrapped_done(future: Future) -> None: + self._decrease_background_ops() # did something go wrong? if exception := future.exception(): if isinstance(exception, Exception): @@ -764,8 +807,9 @@ class AnkiQt(QMainWindow): else: # BaseException like SystemExit; rethrow it future.result() + + result = future.result() try: - result = future.result() if success: success(result) finally: @@ -777,6 +821,17 @@ class AnkiQt(QMainWindow): self.taskman.with_progress(op, wrapped_done) + def _increase_background_ops(self) -> None: + if not self._background_op_count: + gui_hooks.operations_will_execute() + self._background_op_count += 1 + + def _decrease_background_ops(self) -> None: + self._background_op_count -= 1 + if not self._background_op_count: + gui_hooks.operations_did_execute() + assert self._background_op_count >= 0 + def _fire_change_hooks_after_op_performed( self, result: ResultWithChanges, after_hooks: Optional[Callable[[], None]] ) -> None: @@ -991,6 +1046,7 @@ title="%s" %s>%s""" % ( def setupThreads(self) -> None: self._mainThread = QThread.currentThread() + self._background_op_count = 0 def inMainThread(self) -> bool: return self._mainThread == QThread.currentThread() diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index 36592537c..091b93d55 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -9,7 +9,7 @@ from anki.lang import TR from anki.notes import Note from aqt import AnkiQt, QWidget from aqt.main import PerformOpOptionalSuccessCallback -from aqt.utils import show_invalid_search_error, showInfo, tr +from aqt.utils import show_invalid_search_error, showInfo, tooltip, tr def add_note( @@ -48,6 +48,15 @@ def remove_tags( mw.perform_op(lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags)) +def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: + mw.perform_op( + mw.col.tags.clear_unused_tags, + success=lambda out: tooltip( + tr(TR.BROWSING_REMOVED_UNUSED_TAGS_COUNT, count=out.count), parent=parent + ), + ) + + def find_and_replace( *, mw: AnkiQt, diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index 6f3eeab70..9f52e26b2 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -179,7 +179,10 @@ class ProgressManager: if elap >= 0.5: break self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # type: ignore #possibly related to https://github.com/python/mypy/issues/6910 - self._win.cancel() + # if the parent window has been deleted, the progress dialog may have + # already been dropped; delete it if it hasn't been + if not sip.isdeleted(self._win): + self._win.cancel() self._win = None self._shown = 0 diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 7aa53479b..b3b70447c 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -440,16 +440,17 @@ class SidebarTreeView(QTreeView): if not self.isVisible(): return - def on_done(fut: Future) -> None: - self.setUpdatesEnabled(True) - root = fut.result() + def on_done(root: SidebarItem) -> None: + # user may have closed browser + if sip.isdeleted(self): + return + + # block repainting during refreshing to avoid flickering + self.setUpdatesEnabled(False) + model = SidebarModel(self, root) - - # from PyQt5.QtTest import QAbstractItemModelTester - # tester = QAbstractItemModelTester(model) - self.setModel(model) - qconnect(self.selectionModel().selectionChanged, self._on_selection_changed) + if self.current_search: self.search_for(self.current_search) else: @@ -457,9 +458,12 @@ class SidebarTreeView(QTreeView): if is_current: self.restore_current(is_current) - # block repainting during refreshing to avoid flickering - self.setUpdatesEnabled(False) - self.mw.taskman.run_in_background(self._root_tree, on_done) + self.setUpdatesEnabled(True) + + # needs to be set after changing model + qconnect(self.selectionModel().selectionChanged, self._on_selection_changed) + + self.mw.query_op(self._root_tree, success=on_done) def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None: if current := self.find_item(is_current): diff --git a/qt/aqt/taskman.py b/qt/aqt/taskman.py index 0a059e910..3189024f3 100644 --- a/qt/aqt/taskman.py +++ b/qt/aqt/taskman.py @@ -3,6 +3,8 @@ """ Helper for running tasks on background threads. + +See mw.query_op() and mw.perform_op() for slightly higher-level routines. """ from __future__ import annotations @@ -49,6 +51,14 @@ class TaskManager(QObject): the completed future. Args if provided will be passed on as keyword arguments to the task callable.""" + # Before we launch a background task, ensure any pending on_done closure are run on + # main. Qt's signal/slot system will have posted a notification, but it may + # not have been processed yet. The on_done() closures may make small queries + # to the database that we want to run first - if we delay them until after the + # background task starts, and it takes out a long-running lock on the database, + # the UI thread will hang until the end of the op. + self._on_closures_pending() + if args is None: args = {} diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 49dd359d4..692caa9ec 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -400,10 +400,19 @@ hooks = [ """, ), Hook( - name="operation_will_execute", - doc="""Called before an operation is executed with mw.perform_op(). - Subscribers can use this to ensure they don't try to access the collection until the operation completes, - as doing so on the main thread will temporarily freeze the UI.""", + name="operations_will_execute", + doc="""Called before one or more operations are executed with mw.perform_op(). + + Subscribers can use this to set a flag to avoid DB updates until the operation + completes, as doing so will freeze the UI. + """, + ), + Hook( + name="operations_did_execute", + doc="""Called after one or more operations are executed with mw.perform_op(). + Called regardless of the success of individual operations, and only called when + there are no outstanding ops. + """, ), Hook( name="operation_did_execute", diff --git a/rslib/backend.proto b/rslib/backend.proto index 1fa44011e..1bc90634e 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -217,7 +217,7 @@ service DeckConfigService { } service TagsService { - rpc ClearUnusedTags(Empty) returns (Empty); + rpc ClearUnusedTags(Empty) returns (OpChangesWithCount); rpc AllTags(Empty) returns (StringList); rpc ExpungeTags(String) returns (UInt32); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 26a15d72f..4c24d38e3 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -6,7 +6,7 @@ use crate::{backend_proto as pb, prelude::*}; pub(super) use pb::tags_service::Service as TagsService; impl TagsService for Backend { - fn clear_unused_tags(&self, _input: pb::Empty) -> Result { + fn clear_unused_tags(&self, _input: pb::Empty) -> Result { self.with_col(|col| col.transact_no_undo(|col| col.clear_unused_tags().map(Into::into))) } diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 4807719f4..f80bfdec1 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -9,6 +9,7 @@ pub enum Op { AddNote, AnswerCard, Bury, + ClearUnusedTags, FindAndReplace, RemoveDeck, RemoveNote, @@ -48,6 +49,7 @@ impl Op { Op::SetDeck => TR::BrowsingChangeDeck, Op::SetFlag => TR::UndoSetFlag, Op::FindAndReplace => TR::BrowsingFindAndReplace, + Op::ClearUnusedTags => TR::BrowsingClearUnusedTags, }; i18n.tr(key).to_string() diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index ff2ed3cb4..553069db0 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -276,20 +276,25 @@ impl Collection { Ok(None) } - pub fn clear_unused_tags(&self) -> Result<()> { - let expanded: HashSet<_> = self.storage.expanded_tags()?.into_iter().collect(); - self.storage.clear_all_tags()?; - let usn = self.usn()?; - for name in self.storage.all_tags_in_notes()? { - let name = normalize_tag_name(&name).into(); - self.storage.register_tag(&Tag { - expanded: expanded.contains(&name), - name, - usn, - })?; + /// Remove tags not referenced by notes, returning removed count. + pub fn clear_unused_tags(&mut self) -> Result> { + self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner()) + } + + fn clear_unused_tags_inner(&mut self) -> Result { + let mut count = 0; + let in_notes = self.storage.all_tags_in_notes()?; + let need_remove = self + .storage + .all_tags()? + .into_iter() + .filter(|tag| !in_notes.contains(&tag.name)); + for tag in need_remove { + self.remove_single_tag_undoable(tag)?; + count += 1; } - Ok(()) + Ok(count) } /// Take tags as a whitespace-separated string and remove them from all notes and the storage. diff --git a/rslib/src/tags/undo.rs b/rslib/src/tags/undo.rs index 907a7ea86..3775dcfb2 100644 --- a/rslib/src/tags/undo.rs +++ b/rslib/src/tags/undo.rs @@ -13,7 +13,7 @@ pub(crate) enum UndoableTagChange { impl Collection { pub(crate) fn undo_tag_change(&mut self, change: UndoableTagChange) -> Result<()> { match change { - UndoableTagChange::Added(tag) => self.remove_single_tag_undoable(&tag), + UndoableTagChange::Added(tag) => self.remove_single_tag_undoable(*tag), UndoableTagChange::Removed(tag) => self.register_tag_undoable(&tag), } } @@ -24,8 +24,9 @@ impl Collection { self.storage.register_tag(&tag) } - fn remove_single_tag_undoable(&mut self, tag: &Tag) -> Result<()> { - self.save_undo(UndoableTagChange::Removed(Box::new(tag.clone()))); - self.storage.remove_single_tag(&tag.name) + pub(super) fn remove_single_tag_undoable(&mut self, tag: Tag) -> Result<()> { + self.storage.remove_single_tag(&tag.name)?; + self.save_undo(UndoableTagChange::Removed(Box::new(tag))); + Ok(()) } } From 846e7cd4aa7a5ee8aec54a6e087dc53681ae77ab Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 18 Mar 2021 10:54:02 +1000 Subject: [PATCH 22/33] tweak hook names --- qt/aqt/browser.py | 12 ++++++------ qt/aqt/main.py | 4 ++-- qt/tools/genhooks_gui.py | 34 +++++++++++++++++----------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 437a83377..0770cd1a4 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -514,12 +514,12 @@ class Browser(QMainWindow): gui_hooks.browser_will_show(self) self.show() - def on_operations_will_execute(self) -> None: + def on_backend_will_block(self) -> None: # make sure the card list doesn't try to refresh itself during the operation, # as that will block the UI self.model.block_updates = True - def on_operations_did_execute(self) -> None: + def on_backend_did_block(self) -> None: self.model.block_updates = False def on_operation_did_execute(self, changes: OpChanges) -> None: @@ -1424,8 +1424,8 @@ where id in %s""" # fixme: remove these once all items are using `operation_did_execute` gui_hooks.sidebar_should_refresh_decks.append(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) - gui_hooks.operations_will_execute.append(self.on_operations_will_execute) - gui_hooks.operations_did_execute.append(self.on_operations_did_execute) + gui_hooks.backend_will_block.append(self.on_backend_will_block) + gui_hooks.backend_did_block.append(self.on_backend_did_block) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.focus_did_change.append(self.on_focus_change) @@ -1433,8 +1433,8 @@ where id in %s""" gui_hooks.undo_state_did_change.remove(self.onUndoState) gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) - gui_hooks.operations_will_execute.remove(self.on_operations_will_execute) - gui_hooks.operations_did_execute.remove(self.on_operations_will_execute) + gui_hooks.backend_will_block.remove(self.on_backend_will_block) + gui_hooks.backend_did_block.remove(self.on_backend_will_block) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.focus_did_change.remove(self.on_focus_change) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 6667e6048..79eb3041d 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -823,13 +823,13 @@ class AnkiQt(QMainWindow): def _increase_background_ops(self) -> None: if not self._background_op_count: - gui_hooks.operations_will_execute() + gui_hooks.backend_will_block() self._background_op_count += 1 def _decrease_background_ops(self) -> None: self._background_op_count -= 1 if not self._background_op_count: - gui_hooks.operations_did_execute() + gui_hooks.backend_did_block() assert self._background_op_count >= 0 def _fire_change_hooks_after_op_performed( diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 692caa9ec..5e950eb93 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -399,21 +399,6 @@ hooks = [ New code should use `operation_did_execute` instead. """, ), - Hook( - name="operations_will_execute", - doc="""Called before one or more operations are executed with mw.perform_op(). - - Subscribers can use this to set a flag to avoid DB updates until the operation - completes, as doing so will freeze the UI. - """, - ), - Hook( - name="operations_did_execute", - doc="""Called after one or more operations are executed with mw.perform_op(). - Called regardless of the success of individual operations, and only called when - there are no outstanding ops. - """, - ), Hook( name="operation_did_execute", args=[ @@ -422,8 +407,7 @@ hooks = [ doc="""Called after an operation completes. Changes can be inspected to determine whether the UI needs updating. - This will also be called when the legacy mw.reset() is used. When called via - mw.reset(), `operation_will_execute` will not be called. + This will also be called when the legacy mw.reset() is used. """, ), Hook( @@ -435,6 +419,22 @@ hooks = [ doc="""Called each time the focus changes. Can be used to defer updates from `operation_did_execute` until a window is brought to the front.""", ), + Hook( + name="backend_will_block", + doc="""Called before one or more operations are executed with mw.perform_op(). + + Subscribers can use this to set a flag to avoid DB queries until the operation + completes, as doing so will freeze the UI until the long-running operation + completes. + """, + ), + Hook( + name="backend_did_block", + doc="""Called after one or more operations are executed with mw.perform_op(). + Called regardless of the success of individual operations, and only called when + there are no outstanding ops. + """, + ), # Webview ################### Hook( From 0331d8b588e2173af33aea3807538f17daf042bb Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 18 Mar 2021 11:46:11 +1000 Subject: [PATCH 23/33] make reposition undoable --- ftl/core/browsing.ftl | 5 ++ pylib/anki/scheduler/base.py | 39 +++++++++------ pylib/tests/test_schedv1.py | 56 --------------------- pylib/tests/test_schedv2.py | 8 ++- qt/aqt/browser.py | 80 ++++++++++-------------------- qt/aqt/scheduling_ops.py | 70 +++++++++++++++++++++++++- rslib/backend.proto | 4 +- rslib/src/backend/scheduler/mod.rs | 4 +- rslib/src/ops.rs | 2 + rslib/src/scheduler/new.rs | 28 +++++++---- 10 files changed, 155 insertions(+), 141 deletions(-) diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 46b317627..f84bb3890 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -146,3 +146,8 @@ browsing-removed-unused-tags-count = [one] Removed { $count } unused tag. *[other] Removed { $count } unused tags. } +browsing-changed-new-position = + { $count -> + [one] Changed position of { $count } new card. + *[other] Changed position of { $count } new cards. + } diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index 06f2dd61b..4b4af388f 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -5,7 +5,7 @@ from __future__ import annotations import anki import anki._backend.backend_pb2 as _pb -from anki.collection import OpChanges +from anki.collection import OpChanges, OpChangesWithCount from anki.config import Config SchedTimingToday = _pb.SchedTimingTodayOut @@ -167,20 +167,20 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Repositioning new cards ########################################################################## - def sortCards( + def reposition_new_cards( self, - cids: List[int], - start: int = 1, - step: int = 1, - shuffle: bool = False, - shift: bool = False, - ) -> None: - self.col._backend.sort_cards( - card_ids=cids, - starting_from=start, - step_size=step, - randomize=shuffle, - shift_existing=shift, + card_ids: Sequence[int], + starting_from: int, + step_size: int, + randomize: bool, + shift_existing: bool, + ) -> OpChangesWithCount: + return self.col._backend.sort_cards( + card_ids=card_ids, + starting_from=starting_from, + step_size=step_size, + randomize=randomize, + shift_existing=shift_existing, ) def randomizeCards(self, did: int) -> None: @@ -204,3 +204,14 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # in order due? if conf["new"]["order"] == NEW_CARDS_RANDOM: self.randomizeCards(did) + + # legacy + def sortCards( + self, + cids: List[int], + start: int = 1, + step: int = 1, + shuffle: bool = False, + shift: bool = False, + ) -> None: + self.reposition_new_cards(cids, start, step, shuffle, shift) diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py index 6504147b9..4a02066da 100644 --- a/pylib/tests/test_schedv1.py +++ b/pylib/tests/test_schedv1.py @@ -1023,62 +1023,6 @@ def test_deckFlow(): col.sched.answerCard(c, 2) -def test_reorder(): - col = getEmptyCol() - # add a note with default deck - note = col.newNote() - note["Front"] = "one" - col.addNote(note) - note2 = col.newNote() - note2["Front"] = "two" - col.addNote(note2) - assert note2.cards()[0].due == 2 - found = False - # 50/50 chance of being reordered - for i in range(20): - col.sched.randomizeCards(1) - if note.cards()[0].due != note.id: - found = True - break - assert found - col.sched.orderCards(1) - assert note.cards()[0].due == 1 - # shifting - note3 = col.newNote() - note3["Front"] = "three" - col.addNote(note3) - note4 = col.newNote() - note4["Front"] = "four" - col.addNote(note4) - assert note.cards()[0].due == 1 - assert note2.cards()[0].due == 2 - assert note3.cards()[0].due == 3 - assert note4.cards()[0].due == 4 - col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True) - assert note.cards()[0].due == 3 - assert note2.cards()[0].due == 4 - assert note3.cards()[0].due == 1 - assert note4.cards()[0].due == 2 - - -def test_forget(): - col = getEmptyCol() - note = col.newNote() - note["Front"] = "one" - col.addNote(note) - c = note.cards()[0] - c.queue = QUEUE_TYPE_REV - c.type = CARD_TYPE_REV - c.ivl = 100 - c.due = 0 - c.flush() - col.reset() - assert col.sched.counts() == (0, 0, 1) - col.sched.forgetCards([c.id]) - col.reset() - assert col.sched.counts() == (1, 0, 0) - - def test_norelearn(): col = getEmptyCol() # add a note diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index d9876c562..5f9a46aae 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -1211,7 +1211,13 @@ def test_reorder(): assert note2.cards()[0].due == 2 assert note3.cards()[0].due == 3 assert note4.cards()[0].due == 4 - col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True) + col.sched.reposition_new_cards( + [note3.cards()[0].id, note4.cards()[0].id], + starting_from=1, + shift_existing=True, + step_size=1, + randomize=False, + ) assert note.cards()[0].due == 3 assert note2.cards()[0].due == 4 assert note3.cards()[0].due == 1 diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 0770cd1a4..fd8ff34d4 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -37,6 +37,7 @@ from aqt.previewer import Previewer from aqt.qt import * from aqt.scheduling_ops import ( forget_cards, + reposition_new_cards_dialog, set_due_date_dialog, suspend_cards, unsuspend_cards, @@ -247,7 +248,7 @@ class DataModel(QAbstractTableModel): self.endReset() def saveSelection(self) -> None: - cards = self.browser.selectedCards() + cards = self.browser.selected_cards() self.selectedCards = {id: True for id in cards} if getattr(self.browser, "card", None): self.focusedCard = self.browser.card.id @@ -1076,13 +1077,13 @@ QTableView {{ gridline-color: {grid} }} # Menu helpers ###################################################################### - def selectedCards(self) -> List[int]: + def selected_cards(self) -> List[int]: return [ self.model.cards[idx.row()] for idx in self.form.tableView.selectionModel().selectedRows() ] - def selectedNotes(self) -> List[int]: + def selected_notes(self) -> List[int]: return self.col.db.list( """ select distinct nid from cards @@ -1098,11 +1099,11 @@ where id in %s""" def selectedNotesAsCards(self) -> List[int]: return self.col.db.list( "select id from cards where nid in (%s)" - % ",".join([str(s) for s in self.selectedNotes()]) + % ",".join([str(s) for s in self.selected_notes()]) ) def oneModelNotes(self) -> List[int]: - sf = self.selectedNotes() + sf = self.selected_notes() if not sf: return [] mods = self.col.db.scalar( @@ -1119,6 +1120,11 @@ where id in %s""" def onHelp(self) -> None: openHelp(HelpPage.BROWSING) + # legacy + + selectedCards = selected_cards + selectedNotes = selected_notes + # Misc menu options ###################################################################### @@ -1174,7 +1180,7 @@ where id in %s""" return # nothing selected? - nids = self.selectedNotes() + nids = self.selected_notes() if not nids: return @@ -1198,7 +1204,7 @@ where id in %s""" def set_deck_of_selected_cards(self) -> None: from aqt.studydeck import StudyDeck - cids = self.selectedCards() + cids = self.selected_cards() if not cids: return @@ -1235,7 +1241,7 @@ where id in %s""" tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) ): return - add_tags(mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags) + add_tags(mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags) @ensure_editor_saved_on_trigger def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: @@ -1245,7 +1251,7 @@ where id in %s""" ): return remove_tags( - mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags + mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags ) def _prompt_for_tags(self, prompt: str) -> Optional[str]: @@ -1272,7 +1278,7 @@ where id in %s""" @ensure_editor_saved_on_trigger def suspend_selected_cards(self) -> None: want_suspend = not self.current_card_is_suspended() - cids = self.selectedCards() + cids = self.selected_cards() if want_suspend: suspend_cards(mw=self.mw, card_ids=cids) @@ -1300,7 +1306,7 @@ where id in %s""" if flag == self.card.user_flag(): flag = 0 - cids = self.selectedCards() + cids = self.selected_cards() set_card_flag(mw=self.mw, card_ids=cids, flag=flag) def _updateFlagsMenu(self) -> None: @@ -1331,57 +1337,25 @@ where id in %s""" def isMarked(self) -> bool: return bool(self.card and self.card.note().has_tag("Marked")) - # Repositioning + # Scheduling ###################################################################### @ensure_editor_saved_on_trigger def reposition(self) -> None: - cids = self.selectedCards() - cids2 = self.col.db.list( - f"select id from cards where type = {CARD_TYPE_NEW} and id in " - + ids2str(cids) - ) - if not cids2: - showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED)) + if self.card and self.card.queue != QUEUE_TYPE_NEW: + showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED), parent=self) return - d = QDialog(self) - disable_help_button(d) - d.setWindowModality(Qt.WindowModal) - frm = aqt.forms.reposition.Ui_Dialog() - frm.setupUi(d) - (pmin, pmax) = self.col.db.first( - f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0" - ) - pmin = pmin or 0 - pmax = pmax or 0 - txt = tr(TR.BROWSING_QUEUE_TOP, val=pmin) - txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=pmax) - frm.label.setText(txt) - frm.start.selectAll() - if not d.exec_(): - return - self.model.beginReset() - self.mw.checkpoint(tr(TR.ACTIONS_REPOSITION)) - self.col.sched.sortCards( - cids, - start=frm.start.value(), - step=frm.step.value(), - shuffle=frm.randomize.isChecked(), - shift=frm.shift.isChecked(), - ) - self.search() - self.mw.requireReset(reason=ResetReason.BrowserReposition, context=self) - self.model.endReset() - # Scheduling - ###################################################################### + reposition_new_cards_dialog( + mw=self.mw, parent=self, card_ids=self.selected_cards() + ) @ensure_editor_saved_on_trigger def set_due_date(self) -> None: set_due_date_dialog( mw=self.mw, parent=self, - card_ids=self.selectedCards(), + card_ids=self.selected_cards(), config_key=Config.String.SET_DUE_BROWSER, ) @@ -1390,7 +1364,7 @@ where id in %s""" forget_cards( mw=self.mw, parent=self, - card_ids=self.selectedCards(), + card_ids=self.selected_cards(), ) # Edit: selection @@ -1398,7 +1372,7 @@ where id in %s""" @ensure_editor_saved_on_trigger def selectNotes(self) -> None: - nids = self.selectedNotes() + nids = self.selected_notes() # clear the selection so we don't waste energy preserving it tv = self.form.tableView tv.selectionModel().clear() @@ -1465,7 +1439,7 @@ where id in %s""" @ensure_editor_saved_on_trigger def onFindReplace(self) -> None: - nids = self.selectedNotes() + nids = self.selected_notes() if not nids: return import anki.find diff --git a/qt/aqt/scheduling_ops.py b/qt/aqt/scheduling_ops.py index 4d351405c..7468ed00a 100644 --- a/qt/aqt/scheduling_ops.py +++ b/qt/aqt/scheduling_ops.py @@ -6,12 +6,12 @@ from __future__ import annotations from typing import List, Optional, Sequence import aqt -from anki.collection import Config +from anki.collection import CARD_TYPE_NEW, Config from anki.lang import TR from aqt import AnkiQt from aqt.main import PerformOpOptionalSuccessCallback from aqt.qt import * -from aqt.utils import getText, tooltip, tr +from aqt.utils import disable_help_button, getText, tooltip, tr def set_due_date_dialog( @@ -63,6 +63,72 @@ def forget_cards(*, mw: aqt.AnkiQt, parent: QWidget, card_ids: List[int]) -> Non ) +def reposition_new_cards_dialog( + *, mw: AnkiQt, parent: QWidget, card_ids: Sequence[int] +) -> None: + assert mw.col.db + row = mw.col.db.first( + f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0" + ) + assert row + (min_position, max_position) = row + min_position = max(min_position or 0, 0) + max_position = max_position or 0 + + d = QDialog(parent) + disable_help_button(d) + d.setWindowModality(Qt.WindowModal) + frm = aqt.forms.reposition.Ui_Dialog() + frm.setupUi(d) + + txt = tr(TR.BROWSING_QUEUE_TOP, val=min_position) + txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=max_position) + frm.label.setText(txt) + + frm.start.selectAll() + if not d.exec_(): + return + + start = frm.start.value() + step = frm.step.value() + randomize = frm.randomize.isChecked() + shift = frm.shift.isChecked() + + reposition_new_cards( + mw=mw, + parent=parent, + card_ids=card_ids, + starting_from=start, + step_size=step, + randomize=randomize, + shift_existing=shift, + ) + + +def reposition_new_cards( + *, + mw: AnkiQt, + parent: QWidget, + card_ids: Sequence[int], + starting_from: int, + step_size: int, + randomize: bool, + shift_existing: bool, +) -> None: + mw.perform_op( + lambda: mw.col.sched.reposition_new_cards( + card_ids=card_ids, + starting_from=starting_from, + step_size=step_size, + randomize=randomize, + shift_existing=shift_existing, + ), + success=lambda out: tooltip( + tr(TR.BROWSING_CHANGED_NEW_POSITION, count=out.count), parent=parent + ), + ) + + def suspend_cards( *, mw: AnkiQt, diff --git a/rslib/backend.proto b/rslib/backend.proto index 1bc90634e..d8d1475f5 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -120,8 +120,8 @@ service SchedulingService { rpc RebuildFilteredDeck(DeckID) returns (UInt32); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); rpc SetDueDate(SetDueDateIn) returns (OpChanges); - rpc SortCards(SortCardsIn) returns (Empty); - rpc SortDeck(SortDeckIn) returns (Empty); + rpc SortCards(SortCardsIn) returns (OpChangesWithCount); + rpc SortDeck(SortDeckIn) returns (OpChangesWithCount); rpc GetNextCardStates(CardID) returns (NextCardStates); rpc DescribeNextStates(NextCardStates) returns (StringList); rpc StateIsLeech(SchedulingState) returns (Bool); diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 521b8aed4..2dae46537 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -118,7 +118,7 @@ impl SchedulingService for Backend { self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into)) } - fn sort_cards(&self, input: pb::SortCardsIn) -> Result { + fn sort_cards(&self, input: pb::SortCardsIn) -> Result { let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let (start, step, random, shift) = ( input.starting_from, @@ -137,7 +137,7 @@ impl SchedulingService for Backend { }) } - fn sort_deck(&self, input: pb::SortDeckIn) -> Result { + fn sort_deck(&self, input: pb::SortDeckIn) -> Result { self.with_col(|col| { col.sort_deck(input.deck_id.into(), input.randomize) .map(Into::into) diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index f80bfdec1..91f9c785a 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -18,6 +18,7 @@ pub enum Op { SetDeck, SetDueDate, SetFlag, + SortCards, Suspend, UnburyUnsuspend, UpdateCard, @@ -50,6 +51,7 @@ impl Op { Op::SetFlag => TR::UndoSetFlag, Op::FindAndReplace => TR::BrowsingFindAndReplace, Op::ClearUnusedTags => TR::BrowsingClearUnusedTags, + Op::SortCards => TR::BrowsingReschedule, }; i18n.tr(key).to_string() diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index 2e7b3de47..c01913f40 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -24,12 +24,14 @@ impl Card { self.ease_factor = 0; } - /// If the card is new, change its position. - fn set_new_position(&mut self, position: u32) { + /// If the card is new, change its position, and return true. + fn set_new_position(&mut self, position: u32) -> bool { if self.queue != CardQueue::New || self.ctype != CardType::New { - return; + false + } else { + self.due = position as i32; + true } - self.due = position as i32; } } pub(crate) struct NewCardSorter { @@ -130,9 +132,9 @@ impl Collection { step: u32, order: NewCardSortOrder, shift: bool, - ) -> Result<()> { + ) -> Result> { let usn = self.usn()?; - self.transact_no_undo(|col| { + self.transact(Op::SortCards, |col| { col.sort_cards_inner(cids, starting_from, step, order, shift, usn) }) } @@ -145,24 +147,28 @@ impl Collection { order: NewCardSortOrder, shift: bool, usn: Usn, - ) -> Result<()> { + ) -> Result { if shift { self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?; } self.storage.set_search_table_to_card_ids(cids, true)?; let cards = self.storage.all_searched_cards_in_search_order()?; let sorter = NewCardSorter::new(&cards, starting_from, step, order); + let mut count = 0; for mut card in cards { let original = card.clone(); - card.set_new_position(sorter.position(&card)); - self.update_card_inner(&mut card, original, usn)?; + if card.set_new_position(sorter.position(&card)) { + count += 1; + self.update_card_inner(&mut card, original, usn)?; + } } - self.storage.clear_searched_cards_table() + self.storage.clear_searched_cards_table()?; + Ok(count) } /// This creates a transaction - we probably want to split it out /// in the future if calling it as part of a deck options update. - pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result<()> { + pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result> { let cids = self.search_cards(&format!("did:{} is:new", deck), SortMode::NoOrder)?; let order = if random { NewCardSortOrder::Random From 2d8e45b6da4b9b947c66076605ec21884352710e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 18 Mar 2021 12:06:45 +1000 Subject: [PATCH 24/33] tidy up flag/mark code --- pylib/anki/tags.py | 1 + qt/aqt/browser.py | 56 ++++++++++++++++++++++++---------------------- qt/aqt/reviewer.py | 10 ++++----- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 253383ed3..7deb379f0 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -23,6 +23,7 @@ from anki.utils import ids2str # public exports TagTreeNode = _pb.TagTreeNode +MARKED_TAG = "marked" class TagManager: diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index fd8ff34d4..6011ce381 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -19,6 +19,7 @@ from anki.errors import InvalidInput, NotFoundError from anki.lang import without_unicode_isolation from anki.models import NoteType from anki.stats import CardStats +from anki.tags import MARKED_TAG from anki.utils import htmlToTextLine, ids2str, isMac, isWin from aqt import AnkiQt, colors, gui_hooks from aqt.card_ops import set_card_deck, set_card_flag @@ -454,7 +455,7 @@ class StatusDelegate(QItemDelegate): col = None if c.user_flag() > 0: col = getattr(colors, f"FLAG{c.user_flag()}_BG") - elif c.note().has_tag("Marked"): + elif c.note().has_tag(MARKED_TAG): col = colors.MARKED_BG elif c.queue == QUEUE_TYPE_SUSPENDED: col = colors.SUSPENDED_BG @@ -562,7 +563,9 @@ class Browser(QMainWindow): lambda: self.remove_tags_from_selected_notes(), ) qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags) - qconnect(f.actionToggle_Mark.triggered, lambda: self.onMark()) + qconnect( + f.actionToggle_Mark.triggered, lambda: self.toggle_mark_of_selected_notes() + ) qconnect(f.actionChangeModel.triggered, self.onChangeModel) qconnect(f.actionFindDuplicates.triggered, self.onFindDupes) qconnect(f.actionFindReplace.triggered, self.onFindReplace) @@ -575,10 +578,16 @@ class Browser(QMainWindow): qconnect(f.action_set_due_date.triggered, self.set_due_date) qconnect(f.action_forget.triggered, self.forget_cards) qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards) - qconnect(f.actionRed_Flag.triggered, lambda: self.onSetFlag(1)) - qconnect(f.actionOrange_Flag.triggered, lambda: self.onSetFlag(2)) - qconnect(f.actionGreen_Flag.triggered, lambda: self.onSetFlag(3)) - qconnect(f.actionBlue_Flag.triggered, lambda: self.onSetFlag(4)) + qconnect(f.actionRed_Flag.triggered, lambda: self.set_flag_of_selected_cards(1)) + qconnect( + f.actionOrange_Flag.triggered, lambda: self.set_flag_of_selected_cards(2) + ) + qconnect( + f.actionGreen_Flag.triggered, lambda: self.set_flag_of_selected_cards(3) + ) + qconnect( + f.actionBlue_Flag.triggered, lambda: self.set_flag_of_selected_cards(4) + ) qconnect(f.actionExport.triggered, lambda: self._on_export_notes()) # jumps qconnect(f.actionPreviousCard.triggered, self.onPreviousCard) @@ -870,7 +879,7 @@ QTableView {{ gridline-color: {grid} }} self.focusTo = None self.editor.card = self.card self.singleCard = True - self._updateFlagsMenu() + self._update_flags_menu() gui_hooks.browser_did_change_row(self) def currentRow(self) -> int: @@ -1296,29 +1305,26 @@ where id in %s""" # Flags & Marking ###################################################################### - def onSetFlag(self, n: int) -> None: + @ensure_editor_saved + def set_flag_of_selected_cards(self, flag: int) -> None: if not self.card: return - self.editor.call_after_note_saved(lambda: self._on_set_flag(n)) - def _on_set_flag(self, flag: int) -> None: # flag needs toggling off? if flag == self.card.user_flag(): flag = 0 - cids = self.selected_cards() - set_card_flag(mw=self.mw, card_ids=cids, flag=flag) + set_card_flag(mw=self.mw, card_ids=self.selected_cards(), flag=flag) - def _updateFlagsMenu(self) -> None: + def _update_flags_menu(self) -> None: flag = self.card and self.card.user_flag() flag = flag or 0 - f = self.form flagActions = [ - f.actionRed_Flag, - f.actionOrange_Flag, - f.actionGreen_Flag, - f.actionBlue_Flag, + self.form.actionRed_Flag, + self.form.actionOrange_Flag, + self.form.actionGreen_Flag, + self.form.actionBlue_Flag, ] for c, act in enumerate(flagActions): @@ -1326,16 +1332,12 @@ where id in %s""" qtMenuShortcutWorkaround(self.form.menuFlag) - def onMark(self, mark: bool = None) -> None: - if mark is None: - mark = not self.isMarked() - if mark: - self.add_tags_to_selected_notes(tags="marked") + def toggle_mark_of_selected_notes(self) -> None: + have_mark = bool(self.card and self.card.note().has_tag(MARKED_TAG)) + if have_mark: + self.remove_tags_from_selected_notes(tags=MARKED_TAG) else: - self.remove_tags_from_selected_notes(tags="marked") - - def isMarked(self) -> bool: - return bool(self.card and self.card.note().has_tag("Marked")) + self.add_tags_to_selected_notes(tags=MARKED_TAG) # Scheduling ###################################################################### diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index f8154ab1d..088e6dbb5 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -15,6 +15,7 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card from anki.collection import Config, OpChanges +from anki.tags import MARKED_TAG from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.card_ops import set_card_flag @@ -271,7 +272,7 @@ class Reviewer: self.web.eval(f"_drawFlag({self.card.user_flag()});") def _update_mark_icon(self) -> None: - self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag('marked'))});") + self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag(MARKED_TAG))});") _drawMark = _update_mark_icon _drawFlag = _update_flag_icon @@ -844,12 +845,11 @@ time = %(time)d; set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag) def toggle_mark_on_current_note(self) -> None: - tag = "marked" note = self.card.note() - if note.has_tag(tag): - remove_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag) + if note.has_tag(MARKED_TAG): + remove_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG) else: - add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=tag) + add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG) def on_set_due(self) -> None: if self.mw.state != "review" or not self.card: From 157b74b671d5edcbc04590c102e263d8aeb72bcb Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 18 Mar 2021 20:43:04 +1000 Subject: [PATCH 25/33] make tag renaming undoable, and speed it up ~3x speedup when renaming a tag that's on 25k notes --- pylib/anki/tags.py | 9 +- qt/aqt/main.py | 2 +- qt/aqt/note_ops.py | 22 +++++ qt/aqt/sidebar.py | 46 ++++------ rslib/backend.proto | 6 ++ rslib/src/backend/tags.rs | 5 ++ rslib/src/notes/mod.rs | 17 ++++ rslib/src/notes/undo.rs | 19 ++++ rslib/src/ops.rs | 2 + rslib/src/storage/note/get_tags.sql | 5 ++ rslib/src/storage/note/mod.rs | 47 +++++++++- rslib/src/storage/note/update_tags.sql | 5 ++ rslib/src/storage/tag/mod.rs | 2 - rslib/src/tags/mod.rs | 107 +++++++++++++++++++--- rslib/src/tags/prefix_replacer.rs | 119 +++++++++++++++++++++++++ rslib/src/tags/undo.rs | 4 +- 16 files changed, 363 insertions(+), 54 deletions(-) create mode 100644 rslib/src/storage/note/get_tags.sql create mode 100644 rslib/src/storage/note/update_tags.sql create mode 100644 rslib/src/tags/prefix_replacer.rs diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 7deb379f0..71c9d14df 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -85,12 +85,9 @@ class TagManager: return self.bulk_update(nids, tags, "", False) def rename(self, old: str, new: str) -> OpChangesWithCount: - "Rename provided tag, returning number of changed notes." - nids = self.col.find_notes(anki.collection.SearchNode(tag=old)) - if not nids: - return OpChangesWithCount() - escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old) - return self.bulk_update(nids, escaped_name, new, False) + "Rename provided tag and its children, returning number of changed notes." + x = self.col._backend.rename_tags(current_prefix=old, new_prefix=new) + return x def remove(self, tag: str) -> None: self.col._backend.clear_tag(tag) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 79eb3041d..06d18617e 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -789,7 +789,7 @@ class AnkiQt(QMainWindow): after_hooks() will be called after hooks are fired, if it is provided. Components can use this to ignore change notices generated by operations - they invoke themselves. + they invoke themselves, or perform some subsequent action. """ self._increase_background_ops() diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index 091b93d55..b7a1af72a 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Callable, Optional, Sequence +from anki.collection import OpChangesWithCount from anki.lang import TR from anki.notes import Note from aqt import AnkiQt, QWidget @@ -57,6 +58,27 @@ def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: ) +def rename_tag( + *, + mw: AnkiQt, + parent: QWidget, + current_name: str, + new_name: str, + after_rename: Callable[[], None], +) -> None: + def success(out: OpChangesWithCount) -> None: + if out.count: + tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent) + else: + showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY), parent=parent) + + mw.perform_op( + lambda: mw.col.tags.rename(old=current_name, new=new_name), + success=success, + after_hooks=after_rename, + ) + + def find_and_replace( *, mw: AnkiQt, diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index b3b70447c..86f61cb89 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -19,6 +19,7 @@ from aqt.clayout import CardLayout from aqt.deck_ops import remove_decks from aqt.main import ResetReason from aqt.models import Models +from aqt.note_ops import rename_tag from aqt.qt import * from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( @@ -27,7 +28,6 @@ from aqt.utils import ( askUser, getOnlyText, show_invalid_search_error, - showInfo, showWarning, tooltip, tr, @@ -1217,40 +1217,26 @@ class SidebarTreeView(QTreeView): self.mw.taskman.with_progress(do_remove, on_done) def rename_tag(self, item: SidebarItem, new_name: str) -> None: - new_name = new_name.replace(" ", "") - if new_name and new_name != item.name: - # block repainting until collection is updated - self.setUpdatesEnabled(False) - self.browser.editor.call_after_note_saved( - lambda: self._rename_tag(item, new_name) - ) + if not new_name or new_name == item.name: + return + + new_name_base = new_name - def _rename_tag(self, item: SidebarItem, new_name: str) -> None: old_name = item.full_name new_name = item.name_prefix + new_name - def do_rename() -> int: - self.mw.col.tags.remove(old_name) - return self.col.tags.rename(old_name, new_name).count + item.name = new_name_base - def on_done(fut: Future) -> None: - self.setUpdatesEnabled(True) - self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) - self.browser.model.endReset() - - count = fut.result() - if not count: - showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY)) - else: - tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=count), parent=self) - self.refresh( - lambda item: item.item_type == SidebarItemType.TAG - and item.full_name == new_name - ) - - self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG)) - self.browser.model.beginReset() - self.mw.taskman.with_progress(do_rename, on_done) + rename_tag( + mw=self.mw, + parent=self.browser, + current_name=old_name, + new_name=new_name, + after_rename=lambda: self.refresh( + lambda item: item.item_type == SidebarItemType.TAG + and item.full_name == new_name + ), + ) # Saved searches #################################### diff --git a/rslib/backend.proto b/rslib/backend.proto index d8d1475f5..4bde1021b 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -224,6 +224,7 @@ service TagsService { rpc ClearTag(String) returns (Empty); rpc TagTree(Empty) returns (TagTreeNode); rpc DragDropTags(DragDropTagsIn) returns (Empty); + rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); } service SearchService { @@ -930,6 +931,11 @@ message DragDropTagsIn { string target_tag = 2; } +message RenameTagsIn { + string current_prefix = 1; + string new_prefix = 2; +} + message SetConfigJsonIn { string key = 1; bytes value_json = 2; diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 4c24d38e3..3ba67f116 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -59,4 +59,9 @@ impl TagsService for Backend { self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag)) .map(Into::into) } + + fn rename_tags(&self, input: pb::RenameTagsIn) -> Result { + self.with_col(|col| col.rename_tag(&input.current_prefix, &input.new_prefix)) + .map(Into::into) + } } diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index f0a58a1a3..fe415f8c3 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -49,6 +49,23 @@ pub struct Note { pub(crate) checksum: Option, } +/// Information required for updating tags while leaving note content alone. +/// Tags are stored in their DB form, separated by spaces. +#[derive(Debug, PartialEq, Clone)] +pub(crate) struct NoteTags { + pub id: NoteID, + pub mtime: TimestampSecs, + pub usn: Usn, + pub tags: String, +} + +impl NoteTags { + pub(crate) fn set_modified(&mut self, usn: Usn) { + self.mtime = TimestampSecs::now(); + self.usn = usn; + } +} + impl Note { pub(crate) fn new(notetype: &NoteType) -> Self { Note { diff --git a/rslib/src/notes/undo.rs b/rslib/src/notes/undo.rs index a0e176009..c79ebceda 100644 --- a/rslib/src/notes/undo.rs +++ b/rslib/src/notes/undo.rs @@ -3,6 +3,8 @@ use crate::{prelude::*, undo::UndoableChange}; +use super::NoteTags; + #[derive(Debug)] pub(crate) enum UndoableNoteChange { Added(Box), @@ -10,6 +12,7 @@ pub(crate) enum UndoableNoteChange { Removed(Box), GraveAdded(Box<(NoteID, Usn)>), GraveRemoved(Box<(NoteID, Usn)>), + TagsUpdated(Box), } impl Collection { @@ -26,6 +29,13 @@ impl Collection { UndoableNoteChange::Removed(note) => self.restore_deleted_note(*note), UndoableNoteChange::GraveAdded(e) => self.remove_note_grave(e.0, e.1), UndoableNoteChange::GraveRemoved(e) => self.add_note_grave(e.0, e.1), + UndoableNoteChange::TagsUpdated(note_tags) => { + let current = self + .storage + .get_note_tags_by_id(note_tags.id)? + .ok_or_else(|| AnkiError::invalid_input("note disappeared"))?; + self.update_note_tags_undoable(¬e_tags, current) + } } } @@ -81,6 +91,15 @@ impl Collection { Ok(()) } + pub(crate) fn update_note_tags_undoable( + &mut self, + tags: &NoteTags, + original: NoteTags, + ) -> Result<()> { + self.save_undo(UndoableNoteChange::TagsUpdated(Box::new(original))); + self.storage.update_note_tags(tags) + } + fn remove_note_without_grave(&mut self, note: Note) -> Result<()> { self.storage.remove_note(note.id)?; self.save_undo(UndoableNoteChange::Removed(Box::new(note))); diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 91f9c785a..d6466c3c8 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -14,6 +14,7 @@ pub enum Op { RemoveDeck, RemoveNote, RenameDeck, + RenameTag, ScheduleAsNew, SetDeck, SetDueDate, @@ -52,6 +53,7 @@ impl Op { Op::FindAndReplace => TR::BrowsingFindAndReplace, Op::ClearUnusedTags => TR::BrowsingClearUnusedTags, Op::SortCards => TR::BrowsingReschedule, + Op::RenameTag => TR::ActionsRenameTag, }; i18n.tr(key).to_string() diff --git a/rslib/src/storage/note/get_tags.sql b/rslib/src/storage/note/get_tags.sql new file mode 100644 index 000000000..5bcd2be0e --- /dev/null +++ b/rslib/src/storage/note/get_tags.sql @@ -0,0 +1,5 @@ +SELECT id, + mod, + usn, + tags +FROM notes \ No newline at end of file diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index 9ea67cf0d..40a44194c 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use crate::{ err::Result, - notes::{Note, NoteID}, + notes::{Note, NoteID, NoteTags}, notetype::NoteTypeID, tags::{join_tags, split_tags}, timestamp::TimestampMillis, @@ -189,4 +189,49 @@ impl super::SqliteStorage { Ok(()) } + + pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteID) -> Result> { + self.db + .prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))? + .query_and_then(&[note_id], |row| -> Result<_> { + { + Ok(NoteTags { + id: row.get(0)?, + mtime: row.get(1)?, + usn: row.get(2)?, + tags: row.get(3)?, + }) + } + })? + .next() + .transpose() + } + + pub(crate) fn get_note_tags_by_predicate(&mut self, want: F) -> Result> + where + F: Fn(&str) -> bool, + { + let mut query_stmt = self.db.prepare_cached(include_str!("get_tags.sql"))?; + let mut rows = query_stmt.query(NO_PARAMS)?; + let mut output = vec![]; + while let Some(row) = rows.next()? { + let tags = row.get_raw(3).as_str()?; + if want(tags) { + output.push(NoteTags { + id: row.get(0)?, + mtime: row.get(1)?, + usn: row.get(2)?, + tags: tags.to_owned(), + }) + } + } + Ok(output) + } + + pub(crate) fn update_note_tags(&mut self, note: &NoteTags) -> Result<()> { + self.db + .prepare_cached(include_str!("update_tags.sql"))? + .execute(params![note.mtime, note.usn, note.tags, note.id])?; + Ok(()) + } } diff --git a/rslib/src/storage/note/update_tags.sql b/rslib/src/storage/note/update_tags.sql new file mode 100644 index 000000000..9bbfc13c2 --- /dev/null +++ b/rslib/src/storage/note/update_tags.sql @@ -0,0 +1,5 @@ +UPDATE notes +SET mod = ?, + usn = ?, + tags = ? +WHERE id = ? \ No newline at end of file diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index c2aac651e..ce300c522 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -65,8 +65,6 @@ impl SqliteStorage { .map_err(Into::into) } - // for undo in the future - #[allow(dead_code)] pub(crate) fn get_tag_and_children(&self, name: &str) -> Result> { self.db .prepare_cached("select tag, usn, collapsed from tags where tag regexp ?")? diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 553069db0..2a0fa4238 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -1,6 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod prefix_replacer; pub(crate) mod undo; use crate::{ @@ -13,6 +14,7 @@ use crate::{ types::Usn, }; +use prefix_replacer::PrefixReplacer; use regex::{NoExpand, Regex, Replacer}; use std::{borrow::Cow, collections::HashSet, iter::Peekable}; use unicase::UniCase; @@ -231,6 +233,21 @@ impl Collection { /// In the case the tag is already registered, tag will be mutated to match the existing /// name. pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result { + let is_new = self.prepare_tag_for_registering(tag)?; + if is_new { + self.register_tag_undoable(&tag)?; + } + Ok(is_new) + } + + fn register_tag_string(&mut self, tag: String, usn: Usn) -> Result { + let mut tag = Tag::new(tag, usn); + self.register_tag(&mut tag) + } + + /// Create a tag object, normalize text, and match parents/existing case if available. + /// True if tag is new. + fn prepare_tag_for_registering(&self, tag: &mut Tag) -> Result { let normalized_name = normalize_tag_name(&tag.name); if normalized_name.is_empty() { // this should not be possible @@ -245,7 +262,6 @@ impl Collection { } else if let Cow::Owned(new_name) = normalized_name { tag.name = new_name; } - self.register_tag_undoable(&tag)?; Ok(true) } } @@ -446,18 +462,7 @@ impl Collection { .iter() // convert the names into regexps/replacements .map(|(tag, output)| { - Regex::new(&format!( - r#"(?ix) - ^ - {} - # optional children - (::.+)? - $ - "#, - regex::escape(tag) - )) - .map_err(|_| AnkiError::invalid_input("invalid regex")) - .map(|regex| (regex, output)) + regex_matching_tag_and_children_in_single_tag(tag).map(|regex| (regex, output)) }) .collect::>>()?; @@ -505,6 +510,82 @@ impl Collection { Ok(()) } + + /// Rename a given tag and its children on all notes that reference it, returning changed + /// note count. + pub fn rename_tag(&mut self, old_prefix: &str, new_prefix: &str) -> Result> { + self.transact(Op::RenameTag, |col| { + col.rename_tag_inner(old_prefix, new_prefix) + }) + } + + fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result { + if new_prefix.contains(' ') { + return Err(AnkiError::invalid_input( + "replacement name can not contain a space", + )); + } + if new_prefix.trim().is_empty() { + return Err(AnkiError::invalid_input( + "replacement name must not be empty", + )); + } + + let usn = self.usn()?; + + // match existing case if available, and ensure normalized. + let mut tag = Tag::new(new_prefix.to_string(), usn); + self.prepare_tag_for_registering(&mut tag)?; + let new_prefix = &tag.name; + + // gather tags that need replacing + let mut re = PrefixReplacer::new(old_prefix)?; + let matched_notes = self + .storage + .get_note_tags_by_predicate(|tags| re.is_match(tags))?; + let match_count = matched_notes.len(); + if match_count == 0 { + // no matches; exit early so we don't clobber the empty tag entries + return Ok(0); + } + + // remove old prefix from the tag list + for tag in self.storage.get_tag_and_children(old_prefix)? { + self.remove_single_tag_undoable(tag)?; + } + + // replace tags + for mut note in matched_notes { + let original = note.clone(); + note.tags = re.replace(¬e.tags, new_prefix); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + // update tag list + for tag in re.into_seen_tags() { + self.register_tag_string(tag, usn)?; + } + + Ok(match_count) + } +} + +// fixme: merge with prefixmatcher + +/// A regex that will match a string tag that has been split from a list. +fn regex_matching_tag_and_children_in_single_tag(tag: &str) -> Result { + Regex::new(&format!( + r#"(?ix) + ^ + {} + # optional children + (::.+)? + $ + "#, + regex::escape(tag) + )) + .map_err(Into::into) } #[cfg(test)] diff --git a/rslib/src/tags/prefix_replacer.rs b/rslib/src/tags/prefix_replacer.rs new file mode 100644 index 000000000..a14ab4a77 --- /dev/null +++ b/rslib/src/tags/prefix_replacer.rs @@ -0,0 +1,119 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use regex::{Captures, Regex}; +use std::{borrow::Cow, collections::HashSet}; + +use super::{join_tags, split_tags}; +use crate::prelude::*; +pub(crate) struct PrefixReplacer { + regex: Regex, + seen_tags: HashSet, +} + +/// Helper to match any of the provided space-separated tags in a space- +/// separated list of tags, and replace the prefix. +/// +/// Tracks seen tags during replacement, so the tag list can be updated as well. +impl PrefixReplacer { + pub fn new(space_separated_tags: &str) -> Result { + // convert "fo*o bar" into "fo\*o|bar" + let tags: Vec<_> = split_tags(space_separated_tags) + .map(regex::escape) + .collect(); + let tags = tags.join("|"); + + let regex = Regex::new(&format!( + r#"(?ix) + # start of string, or a space + (?:^|\ ) + # 1: the tag prefix + ( + {} + ) + (?: + # 2: an optional child separator + (::) + # or a space/end of string the end of the string + |\ |$ + ) + "#, + tags + ))?; + + Ok(Self { + regex, + seen_tags: HashSet::new(), + }) + } + + pub fn is_match(&self, space_separated_tags: &str) -> bool { + self.regex.is_match(space_separated_tags) + } + + pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String { + let tags: Vec<_> = split_tags(space_separated_tags) + .map(|tag| { + self.regex + .replace(tag, |caps: &Captures| { + // if we captured the child separator, add it to the replacement + if caps.get(2).is_some() { + Cow::Owned(format!("{}::", replacement)) + } else { + Cow::Borrowed(replacement) + } + }) + .to_string() + }) + .collect(); + + for tag in &tags { + // sadly HashSet doesn't have an entry API at the moment + if !self.seen_tags.contains(tag) { + self.seen_tags.insert(tag.clone()); + } + } + + join_tags(tags.as_slice()) + } + + pub fn into_seen_tags(self) -> HashSet { + self.seen_tags + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn regex() -> Result<()> { + let re = PrefixReplacer::new("one two")?; + assert_eq!(re.is_match(" foo "), false); + assert_eq!(re.is_match(" foo one "), true); + assert_eq!(re.is_match(" two foo "), true); + + let mut re = PrefixReplacer::new("foo")?; + assert_eq!(re.is_match("foo"), true); + assert_eq!(re.is_match(" foo "), true); + assert_eq!(re.is_match(" bar foo baz "), true); + assert_eq!(re.is_match(" bar FOO baz "), true); + assert_eq!(re.is_match(" bar foof baz "), false); + assert_eq!(re.is_match(" barfoo "), false); + + let mut as_xxx = |text| re.replace(text, "xxx"); + + assert_eq!(&as_xxx(" baz FOO "), " baz xxx "); + assert_eq!(&as_xxx(" x foo::bar x "), " x xxx::bar x "); + assert_eq!( + &as_xxx(" x foo::bar bar::foo x "), + " x xxx::bar bar::foo x " + ); + assert_eq!( + &as_xxx(" x foo::bar foo::bar::baz x "), + " x xxx::bar xxx::bar::baz x " + ); + + Ok(()) + } +} diff --git a/rslib/src/tags/undo.rs b/rslib/src/tags/undo.rs index 3775dcfb2..49062a881 100644 --- a/rslib/src/tags/undo.rs +++ b/rslib/src/tags/undo.rs @@ -17,13 +17,15 @@ impl Collection { UndoableTagChange::Removed(tag) => self.register_tag_undoable(&tag), } } - /// Adds an already-validated tag to the DB and undo list. + /// Adds an already-validated tag to the tag list, saving an undo entry. /// Caller is responsible for setting usn. pub(super) fn register_tag_undoable(&mut self, tag: &Tag) -> Result<()> { self.save_undo(UndoableTagChange::Added(Box::new(tag.clone()))); self.storage.register_tag(&tag) } + /// Remove a single tag from the tag list, saving an undo entry. Does not alter notes. + /// FIXME: caller will need to update usn when we make tags incrementally syncable. pub(super) fn remove_single_tag_undoable(&mut self, tag: Tag) -> Result<()> { self.storage.remove_single_tag(&tag.name)?; self.save_undo(UndoableTagChange::Removed(Box::new(tag))); From 09076da937d25f779bc1ddec8a40402dabd14da0 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 18 Mar 2021 21:35:32 +1000 Subject: [PATCH 26/33] make tag deletion undoable, and speed it up - ~4x faster than before on tag tree with 30k notes - remove the separate clear_tag() backend method --- pylib/anki/tags.py | 32 ++++++++-------- qt/aqt/browser.py | 4 +- qt/aqt/note_ops.py | 13 ++++++- qt/aqt/reviewer.py | 6 ++- qt/aqt/sidebar.py | 23 ++++------- rslib/backend.proto | 3 +- rslib/src/backend/tags.rs | 13 +------ rslib/src/notes/mod.rs | 6 --- rslib/src/ops.rs | 2 + rslib/src/storage/tag/get.sql | 4 ++ rslib/src/storage/tag/mod.rs | 36 +++++++++-------- rslib/src/tags/mod.rs | 64 +++++++++++++++---------------- rslib/src/tags/prefix_replacer.rs | 10 +++++ 13 files changed, 113 insertions(+), 103 deletions(-) create mode 100644 rslib/src/storage/tag/get.sql diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 71c9d14df..2726e6a7c 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -65,7 +65,7 @@ class TagManager: "Set browser expansion state for tag, registering the tag if missing." self.col._backend.set_tag_expanded(name=tag, expanded=expanded) - # Bulk addition/removal from notes + # Bulk addition/removal from specific notes ############################################################# def bulk_add(self, nids: Sequence[int], tags: str) -> OpChangesWithCount: @@ -84,31 +84,23 @@ class TagManager: def bulk_remove(self, nids: Sequence[int], tags: str) -> OpChangesWithCount: return self.bulk_update(nids, tags, "", False) + # Bulk addition/removal based on tag + ############################################################# + def rename(self, old: str, new: str) -> OpChangesWithCount: "Rename provided tag and its children, returning number of changed notes." x = self.col._backend.rename_tags(current_prefix=old, new_prefix=new) return x - def remove(self, tag: str) -> None: - self.col._backend.clear_tag(tag) + def remove(self, space_separated_tags: str) -> OpChangesWithCount: + "Remove the provided tag(s) and their children from notes and the tag list." + return self.col._backend.remove_tags(val=space_separated_tags) def drag_drop(self, source_tags: List[str], target_tag: str) -> None: """Rename one or more source tags that were dropped on `target_tag`. If target_tag is "", tags will be placed at the top level.""" self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag) - # legacy routines - - def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None: - "Add tags in bulk. TAGS is space-separated." - if add: - self.bulk_add(ids, tags) - else: - self.bulk_update(ids, tags, "", False) - - def bulkRem(self, ids: List[int], tags: str) -> None: - self.bulkAdd(ids, tags, False) - # String-based utilities ########################################################################## @@ -170,3 +162,13 @@ class TagManager: self, tags: Collection[str], usn: Optional[int] = None, clear: bool = False ) -> None: print("tags.register() is deprecated and no longer works") + + def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None: + "Add tags in bulk. TAGS is space-separated." + if add: + self.bulk_add(ids, tags) + else: + self.bulk_update(ids, tags, "", False) + + def bulkRem(self, ids: List[int], tags: str) -> None: + self.bulkAdd(ids, tags, False) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 6011ce381..16e3ac27d 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -31,7 +31,7 @@ from aqt.note_ops import ( clear_unused_tags, find_and_replace, remove_notes, - remove_tags, + remove_tags_for_notes, ) from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer @@ -1259,7 +1259,7 @@ where id in %s""" tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_DELETE)) ): return - remove_tags( + remove_tags_for_notes( mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags ) diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index b7a1af72a..49f962555 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -43,7 +43,7 @@ def add_tags(*, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str) mw.perform_op(lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags)) -def remove_tags( +def remove_tags_for_notes( *, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str ) -> None: mw.perform_op(lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags)) @@ -79,6 +79,17 @@ def rename_tag( ) +def remove_tags_for_all_notes( + *, mw: AnkiQt, parent: QWidget, space_separated_tags: str +) -> None: + mw.perform_op( + lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent + ), + ) + + def find_and_replace( *, mw: AnkiQt, diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 088e6dbb5..4dc6c00f5 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -19,7 +19,7 @@ from anki.tags import MARKED_TAG from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.card_ops import set_card_flag -from aqt.note_ops import add_tags, remove_notes, remove_tags +from aqt.note_ops import add_tags, remove_notes, remove_tags_for_notes from aqt.profiles import VideoDriver from aqt.qt import * from aqt.scheduling_ops import ( @@ -847,7 +847,9 @@ time = %(time)d; def toggle_mark_on_current_note(self) -> None: note = self.card.note() if note.has_tag(MARKED_TAG): - remove_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG) + remove_tags_for_notes( + mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG + ) else: add_tags(mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 86f61cb89..9e0637f4e 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -19,7 +19,7 @@ from aqt.clayout import CardLayout from aqt.deck_ops import remove_decks from aqt.main import ResetReason from aqt.models import Models -from aqt.note_ops import rename_tag +from aqt.note_ops import remove_tags_for_all_notes, rename_tag from aqt.qt import * from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( @@ -29,7 +29,6 @@ from aqt.utils import ( getOnlyText, show_invalid_search_error, showWarning, - tooltip, tr, ) @@ -1200,21 +1199,13 @@ class SidebarTreeView(QTreeView): # Tags ########################### - def remove_tags(self, _item: SidebarItem) -> None: - tags = self._selected_tags() + def remove_tags(self, item: SidebarItem) -> None: + tags = self.mw.col.tags.join(self._selected_tags()) + item.name = "..." - def do_remove() -> int: - return self.col._backend.expunge_tags(" ".join(tags)) - - def on_done(fut: Future) -> None: - self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self) - self.browser.model.endReset() - tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=fut.result()), parent=self) - self.refresh() - - self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG)) - self.browser.model.beginReset() - self.mw.taskman.with_progress(do_remove, on_done) + remove_tags_for_all_notes( + mw=self.mw, parent=self.browser, space_separated_tags=tags + ) def rename_tag(self, item: SidebarItem, new_name: str) -> None: if not new_name or new_name == item.name: diff --git a/rslib/backend.proto b/rslib/backend.proto index 4bde1021b..20dad19d7 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -219,9 +219,8 @@ service DeckConfigService { service TagsService { rpc ClearUnusedTags(Empty) returns (OpChangesWithCount); rpc AllTags(Empty) returns (StringList); - rpc ExpungeTags(String) returns (UInt32); + rpc RemoveTags(String) returns (OpChangesWithCount); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); - rpc ClearTag(String) returns (Empty); rpc TagTree(Empty) returns (TagTreeNode); rpc DragDropTags(DragDropTagsIn) returns (Empty); rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 3ba67f116..f3e6e09a4 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -23,8 +23,8 @@ impl TagsService for Backend { }) } - fn expunge_tags(&self, tags: pb::String) -> Result { - self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into)) + fn remove_tags(&self, tags: pb::String) -> Result { + self.with_col(|col| col.remove_tags(tags.val.as_str()).map(Into::into)) } fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result { @@ -36,15 +36,6 @@ impl TagsService for Backend { }) } - fn clear_tag(&self, tag: pb::String) -> Result { - self.with_col(|col| { - col.transact_no_undo(|col| { - col.storage.clear_tag_and_children(tag.val.as_str())?; - Ok(().into()) - }) - }) - } - fn tag_tree(&self, _input: pb::Empty) -> Result { self.with_col(|col| col.tag_tree()) } diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index fe415f8c3..4feeec601 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -210,12 +210,6 @@ impl Note { .collect() } - pub(crate) fn remove_tags(&mut self, re: &Regex) -> bool { - let old_len = self.tags.len(); - self.tags.retain(|tag| !re.is_match(tag)); - old_len > self.tags.len() - } - pub(crate) fn replace_tags(&mut self, re: &Regex, mut repl: T) -> bool { let mut changed = false; for tag in &mut self.tags { diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index d6466c3c8..dd5208d00 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -15,6 +15,7 @@ pub enum Op { RemoveNote, RenameDeck, RenameTag, + RemoveTag, ScheduleAsNew, SetDeck, SetDueDate, @@ -54,6 +55,7 @@ impl Op { Op::ClearUnusedTags => TR::BrowsingClearUnusedTags, Op::SortCards => TR::BrowsingReschedule, Op::RenameTag => TR::ActionsRenameTag, + Op::RemoveTag => TR::ActionsRemoveTag, }; i18n.tr(key).to_string() diff --git a/rslib/src/storage/tag/get.sql b/rslib/src/storage/tag/get.sql new file mode 100644 index 000000000..3bc48697c --- /dev/null +++ b/rslib/src/storage/tag/get.sql @@ -0,0 +1,4 @@ +SELECT tag, + usn, + collapsed +FROM tags \ No newline at end of file diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index ce300c522..097da8f31 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -19,7 +19,7 @@ impl SqliteStorage { /// All tags in the collection, in alphabetical order. pub(crate) fn all_tags(&self) -> Result> { self.db - .prepare_cached("select tag, usn, collapsed from tags")? + .prepare_cached(include_str!("get.sql"))? .query_and_then(NO_PARAMS, row_to_tag)? .collect() } @@ -43,7 +43,7 @@ impl SqliteStorage { pub(crate) fn get_tag(&self, name: &str) -> Result> { self.db - .prepare_cached("select tag, usn, collapsed from tags where tag = ?")? + .prepare_cached(&format!("{} where tag = ?", include_str!("get.sql")))? .query_and_then(&[name], row_to_tag)? .next() .transpose() @@ -65,11 +65,24 @@ impl SqliteStorage { .map_err(Into::into) } - pub(crate) fn get_tag_and_children(&self, name: &str) -> Result> { - self.db - .prepare_cached("select tag, usn, collapsed from tags where tag regexp ?")? - .query_and_then(&[format!("(?i)^{}($|::)", regex::escape(name))], row_to_tag)? - .collect() + pub(crate) fn get_tags_by_predicate(&self, want: F) -> Result> + where + F: Fn(&str) -> bool, + { + let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?; + let mut rows = query_stmt.query(NO_PARAMS)?; + let mut output = vec![]; + while let Some(row) = rows.next()? { + let tag = row.get_raw(0).as_str()?; + if want(tag) { + output.push(Tag { + name: tag.to_owned(), + usn: row.get(1)?, + expanded: !row.get(2)?, + }) + } + } + Ok(output) } pub(crate) fn remove_single_tag(&self, tag: &str) -> Result<()> { @@ -88,15 +101,6 @@ impl SqliteStorage { Ok(()) } - /// Clear all matching tags where tag_group is a regexp group that should not match whitespace. - pub(crate) fn clear_tag_group(&self, tag_group: &str) -> Result<()> { - self.db - .prepare_cached("delete from tags where tag regexp ?")? - .execute(&[format!("(?i)^{}($|::)", tag_group)])?; - - Ok(()) - } - pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> { self.db .prepare_cached("update tags set collapsed = ? where tag = ?")? diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 2a0fa4238..e4d87e576 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -313,37 +313,6 @@ impl Collection { Ok(count) } - /// Take tags as a whitespace-separated string and remove them from all notes and the storage. - pub fn expunge_tags(&mut self, tags: &str) -> Result { - let tag_group = format!("({})", regex::escape(tags.trim()).replace(' ', "|")); - let nids = self.nids_for_tags(&tag_group)?; - let re = Regex::new(&format!("(?i)^{}(::.*)?$", tag_group))?; - self.transact_no_undo(|col| { - col.storage.clear_tag_group(&tag_group)?; - col.transform_notes(&nids, |note, _nt| { - Ok(TransformNoteOutput { - changed: note.remove_tags(&re), - generate_cards: false, - mark_modified: true, - }) - }) - }) - } - - /// Take tags as a regexp group, i.e. separated with pipes and wrapped in brackets, and return - /// the ids of all notes with one of them. - fn nids_for_tags(&mut self, tag_group: &str) -> Result> { - let mut stmt = self - .storage - .db - .prepare("select id from notes where tags regexp ?")?; - let args = format!("(?i).* {}(::| ).*", tag_group); - let nids = stmt - .query_map(&[args], |row| row.get(0))? - .collect::>()?; - Ok(nids) - } - pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> { let mut name = name; let tag; @@ -550,7 +519,7 @@ impl Collection { } // remove old prefix from the tag list - for tag in self.storage.get_tag_and_children(old_prefix)? { + for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { self.remove_single_tag_undoable(tag)?; } @@ -569,6 +538,37 @@ impl Collection { Ok(match_count) } + + /// Take tags as a whitespace-separated string and remove them from all notes and the tag list. + pub fn remove_tags(&mut self, tags: &str) -> Result> { + self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags)) + } + + fn remove_tags_inner(&mut self, tags: &str) -> Result { + let usn = self.usn()?; + + // gather tags that need removing + let mut re = PrefixReplacer::new(tags)?; + let matched_notes = self + .storage + .get_note_tags_by_predicate(|tags| re.is_match(tags))?; + let match_count = matched_notes.len(); + + // remove from the tag list + for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { + self.remove_single_tag_undoable(tag)?; + } + + // replace tags + for mut note in matched_notes { + let original = note.clone(); + note.tags = re.remove(¬e.tags); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + Ok(match_count) + } } // fixme: merge with prefixmatcher diff --git a/rslib/src/tags/prefix_replacer.rs b/rslib/src/tags/prefix_replacer.rs index a14ab4a77..e2edb4369 100644 --- a/rslib/src/tags/prefix_replacer.rs +++ b/rslib/src/tags/prefix_replacer.rs @@ -77,6 +77,16 @@ impl PrefixReplacer { join_tags(tags.as_slice()) } + /// Remove any matching tags. Does not update seen_tags. + pub fn remove(&mut self, space_separated_tags: &str) -> String { + let tags: Vec<_> = split_tags(space_separated_tags) + .filter(|&tag| !self.is_match(tag)) + .map(ToString::to_string) + .collect(); + + join_tags(tags.as_slice()) + } + pub fn into_seen_tags(self) -> HashSet { self.seen_tags } From 05876f1299fb7751b51c4b821e8562c28ece469a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 00:06:54 +1000 Subject: [PATCH 27/33] cache card list cell content Qt is pretty enthusiastic about redrawing the card list when any sort of activity occurs, and by serving blank cells while the DB was busy, we were getting ugly flashes, and cells getting stuck blank. Resolve the issue by calculating a row up front and caching it, then serving stale content when updates are blocked. --- qt/aqt/browser.py | 263 ++++++++++++++++++++++++++++++++-------------- qt/aqt/main.py | 2 + 2 files changed, 185 insertions(+), 80 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 16e3ac27d..fffcfc91e 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -6,7 +6,7 @@ from __future__ import annotations import html import time from concurrent.futures import Future -from dataclasses import dataclass +from dataclasses import dataclass, field from operator import itemgetter from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast @@ -98,6 +98,25 @@ class SearchContext: # Data model ########################################################################## +# temporary cache to avoid hitting the database on redraw +@dataclass +class Cell: + text: str = "" + font: Optional[Tuple[str, int]] = None + is_rtl: bool = False + + +@dataclass +class CellRow: + columns: List[Cell] + refreshed_at: float = field(default_factory=time.time) + card_flag: int = 0 + marked: bool = False + suspended: bool = False + + def is_stale(self, threshold: float) -> bool: + return self.refreshed_at < threshold + class DataModel(QAbstractTableModel): def __init__(self, browser: Browser) -> None: @@ -110,11 +129,17 @@ class DataModel(QAbstractTableModel): ) self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} - self._refresh_needed = False + self._row_cache: Dict[int, CellRow] = {} + self._last_refresh = 0.0 + # serve stale content to avoid hitting the DB? self.block_updates = False def getCard(self, index: QModelIndex) -> Optional[Card]: - id = self.cards[index.row()] + return self._get_card_by_row(index.row()) + + def _get_card_by_row(self, row: int) -> Optional[Card]: + "None if card is not in DB." + id = self.cards[row] if not id in self.cardObjs: try: card = self.col.getCard(id) @@ -124,6 +149,53 @@ class DataModel(QAbstractTableModel): self.cardObjs[id] = card return self.cardObjs[id] + # Card and cell data cache + ###################################################################### + # Stopgap until we can fetch this data a row at a time from Rust. + + def get_cell(self, index: QModelIndex) -> Cell: + row = self.get_row(index.row()) + return row.columns[index.column()] + + def get_row(self, row: int) -> CellRow: + if entry := self._row_cache.get(row): + if not self.block_updates and entry.is_stale(self._last_refresh): + # need to refresh + entry = self._build_cell_row(row) + self._row_cache[row] = entry + return entry + else: + # return entry, even if it's stale + return entry + elif self.block_updates: + # blank entry until we unblock + return CellRow(columns=[Cell(text="blocked")] * len(self.activeCols)) + else: + # missing entry, need to build + entry = self._build_cell_row(row) + self._row_cache[row] = entry + return entry + + def _build_cell_row(self, row: int) -> CellRow: + if not (card := self._get_card_by_row(row)): + cell = Cell(text=tr(TR.BROWSING_ROW_DELETED)) + return CellRow(columns=[cell] * len(self.activeCols)) + + return CellRow( + columns=[ + Cell( + text=self._column_data(card, column_type), + font=self._font(card, column_type), + is_rtl=self._is_rtl(card, column_type), + ) + for column_type in self.activeCols + ], + # should probably make these an enum instead? + card_flag=card.user_flag(), + marked=card.note().has_tag(MARKED_TAG), + suspended=card.queue == QUEUE_TYPE_SUSPENDED, + ) + # Model interface ###################################################################### @@ -138,23 +210,16 @@ class DataModel(QAbstractTableModel): return len(self.activeCols) def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: - if self.block_updates: - return if not index.isValid(): return if role == Qt.FontRole: - if self.activeCols[index.column()] not in ("question", "answer", "noteFld"): - return - c = self.getCard(index) - if not c: - return - t = c.template() - if not t.get("bfont"): - return - f = QFont() - f.setFamily(cast(str, t.get("bfont", "arial"))) - f.setPixelSize(cast(int, t.get("bsize", 12))) - return f + if font := self.get_cell(index).font: + qfont = QFont() + qfont.setFamily(font[0]) + qfont.setPixelSize(font[1]) + return qfont + else: + return None elif role == Qt.TextAlignmentRole: align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter @@ -170,7 +235,7 @@ class DataModel(QAbstractTableModel): align |= Qt.AlignHCenter return align elif role == Qt.DisplayRole or role == Qt.EditRole: - return self.columnData(index) + return self.get_cell(index).text else: return @@ -220,7 +285,7 @@ class DataModel(QAbstractTableModel): return top_left = self.index(0, 0) bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1) - self.cardObjs = {} + self._last_refresh = time.time() self.dataChanged.emit(top_left, bottom_right) # type: ignore def reset(self) -> None: @@ -234,6 +299,7 @@ class DataModel(QAbstractTableModel): self.saveSelection() self.beginResetModel() self.cardObjs = {} + self._row_cache = {} def endReset(self) -> None: self.endResetModel() @@ -305,15 +371,19 @@ class DataModel(QAbstractTableModel): tv.selectRow(0) def op_executed(self, op: OpChanges, focused: bool) -> None: + print("op executed") if op.card or op.note or op.deck or op.notetype: - self._refresh_needed = True + # clear card cache + self.cardObjs = {} if focused: - self.refresh_if_needed() - - def refresh_if_needed(self) -> None: - if self._refresh_needed: self.redraw_cells() - self._refresh_needed = False + + def begin_blocking(self) -> None: + self.block_updates = True + + def end_blocking(self) -> None: + self.block_updates = False + self.redraw_cells() # Column data ###################################################################### @@ -324,66 +394,87 @@ class DataModel(QAbstractTableModel): def time_format(self) -> str: return "%Y-%m-%d" + def _font(self, card: Card, column_type: str) -> Optional[Tuple[str, int]]: + if column_type not in ("question", "answer", "noteFld"): + return None + + template = card.template() + if not template.get("bfont"): + return None + + return ( + cast(str, template.get("bfont", "arial")), + cast(int, template.get("bsize", 12)), + ) + + # legacy def columnData(self, index: QModelIndex) -> str: col = index.column() type = self.columnType(col) c = self.getCard(index) if not c: return tr(TR.BROWSING_ROW_DELETED) + else: + return self._column_data(c, type) + + def _column_data(self, card: Card, column_type: str) -> str: + type = column_type if type == "question": - return self.question(c) + return self.question(card) elif type == "answer": - return self.answer(c) + return self.answer(card) elif type == "noteFld": - f = c.note() + f = card.note() return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())]) elif type == "template": - t = c.template()["name"] - if c.model()["type"] == MODEL_CLOZE: - t = f"{t} {c.ord + 1}" + t = card.template()["name"] + if card.model()["type"] == MODEL_CLOZE: + t = f"{t} {card.ord + 1}" return cast(str, t) elif type == "cardDue": # catch invalid dates try: - t = self.nextDue(c, index) + t = self._next_due(card) except: t = "" - if c.queue < 0: + if card.queue < 0: t = f"({t})" return t elif type == "noteCrt": - return time.strftime(self.time_format(), time.localtime(c.note().id / 1000)) + return time.strftime( + self.time_format(), time.localtime(card.note().id / 1000) + ) elif type == "noteMod": - return time.strftime(self.time_format(), time.localtime(c.note().mod)) + return time.strftime(self.time_format(), time.localtime(card.note().mod)) elif type == "cardMod": - return time.strftime(self.time_format(), time.localtime(c.mod)) + return time.strftime(self.time_format(), time.localtime(card.mod)) elif type == "cardReps": - return str(c.reps) + return str(card.reps) elif type == "cardLapses": - return str(c.lapses) + return str(card.lapses) elif type == "noteTags": - return " ".join(c.note().tags) + return " ".join(card.note().tags) elif type == "note": - return c.model()["name"] + return card.model()["name"] elif type == "cardIvl": - if c.type == CARD_TYPE_NEW: + if card.type == CARD_TYPE_NEW: return tr(TR.BROWSING_NEW) - elif c.type == CARD_TYPE_LRN: + elif card.type == CARD_TYPE_LRN: return tr(TR.BROWSING_LEARNING) - return self.col.format_timespan(c.ivl * 86400) + return self.col.format_timespan(card.ivl * 86400) elif type == "cardEase": - if c.type == CARD_TYPE_NEW: + if card.type == CARD_TYPE_NEW: return tr(TR.BROWSING_NEW) - return "%d%%" % (c.factor / 10) + return "%d%%" % (card.factor / 10) elif type == "deck": - if c.odid: + if card.odid: # in a cram deck return "%s (%s)" % ( - self.browser.mw.col.decks.name(c.did), - self.browser.mw.col.decks.name(c.odid), + self.browser.mw.col.decks.name(card.did), + self.browser.mw.col.decks.name(card.odid), ) # normal deck - return self.browser.mw.col.decks.name(c.did) + return self.browser.mw.col.decks.name(card.did) else: return "" @@ -402,30 +493,38 @@ class DataModel(QAbstractTableModel): return a[len(q) :].strip() return a + # legacy def nextDue(self, c: Card, index: QModelIndex) -> str: + return self._next_due(c) + + def _next_due(self, card: Card) -> str: date: float - if c.odid: + if card.odid: return tr(TR.BROWSING_FILTERED) - elif c.queue == QUEUE_TYPE_LRN: - date = c.due - elif c.queue == QUEUE_TYPE_NEW or c.type == CARD_TYPE_NEW: - return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=c.due) - elif c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or ( - c.type == CARD_TYPE_REV and c.queue < 0 + elif card.queue == QUEUE_TYPE_LRN: + date = card.due + elif card.queue == QUEUE_TYPE_NEW or card.type == CARD_TYPE_NEW: + return tr(TR.STATISTICS_DUE_FOR_NEW_CARD, number=card.due) + elif card.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN) or ( + card.type == CARD_TYPE_REV and card.queue < 0 ): - date = time.time() + ((c.due - self.col.sched.today) * 86400) + date = time.time() + ((card.due - self.col.sched.today) * 86400) else: return "" return time.strftime(self.time_format(), time.localtime(date)) + # legacy def isRTL(self, index: QModelIndex) -> bool: col = index.column() type = self.columnType(col) - if type != "noteFld": + c = self.getCard(index) + return self._is_rtl(c, type) + + def _is_rtl(self, card: Card, column_type: str) -> bool: + if column_type != "noteFld": return False - c = self.getCard(index) - nt = c.note().model() + nt = card.note().model() return nt["flds"][self.col.models.sortIdx(nt)]["rtl"] @@ -442,25 +541,23 @@ class StatusDelegate(QItemDelegate): def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: - if self.model.block_updates: - return QItemDelegate.paint(self, painter, option, index) + row = self.model.get_row(index.row()) + cell = row.columns[index.column()] - c = self.model.getCard(index) - if not c: - return QItemDelegate.paint(self, painter, option, index) - - if self.model.isRTL(index): + if cell.is_rtl: option.direction = Qt.RightToLeft - col = None - if c.user_flag() > 0: - col = getattr(colors, f"FLAG{c.user_flag()}_BG") - elif c.note().has_tag(MARKED_TAG): - col = colors.MARKED_BG - elif c.queue == QUEUE_TYPE_SUSPENDED: - col = colors.SUSPENDED_BG - if col: - brush = QBrush(theme_manager.qcolor(col)) + if row.card_flag: + color = getattr(colors, f"FLAG{row.card_flag}_BG") + elif row.marked: + color = colors.MARKED_BG + elif row.suspended: + color = colors.SUSPENDED_BG + else: + color = None + + if color: + brush = QBrush(theme_manager.qcolor(color)) painter.save() painter.fillRect(option.rect, brush) painter.restore() @@ -519,10 +616,10 @@ class Browser(QMainWindow): def on_backend_will_block(self) -> None: # make sure the card list doesn't try to refresh itself during the operation, # as that will block the UI - self.model.block_updates = True + self.model.begin_blocking() def on_backend_did_block(self) -> None: - self.model.block_updates = False + self.model.end_blocking() def on_operation_did_execute(self, changes: OpChanges) -> None: focused = current_top_level_widget() == self @@ -530,9 +627,15 @@ class Browser(QMainWindow): self.sidebar.op_executed(changes, focused) if changes.note or changes.notetype: if not self.editor.is_updating_note(): + # fixme: this will leave the splitter shown, but with no current + # note being edited note = self.editor.note if note: - note.load() + try: + note.load() + except NotFoundError: + self.editor.set_note(None) + return self.editor.set_note(note) self._renderPreview() @@ -540,7 +643,7 @@ class Browser(QMainWindow): def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: if current_top_level_widget() == self: self.setUpdatesEnabled(True) - self.model.refresh_if_needed() + self.model.redraw_cells() self.sidebar.refresh_if_needed() def setupMenus(self) -> None: diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 06d18617e..f8d09ff1d 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1254,6 +1254,8 @@ title="%s" %s>%s""" % ( if on_done: on_done(result) + # fixme: perform_op? -> needs to save + # fixme: parent self.taskman.with_progress(self.col.undo, on_done_outer) def update_undo_actions(self) -> None: From 1621f2ff2673dae04bfaccfc00fdd1662e50b679 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 00:09:15 +1000 Subject: [PATCH 28/33] remove temp variable --- pylib/anki/tags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 2726e6a7c..49310f2b3 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -89,8 +89,7 @@ class TagManager: def rename(self, old: str, new: str) -> OpChangesWithCount: "Rename provided tag and its children, returning number of changed notes." - x = self.col._backend.rename_tags(current_prefix=old, new_prefix=new) - return x + return self.col._backend.rename_tags(current_prefix=old, new_prefix=new) def remove(self, space_separated_tags: str) -> OpChangesWithCount: "Remove the provided tag(s) and their children from notes and the tag list." From 33d467e68870ad2550dad32098eab85e7a6fc03c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 10:16:06 +1000 Subject: [PATCH 29/33] split tags.rs up --- rslib/src/tags/dragdrop.rs | 196 ++++++++ rslib/src/tags/mod.rs | 853 +------------------------------- rslib/src/tags/register.rs | 194 ++++++++ rslib/src/tags/remove.rs | 84 ++++ rslib/src/tags/rename.rs | 68 +++ rslib/src/tags/selectednotes.rs | 172 +++++++ rslib/src/tags/tree.rs | 203 ++++++++ 7 files changed, 925 insertions(+), 845 deletions(-) create mode 100644 rslib/src/tags/dragdrop.rs create mode 100644 rslib/src/tags/register.rs create mode 100644 rslib/src/tags/remove.rs create mode 100644 rslib/src/tags/rename.rs create mode 100644 rslib/src/tags/selectednotes.rs create mode 100644 rslib/src/tags/tree.rs diff --git a/rslib/src/tags/dragdrop.rs b/rslib/src/tags/dragdrop.rs new file mode 100644 index 000000000..f541b31b6 --- /dev/null +++ b/rslib/src/tags/dragdrop.rs @@ -0,0 +1,196 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use regex::{NoExpand, Regex, Replacer}; + +use super::split_tags; +use crate::{notes::TransformNoteOutput, prelude::*}; + +impl Collection { + pub fn drag_drop_tags( + &mut self, + source_tags: &[String], + target_tag: Option, + ) -> Result<()> { + let source_tags_and_outputs: Vec<_> = source_tags + .iter() + // generate resulting names and filter out invalid ones + .flat_map(|source_tag| { + if let Some(output_name) = drag_drop_tag_name(source_tag, target_tag.as_deref()) { + Some((source_tag, output_name)) + } else { + // invalid rename, ignore this tag + None + } + }) + .collect(); + + let regexps_and_replacements = source_tags_and_outputs + .iter() + // convert the names into regexps/replacements + .map(|(tag, output)| { + regex_matching_tag_and_children_in_single_tag(tag).map(|regex| (regex, output)) + }) + .collect::>>()?; + + // locate notes that match them + let mut nids = vec![]; + self.storage.for_each_note_tags(|nid, tags| { + for tag in split_tags(&tags) { + for (regex, _) in ®exps_and_replacements { + if regex.is_match(&tag) { + nids.push(nid); + break; + } + } + } + + Ok(()) + })?; + + if nids.is_empty() { + return Ok(()); + } + + // update notes + self.transact_no_undo(|col| { + // clear the existing original tags + for (source_tag, _) in &source_tags_and_outputs { + col.storage.clear_tag_and_children(source_tag)?; + } + + col.transform_notes(&nids, |note, _nt| { + let mut changed = false; + for (re, repl) in ®exps_and_replacements { + if note.replace_tags(re, NoExpand(&repl).by_ref()) { + changed = true; + } + } + + Ok(TransformNoteOutput { + changed, + generate_cards: false, + mark_modified: true, + }) + }) + })?; + + Ok(()) + } +} + +/// Arguments are expected in 'human' form with an :: separator. +pub(crate) fn drag_drop_tag_name(dragged: &str, dropped: Option<&str>) -> Option { + let dragged_base = dragged.rsplit("::").next().unwrap(); + if let Some(dropped) = dropped { + if dropped.starts_with(dragged) { + // foo onto foo::bar, or foo onto itself -> no-op + None + } else { + // foo::bar onto baz -> baz::bar + Some(format!("{}::{}", dropped, dragged_base)) + } + } else { + // foo::bar onto top level -> bar + Some(dragged_base.into()) + } +} + +/// A regex that will match a string tag that has been split from a list. +fn regex_matching_tag_and_children_in_single_tag(tag: &str) -> Result { + Regex::new(&format!( + r#"(?ix) + ^ + {} + # optional children + (::.+)? + $ + "#, + regex::escape(tag) + )) + .map_err(Into::into) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::collection::open_test_collection; + + fn alltags(col: &Collection) -> Vec { + col.storage + .all_tags() + .unwrap() + .into_iter() + .map(|t| t.name) + .collect() + } + + #[test] + fn dragdrop() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + for tag in &[ + "another", + "parent1::child1::grandchild1", + "parent1::child1", + "parent1", + "parent2", + "yet::another", + ] { + let mut note = nt.new_note(); + note.tags.push(tag.to_string()); + col.add_note(&mut note, DeckID(1))?; + } + + // two decks with the same base name; they both get mapped + // to parent1::another + col.drag_drop_tags( + &["another".to_string(), "yet::another".to_string()], + Some("parent1".to_string()), + )?; + + assert_eq!( + alltags(&col), + &[ + "parent1", + "parent1::another", + "parent1::child1", + "parent1::child1::grandchild1", + "parent2", + ] + ); + + // child and children moved to parent2 + col.drag_drop_tags( + &["parent1::child1".to_string()], + Some("parent2".to_string()), + )?; + + assert_eq!( + alltags(&col), + &[ + "parent1", + "parent1::another", + "parent2", + "parent2::child1", + "parent2::child1::grandchild1", + ] + ); + + // empty target reparents to root + col.drag_drop_tags(&["parent1::another".to_string()], None)?; + + assert_eq!( + alltags(&col), + &[ + "another", + "parent1", + "parent2", + "parent2::child1", + "parent2::child1::grandchild1", + ] + ); + + Ok(()) + } +} diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index e4d87e576..e243840f3 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -1,24 +1,19 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod dragdrop; mod prefix_replacer; +mod register; +mod remove; +mod rename; +mod selectednotes; +mod tree; pub(crate) mod undo; -use crate::{ - backend_proto::TagTreeNode, - collection::Collection, - err::{AnkiError, Result}, - notes::{NoteID, TransformNoteOutput}, - prelude::*, - text::{normalize_to_nfc, to_re}, - types::Usn, -}; - -use prefix_replacer::PrefixReplacer; -use regex::{NoExpand, Regex, Replacer}; -use std::{borrow::Cow, collections::HashSet, iter::Peekable}; use unicase::UniCase; +use crate::prelude::*; + #[derive(Debug, Clone, PartialEq)] pub struct Tag { pub name: String, @@ -52,41 +47,6 @@ fn is_tag_separator(c: char) -> bool { c == ' ' || c == '\u{3000}' } -fn invalid_char_for_tag(c: char) -> bool { - c.is_ascii_control() || is_tag_separator(c) || c == '"' -} - -fn normalized_tag_name_component(comp: &str) -> Cow { - let mut out = normalize_to_nfc(comp); - if out.contains(invalid_char_for_tag) { - out = out.replace(invalid_char_for_tag, "").into(); - } - let trimmed = out.trim(); - if trimmed.is_empty() { - "blank".to_string().into() - } else if trimmed.len() != out.len() { - trimmed.to_string().into() - } else { - out - } -} - -fn normalize_tag_name(name: &str) -> Cow { - if name - .split("::") - .any(|comp| matches!(normalized_tag_name_component(comp), Cow::Owned(_))) - { - let comps: Vec<_> = name - .split("::") - .map(normalized_tag_name_component) - .collect::>(); - comps.join("::").into() - } else { - // no changes required - name.into() - } -} - fn immediate_parent_name_unicase(tag_name: UniCase<&str>) -> Option> { tag_name.rsplitn(2, '\x1f').nth(1).map(UniCase::new) } @@ -95,224 +55,7 @@ fn immediate_parent_name_str(tag_name: &str) -> Option<&str> { tag_name.rsplitn(2, "::").nth(1) } -/// Arguments are expected in 'human' form with an :: separator. -pub(crate) fn drag_drop_tag_name(dragged: &str, dropped: Option<&str>) -> Option { - let dragged_base = dragged.rsplit("::").next().unwrap(); - if let Some(dropped) = dropped { - if dropped.starts_with(dragged) { - // foo onto foo::bar, or foo onto itself -> no-op - None - } else { - // foo::bar onto baz -> baz::bar - Some(format!("{}::{}", dropped, dragged_base)) - } - } else { - // foo::bar onto top level -> bar - Some(dragged_base.into()) - } -} - -/// For the given tag, check if immediate parent exists. If so, add -/// tag and return. -/// If the immediate parent is missing, check and add any missing parents. -/// This should ensure that if an immediate parent is found, all ancestors -/// are guaranteed to already exist. -fn add_tag_and_missing_parents<'a, 'b>( - all: &'a mut HashSet>, - missing: &'a mut Vec>, - tag_name: UniCase<&'b str>, -) { - if let Some(parent) = immediate_parent_name_unicase(tag_name) { - if !all.contains(&parent) { - missing.push(parent); - add_tag_and_missing_parents(all, missing, parent); - } - } - // finally, add provided tag - all.insert(tag_name); -} - -/// Append any missing parents. Caller must sort afterwards. -fn add_missing_parents(tags: &mut Vec) { - let mut all_names: HashSet> = HashSet::new(); - let mut missing = vec![]; - for tag in &*tags { - add_tag_and_missing_parents(&mut all_names, &mut missing, UniCase::new(&tag.name)) - } - let mut missing: Vec<_> = missing - .into_iter() - .map(|n| Tag::new(n.to_string(), Usn(0))) - .collect(); - tags.append(&mut missing); -} - -fn tags_to_tree(mut tags: Vec) -> TagTreeNode { - for tag in &mut tags { - tag.name = tag.name.replace("::", "\x1f"); - } - add_missing_parents(&mut tags); - tags.sort_unstable_by(|a, b| UniCase::new(&a.name).cmp(&UniCase::new(&b.name))); - let mut top = TagTreeNode::default(); - let mut it = tags.into_iter().peekable(); - add_child_nodes(&mut it, &mut top); - - top -} - -fn add_child_nodes(tags: &mut Peekable>, parent: &mut TagTreeNode) { - while let Some(tag) = tags.peek() { - let split_name: Vec<_> = tag.name.split('\x1f').collect(); - match split_name.len() as u32 { - l if l <= parent.level => { - // next item is at a higher level - return; - } - l if l == parent.level + 1 => { - // next item is an immediate descendent of parent - parent.children.push(TagTreeNode { - name: (*split_name.last().unwrap()).into(), - children: vec![], - level: parent.level + 1, - expanded: tag.expanded, - }); - tags.next(); - } - _ => { - // next item is at a lower level - if let Some(last_child) = parent.children.last_mut() { - add_child_nodes(tags, last_child) - } else { - // immediate parent is missing - tags.next(); - } - } - } - } -} - impl Collection { - pub fn tag_tree(&mut self) -> Result { - let tags = self.storage.all_tags()?; - let tree = tags_to_tree(tags); - - Ok(tree) - } - - /// Given a list of tags, fix case, ordering and duplicates. - /// Returns true if any new tags were added. - pub(crate) fn canonify_tags( - &mut self, - tags: Vec, - usn: Usn, - ) -> Result<(Vec, bool)> { - let mut seen = HashSet::new(); - let mut added = false; - - let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect(); - for tag in tags { - let mut tag = Tag::new(tag.to_string(), usn); - added |= self.register_tag(&mut tag)?; - seen.insert(UniCase::new(tag.name)); - } - - // exit early if no non-empty tags - if seen.is_empty() { - return Ok((vec![], added)); - } - - // return the sorted, canonified tags - let mut tags = seen.into_iter().collect::>(); - tags.sort_unstable(); - let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect(); - - Ok((tags, added)) - } - - /// Adjust tag casing to match any existing parents, and register it if it's not already - /// in the tags list. True if the tag was added and not already in tag list. - /// In the case the tag is already registered, tag will be mutated to match the existing - /// name. - pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result { - let is_new = self.prepare_tag_for_registering(tag)?; - if is_new { - self.register_tag_undoable(&tag)?; - } - Ok(is_new) - } - - fn register_tag_string(&mut self, tag: String, usn: Usn) -> Result { - let mut tag = Tag::new(tag, usn); - self.register_tag(&mut tag) - } - - /// Create a tag object, normalize text, and match parents/existing case if available. - /// True if tag is new. - fn prepare_tag_for_registering(&self, tag: &mut Tag) -> Result { - let normalized_name = normalize_tag_name(&tag.name); - if normalized_name.is_empty() { - // this should not be possible - return Err(AnkiError::invalid_input("blank tag")); - } - if let Some(existing_tag) = self.storage.get_tag(&normalized_name)? { - tag.name = existing_tag.name; - Ok(false) - } else { - if let Some(new_name) = self.adjusted_case_for_parents(&normalized_name)? { - tag.name = new_name; - } else if let Cow::Owned(new_name) = normalized_name { - tag.name = new_name; - } - Ok(true) - } - } - - /// If parent tag(s) exist and differ in case, return a rewritten tag. - fn adjusted_case_for_parents(&self, tag: &str) -> Result> { - if let Some(parent_tag) = self.first_existing_parent_tag(&tag)? { - let child_split: Vec<_> = tag.split("::").collect(); - let parent_count = parent_tag.matches("::").count() + 1; - Ok(Some(format!( - "{}::{}", - parent_tag, - &child_split[parent_count..].join("::") - ))) - } else { - Ok(None) - } - } - - fn first_existing_parent_tag(&self, mut tag: &str) -> Result> { - while let Some(parent_name) = immediate_parent_name_str(tag) { - if let Some(parent_tag) = self.storage.preferred_tag_case(parent_name)? { - return Ok(Some(parent_tag)); - } - tag = parent_name; - } - - Ok(None) - } - - /// Remove tags not referenced by notes, returning removed count. - pub fn clear_unused_tags(&mut self) -> Result> { - self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner()) - } - - fn clear_unused_tags_inner(&mut self) -> Result { - let mut count = 0; - let in_notes = self.storage.all_tags_in_notes()?; - let need_remove = self - .storage - .all_tags()? - .into_iter() - .filter(|tag| !in_notes.contains(&tag.name)); - for tag in need_remove { - self.remove_single_tag_undoable(tag)?; - count += 1; - } - - Ok(count) - } - pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> { let mut name = name; let tag; @@ -324,584 +67,4 @@ impl Collection { } self.storage.set_tag_collapsed(name, !expanded) } - - fn replace_tags_for_notes_inner( - &mut self, - nids: &[NoteID], - tags: &[Regex], - mut repl: R, - ) -> Result> { - self.transact(Op::UpdateTag, |col| { - col.transform_notes(nids, |note, _nt| { - let mut changed = false; - for re in tags { - if note.replace_tags(re, repl.by_ref()) { - changed = true; - } - } - - Ok(TransformNoteOutput { - changed, - generate_cards: false, - mark_modified: true, - }) - }) - }) - } - - /// Apply the provided list of regular expressions to note tags, - /// saving any modified notes. - pub fn replace_tags_for_notes( - &mut self, - nids: &[NoteID], - tags: &str, - repl: &str, - regex: bool, - ) -> Result> { - // generate regexps - let tags = split_tags(tags) - .map(|tag| { - let tag = if regex { tag.into() } else { to_re(tag) }; - Regex::new(&format!("(?i)^{}(::.*)?$", tag)) - .map_err(|_| AnkiError::invalid_input("invalid regex")) - }) - .collect::>>()?; - if !regex { - self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl)) - } else { - self.replace_tags_for_notes_inner(nids, &tags, repl) - } - } - - pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result> { - let tags: Vec<_> = split_tags(tags).collect(); - let matcher = regex::RegexSet::new( - tags.iter() - .map(|s| regex::escape(s)) - .map(|s| format!("(?i)^{}$", s)), - ) - .map_err(|_| AnkiError::invalid_input("invalid regex"))?; - - self.transact(Op::UpdateTag, |col| { - col.transform_notes(nids, |note, _nt| { - let mut need_to_add = true; - let mut match_count = 0; - for tag in ¬e.tags { - if matcher.is_match(tag) { - match_count += 1; - } - if match_count == tags.len() { - need_to_add = false; - break; - } - } - - if need_to_add { - note.tags.extend(tags.iter().map(|&s| s.to_string())) - } - - Ok(TransformNoteOutput { - changed: need_to_add, - generate_cards: false, - mark_modified: true, - }) - }) - }) - } - - pub fn drag_drop_tags( - &mut self, - source_tags: &[String], - target_tag: Option, - ) -> Result<()> { - let source_tags_and_outputs: Vec<_> = source_tags - .iter() - // generate resulting names and filter out invalid ones - .flat_map(|source_tag| { - if let Some(output_name) = drag_drop_tag_name(source_tag, target_tag.as_deref()) { - Some((source_tag, output_name)) - } else { - // invalid rename, ignore this tag - None - } - }) - .collect(); - - let regexps_and_replacements = source_tags_and_outputs - .iter() - // convert the names into regexps/replacements - .map(|(tag, output)| { - regex_matching_tag_and_children_in_single_tag(tag).map(|regex| (regex, output)) - }) - .collect::>>()?; - - // locate notes that match them - let mut nids = vec![]; - self.storage.for_each_note_tags(|nid, tags| { - for tag in split_tags(&tags) { - for (regex, _) in ®exps_and_replacements { - if regex.is_match(&tag) { - nids.push(nid); - break; - } - } - } - - Ok(()) - })?; - - if nids.is_empty() { - return Ok(()); - } - - // update notes - self.transact_no_undo(|col| { - // clear the existing original tags - for (source_tag, _) in &source_tags_and_outputs { - col.storage.clear_tag_and_children(source_tag)?; - } - - col.transform_notes(&nids, |note, _nt| { - let mut changed = false; - for (re, repl) in ®exps_and_replacements { - if note.replace_tags(re, NoExpand(&repl).by_ref()) { - changed = true; - } - } - - Ok(TransformNoteOutput { - changed, - generate_cards: false, - mark_modified: true, - }) - }) - })?; - - Ok(()) - } - - /// Rename a given tag and its children on all notes that reference it, returning changed - /// note count. - pub fn rename_tag(&mut self, old_prefix: &str, new_prefix: &str) -> Result> { - self.transact(Op::RenameTag, |col| { - col.rename_tag_inner(old_prefix, new_prefix) - }) - } - - fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result { - if new_prefix.contains(' ') { - return Err(AnkiError::invalid_input( - "replacement name can not contain a space", - )); - } - if new_prefix.trim().is_empty() { - return Err(AnkiError::invalid_input( - "replacement name must not be empty", - )); - } - - let usn = self.usn()?; - - // match existing case if available, and ensure normalized. - let mut tag = Tag::new(new_prefix.to_string(), usn); - self.prepare_tag_for_registering(&mut tag)?; - let new_prefix = &tag.name; - - // gather tags that need replacing - let mut re = PrefixReplacer::new(old_prefix)?; - let matched_notes = self - .storage - .get_note_tags_by_predicate(|tags| re.is_match(tags))?; - let match_count = matched_notes.len(); - if match_count == 0 { - // no matches; exit early so we don't clobber the empty tag entries - return Ok(0); - } - - // remove old prefix from the tag list - for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { - self.remove_single_tag_undoable(tag)?; - } - - // replace tags - for mut note in matched_notes { - let original = note.clone(); - note.tags = re.replace(¬e.tags, new_prefix); - note.set_modified(usn); - self.update_note_tags_undoable(¬e, original)?; - } - - // update tag list - for tag in re.into_seen_tags() { - self.register_tag_string(tag, usn)?; - } - - Ok(match_count) - } - - /// Take tags as a whitespace-separated string and remove them from all notes and the tag list. - pub fn remove_tags(&mut self, tags: &str) -> Result> { - self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags)) - } - - fn remove_tags_inner(&mut self, tags: &str) -> Result { - let usn = self.usn()?; - - // gather tags that need removing - let mut re = PrefixReplacer::new(tags)?; - let matched_notes = self - .storage - .get_note_tags_by_predicate(|tags| re.is_match(tags))?; - let match_count = matched_notes.len(); - - // remove from the tag list - for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { - self.remove_single_tag_undoable(tag)?; - } - - // replace tags - for mut note in matched_notes { - let original = note.clone(); - note.tags = re.remove(¬e.tags); - note.set_modified(usn); - self.update_note_tags_undoable(¬e, original)?; - } - - Ok(match_count) - } -} - -// fixme: merge with prefixmatcher - -/// A regex that will match a string tag that has been split from a list. -fn regex_matching_tag_and_children_in_single_tag(tag: &str) -> Result { - Regex::new(&format!( - r#"(?ix) - ^ - {} - # optional children - (::.+)? - $ - "#, - regex::escape(tag) - )) - .map_err(Into::into) -} - -#[cfg(test)] -mod test { - use super::*; - use crate::{collection::open_test_collection, decks::DeckID}; - - #[test] - fn tags() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - let mut note = nt.new_note(); - col.add_note(&mut note, DeckID(1))?; - - let tags: String = col.storage.db_scalar("select tags from notes")?; - assert_eq!(tags, ""); - - // first instance wins in case of duplicates - note.tags = vec!["foo".into(), "FOO".into()]; - col.update_note(&mut note)?; - assert_eq!(¬e.tags, &["foo"]); - let tags: String = col.storage.db_scalar("select tags from notes")?; - assert_eq!(tags, " foo "); - - // existing case is used if in DB - note.tags = vec!["FOO".into()]; - col.update_note(&mut note)?; - assert_eq!(¬e.tags, &["foo"]); - assert_eq!(tags, " foo "); - - // tags are normalized to nfc - note.tags = vec!["\u{fa47}".into()]; - col.update_note(&mut note)?; - assert_eq!(¬e.tags, &["\u{6f22}"]); - - // if code incorrectly adds a space to a tag, it gets split - note.tags = vec!["one two".into()]; - col.update_note(&mut note)?; - assert_eq!(¬e.tags, &["one", "two"]); - - // blanks should be handled - note.tags = vec![ - "".into(), - "foo".into(), - " ".into(), - "::".into(), - "foo::".into(), - ]; - col.update_note(&mut note)?; - assert_eq!(¬e.tags, &["blank::blank", "foo", "foo::blank"]); - - Ok(()) - } - - #[test] - fn bulk() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - let mut note = nt.new_note(); - note.tags.push("test".into()); - col.add_note(&mut note, DeckID(1))?; - - col.replace_tags_for_notes(&[note.id], "foo test", "bar", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "bar"); - - col.replace_tags_for_notes(&[note.id], "b.r", "baz", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "bar"); - - col.replace_tags_for_notes(&[note.id], "b*r", "baz", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "baz"); - - col.replace_tags_for_notes(&[note.id], "b.r", "baz", true)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "baz"); - - let out = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(out.output, 1); - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["aye", "baz", "cee"]); - - // if all tags already on note, it doesn't get updated - let out = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(out.output, 0); - - // empty replacement deletes tag - col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["cee"]); - - let mut note = col.storage.get_note(note.id)?.unwrap(); - note.tags = vec![ - "foo::bar".into(), - "foo::bar::foo".into(), - "bar::foo".into(), - "bar::foo::bar".into(), - ]; - col.update_note(&mut note)?; - col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]); - - // ensure replacements fully match - let mut note = col.storage.get_note(note.id)?.unwrap(); - note.tags = vec!["foobar".into(), "barfoo".into(), "foo".into()]; - col.update_note(&mut note)?; - col.replace_tags_for_notes(&[note.id], "foo", "", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["barfoo", "foobar"]); - - // tag children are also cleared when clearing their parent - col.storage.clear_all_tags()?; - for name in vec!["a", "a::b", "A::b::c"] { - col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?; - } - col.storage.clear_tag_and_children("a")?; - assert_eq!(col.storage.all_tags()?, vec![]); - - Ok(()) - } - - fn node(name: &str, level: u32, children: Vec) -> TagTreeNode { - TagTreeNode { - name: name.into(), - level, - children, - - ..Default::default() - } - } - - fn leaf(name: &str, level: u32) -> TagTreeNode { - node(name, level, vec![]) - } - - #[test] - fn tree() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - let mut note = nt.new_note(); - note.tags.push("foo::bar::a".into()); - note.tags.push("foo::bar::b".into()); - col.add_note(&mut note, DeckID(1))?; - - // missing parents are added - assert_eq!( - col.tag_tree()?, - node( - "", - 0, - vec![node( - "foo", - 1, - vec![node("bar", 2, vec![leaf("a", 3), leaf("b", 3)])] - )] - ) - ); - - // differing case should result in only one parent case being added - - // the first one - col.storage.clear_all_tags()?; - note.tags[0] = "foo::BAR::a".into(); - note.tags[1] = "FOO::bar::b".into(); - col.update_note(&mut note)?; - assert_eq!( - col.tag_tree()?, - node( - "", - 0, - vec![node( - "foo", - 1, - vec![node("BAR", 2, vec![leaf("a", 3), leaf("b", 3)])] - )] - ) - ); - - // things should work even if the immediate parent is not missing - col.storage.clear_all_tags()?; - note.tags[0] = "foo::bar::baz".into(); - note.tags[1] = "foo::bar::baz::quux".into(); - col.update_note(&mut note)?; - assert_eq!( - col.tag_tree()?, - node( - "", - 0, - vec![node( - "foo", - 1, - vec![node("bar", 2, vec![node("baz", 3, vec![leaf("quux", 4)])])] - )] - ) - ); - - // numbers have a smaller ascii number than ':', so a naive sort on - // '::' would result in one::two being nested under one1. - col.storage.clear_all_tags()?; - note.tags[0] = "one".into(); - note.tags[1] = "one1".into(); - note.tags.push("one::two".into()); - col.update_note(&mut note)?; - assert_eq!( - col.tag_tree()?, - node( - "", - 0, - vec![node("one", 1, vec![leaf("two", 2)]), leaf("one1", 1)] - ) - ); - - // children should match the case of their parents - col.storage.clear_all_tags()?; - note.tags[0] = "FOO".into(); - note.tags[1] = "foo::BAR".into(); - note.tags[2] = "foo::bar::baz".into(); - col.update_note(&mut note)?; - assert_eq!(note.tags, vec!["FOO", "FOO::BAR", "FOO::BAR::baz"]); - - Ok(()) - } - - #[test] - fn clearing() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - let mut note = nt.new_note(); - note.tags.push("one".into()); - note.tags.push("two".into()); - col.add_note(&mut note, DeckID(1))?; - - col.set_tag_expanded("one", true)?; - col.clear_unused_tags()?; - assert_eq!(col.storage.get_tag("one")?.unwrap().expanded, true); - assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false); - - Ok(()) - } - - fn alltags(col: &Collection) -> Vec { - col.storage - .all_tags() - .unwrap() - .into_iter() - .map(|t| t.name) - .collect() - } - - #[test] - fn dragdrop() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - for tag in &[ - "another", - "parent1::child1::grandchild1", - "parent1::child1", - "parent1", - "parent2", - "yet::another", - ] { - let mut note = nt.new_note(); - note.tags.push(tag.to_string()); - col.add_note(&mut note, DeckID(1))?; - } - - // two decks with the same base name; they both get mapped - // to parent1::another - col.drag_drop_tags( - &["another".to_string(), "yet::another".to_string()], - Some("parent1".to_string()), - )?; - - assert_eq!( - alltags(&col), - &[ - "parent1", - "parent1::another", - "parent1::child1", - "parent1::child1::grandchild1", - "parent2", - ] - ); - - // child and children moved to parent2 - col.drag_drop_tags( - &["parent1::child1".to_string()], - Some("parent2".to_string()), - )?; - - assert_eq!( - alltags(&col), - &[ - "parent1", - "parent1::another", - "parent2", - "parent2::child1", - "parent2::child1::grandchild1", - ] - ); - - // empty target reparents to root - col.drag_drop_tags(&["parent1::another".to_string()], None)?; - - assert_eq!( - alltags(&col), - &[ - "another", - "parent1", - "parent2", - "parent2::child1", - "parent2::child1::grandchild1", - ] - ); - - Ok(()) - } } diff --git a/rslib/src/tags/register.rs b/rslib/src/tags/register.rs new file mode 100644 index 000000000..d7c2b356b --- /dev/null +++ b/rslib/src/tags/register.rs @@ -0,0 +1,194 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{immediate_parent_name_str, is_tag_separator, split_tags, Tag}; +use crate::{prelude::*, text::normalize_to_nfc, types::Usn}; + +use std::{borrow::Cow, collections::HashSet}; +use unicase::UniCase; + +impl Collection { + /// Given a list of tags, fix case, ordering and duplicates. + /// Returns true if any new tags were added. + pub(crate) fn canonify_tags( + &mut self, + tags: Vec, + usn: Usn, + ) -> Result<(Vec, bool)> { + let mut seen = HashSet::new(); + let mut added = false; + + let tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect(); + for tag in tags { + let mut tag = Tag::new(tag.to_string(), usn); + added |= self.register_tag(&mut tag)?; + seen.insert(UniCase::new(tag.name)); + } + + // exit early if no non-empty tags + if seen.is_empty() { + return Ok((vec![], added)); + } + + // return the sorted, canonified tags + let mut tags = seen.into_iter().collect::>(); + tags.sort_unstable(); + let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect(); + + Ok((tags, added)) + } + + /// Adjust tag casing to match any existing parents, and register it if it's not already + /// in the tags list. True if the tag was added and not already in tag list. + /// In the case the tag is already registered, tag will be mutated to match the existing + /// name. + pub(crate) fn register_tag(&mut self, tag: &mut Tag) -> Result { + let is_new = self.prepare_tag_for_registering(tag)?; + if is_new { + self.register_tag_undoable(&tag)?; + } + Ok(is_new) + } + + /// Create a tag object, normalize text, and match parents/existing case if available. + /// True if tag is new. + pub(super) fn prepare_tag_for_registering(&self, tag: &mut Tag) -> Result { + let normalized_name = normalize_tag_name(&tag.name); + if normalized_name.is_empty() { + // this should not be possible + return Err(AnkiError::invalid_input("blank tag")); + } + if let Some(existing_tag) = self.storage.get_tag(&normalized_name)? { + tag.name = existing_tag.name; + Ok(false) + } else { + if let Some(new_name) = self.adjusted_case_for_parents(&normalized_name)? { + tag.name = new_name; + } else if let Cow::Owned(new_name) = normalized_name { + tag.name = new_name; + } + Ok(true) + } + } + + pub(super) fn register_tag_string(&mut self, tag: String, usn: Usn) -> Result { + let mut tag = Tag::new(tag, usn); + self.register_tag(&mut tag) + } +} + +impl Collection { + /// If parent tag(s) exist and differ in case, return a rewritten tag. + fn adjusted_case_for_parents(&self, tag: &str) -> Result> { + if let Some(parent_tag) = self.first_existing_parent_tag(&tag)? { + let child_split: Vec<_> = tag.split("::").collect(); + let parent_count = parent_tag.matches("::").count() + 1; + Ok(Some(format!( + "{}::{}", + parent_tag, + &child_split[parent_count..].join("::") + ))) + } else { + Ok(None) + } + } + + fn first_existing_parent_tag(&self, mut tag: &str) -> Result> { + while let Some(parent_name) = immediate_parent_name_str(tag) { + if let Some(parent_tag) = self.storage.preferred_tag_case(parent_name)? { + return Ok(Some(parent_tag)); + } + tag = parent_name; + } + + Ok(None) + } +} + +fn invalid_char_for_tag(c: char) -> bool { + c.is_ascii_control() || is_tag_separator(c) || c == '"' +} + +fn normalized_tag_name_component(comp: &str) -> Cow { + let mut out = normalize_to_nfc(comp); + if out.contains(invalid_char_for_tag) { + out = out.replace(invalid_char_for_tag, "").into(); + } + let trimmed = out.trim(); + if trimmed.is_empty() { + "blank".to_string().into() + } else if trimmed.len() != out.len() { + trimmed.to_string().into() + } else { + out + } +} + +fn normalize_tag_name(name: &str) -> Cow { + if name + .split("::") + .any(|comp| matches!(normalized_tag_name_component(comp), Cow::Owned(_))) + { + let comps: Vec<_> = name + .split("::") + .map(normalized_tag_name_component) + .collect::>(); + comps.join("::").into() + } else { + // no changes required + name.into() + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{collection::open_test_collection, decks::DeckID}; + + #[test] + fn tags() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, DeckID(1))?; + + let tags: String = col.storage.db_scalar("select tags from notes")?; + assert_eq!(tags, ""); + + // first instance wins in case of duplicates + note.tags = vec!["foo".into(), "FOO".into()]; + col.update_note(&mut note)?; + assert_eq!(¬e.tags, &["foo"]); + let tags: String = col.storage.db_scalar("select tags from notes")?; + assert_eq!(tags, " foo "); + + // existing case is used if in DB + note.tags = vec!["FOO".into()]; + col.update_note(&mut note)?; + assert_eq!(¬e.tags, &["foo"]); + assert_eq!(tags, " foo "); + + // tags are normalized to nfc + note.tags = vec!["\u{fa47}".into()]; + col.update_note(&mut note)?; + assert_eq!(¬e.tags, &["\u{6f22}"]); + + // if code incorrectly adds a space to a tag, it gets split + note.tags = vec!["one two".into()]; + col.update_note(&mut note)?; + assert_eq!(¬e.tags, &["one", "two"]); + + // blanks should be handled + note.tags = vec![ + "".into(), + "foo".into(), + " ".into(), + "::".into(), + "foo::".into(), + ]; + col.update_note(&mut note)?; + assert_eq!(¬e.tags, &["blank::blank", "foo", "foo::blank"]); + + Ok(()) + } +} diff --git a/rslib/src/tags/remove.rs b/rslib/src/tags/remove.rs new file mode 100644 index 000000000..21df16575 --- /dev/null +++ b/rslib/src/tags/remove.rs @@ -0,0 +1,84 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::prefix_replacer::PrefixReplacer; +use crate::prelude::*; + +impl Collection { + /// Take tags as a whitespace-separated string and remove them from all notes and the tag list. + pub fn remove_tags(&mut self, tags: &str) -> Result> { + self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags)) + } + + /// Remove tags not referenced by notes, returning removed count. + pub fn clear_unused_tags(&mut self) -> Result> { + self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner()) + } +} + +impl Collection { + fn remove_tags_inner(&mut self, tags: &str) -> Result { + let usn = self.usn()?; + + // gather tags that need removing + let mut re = PrefixReplacer::new(tags)?; + let matched_notes = self + .storage + .get_note_tags_by_predicate(|tags| re.is_match(tags))?; + let match_count = matched_notes.len(); + + // remove from the tag list + for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { + self.remove_single_tag_undoable(tag)?; + } + + // replace tags + for mut note in matched_notes { + let original = note.clone(); + note.tags = re.remove(¬e.tags); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + Ok(match_count) + } + + fn clear_unused_tags_inner(&mut self) -> Result { + let mut count = 0; + let in_notes = self.storage.all_tags_in_notes()?; + let need_remove = self + .storage + .all_tags()? + .into_iter() + .filter(|tag| !in_notes.contains(&tag.name)); + for tag in need_remove { + self.remove_single_tag_undoable(tag)?; + count += 1; + } + + Ok(count) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::collection::open_test_collection; + + #[test] + fn clearing() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + note.tags.push("one".into()); + note.tags.push("two".into()); + col.add_note(&mut note, DeckID(1))?; + + col.set_tag_expanded("one", true)?; + col.clear_unused_tags()?; + assert_eq!(col.storage.get_tag("one")?.unwrap().expanded, true); + assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false); + + Ok(()) + } +} diff --git a/rslib/src/tags/rename.rs b/rslib/src/tags/rename.rs new file mode 100644 index 000000000..d75805c8a --- /dev/null +++ b/rslib/src/tags/rename.rs @@ -0,0 +1,68 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{prefix_replacer::PrefixReplacer, Tag}; +use crate::prelude::*; + +impl Collection { + /// Rename a given tag and its children on all notes that reference it, returning changed + /// note count. + pub fn rename_tag(&mut self, old_prefix: &str, new_prefix: &str) -> Result> { + self.transact(Op::RenameTag, |col| { + col.rename_tag_inner(old_prefix, new_prefix) + }) + } +} + +impl Collection { + fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result { + if new_prefix.contains(' ') { + return Err(AnkiError::invalid_input( + "replacement name can not contain a space", + )); + } + if new_prefix.trim().is_empty() { + return Err(AnkiError::invalid_input( + "replacement name must not be empty", + )); + } + + let usn = self.usn()?; + + // match existing case if available, and ensure normalized. + let mut tag = Tag::new(new_prefix.to_string(), usn); + self.prepare_tag_for_registering(&mut tag)?; + let new_prefix = &tag.name; + + // gather tags that need replacing + let mut re = PrefixReplacer::new(old_prefix)?; + let matched_notes = self + .storage + .get_note_tags_by_predicate(|tags| re.is_match(tags))?; + let match_count = matched_notes.len(); + if match_count == 0 { + // no matches; exit early so we don't clobber the empty tag entries + return Ok(0); + } + + // remove old prefix from the tag list + for tag in self.storage.get_tags_by_predicate(|tag| re.is_match(tag))? { + self.remove_single_tag_undoable(tag)?; + } + + // replace tags + for mut note in matched_notes { + let original = note.clone(); + note.tags = re.replace(¬e.tags, new_prefix); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + // update tag list + for tag in re.into_seen_tags() { + self.register_tag_string(tag, usn)?; + } + + Ok(match_count) + } +} diff --git a/rslib/src/tags/selectednotes.rs b/rslib/src/tags/selectednotes.rs new file mode 100644 index 000000000..31bf42cde --- /dev/null +++ b/rslib/src/tags/selectednotes.rs @@ -0,0 +1,172 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +//! Add/update/remove tags on selected notes + +use crate::{notes::TransformNoteOutput, prelude::*, text::to_re}; + +use regex::{NoExpand, Regex, Replacer}; + +use super::split_tags; + +impl Collection { + fn replace_tags_for_notes_inner( + &mut self, + nids: &[NoteID], + tags: &[Regex], + mut repl: R, + ) -> Result> { + self.transact(Op::UpdateTag, |col| { + col.transform_notes(nids, |note, _nt| { + let mut changed = false; + for re in tags { + if note.replace_tags(re, repl.by_ref()) { + changed = true; + } + } + + Ok(TransformNoteOutput { + changed, + generate_cards: false, + mark_modified: true, + }) + }) + }) + } + + /// Apply the provided list of regular expressions to note tags, + /// saving any modified notes. + pub fn replace_tags_for_notes( + &mut self, + nids: &[NoteID], + tags: &str, + repl: &str, + regex: bool, + ) -> Result> { + // generate regexps + let tags = split_tags(tags) + .map(|tag| { + let tag = if regex { tag.into() } else { to_re(tag) }; + Regex::new(&format!("(?i)^{}(::.*)?$", tag)) + .map_err(|_| AnkiError::invalid_input("invalid regex")) + }) + .collect::>>()?; + if !regex { + self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl)) + } else { + self.replace_tags_for_notes_inner(nids, &tags, repl) + } + } + + pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result> { + let tags: Vec<_> = split_tags(tags).collect(); + let matcher = regex::RegexSet::new( + tags.iter() + .map(|s| regex::escape(s)) + .map(|s| format!("(?i)^{}$", s)), + ) + .map_err(|_| AnkiError::invalid_input("invalid regex"))?; + + self.transact(Op::UpdateTag, |col| { + col.transform_notes(nids, |note, _nt| { + let mut need_to_add = true; + let mut match_count = 0; + for tag in ¬e.tags { + if matcher.is_match(tag) { + match_count += 1; + } + if match_count == tags.len() { + need_to_add = false; + break; + } + } + + if need_to_add { + note.tags.extend(tags.iter().map(|&s| s.to_string())) + } + + Ok(TransformNoteOutput { + changed: need_to_add, + generate_cards: false, + mark_modified: true, + }) + }) + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::tags::Tag; + use crate::{collection::open_test_collection, decks::DeckID}; + + #[test] + fn bulk() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + note.tags.push("test".into()); + col.add_note(&mut note, DeckID(1))?; + + col.replace_tags_for_notes(&[note.id], "foo test", "bar", false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "bar"); + + col.replace_tags_for_notes(&[note.id], "b.r", "baz", false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "bar"); + + col.replace_tags_for_notes(&[note.id], "b*r", "baz", false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "baz"); + + col.replace_tags_for_notes(&[note.id], "b.r", "baz", true)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "baz"); + + let out = col.add_tags_to_notes(&[note.id], "cee aye")?; + assert_eq!(out.output, 1); + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(¬e.tags, &["aye", "baz", "cee"]); + + // if all tags already on note, it doesn't get updated + let out = col.add_tags_to_notes(&[note.id], "cee aye")?; + assert_eq!(out.output, 0); + + // empty replacement deletes tag + col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(¬e.tags, &["cee"]); + + let mut note = col.storage.get_note(note.id)?.unwrap(); + note.tags = vec![ + "foo::bar".into(), + "foo::bar::foo".into(), + "bar::foo".into(), + "bar::foo::bar".into(), + ]; + col.update_note(&mut note)?; + col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(¬e.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]); + + // ensure replacements fully match + let mut note = col.storage.get_note(note.id)?.unwrap(); + note.tags = vec!["foobar".into(), "barfoo".into(), "foo".into()]; + col.update_note(&mut note)?; + col.replace_tags_for_notes(&[note.id], "foo", "", false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(¬e.tags, &["barfoo", "foobar"]); + + // tag children are also cleared when clearing their parent + col.storage.clear_all_tags()?; + for name in vec!["a", "a::b", "A::b::c"] { + col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?; + } + col.storage.clear_tag_and_children("a")?; + assert_eq!(col.storage.all_tags()?, vec![]); + + Ok(()) + } +} diff --git a/rslib/src/tags/tree.rs b/rslib/src/tags/tree.rs new file mode 100644 index 000000000..515e39d5d --- /dev/null +++ b/rslib/src/tags/tree.rs @@ -0,0 +1,203 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::{collections::HashSet, iter::Peekable}; + +use unicase::UniCase; + +use super::{immediate_parent_name_unicase, Tag}; +use crate::{backend_proto::TagTreeNode, prelude::*}; + +impl Collection { + pub fn tag_tree(&mut self) -> Result { + let tags = self.storage.all_tags()?; + let tree = tags_to_tree(tags); + + Ok(tree) + } +} + +/// Append any missing parents. Caller must sort afterwards. +fn add_missing_parents(tags: &mut Vec) { + let mut all_names: HashSet> = HashSet::new(); + let mut missing = vec![]; + for tag in &*tags { + add_tag_and_missing_parents(&mut all_names, &mut missing, UniCase::new(&tag.name)) + } + let mut missing: Vec<_> = missing + .into_iter() + .map(|n| Tag::new(n.to_string(), Usn(0))) + .collect(); + tags.append(&mut missing); +} + +fn tags_to_tree(mut tags: Vec) -> TagTreeNode { + for tag in &mut tags { + tag.name = tag.name.replace("::", "\x1f"); + } + add_missing_parents(&mut tags); + tags.sort_unstable_by(|a, b| UniCase::new(&a.name).cmp(&UniCase::new(&b.name))); + let mut top = TagTreeNode::default(); + let mut it = tags.into_iter().peekable(); + add_child_nodes(&mut it, &mut top); + + top +} + +fn add_child_nodes(tags: &mut Peekable>, parent: &mut TagTreeNode) { + while let Some(tag) = tags.peek() { + let split_name: Vec<_> = tag.name.split('\x1f').collect(); + match split_name.len() as u32 { + l if l <= parent.level => { + // next item is at a higher level + return; + } + l if l == parent.level + 1 => { + // next item is an immediate descendent of parent + parent.children.push(TagTreeNode { + name: (*split_name.last().unwrap()).into(), + children: vec![], + level: parent.level + 1, + expanded: tag.expanded, + }); + tags.next(); + } + _ => { + // next item is at a lower level + if let Some(last_child) = parent.children.last_mut() { + add_child_nodes(tags, last_child) + } else { + // immediate parent is missing + tags.next(); + } + } + } + } +} + +/// For the given tag, check if immediate parent exists. If so, add +/// tag and return. +/// If the immediate parent is missing, check and add any missing parents. +/// This should ensure that if an immediate parent is found, all ancestors +/// are guaranteed to already exist. +fn add_tag_and_missing_parents<'a, 'b>( + all: &'a mut HashSet>, + missing: &'a mut Vec>, + tag_name: UniCase<&'b str>, +) { + if let Some(parent) = immediate_parent_name_unicase(tag_name) { + if !all.contains(&parent) { + missing.push(parent); + add_tag_and_missing_parents(all, missing, parent); + } + } + // finally, add provided tag + all.insert(tag_name); +} + +#[cfg(test)] +mod test { + use super::*; + use crate::collection::open_test_collection; + + fn node(name: &str, level: u32, children: Vec) -> TagTreeNode { + TagTreeNode { + name: name.into(), + level, + children, + + ..Default::default() + } + } + + fn leaf(name: &str, level: u32) -> TagTreeNode { + node(name, level, vec![]) + } + + #[test] + fn tree() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + note.tags.push("foo::bar::a".into()); + note.tags.push("foo::bar::b".into()); + col.add_note(&mut note, DeckID(1))?; + + // missing parents are added + assert_eq!( + col.tag_tree()?, + node( + "", + 0, + vec![node( + "foo", + 1, + vec![node("bar", 2, vec![leaf("a", 3), leaf("b", 3)])] + )] + ) + ); + + // differing case should result in only one parent case being added - + // the first one + col.storage.clear_all_tags()?; + note.tags[0] = "foo::BAR::a".into(); + note.tags[1] = "FOO::bar::b".into(); + col.update_note(&mut note)?; + assert_eq!( + col.tag_tree()?, + node( + "", + 0, + vec![node( + "foo", + 1, + vec![node("BAR", 2, vec![leaf("a", 3), leaf("b", 3)])] + )] + ) + ); + + // things should work even if the immediate parent is not missing + col.storage.clear_all_tags()?; + note.tags[0] = "foo::bar::baz".into(); + note.tags[1] = "foo::bar::baz::quux".into(); + col.update_note(&mut note)?; + assert_eq!( + col.tag_tree()?, + node( + "", + 0, + vec![node( + "foo", + 1, + vec![node("bar", 2, vec![node("baz", 3, vec![leaf("quux", 4)])])] + )] + ) + ); + + // numbers have a smaller ascii number than ':', so a naive sort on + // '::' would result in one::two being nested under one1. + col.storage.clear_all_tags()?; + note.tags[0] = "one".into(); + note.tags[1] = "one1".into(); + note.tags.push("one::two".into()); + col.update_note(&mut note)?; + assert_eq!( + col.tag_tree()?, + node( + "", + 0, + vec![node("one", 1, vec![leaf("two", 2)]), leaf("one1", 1)] + ) + ); + + // children should match the case of their parents + col.storage.clear_all_tags()?; + note.tags[0] = "FOO".into(); + note.tags[1] = "foo::BAR".into(); + note.tags[2] = "foo::bar::baz".into(); + col.update_note(&mut note)?; + assert_eq!(note.tags, vec!["FOO", "FOO::BAR", "FOO::BAR::baz"]); + + Ok(()) + } +} From 08895c58d9056d54b55a5523e79ff8d624fb47c3 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 13:37:42 +1000 Subject: [PATCH 30/33] introduce separate routine to remove tags from specific notes We were (ab)using the bulk update routine to do deletions, but that code was really intended to be used for finding&replacing, where an exact match is not a requirement. --- pylib/anki/tags.py | 15 +-- qt/aqt/browser.py | 16 ++- qt/aqt/note_ops.py | 22 +++- rslib/backend.proto | 9 +- rslib/src/backend/notes.rs | 21 +--- rslib/src/backend/tags.rs | 28 ++++- rslib/src/storage/card/mod.rs | 2 +- rslib/src/storage/note/mod.rs | 103 +++++++++++++------ rslib/src/storage/note/search_nids_setup.sql | 2 + rslib/src/tags/remove.rs | 33 ++++++ 10 files changed, 181 insertions(+), 70 deletions(-) create mode 100644 rslib/src/storage/note/search_nids_setup.sql diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 49310f2b3..fa0800e7d 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -68,9 +68,15 @@ class TagManager: # Bulk addition/removal from specific notes ############################################################# - def bulk_add(self, nids: Sequence[int], tags: str) -> OpChangesWithCount: + def bulk_add(self, note_ids: Sequence[int], tags: str) -> OpChangesWithCount: """Add space-separate tags to provided notes, returning changed count.""" - return self.col._backend.add_note_tags(nids=nids, tags=tags) + return self.col._backend.add_note_tags(note_ids=note_ids, tags=tags) + + def bulk_remove(self, note_ids: Sequence[int], tags: str) -> OpChangesWithCount: + return self.col._backend.remove_note_tags(note_ids=note_ids, tags=tags) + + # Find&replace + ############################################################# def bulk_update( self, nids: Sequence[int], tags: str, replacement: str, regex: bool @@ -81,9 +87,6 @@ class TagManager: nids=nids, tags=tags, replacement=replacement, regex=regex ) - def bulk_remove(self, nids: Sequence[int], tags: str) -> OpChangesWithCount: - return self.bulk_update(nids, tags, "", False) - # Bulk addition/removal based on tag ############################################################# @@ -167,7 +170,7 @@ class TagManager: if add: self.bulk_add(ids, tags) else: - self.bulk_update(ids, tags, "", False) + self.bulk_remove(ids, tags) def bulkRem(self, ids: List[int], tags: str) -> None: self.bulkAdd(ids, tags, False) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index fffcfc91e..8640694b9 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1353,7 +1353,14 @@ where id in %s""" tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) ): return - add_tags(mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags) + add_tags( + mw=self.mw, + note_ids=self.selected_notes(), + space_separated_tags=tags, + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=self + ), + ) @ensure_editor_saved_on_trigger def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: @@ -1363,7 +1370,12 @@ where id in %s""" ): return remove_tags_for_notes( - mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags + mw=self.mw, + note_ids=self.selected_notes(), + space_separated_tags=tags, + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=self + ), ) def _prompt_for_tags(self, prompt: str) -> Optional[str]: diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index 49f962555..db8fd0751 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -39,14 +39,28 @@ def remove_notes( mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) -def add_tags(*, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str) -> None: - mw.perform_op(lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags)) +def add_tags( + *, + mw: AnkiQt, + note_ids: Sequence[int], + space_separated_tags: str, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op( + lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success + ) def remove_tags_for_notes( - *, mw: AnkiQt, note_ids: Sequence[int], space_separated_tags: str + *, + mw: AnkiQt, + note_ids: Sequence[int], + space_separated_tags: str, + success: PerformOpOptionalSuccessCallback = None, ) -> None: - mw.perform_op(lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags)) + mw.perform_op( + lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success + ) def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: diff --git a/rslib/backend.proto b/rslib/backend.proto index 20dad19d7..523cd0272 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -152,8 +152,6 @@ service NotesService { rpc UpdateNote(UpdateNoteIn) returns (OpChanges); rpc GetNote(NoteID) returns (Note); rpc RemoveNotes(RemoveNotesIn) returns (OpChanges); - rpc AddNoteTags(AddNoteTagsIn) returns (OpChangesWithCount); - rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); @@ -224,6 +222,9 @@ service TagsService { rpc TagTree(Empty) returns (TagTreeNode); rpc DragDropTags(DragDropTagsIn) returns (Empty); rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); + rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); + rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); + rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount); } service SearchService { @@ -1043,8 +1044,8 @@ message AfterNoteUpdatesIn { bool generate_cards = 3; } -message AddNoteTagsIn { - repeated int64 nids = 1; +message NoteIDsAndTagsIn { + repeated int64 note_ids = 1; string tags = 2; } diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index 0f4522f37..f147d14cd 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -87,25 +87,6 @@ impl NotesService for Backend { }) } - fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result { - self.with_col(|col| { - col.add_tags_to_notes(&to_note_ids(input.nids), &input.tags) - .map(Into::into) - }) - } - - fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { - self.with_col(|col| { - col.replace_tags_for_notes( - &to_note_ids(input.nids), - &input.tags, - &input.replacement, - input.regex, - ) - .map(Into::into) - }) - } - fn cloze_numbers_in_note(&self, note: pb::Note) -> Result { let mut set = HashSet::with_capacity(4); for field in ¬e.fields { @@ -158,6 +139,6 @@ impl NotesService for Backend { } } -fn to_note_ids(ids: Vec) -> Vec { +pub(super) fn to_note_ids(ids: Vec) -> Vec { ids.into_iter().map(NoteID).collect() } diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index f3e6e09a4..28d72dd8c 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::Backend; +use super::{notes::to_note_ids, Backend}; use crate::{backend_proto as pb, prelude::*}; pub(super) use pb::tags_service::Service as TagsService; @@ -55,4 +55,30 @@ impl TagsService for Backend { self.with_col(|col| col.rename_tag(&input.current_prefix, &input.new_prefix)) .map(Into::into) } + + fn add_note_tags(&self, input: pb::NoteIDsAndTagsIn) -> Result { + self.with_col(|col| { + col.add_tags_to_notes(&to_note_ids(input.note_ids), &input.tags) + .map(Into::into) + }) + } + + fn remove_note_tags(&self, input: pb::NoteIDsAndTagsIn) -> Result { + self.with_col(|col| { + col.remove_tags_from_notes(&to_note_ids(input.note_ids), &input.tags) + .map(Into::into) + }) + } + + fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { + self.with_col(|col| { + col.replace_tags_for_notes( + &to_note_ids(input.nids), + &input.tags, + &input.replacement, + input.regex, + ) + .map(Into::into) + }) + } } diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 40e476ff2..d04f2c051 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -445,7 +445,7 @@ impl super::SqliteStorage { /// Injects the provided card IDs into the search_cids table, for /// when ids have arrived outside of a search. - /// Clear with clear_searched_cards(). + /// Clear with clear_searched_cards_table(). pub(crate) fn set_search_table_to_card_ids( &mut self, cards: &[CardID], diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index 40a44194c..434c625b0 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -20,22 +20,6 @@ pub(crate) fn join_fields(fields: &[String]) -> String { fields.join("\x1f") } -fn row_to_note(row: &Row) -> Result { - Ok(Note::new_from_storage( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - split_tags(row.get_raw(5).as_str()?) - .map(Into::into) - .collect(), - split_fields(row.get_raw(6).as_str()?), - Some(row.get(7)?), - Some(row.get(8).unwrap_or_default()), - )) -} - impl super::SqliteStorage { pub fn get_note(&self, nid: NoteID) -> Result> { self.db @@ -193,20 +177,28 @@ impl super::SqliteStorage { pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteID) -> Result> { self.db .prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))? - .query_and_then(&[note_id], |row| -> Result<_> { - { - Ok(NoteTags { - id: row.get(0)?, - mtime: row.get(1)?, - usn: row.get(2)?, - tags: row.get(3)?, - }) - } - })? + .query_and_then(&[note_id], row_to_note_tags)? .next() .transpose() } + pub(crate) fn get_note_tags_by_id_list( + &mut self, + note_ids: &[NoteID], + ) -> Result> { + self.set_search_table_to_note_ids(note_ids)?; + let out = self + .db + .prepare_cached(&format!( + "{} where id in (select nid from search_nids)", + include_str!("get_tags.sql") + ))? + .query_and_then(NO_PARAMS, row_to_note_tags)? + .collect::>>()?; + self.clear_searched_notes_table()?; + Ok(out) + } + pub(crate) fn get_note_tags_by_predicate(&mut self, want: F) -> Result> where F: Fn(&str) -> bool, @@ -217,12 +209,7 @@ impl super::SqliteStorage { while let Some(row) = rows.next()? { let tags = row.get_raw(3).as_str()?; if want(tags) { - output.push(NoteTags { - id: row.get(0)?, - mtime: row.get(1)?, - usn: row.get(2)?, - tags: tags.to_owned(), - }) + output.push(row_to_note_tags(row)?) } } Ok(output) @@ -234,4 +221,56 @@ impl super::SqliteStorage { .execute(params![note.mtime, note.usn, note.tags, note.id])?; Ok(()) } + + fn setup_searched_notes_table(&self) -> Result<()> { + self.db + .execute_batch(include_str!("search_nids_setup.sql"))?; + Ok(()) + } + + fn clear_searched_notes_table(&self) -> Result<()> { + self.db + .execute("drop table if exists search_nids", NO_PARAMS)?; + Ok(()) + } + + /// Injects the provided card IDs into the search_nids table, for + /// when ids have arrived outside of a search. + /// Clear with clear_searched_notes_table(). + fn set_search_table_to_note_ids(&mut self, notes: &[NoteID]) -> Result<()> { + self.setup_searched_notes_table()?; + let mut stmt = self + .db + .prepare_cached("insert into search_nids values (?)")?; + for nid in notes { + stmt.execute(&[nid])?; + } + + Ok(()) + } +} + +fn row_to_note(row: &Row) -> Result { + Ok(Note::new_from_storage( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + split_tags(row.get_raw(5).as_str()?) + .map(Into::into) + .collect(), + split_fields(row.get_raw(6).as_str()?), + Some(row.get(7)?), + Some(row.get(8).unwrap_or_default()), + )) +} + +fn row_to_note_tags(row: &Row) -> Result { + Ok(NoteTags { + id: row.get(0)?, + mtime: row.get(1)?, + usn: row.get(2)?, + tags: row.get(3)?, + }) } diff --git a/rslib/src/storage/note/search_nids_setup.sql b/rslib/src/storage/note/search_nids_setup.sql new file mode 100644 index 000000000..f769c8047 --- /dev/null +++ b/rslib/src/storage/note/search_nids_setup.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS search_nids; +CREATE TEMPORARY TABLE search_nids (nid integer PRIMARY KEY NOT NULL); \ No newline at end of file diff --git a/rslib/src/tags/remove.rs b/rslib/src/tags/remove.rs index 21df16575..aacb42fd9 100644 --- a/rslib/src/tags/remove.rs +++ b/rslib/src/tags/remove.rs @@ -10,6 +10,17 @@ impl Collection { self.transact(Op::RemoveTag, |col| col.remove_tags_inner(tags)) } + /// Remove whitespace-separated tags from provided notes. + pub fn remove_tags_from_notes( + &mut self, + nids: &[NoteID], + tags: &str, + ) -> Result> { + self.transact(Op::RemoveTag, |col| { + col.remove_tags_from_notes_inner(nids, tags) + }) + } + /// Remove tags not referenced by notes, returning removed count. pub fn clear_unused_tags(&mut self) -> Result> { self.transact(Op::ClearUnusedTags, |col| col.clear_unused_tags_inner()) @@ -43,6 +54,28 @@ impl Collection { Ok(match_count) } + fn remove_tags_from_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result { + let usn = self.usn()?; + + let mut re = PrefixReplacer::new(tags)?; + let mut match_count = 0; + let notes = self.storage.get_note_tags_by_id_list(nids)?; + + for mut note in notes { + if !re.is_match(¬e.tags) { + continue; + } + + match_count += 1; + let original = note.clone(); + note.tags = re.remove(¬e.tags); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + Ok(match_count) + } + fn clear_unused_tags_inner(&mut self) -> Result { let mut count = 0; let in_notes = self.storage.all_tags_in_notes()?; From b287cd5238952ff29cd0d9687da41242727d0918 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 14:35:44 +1000 Subject: [PATCH 31/33] speed up "add tags" and avoid usage of regex --- rslib/src/tags/bulkadd.rs | 88 +++++++++++++++++++++++++++++++++ rslib/src/tags/mod.rs | 1 + rslib/src/tags/register.rs | 17 +++++++ rslib/src/tags/selectednotes.rs | 36 -------------- 4 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 rslib/src/tags/bulkadd.rs diff --git a/rslib/src/tags/bulkadd.rs b/rslib/src/tags/bulkadd.rs new file mode 100644 index 000000000..dcb9c5f03 --- /dev/null +++ b/rslib/src/tags/bulkadd.rs @@ -0,0 +1,88 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +//! Adding tags to selected notes in the browse screen. + +use std::collections::HashSet; +use unicase::UniCase; + +use super::{join_tags, split_tags}; +use crate::{notes::NoteTags, prelude::*}; + +impl Collection { + pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result> { + self.transact(Op::UpdateTag, |col| col.add_tags_to_notes_inner(nids, tags)) + } +} + +impl Collection { + fn add_tags_to_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result { + let usn = self.usn()?; + + // will update tag list for any new tags, and match case + let tags_to_add = self.canonified_tags_as_vec(tags, usn)?; + + // modify notes + let mut match_count = 0; + let notes = self.storage.get_note_tags_by_id_list(nids)?; + for original in notes { + if let Some(updated_tags) = add_missing_tags(&original.tags, &tags_to_add) { + match_count += 1; + let mut note = NoteTags { + tags: updated_tags, + ..original + }; + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + } + + Ok(match_count) + } +} + +/// Returns the sorted new tag string if any tags were added. +fn add_missing_tags(note_tags: &str, desired: &[UniCase]) -> Option { + let mut note_tags: HashSet<_> = split_tags(note_tags) + .map(ToOwned::to_owned) + .map(UniCase::new) + .collect(); + + let mut modified = false; + for tag in desired { + if !note_tags.contains(tag) { + note_tags.insert(tag.clone()); + modified = true; + } + } + if !modified { + return None; + } + + // sort + let mut tags: Vec<_> = note_tags.into_iter().collect::>(); + tags.sort_unstable(); + + // turn back into a string + let tags: Vec<_> = tags.into_iter().map(|s| s.into_inner()).collect(); + Some(join_tags(&tags)) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn add_missing() { + let desired: Vec<_> = ["xyz", "abc", "DEF"] + .iter() + .map(|s| UniCase::new(s.to_string())) + .collect(); + + let add_to = |text| add_missing_tags(text, &desired).unwrap(); + + assert_eq!(&add_to(""), " abc DEF xyz "); + assert_eq!(&add_to("XYZ deF aaa"), " aaa abc deF XYZ "); + assert_eq!(add_missing_tags("def xyz abc", &desired).is_none(), true); + } +} diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index e243840f3..289d97525 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -1,6 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod bulkadd; mod dragdrop; mod prefix_replacer; mod register; diff --git a/rslib/src/tags/register.rs b/rslib/src/tags/register.rs index d7c2b356b..7be6f475b 100644 --- a/rslib/src/tags/register.rs +++ b/rslib/src/tags/register.rs @@ -38,6 +38,23 @@ impl Collection { Ok((tags, added)) } + /// Returns true if any cards were added to the tag list. + pub(crate) fn canonified_tags_as_vec( + &mut self, + tags: &str, + usn: Usn, + ) -> Result>> { + let mut out_tags = vec![]; + + for tag in split_tags(tags) { + let mut tag = Tag::new(tag.to_string(), usn); + self.register_tag(&mut tag)?; + out_tags.push(UniCase::new(tag.name)); + } + + Ok(out_tags) + } + /// Adjust tag casing to match any existing parents, and register it if it's not already /// in the tags list. True if the tag was added and not already in tag list. /// In the case the tag is already registered, tag will be mutated to match the existing diff --git a/rslib/src/tags/selectednotes.rs b/rslib/src/tags/selectednotes.rs index 31bf42cde..cc0008458 100644 --- a/rslib/src/tags/selectednotes.rs +++ b/rslib/src/tags/selectednotes.rs @@ -57,42 +57,6 @@ impl Collection { self.replace_tags_for_notes_inner(nids, &tags, repl) } } - - pub fn add_tags_to_notes(&mut self, nids: &[NoteID], tags: &str) -> Result> { - let tags: Vec<_> = split_tags(tags).collect(); - let matcher = regex::RegexSet::new( - tags.iter() - .map(|s| regex::escape(s)) - .map(|s| format!("(?i)^{}$", s)), - ) - .map_err(|_| AnkiError::invalid_input("invalid regex"))?; - - self.transact(Op::UpdateTag, |col| { - col.transform_notes(nids, |note, _nt| { - let mut need_to_add = true; - let mut match_count = 0; - for tag in ¬e.tags { - if matcher.is_match(tag) { - match_count += 1; - } - if match_count == tags.len() { - need_to_add = false; - break; - } - } - - if need_to_add { - note.tags.extend(tags.iter().map(|&s| s.to_string())) - } - - Ok(TransformNoteOutput { - changed: need_to_add, - generate_cards: false, - mark_modified: true, - }) - }) - }) - } } #[cfg(test)] From 9c2bff5b6d729f0f4f94c39ca0a3418e9587e73a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 16:55:10 +1000 Subject: [PATCH 32/33] change bulk_update() into find_and_replace_tag() Now behaves the same way as standard find&replace: - Will match substrings - Regexs can be used to match multiple items; we no longer split input on spaces. - The find&replace dialog has been updated to add tags to the field list. --- pylib/anki/collection.py | 3 + pylib/anki/find.py | 2 +- pylib/anki/tags.py | 22 ++-- qt/aqt/browser.py | 74 +------------ qt/aqt/find_and_replace.py | 182 ++++++++++++++++++++++++++++++++ qt/aqt/note_ops.py | 32 +----- qt/mypy.ini | 2 + rslib/backend.proto | 9 +- rslib/src/backend/tags.rs | 12 ++- rslib/src/tags/findreplace.rs | 142 +++++++++++++++++++++++++ rslib/src/tags/mod.rs | 2 +- rslib/src/tags/register.rs | 2 + rslib/src/tags/remove.rs | 9 ++ rslib/src/tags/rename.rs | 4 +- rslib/src/tags/selectednotes.rs | 136 ------------------------ 15 files changed, 380 insertions(+), 253 deletions(-) create mode 100644 qt/aqt/find_and_replace.py create mode 100644 rslib/src/tags/findreplace.rs delete mode 100644 rslib/src/tags/selectednotes.rs diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 3bdf63574..01b0fb3c4 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -558,6 +558,9 @@ class Collection: field_name=field_name or "", ) + def field_names_for_note_ids(self, nids: Sequence[int]) -> Sequence[str]: + return self._backend.field_names_for_notes(nids) + # returns array of ("dupestr", [nids]) def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: nids = self.findNotes(search, SearchNode(field_name=fieldName)) diff --git a/pylib/anki/find.py b/pylib/anki/find.py index 346eff311..14692531a 100644 --- a/pylib/anki/find.py +++ b/pylib/anki/find.py @@ -49,7 +49,7 @@ def findReplace( def fieldNamesForNotes(col: Collection, nids: List[int]) -> List[str]: - return list(col._backend.field_names_for_notes(nids)) + return list(col.field_names_for_note_ids(nids)) # Find duplicates diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index fa0800e7d..4e4eaed22 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -78,13 +78,23 @@ class TagManager: # Find&replace ############################################################# - def bulk_update( - self, nids: Sequence[int], tags: str, replacement: str, regex: bool + def find_and_replace( + self, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool, + match_case: bool, ) -> OpChangesWithCount: - """Replace space-separated tags, returning changed count. - Tags replaced with an empty string will be removed.""" - return self.col._backend.update_note_tags( - nids=nids, tags=tags, replacement=replacement, regex=regex + """Replace instances of 'search' with 'replacement' in tags. + Each tag is matched separately. If the replacement results in an empty string, + the tag will be removed.""" + return self.col._backend.find_and_replace_tag( + note_ids=note_ids, + search=search, + replacement=replacement, + regex=regex, + match_case=match_case, ) # Bulk addition/removal based on tag diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 8640694b9..729b0ca73 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -5,7 +5,6 @@ from __future__ import annotations import html import time -from concurrent.futures import Future from dataclasses import dataclass, field from operator import itemgetter from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast @@ -25,11 +24,11 @@ from aqt import AnkiQt, colors, gui_hooks from aqt.card_ops import set_card_deck, set_card_flag from aqt.editor import Editor from aqt.exporting import ExportDialog +from aqt.find_and_replace import FindAndReplaceDialog from aqt.main import ResetReason from aqt.note_ops import ( add_tags, clear_unused_tags, - find_and_replace, remove_notes, remove_tags_for_notes, ) @@ -59,14 +58,12 @@ from aqt.utils import ( qtMenuShortcutWorkaround, restore_combo_history, restore_combo_index_for_session, - restore_is_checked, restoreGeom, restoreHeader, restoreSplitter, restoreState, save_combo_history, save_combo_index_for_session, - save_is_checked, saveGeom, saveHeader, saveSplitter, @@ -169,7 +166,7 @@ class DataModel(QAbstractTableModel): return entry elif self.block_updates: # blank entry until we unblock - return CellRow(columns=[Cell(text="blocked")] * len(self.activeCols)) + return CellRow(columns=[Cell(text="...")] * len(self.activeCols)) else: # missing entry, need to build entry = self._build_cell_row(row) @@ -1559,77 +1556,16 @@ where id in %s""" nids = self.selected_notes() if not nids: return - import anki.find - def find() -> List[str]: - return anki.find.fieldNamesForNotes(self.mw.col, nids) - - def on_done(fut: Future) -> None: - self._on_find_replace_diag(fut.result(), nids) - - self.mw.taskman.with_progress(find, on_done, self) - - def _on_find_replace_diag(self, fields: List[str], nids: List[int]) -> None: - d = QDialog(self) - disable_help_button(d) - frm = aqt.forms.findreplace.Ui_Dialog() - frm.setupUi(d) - d.setWindowModality(Qt.WindowModal) - - combo = "BrowserFindAndReplace" - findhistory = restore_combo_history(frm.find, combo + "Find") - frm.find._completer().setCaseSensitivity(True) - replacehistory = restore_combo_history(frm.replace, combo + "Replace") - frm.replace._completer().setCaseSensitivity(True) - - restore_is_checked(frm.re, combo + "Regex") - restore_is_checked(frm.ignoreCase, combo + "ignoreCase") - - frm.find.setFocus() - allfields = [tr(TR.BROWSING_ALL_FIELDS)] + fields - frm.field.addItems(allfields) - restore_combo_index_for_session(frm.field, allfields, combo + "Field") - qconnect(frm.buttonBox.helpRequested, self.onFindReplaceHelp) - restoreGeom(d, "findreplace") - r = d.exec_() - saveGeom(d, "findreplace") - if not r: - return - - save_combo_index_for_session(frm.field, combo + "Field") - if frm.field.currentIndex() == 0: - field = None - else: - field = fields[frm.field.currentIndex() - 1] - - search = save_combo_history(frm.find, findhistory, combo + "Find") - replace = save_combo_history(frm.replace, replacehistory, combo + "Replace") - - regex = frm.re.isChecked() - match_case = not frm.ignoreCase.isChecked() - - save_is_checked(frm.re, combo + "Regex") - save_is_checked(frm.ignoreCase, combo + "ignoreCase") - - find_and_replace( - mw=self.mw, - parent=self, - note_ids=nids, - search=search, - replacement=replace, - regex=regex, - field_name=field, - match_case=match_case, - ) - - def onFindReplaceHelp(self) -> None: - openHelp(HelpPage.BROWSING_FIND_AND_REPLACE) + FindAndReplaceDialog(self, mw=self.mw, note_ids=nids) # Edit: finding dupes ###################################################################### @ensure_editor_saved def onFindDupes(self) -> None: + import anki.find + d = QDialog(self) self.mw.garbage_collect_on_dialog_finish(d) frm = aqt.forms.finddupes.Ui_Dialog() diff --git a/qt/aqt/find_and_replace.py b/qt/aqt/find_and_replace.py new file mode 100644 index 000000000..5a57d134f --- /dev/null +++ b/qt/aqt/find_and_replace.py @@ -0,0 +1,182 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from typing import List, Optional, Sequence + +import aqt +from anki.lang import TR +from aqt import AnkiQt, QWidget +from aqt.qt import QDialog, Qt +from aqt.utils import ( + HelpPage, + disable_help_button, + openHelp, + qconnect, + restore_combo_history, + restore_combo_index_for_session, + restore_is_checked, + restoreGeom, + save_combo_history, + save_combo_index_for_session, + save_is_checked, + saveGeom, + show_invalid_search_error, + tooltip, + tr, +) + + +def find_and_replace( + *, + mw: AnkiQt, + parent: QWidget, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool, + field_name: Optional[str], + match_case: bool, +) -> None: + mw.perform_op( + lambda: mw.col.find_and_replace( + note_ids=note_ids, + search=search, + replacement=replacement, + regex=regex, + field_name=field_name, + match_case=match_case, + ), + success=lambda out: tooltip( + tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)), + parent=parent, + ), + failure=lambda exc: show_invalid_search_error(exc, parent=parent), + ) + + +def find_and_replace_tag( + *, + mw: AnkiQt, + parent: QWidget, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool, + match_case: bool, +) -> None: + mw.perform_op( + lambda: mw.col.tags.find_and_replace( + note_ids=note_ids, + search=search, + replacement=replacement, + regex=regex, + match_case=match_case, + ), + success=lambda out: tooltip( + tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)), + parent=parent, + ), + failure=lambda exc: show_invalid_search_error(exc, parent=parent), + ) + + +class FindAndReplaceDialog(QDialog): + COMBO_NAME = "BrowserFindAndReplace" + + def __init__(self, parent: QWidget, *, mw: AnkiQt, note_ids: Sequence[int]) -> None: + super().__init__(parent) + self.mw = mw + self.note_ids = note_ids + self.field_names: List[str] = [] + + # fetch field names and then show + mw.query_op( + lambda: mw.col.field_names_for_note_ids(note_ids), + success=self._show, + ) + + def _show(self, field_names: Sequence[str]) -> None: + # add "all fields" and "tags" to the top of the list + self.field_names = [ + tr(TR.BROWSING_ALL_FIELDS), + tr(TR.EDITING_TAGS), + ] + list(field_names) + + disable_help_button(self) + self.form = aqt.forms.findreplace.Ui_Dialog() + self.form.setupUi(self) + self.setWindowModality(Qt.WindowModal) + + self._find_history = restore_combo_history( + self.form.find, self.COMBO_NAME + "Find" + ) + self.form.find.completer().setCaseSensitivity(True) + self._replace_history = restore_combo_history( + self.form.replace, self.COMBO_NAME + "Replace" + ) + self.form.replace.completer().setCaseSensitivity(True) + + restore_is_checked(self.form.re, self.COMBO_NAME + "Regex") + restore_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") + + self.form.field.addItems(self.field_names) + restore_combo_index_for_session( + self.form.field, self.field_names, self.COMBO_NAME + "Field" + ) + + qconnect(self.form.buttonBox.helpRequested, self.show_help) + + restoreGeom(self, "findreplace") + self.show() + self.form.find.setFocus() + + def accept(self) -> None: + saveGeom(self, "findreplace") + save_combo_index_for_session(self.form.field, self.COMBO_NAME + "Field") + + search = save_combo_history( + self.form.find, self._find_history, self.COMBO_NAME + "Find" + ) + replace = save_combo_history( + self.form.replace, self._replace_history, self.COMBO_NAME + "Replace" + ) + regex = self.form.re.isChecked() + match_case = not self.form.ignoreCase.isChecked() + save_is_checked(self.form.re, self.COMBO_NAME + "Regex") + save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") + + if self.form.field.currentIndex() == 1: + # tags + find_and_replace_tag( + mw=self.mw, + parent=self.parentWidget(), + note_ids=self.note_ids, + search=search, + replacement=replace, + regex=regex, + match_case=match_case, + ) + return + + if self.form.field.currentIndex() == 0: + field = None + else: + field = self.field_names[self.form.field.currentIndex() - 2] + + find_and_replace( + mw=self.mw, + parent=self.parentWidget(), + note_ids=self.note_ids, + search=search, + replacement=replace, + regex=regex, + field_name=field, + match_case=match_case, + ) + + super().accept() + + def show_help(self) -> None: + openHelp(HelpPage.BROWSING_FIND_AND_REPLACE) diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index db8fd0751..2e29eb977 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -3,14 +3,14 @@ from __future__ import annotations -from typing import Callable, Optional, Sequence +from typing import Callable, Sequence from anki.collection import OpChangesWithCount from anki.lang import TR from anki.notes import Note from aqt import AnkiQt, QWidget from aqt.main import PerformOpOptionalSuccessCallback -from aqt.utils import show_invalid_search_error, showInfo, tooltip, tr +from aqt.utils import showInfo, tooltip, tr def add_note( @@ -102,31 +102,3 @@ def remove_tags_for_all_notes( tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent ), ) - - -def find_and_replace( - *, - mw: AnkiQt, - parent: QWidget, - note_ids: Sequence[int], - search: str, - replacement: str, - regex: bool, - field_name: Optional[str], - match_case: bool, -) -> None: - mw.perform_op( - lambda: mw.col.find_and_replace( - note_ids=note_ids, - search=search, - replacement=replacement, - regex=regex, - field_name=field_name, - match_case=match_case, - ), - success=lambda out: showInfo( - tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)), - parent=parent, - ), - failure=lambda exc: show_invalid_search_error(exc, parent=parent), - ) diff --git a/qt/mypy.ini b/qt/mypy.ini index 3df1f2a6a..01549f4e7 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -17,6 +17,8 @@ no_strict_optional = false no_strict_optional = false [mypy-aqt.deck_ops] no_strict_optional = false +[mypy-aqt.find_and_replace] +no_strict_optional = false [mypy-aqt.winpaths] disallow_untyped_defs=false diff --git a/rslib/backend.proto b/rslib/backend.proto index 523cd0272..7f90453eb 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -224,7 +224,7 @@ service TagsService { rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); - rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount); + rpc FindAndReplaceTag(FindAndReplaceTagIn) returns (OpChangesWithCount); } service SearchService { @@ -1049,11 +1049,12 @@ message NoteIDsAndTagsIn { string tags = 2; } -message UpdateNoteTagsIn { - repeated int64 nids = 1; - string tags = 2; +message FindAndReplaceTagIn { + repeated int64 note_ids = 1; + string search = 2; string replacement = 3; bool regex = 4; + bool match_case = 5; } message CheckDatabaseOut { diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 28d72dd8c..bb20d8cf9 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -70,13 +70,17 @@ impl TagsService for Backend { }) } - fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { + fn find_and_replace_tag( + &self, + input: pb::FindAndReplaceTagIn, + ) -> Result { self.with_col(|col| { - col.replace_tags_for_notes( - &to_note_ids(input.nids), - &input.tags, + col.find_and_replace_tag( + &to_note_ids(input.note_ids), + &input.search, &input.replacement, input.regex, + input.match_case, ) .map(Into::into) }) diff --git a/rslib/src/tags/findreplace.rs b/rslib/src/tags/findreplace.rs new file mode 100644 index 000000000..746d4b423 --- /dev/null +++ b/rslib/src/tags/findreplace.rs @@ -0,0 +1,142 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::borrow::Cow; + +use regex::{NoExpand, Regex, Replacer}; + +use super::{is_tag_separator, join_tags, split_tags}; +use crate::{notes::NoteTags, prelude::*}; + +impl Collection { + /// Replace occurences of a search with a new value in tags. + pub fn find_and_replace_tag( + &mut self, + nids: &[NoteID], + search: &str, + replacement: &str, + regex: bool, + match_case: bool, + ) -> Result> { + if replacement.contains(is_tag_separator) { + return Err(AnkiError::invalid_input( + "replacement name can not contain a space", + )); + } + + let mut search = if regex { + Cow::from(search) + } else { + Cow::from(regex::escape(search)) + }; + + if !match_case { + search = format!("(?i){}", search).into(); + } + + self.transact(Op::UpdateTag, |col| { + if regex { + col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, replacement) + } else { + col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, NoExpand(replacement)) + } + }) + } +} + +impl Collection { + fn replace_tags_for_notes_inner( + &mut self, + nids: &[NoteID], + regex: Regex, + mut repl: R, + ) -> Result { + let usn = self.usn()?; + let mut match_count = 0; + let notes = self.storage.get_note_tags_by_id_list(nids)?; + + for original in notes { + if let Some(updated_tags) = replace_tags(&original.tags, ®ex, repl.by_ref()) { + let (tags, _) = self.canonify_tags(updated_tags, usn)?; + + match_count += 1; + let mut note = NoteTags { + tags: join_tags(&tags), + ..original + }; + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + } + + Ok(match_count) + } +} + +/// If any tags are changed, return the new tags list. +/// The returned tags will need to be canonified. +fn replace_tags(tags: &str, regex: &Regex, mut repl: R) -> Option> +where + R: Replacer, +{ + let maybe_replaced: Vec<_> = split_tags(tags) + .map(|tag| regex.replace_all(tag, repl.by_ref())) + .collect(); + + if maybe_replaced + .iter() + .any(|cow| matches!(cow, Cow::Owned(_))) + { + Some(maybe_replaced.into_iter().map(|s| s.to_string()).collect()) + } else { + // nothing matched + None + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{collection::open_test_collection, decks::DeckID}; + + #[test] + fn find_replace() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + note.tags.push("test".into()); + col.add_note(&mut note, DeckID(1))?; + + col.find_and_replace_tag(&[note.id], "foo|test", "bar", true, false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "bar"); + + col.find_and_replace_tag(&[note.id], "BAR", "baz", false, true)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "bar"); + + col.find_and_replace_tag(&[note.id], "b.r", "baz", false, false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "bar"); + + col.find_and_replace_tag(&[note.id], "b.r", "baz", true, false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(note.tags[0], "baz"); + + let out = col.add_tags_to_notes(&[note.id], "cee aye")?; + assert_eq!(out.output, 1); + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(¬e.tags, &["aye", "baz", "cee"]); + + // if all tags already on note, it doesn't get updated + let out = col.add_tags_to_notes(&[note.id], "cee aye")?; + assert_eq!(out.output, 0); + + // empty replacement deletes tag + col.find_and_replace_tag(&[note.id], "b.*|.*ye", "", true, false)?; + let note = col.storage.get_note(note.id)?.unwrap(); + assert_eq!(¬e.tags, &["cee"]); + + Ok(()) + } +} diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 289d97525..836a0981e 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -3,11 +3,11 @@ mod bulkadd; mod dragdrop; +mod findreplace; mod prefix_replacer; mod register; mod remove; mod rename; -mod selectednotes; mod tree; pub(crate) mod undo; diff --git a/rslib/src/tags/register.rs b/rslib/src/tags/register.rs index 7be6f475b..666a8f63d 100644 --- a/rslib/src/tags/register.rs +++ b/rslib/src/tags/register.rs @@ -10,6 +10,8 @@ use unicase::UniCase; impl Collection { /// Given a list of tags, fix case, ordering and duplicates. /// Returns true if any new tags were added. + /// Each tag is split on spaces, so if you have a &str, you + /// can pass that in as a one-element vec. pub(crate) fn canonify_tags( &mut self, tags: Vec, diff --git a/rslib/src/tags/remove.rs b/rslib/src/tags/remove.rs index aacb42fd9..1bcaaf5db 100644 --- a/rslib/src/tags/remove.rs +++ b/rslib/src/tags/remove.rs @@ -97,6 +97,7 @@ impl Collection { mod test { use super::*; use crate::collection::open_test_collection; + use crate::tags::Tag; #[test] fn clearing() -> Result<()> { @@ -112,6 +113,14 @@ mod test { assert_eq!(col.storage.get_tag("one")?.unwrap().expanded, true); assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false); + // tag children are also cleared when clearing their parent + col.storage.clear_all_tags()?; + for name in vec!["a", "a::b", "A::b::c"] { + col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?; + } + col.remove_tags("a")?; + assert_eq!(col.storage.all_tags()?, vec![]); + Ok(()) } } diff --git a/rslib/src/tags/rename.rs b/rslib/src/tags/rename.rs index d75805c8a..b33b99716 100644 --- a/rslib/src/tags/rename.rs +++ b/rslib/src/tags/rename.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::{prefix_replacer::PrefixReplacer, Tag}; +use super::{is_tag_separator, prefix_replacer::PrefixReplacer, Tag}; use crate::prelude::*; impl Collection { @@ -16,7 +16,7 @@ impl Collection { impl Collection { fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result { - if new_prefix.contains(' ') { + if new_prefix.contains(is_tag_separator) { return Err(AnkiError::invalid_input( "replacement name can not contain a space", )); diff --git a/rslib/src/tags/selectednotes.rs b/rslib/src/tags/selectednotes.rs deleted file mode 100644 index cc0008458..000000000 --- a/rslib/src/tags/selectednotes.rs +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -//! Add/update/remove tags on selected notes - -use crate::{notes::TransformNoteOutput, prelude::*, text::to_re}; - -use regex::{NoExpand, Regex, Replacer}; - -use super::split_tags; - -impl Collection { - fn replace_tags_for_notes_inner( - &mut self, - nids: &[NoteID], - tags: &[Regex], - mut repl: R, - ) -> Result> { - self.transact(Op::UpdateTag, |col| { - col.transform_notes(nids, |note, _nt| { - let mut changed = false; - for re in tags { - if note.replace_tags(re, repl.by_ref()) { - changed = true; - } - } - - Ok(TransformNoteOutput { - changed, - generate_cards: false, - mark_modified: true, - }) - }) - }) - } - - /// Apply the provided list of regular expressions to note tags, - /// saving any modified notes. - pub fn replace_tags_for_notes( - &mut self, - nids: &[NoteID], - tags: &str, - repl: &str, - regex: bool, - ) -> Result> { - // generate regexps - let tags = split_tags(tags) - .map(|tag| { - let tag = if regex { tag.into() } else { to_re(tag) }; - Regex::new(&format!("(?i)^{}(::.*)?$", tag)) - .map_err(|_| AnkiError::invalid_input("invalid regex")) - }) - .collect::>>()?; - if !regex { - self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl)) - } else { - self.replace_tags_for_notes_inner(nids, &tags, repl) - } - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::tags::Tag; - use crate::{collection::open_test_collection, decks::DeckID}; - - #[test] - fn bulk() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - let mut note = nt.new_note(); - note.tags.push("test".into()); - col.add_note(&mut note, DeckID(1))?; - - col.replace_tags_for_notes(&[note.id], "foo test", "bar", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "bar"); - - col.replace_tags_for_notes(&[note.id], "b.r", "baz", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "bar"); - - col.replace_tags_for_notes(&[note.id], "b*r", "baz", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "baz"); - - col.replace_tags_for_notes(&[note.id], "b.r", "baz", true)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(note.tags[0], "baz"); - - let out = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(out.output, 1); - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["aye", "baz", "cee"]); - - // if all tags already on note, it doesn't get updated - let out = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(out.output, 0); - - // empty replacement deletes tag - col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["cee"]); - - let mut note = col.storage.get_note(note.id)?.unwrap(); - note.tags = vec![ - "foo::bar".into(), - "foo::bar::foo".into(), - "bar::foo".into(), - "bar::foo::bar".into(), - ]; - col.update_note(&mut note)?; - col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]); - - // ensure replacements fully match - let mut note = col.storage.get_note(note.id)?.unwrap(); - note.tags = vec!["foobar".into(), "barfoo".into(), "foo".into()]; - col.update_note(&mut note)?; - col.replace_tags_for_notes(&[note.id], "foo", "", false)?; - let note = col.storage.get_note(note.id)?.unwrap(); - assert_eq!(¬e.tags, &["barfoo", "foobar"]); - - // tag children are also cleared when clearing their parent - col.storage.clear_all_tags()?; - for name in vec!["a", "a::b", "A::b::c"] { - col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?; - } - col.storage.clear_tag_and_children("a")?; - assert_eq!(col.storage.all_tags()?, vec![]); - - Ok(()) - } -} From 4c61c9280687b04d8dde24f4d74c306c6e94ea2d Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 19:15:17 +1000 Subject: [PATCH 33/33] speed up tag drag&drop and finish tag tidyup approx 4x speedup when reparenting 10-15 tags and their children at once --- ftl/core/undo.ftl | 2 + pylib/anki/tags.py | 8 +- qt/aqt/browser.py | 8 +- qt/aqt/note_ops.py | 70 +------ qt/aqt/reviewer.py | 3 +- qt/aqt/sidebar.py | 25 +-- qt/aqt/tag_ops.py | 88 ++++++++ qt/mypy.ini | 2 + rslib/backend.proto | 8 +- rslib/src/backend/tags.rs | 10 +- rslib/src/notes/mod.rs | 27 --- rslib/src/ops.rs | 4 +- rslib/src/storage/note/mod.rs | 15 -- rslib/src/storage/tag/mod.rs | 8 - rslib/src/tags/dragdrop.rs | 196 ------------------ .../tags/{prefix_replacer.rs => matcher.rs} | 79 ++++--- rslib/src/tags/mod.rs | 4 +- rslib/src/tags/remove.rs | 6 +- rslib/src/tags/rename.rs | 6 +- rslib/src/tags/reparent.rs | 195 +++++++++++++++++ 20 files changed, 377 insertions(+), 387 deletions(-) create mode 100644 qt/aqt/tag_ops.py delete mode 100644 rslib/src/tags/dragdrop.rs rename rslib/src/tags/{prefix_replacer.rs => matcher.rs} (57%) create mode 100644 rslib/src/tags/reparent.rs diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index 61d60d945..89c92c6b5 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -19,3 +19,5 @@ undo-update-card = Update Card undo-update-deck = Update Deck undo-forget-card = Forget Card undo-set-flag = Set Flag +# when dragging/dropping tags and decks in the sidebar +undo-reparent = Change Parent diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 4e4eaed22..d139d9f23 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -108,10 +108,10 @@ class TagManager: "Remove the provided tag(s) and their children from notes and the tag list." return self.col._backend.remove_tags(val=space_separated_tags) - def drag_drop(self, source_tags: List[str], target_tag: str) -> None: - """Rename one or more source tags that were dropped on `target_tag`. - If target_tag is "", tags will be placed at the top level.""" - self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag) + def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount: + """Change the parent of the provided tags. + If new_parent is empty, tags will be reparented to the top-level.""" + return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent) # String-based utilities ########################################################################## diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 729b0ca73..d4f25c774 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -26,12 +26,7 @@ from aqt.editor import Editor from aqt.exporting import ExportDialog from aqt.find_and_replace import FindAndReplaceDialog from aqt.main import ResetReason -from aqt.note_ops import ( - add_tags, - clear_unused_tags, - remove_notes, - remove_tags_for_notes, -) +from aqt.note_ops import remove_notes from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer from aqt.qt import * @@ -43,6 +38,7 @@ from aqt.scheduling_ops import ( unsuspend_cards, ) from aqt.sidebar import SidebarTreeView +from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes from aqt.theme import theme_manager from aqt.utils import ( TR, diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index 2e29eb977..582a380b4 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -5,12 +5,9 @@ from __future__ import annotations from typing import Callable, Sequence -from anki.collection import OpChangesWithCount -from anki.lang import TR from anki.notes import Note -from aqt import AnkiQt, QWidget +from aqt import AnkiQt from aqt.main import PerformOpOptionalSuccessCallback -from aqt.utils import showInfo, tooltip, tr def add_note( @@ -37,68 +34,3 @@ def remove_notes( success: PerformOpOptionalSuccessCallback = None, ) -> None: mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) - - -def add_tags( - *, - mw: AnkiQt, - note_ids: Sequence[int], - space_separated_tags: str, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op( - lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success - ) - - -def remove_tags_for_notes( - *, - mw: AnkiQt, - note_ids: Sequence[int], - space_separated_tags: str, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op( - lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success - ) - - -def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: - mw.perform_op( - mw.col.tags.clear_unused_tags, - success=lambda out: tooltip( - tr(TR.BROWSING_REMOVED_UNUSED_TAGS_COUNT, count=out.count), parent=parent - ), - ) - - -def rename_tag( - *, - mw: AnkiQt, - parent: QWidget, - current_name: str, - new_name: str, - after_rename: Callable[[], None], -) -> None: - def success(out: OpChangesWithCount) -> None: - if out.count: - tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent) - else: - showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY), parent=parent) - - mw.perform_op( - lambda: mw.col.tags.rename(old=current_name, new=new_name), - success=success, - after_hooks=after_rename, - ) - - -def remove_tags_for_all_notes( - *, mw: AnkiQt, parent: QWidget, space_separated_tags: str -) -> None: - mw.perform_op( - lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), - success=lambda out: tooltip( - tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent - ), - ) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 4dc6c00f5..169b4d216 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -19,7 +19,7 @@ from anki.tags import MARKED_TAG from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.card_ops import set_card_flag -from aqt.note_ops import add_tags, remove_notes, remove_tags_for_notes +from aqt.note_ops import remove_notes from aqt.profiles import VideoDriver from aqt.qt import * from aqt.scheduling_ops import ( @@ -30,6 +30,7 @@ from aqt.scheduling_ops import ( suspend_note, ) from aqt.sound import av_player, play_clicked_audio, record_audio +from aqt.tag_ops import add_tags, remove_tags_for_notes from aqt.theme import theme_manager from aqt.toolbar import BottomBar from aqt.utils import ( diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 9e0637f4e..757c090af 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -17,10 +17,9 @@ from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.clayout import CardLayout from aqt.deck_ops import remove_decks -from aqt.main import ResetReason from aqt.models import Models -from aqt.note_ops import remove_tags_for_all_notes, rename_tag from aqt.qt import * +from aqt.tag_ops import remove_tags_for_all_notes, rename_tag, reparent_tags from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( TR, @@ -634,33 +633,21 @@ class SidebarTreeView(QTreeView): def _handle_drag_drop_tags( self, sources: List[SidebarItem], target: SidebarItem ) -> bool: - source_ids = [ + tags = [ source.full_name for source in sources if source.item_type == SidebarItemType.TAG ] - if not source_ids: + if not tags: return False - def on_done(fut: Future) -> None: - self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) - self.browser.model.endReset() - fut.result() - self.refresh() - if target.item_type == SidebarItemType.TAG_ROOT: - target_name = "" + new_parent = "" else: - target_name = target.full_name + new_parent = target.full_name - def on_save() -> None: - self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG)) - self.browser.model.beginReset() - self.mw.taskman.with_progress( - lambda: self.col.tags.drag_drop(source_ids, target_name), on_done - ) + reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent) - self.browser.editor.call_after_note_saved(on_save) return True def _on_search(self, index: QModelIndex) -> None: diff --git a/qt/aqt/tag_ops.py b/qt/aqt/tag_ops.py new file mode 100644 index 000000000..f5f68abf4 --- /dev/null +++ b/qt/aqt/tag_ops.py @@ -0,0 +1,88 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from typing import Callable, Sequence + +from anki.collection import OpChangesWithCount +from anki.lang import TR +from aqt import AnkiQt, QWidget +from aqt.main import PerformOpOptionalSuccessCallback +from aqt.utils import showInfo, tooltip, tr + + +def add_tags( + *, + mw: AnkiQt, + note_ids: Sequence[int], + space_separated_tags: str, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op( + lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success + ) + + +def remove_tags_for_notes( + *, + mw: AnkiQt, + note_ids: Sequence[int], + space_separated_tags: str, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op( + lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success + ) + + +def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: + mw.perform_op( + mw.col.tags.clear_unused_tags, + success=lambda out: tooltip( + tr(TR.BROWSING_REMOVED_UNUSED_TAGS_COUNT, count=out.count), parent=parent + ), + ) + + +def rename_tag( + *, + mw: AnkiQt, + parent: QWidget, + current_name: str, + new_name: str, + after_rename: Callable[[], None], +) -> None: + def success(out: OpChangesWithCount) -> None: + if out.count: + tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent) + else: + showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY), parent=parent) + + mw.perform_op( + lambda: mw.col.tags.rename(old=current_name, new=new_name), + success=success, + after_hooks=after_rename, + ) + + +def remove_tags_for_all_notes( + *, mw: AnkiQt, parent: QWidget, space_separated_tags: str +) -> None: + mw.perform_op( + lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent + ), + ) + + +def reparent_tags( + *, mw: AnkiQt, parent: QWidget, tags: Sequence[str], new_parent: str +) -> None: + mw.perform_op( + lambda: mw.col.tags.reparent(tags=tags, new_parent=new_parent), + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent + ), + ) diff --git a/qt/mypy.ini b/qt/mypy.ini index 01549f4e7..089683636 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -19,6 +19,8 @@ no_strict_optional = false no_strict_optional = false [mypy-aqt.find_and_replace] no_strict_optional = false +[mypy-aqt.tag_ops] +no_strict_optional = false [mypy-aqt.winpaths] disallow_untyped_defs=false diff --git a/rslib/backend.proto b/rslib/backend.proto index 7f90453eb..443a59972 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -220,7 +220,7 @@ service TagsService { rpc RemoveTags(String) returns (OpChangesWithCount); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc TagTree(Empty) returns (TagTreeNode); - rpc DragDropTags(DragDropTagsIn) returns (Empty); + rpc ReparentTags(ReparentTagsIn) returns (OpChangesWithCount); rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); @@ -926,9 +926,9 @@ message TagTreeNode { bool expanded = 4; } -message DragDropTagsIn { - repeated string source_tags = 1; - string target_tag = 2; +message ReparentTagsIn { + repeated string tags = 1; + string new_parent = 2; } message RenameTagsIn { diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index bb20d8cf9..f7ea19a41 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -40,14 +40,14 @@ impl TagsService for Backend { self.with_col(|col| col.tag_tree()) } - fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result { - let source_tags = input.source_tags; - let target_tag = if input.target_tag.is_empty() { + fn reparent_tags(&self, input: pb::ReparentTagsIn) -> Result { + let source_tags = input.tags; + let target_tag = if input.new_parent.is_empty() { None } else { - Some(input.target_tag) + Some(input.new_parent) }; - self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag)) + self.with_col(|col| col.reparent_tags(&source_tags, target_tag)) .map(Into::into) } diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 4feeec601..4522400f6 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -20,7 +20,6 @@ use crate::{ }; use itertools::Itertools; use num_integer::Integer; -use regex::{Regex, Replacer}; use std::{ borrow::Cow, collections::{HashMap, HashSet}, @@ -210,32 +209,6 @@ impl Note { .collect() } - pub(crate) fn replace_tags(&mut self, re: &Regex, mut repl: T) -> bool { - let mut changed = false; - for tag in &mut self.tags { - if let Cow::Owned(rep) = re.replace_all(tag, |caps: ®ex::Captures| { - if let Some(expanded) = repl.by_ref().no_expansion() { - if expanded.trim().is_empty() { - "".to_string() - } else { - // include "::" if it was matched - format!( - "{}{}", - expanded, - caps.get(caps.len() - 1).map_or("", |m| m.as_str()) - ) - } - } else { - tag.to_string() - } - }) { - *tag = rep; - changed = true; - } - } - changed - } - /// Pad or merge fields to match note type. pub(crate) fn fix_field_count(&mut self, nt: &NoteType) { while self.fields.len() < nt.fields.len() { diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index dd5208d00..c0bad2827 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -13,9 +13,10 @@ pub enum Op { FindAndReplace, RemoveDeck, RemoveNote, + RemoveTag, RenameDeck, RenameTag, - RemoveTag, + ReparentTag, ScheduleAsNew, SetDeck, SetDueDate, @@ -56,6 +57,7 @@ impl Op { Op::SortCards => TR::BrowsingReschedule, Op::RenameTag => TR::ActionsRenameTag, Op::RemoveTag => TR::ActionsRemoveTag, + Op::ReparentTag => TR::UndoReparent, }; i18n.tr(key).to_string() diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index 434c625b0..d8a28b630 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -159,21 +159,6 @@ impl super::SqliteStorage { Ok(seen) } - pub(crate) fn for_each_note_tags(&self, mut func: F) -> Result<()> - where - F: FnMut(NoteID, String) -> Result<()>, - { - let mut stmt = self.db.prepare_cached("select id, tags from notes")?; - let mut rows = stmt.query(NO_PARAMS)?; - while let Some(row) = rows.next()? { - let id: NoteID = row.get(0)?; - let tags: String = row.get(1)?; - func(id, tags)? - } - - Ok(()) - } - pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteID) -> Result> { self.db .prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))? diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index 097da8f31..a84531c56 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -93,14 +93,6 @@ impl SqliteStorage { Ok(()) } - pub(crate) fn clear_tag_and_children(&self, tag: &str) -> Result<()> { - self.db - .prepare_cached("delete from tags where tag regexp ?")? - .execute(&[format!("(?i)^{}($|::)", regex::escape(tag))])?; - - Ok(()) - } - pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> { self.db .prepare_cached("update tags set collapsed = ? where tag = ?")? diff --git a/rslib/src/tags/dragdrop.rs b/rslib/src/tags/dragdrop.rs deleted file mode 100644 index f541b31b6..000000000 --- a/rslib/src/tags/dragdrop.rs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use regex::{NoExpand, Regex, Replacer}; - -use super::split_tags; -use crate::{notes::TransformNoteOutput, prelude::*}; - -impl Collection { - pub fn drag_drop_tags( - &mut self, - source_tags: &[String], - target_tag: Option, - ) -> Result<()> { - let source_tags_and_outputs: Vec<_> = source_tags - .iter() - // generate resulting names and filter out invalid ones - .flat_map(|source_tag| { - if let Some(output_name) = drag_drop_tag_name(source_tag, target_tag.as_deref()) { - Some((source_tag, output_name)) - } else { - // invalid rename, ignore this tag - None - } - }) - .collect(); - - let regexps_and_replacements = source_tags_and_outputs - .iter() - // convert the names into regexps/replacements - .map(|(tag, output)| { - regex_matching_tag_and_children_in_single_tag(tag).map(|regex| (regex, output)) - }) - .collect::>>()?; - - // locate notes that match them - let mut nids = vec![]; - self.storage.for_each_note_tags(|nid, tags| { - for tag in split_tags(&tags) { - for (regex, _) in ®exps_and_replacements { - if regex.is_match(&tag) { - nids.push(nid); - break; - } - } - } - - Ok(()) - })?; - - if nids.is_empty() { - return Ok(()); - } - - // update notes - self.transact_no_undo(|col| { - // clear the existing original tags - for (source_tag, _) in &source_tags_and_outputs { - col.storage.clear_tag_and_children(source_tag)?; - } - - col.transform_notes(&nids, |note, _nt| { - let mut changed = false; - for (re, repl) in ®exps_and_replacements { - if note.replace_tags(re, NoExpand(&repl).by_ref()) { - changed = true; - } - } - - Ok(TransformNoteOutput { - changed, - generate_cards: false, - mark_modified: true, - }) - }) - })?; - - Ok(()) - } -} - -/// Arguments are expected in 'human' form with an :: separator. -pub(crate) fn drag_drop_tag_name(dragged: &str, dropped: Option<&str>) -> Option { - let dragged_base = dragged.rsplit("::").next().unwrap(); - if let Some(dropped) = dropped { - if dropped.starts_with(dragged) { - // foo onto foo::bar, or foo onto itself -> no-op - None - } else { - // foo::bar onto baz -> baz::bar - Some(format!("{}::{}", dropped, dragged_base)) - } - } else { - // foo::bar onto top level -> bar - Some(dragged_base.into()) - } -} - -/// A regex that will match a string tag that has been split from a list. -fn regex_matching_tag_and_children_in_single_tag(tag: &str) -> Result { - Regex::new(&format!( - r#"(?ix) - ^ - {} - # optional children - (::.+)? - $ - "#, - regex::escape(tag) - )) - .map_err(Into::into) -} - -#[cfg(test)] -mod test { - use super::*; - use crate::collection::open_test_collection; - - fn alltags(col: &Collection) -> Vec { - col.storage - .all_tags() - .unwrap() - .into_iter() - .map(|t| t.name) - .collect() - } - - #[test] - fn dragdrop() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - for tag in &[ - "another", - "parent1::child1::grandchild1", - "parent1::child1", - "parent1", - "parent2", - "yet::another", - ] { - let mut note = nt.new_note(); - note.tags.push(tag.to_string()); - col.add_note(&mut note, DeckID(1))?; - } - - // two decks with the same base name; they both get mapped - // to parent1::another - col.drag_drop_tags( - &["another".to_string(), "yet::another".to_string()], - Some("parent1".to_string()), - )?; - - assert_eq!( - alltags(&col), - &[ - "parent1", - "parent1::another", - "parent1::child1", - "parent1::child1::grandchild1", - "parent2", - ] - ); - - // child and children moved to parent2 - col.drag_drop_tags( - &["parent1::child1".to_string()], - Some("parent2".to_string()), - )?; - - assert_eq!( - alltags(&col), - &[ - "parent1", - "parent1::another", - "parent2", - "parent2::child1", - "parent2::child1::grandchild1", - ] - ); - - // empty target reparents to root - col.drag_drop_tags(&["parent1::another".to_string()], None)?; - - assert_eq!( - alltags(&col), - &[ - "another", - "parent1", - "parent2", - "parent2::child1", - "parent2::child1::grandchild1", - ] - ); - - Ok(()) - } -} diff --git a/rslib/src/tags/prefix_replacer.rs b/rslib/src/tags/matcher.rs similarity index 57% rename from rslib/src/tags/prefix_replacer.rs rename to rslib/src/tags/matcher.rs index e2edb4369..e093a726e 100644 --- a/rslib/src/tags/prefix_replacer.rs +++ b/rslib/src/tags/matcher.rs @@ -6,16 +6,16 @@ use std::{borrow::Cow, collections::HashSet}; use super::{join_tags, split_tags}; use crate::prelude::*; -pub(crate) struct PrefixReplacer { +pub(crate) struct TagMatcher { regex: Regex, - seen_tags: HashSet, + new_tags: HashSet, } /// Helper to match any of the provided space-separated tags in a space- /// separated list of tags, and replace the prefix. /// /// Tracks seen tags during replacement, so the tag list can be updated as well. -impl PrefixReplacer { +impl TagMatcher { pub fn new(space_separated_tags: &str) -> Result { // convert "fo*o bar" into "fo\*o|bar" let tags: Vec<_> = split_tags(space_separated_tags) @@ -43,7 +43,7 @@ impl PrefixReplacer { Ok(Self { regex, - seen_tags: HashSet::new(), + new_tags: HashSet::new(), }) } @@ -54,25 +54,54 @@ impl PrefixReplacer { pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String { let tags: Vec<_> = split_tags(space_separated_tags) .map(|tag| { - self.regex - .replace(tag, |caps: &Captures| { - // if we captured the child separator, add it to the replacement - if caps.get(2).is_some() { - Cow::Owned(format!("{}::", replacement)) - } else { - Cow::Borrowed(replacement) - } - }) - .to_string() + let out = self.regex.replace(tag, |caps: &Captures| { + // if we captured the child separator, add it to the replacement + if caps.get(2).is_some() { + Cow::Owned(format!("{}::", replacement)) + } else { + Cow::Borrowed(replacement) + } + }); + if let Cow::Owned(out) = out { + if !self.new_tags.contains(&out) { + self.new_tags.insert(out.clone()); + } + out + } else { + out.to_string() + } }) .collect(); - for tag in &tags { - // sadly HashSet doesn't have an entry API at the moment - if !self.seen_tags.contains(tag) { - self.seen_tags.insert(tag.clone()); - } - } + join_tags(tags.as_slice()) + } + + /// The `replacement` function should return the text to use as a replacement. + pub fn replace_with_fn(&mut self, space_separated_tags: &str, replacer: F) -> String + where + F: Fn(&str) -> String, + { + let tags: Vec<_> = split_tags(space_separated_tags) + .map(|tag| { + let out = self.regex.replace(tag, |caps: &Captures| { + let replacement = replacer(caps.get(1).unwrap().as_str()); + // if we captured the child separator, add it to the replacement + if caps.get(2).is_some() { + format!("{}::", replacement) + } else { + replacement + } + }); + if let Cow::Owned(out) = out { + if !self.new_tags.contains(&out) { + self.new_tags.insert(out.clone()); + } + out + } else { + out.to_string() + } + }) + .collect(); join_tags(tags.as_slice()) } @@ -87,8 +116,10 @@ impl PrefixReplacer { join_tags(tags.as_slice()) } - pub fn into_seen_tags(self) -> HashSet { - self.seen_tags + /// Returns all replaced values that were used, so they can be registered + /// into the tag list. + pub fn into_new_tags(self) -> HashSet { + self.new_tags } } @@ -98,12 +129,12 @@ mod test { #[test] fn regex() -> Result<()> { - let re = PrefixReplacer::new("one two")?; + let re = TagMatcher::new("one two")?; assert_eq!(re.is_match(" foo "), false); assert_eq!(re.is_match(" foo one "), true); assert_eq!(re.is_match(" two foo "), true); - let mut re = PrefixReplacer::new("foo")?; + let mut re = TagMatcher::new("foo")?; assert_eq!(re.is_match("foo"), true); assert_eq!(re.is_match(" foo "), true); assert_eq!(re.is_match(" bar foo baz "), true); diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 836a0981e..127b2c4ea 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -2,12 +2,12 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod bulkadd; -mod dragdrop; mod findreplace; -mod prefix_replacer; +mod matcher; mod register; mod remove; mod rename; +mod reparent; mod tree; pub(crate) mod undo; diff --git a/rslib/src/tags/remove.rs b/rslib/src/tags/remove.rs index 1bcaaf5db..b56e73d27 100644 --- a/rslib/src/tags/remove.rs +++ b/rslib/src/tags/remove.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::prefix_replacer::PrefixReplacer; +use super::matcher::TagMatcher; use crate::prelude::*; impl Collection { @@ -32,7 +32,7 @@ impl Collection { let usn = self.usn()?; // gather tags that need removing - let mut re = PrefixReplacer::new(tags)?; + let mut re = TagMatcher::new(tags)?; let matched_notes = self .storage .get_note_tags_by_predicate(|tags| re.is_match(tags))?; @@ -57,7 +57,7 @@ impl Collection { fn remove_tags_from_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result { let usn = self.usn()?; - let mut re = PrefixReplacer::new(tags)?; + let mut re = TagMatcher::new(tags)?; let mut match_count = 0; let notes = self.storage.get_note_tags_by_id_list(nids)?; diff --git a/rslib/src/tags/rename.rs b/rslib/src/tags/rename.rs index b33b99716..0547d6190 100644 --- a/rslib/src/tags/rename.rs +++ b/rslib/src/tags/rename.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::{is_tag_separator, prefix_replacer::PrefixReplacer, Tag}; +use super::{is_tag_separator, matcher::TagMatcher, Tag}; use crate::prelude::*; impl Collection { @@ -35,7 +35,7 @@ impl Collection { let new_prefix = &tag.name; // gather tags that need replacing - let mut re = PrefixReplacer::new(old_prefix)?; + let mut re = TagMatcher::new(old_prefix)?; let matched_notes = self .storage .get_note_tags_by_predicate(|tags| re.is_match(tags))?; @@ -59,7 +59,7 @@ impl Collection { } // update tag list - for tag in re.into_seen_tags() { + for tag in re.into_new_tags() { self.register_tag_string(tag, usn)?; } diff --git a/rslib/src/tags/reparent.rs b/rslib/src/tags/reparent.rs new file mode 100644 index 000000000..915b6d1ee --- /dev/null +++ b/rslib/src/tags/reparent.rs @@ -0,0 +1,195 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::collections::HashMap; + +use super::{join_tags, matcher::TagMatcher}; +use crate::prelude::*; + +impl Collection { + /// Reparent the provided tags under a new parent. + /// + /// Parents of the provided tags are left alone - only the final component + /// and its children are moved. If a source tag is the parent of the target + /// tag, it will remain unchanged. If `new_parent` is not provided, tags + /// will be reparented to the root element. When reparenting tags, any + /// children they have are reparented as well. + /// + /// For example: + /// - foo, bar -> bar::foo + /// - foo::bar, baz -> baz::bar + /// - foo, foo::bar -> no action + /// - foo::bar, none -> bar + pub fn reparent_tags( + &mut self, + tags_to_reparent: &[String], + new_parent: Option, + ) -> Result> { + self.transact(Op::ReparentTag, |col| { + col.reparent_tags_inner(tags_to_reparent, new_parent) + }) + } + + pub fn reparent_tags_inner( + &mut self, + tags_to_reparent: &[String], + new_parent: Option, + ) -> Result { + let usn = self.usn()?; + let mut matcher = TagMatcher::new(&join_tags(tags_to_reparent))?; + let old_to_new_names = old_to_new_names(tags_to_reparent, new_parent); + + let matched_notes = self + .storage + .get_note_tags_by_predicate(|tags| matcher.is_match(tags))?; + let match_count = matched_notes.len(); + if match_count == 0 { + // no matches; exit early so we don't clobber the empty tag entries + return Ok(0); + } + + // remove old prefixes from the tag list + for tag in self + .storage + .get_tags_by_predicate(|tag| matcher.is_match(tag))? + { + self.remove_single_tag_undoable(tag)?; + } + + // replace tags + for mut note in matched_notes { + let original = note.clone(); + note.tags = matcher + .replace_with_fn(¬e.tags, |cap| old_to_new_names.get(cap).unwrap().clone()); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + // update tag list + for tag in matcher.into_new_tags() { + self.register_tag_string(tag, usn)?; + } + + Ok(match_count) + } +} + +fn old_to_new_names( + tags_to_reparent: &[String], + new_parent: Option, +) -> HashMap<&str, String> { + tags_to_reparent + .iter() + // generate resulting names and filter out invalid ones + .flat_map(|source_tag| { + if let Some(output_name) = reparented_name(source_tag, new_parent.as_deref()) { + Some((source_tag.as_str(), output_name)) + } else { + // invalid rename, ignore this tag + None + } + }) + .collect() +} + +/// Arguments are expected in 'human' form with a :: separator. +/// Returns None if new parent is a child of the tag to be reparented. +fn reparented_name(existing_name: &str, new_parent: Option<&str>) -> Option { + let existing_base = existing_name.rsplit("::").next().unwrap(); + if let Some(new_parent) = new_parent { + if new_parent.starts_with(existing_name) { + // foo onto foo::bar, or foo onto itself -> no-op + None + } else { + // foo::bar onto baz -> baz::bar + Some(format!("{}::{}", new_parent, existing_base)) + } + } else { + // foo::bar onto top level -> bar + Some(existing_base.into()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::collection::open_test_collection; + + fn alltags(col: &Collection) -> Vec { + col.storage + .all_tags() + .unwrap() + .into_iter() + .map(|t| t.name) + .collect() + } + + #[test] + fn dragdrop() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + for tag in &[ + "another", + "parent1::child1::grandchild1", + "parent1::child1", + "parent1", + "parent2", + "yet::another", + ] { + let mut note = nt.new_note(); + note.tags.push(tag.to_string()); + col.add_note(&mut note, DeckID(1))?; + } + + // two decks with the same base name; they both get mapped + // to parent1::another + col.reparent_tags( + &["another".to_string(), "yet::another".to_string()], + Some("parent1".to_string()), + )?; + + assert_eq!( + alltags(&col), + &[ + "parent1", + "parent1::another", + "parent1::child1", + "parent1::child1::grandchild1", + "parent2", + ] + ); + + // child and children moved to parent2 + col.reparent_tags( + &["parent1::child1".to_string()], + Some("parent2".to_string()), + )?; + + assert_eq!( + alltags(&col), + &[ + "parent1", + "parent1::another", + "parent2", + "parent2::child1", + "parent2::child1::grandchild1", + ] + ); + + // empty target reparents to root + col.reparent_tags(&["parent1::another".to_string()], None)?; + + assert_eq!( + alltags(&col), + &[ + "another", + "parent1", + "parent2", + "parent2::child1", + "parent2::child1::grandchild1", + ] + ); + + Ok(()) + } +}