diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index cb676d53c..f84bb3890 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -140,3 +140,14 @@ browsing-edited-today = Edited 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. + } +browsing-changed-new-position = + { $count -> + [one] Changed position of { $count } new card. + *[other] Changed position of { $count } new cards. + } diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index c2e95f1e9..89c92c6b5 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -18,3 +18,6 @@ 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 +# when dragging/dropping tags and decks in the sidebar +undo-reparent = Change Parent 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/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/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 098b69854..01b0fb3c4 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,16 +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 -DefaultsForAdding = _pb.DeckAndNotetype @dataclass @@ -195,23 +201,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() @@ -328,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 @@ -366,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(): @@ -445,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() @@ -536,14 +540,26 @@ 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 "", + ) + + 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]]: @@ -788,8 +804,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 @@ -817,6 +831,18 @@ table.review-log {{ {revlog_style} }} assert_exhaustive(self._undo) assert False + def op_affects_study_queue(self, changes: OpChanges) -> bool: + if changes.kind == changes.SET_CARD_FLAG: + return False + 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. If backend has undo available, clear the Python undo state.""" @@ -986,21 +1012,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(), - ) - - ########################################################################## + 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..14692531a 100644 --- a/pylib/anki/find.py +++ b/pylib/anki/find.py @@ -37,18 +37,19 @@ 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]: - 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/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..4b4af388f 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, OpChangesWithCount 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." @@ -164,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: @@ -201,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/anki/tags.py b/pylib/anki/tags.py index 5e2ae27ce..d139d9f23 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -18,10 +18,12 @@ 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 TagTreeNode = _pb.TagTreeNode +MARKED_TAG = "marked" class TagManager: @@ -43,17 +45,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" @@ -72,52 +65,53 @@ 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: List[int], tags: str) -> int: + 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_update( - self, nids: Sequence[int], tags: str, replacement: str, regex: bool - ) -> int: - """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, note_ids: Sequence[int], tags: str) -> OpChangesWithCount: + return self.col._backend.remove_note_tags(note_ids=note_ids, tags=tags) + + # Find&replace + ############################################################# + + def find_and_replace( + self, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool, + match_case: bool, + ) -> OpChangesWithCount: + """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, ) - def bulk_remove(self, nids: Sequence[int], tags: str) -> int: - return self.bulk_update(nids, tags, "", False) + # Bulk addition/removal based on tag + ############################################################# - def rename(self, old: str, new: str) -> int: - "Rename provided tag, returning number of changed notes." - nids = self.col.find_notes(anki.collection.SearchNode(tag=old)) - if not nids: - return 0 - escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old) - return self.bulk_update(nids, escaped_name, new, False) + def rename(self, old: str, new: str) -> OpChangesWithCount: + "Rename provided tag and its children, returning number of changed notes." + return self.col._backend.rename_tags(current_prefix=old, new_prefix=new) - 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) + 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 ########################################################################## @@ -169,3 +163,24 @@ 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") + + 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_remove(ids, tags) + + def bulkRem(self, ids: List[int], tags: str) -> None: + self.bulkAdd(ids, tags, 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/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/.pylintrc b/qt/.pylintrc index 3507ed84c..4a701207b 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -8,6 +8,7 @@ ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-classes= SearchNode, Config, + OpChanges [REPORTS] output-format=colorized 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/addcards.py b/qt/aqt/addcards.py index 6c38f16e3..0b1c7f3c2 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 @@ -104,10 +104,10 @@ 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) + 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 @@ -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() @@ -258,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/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 0ff9db88f..d4f25c774 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1,53 +1,65 @@ # 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 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 import aqt import aqt.forms from anki.cards import Card -from anki.collection import Collection, Config, SearchNode +from anki.collection import Collection, Config, OpChanges, 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 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 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 remove_notes 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.sidebar import SidebarSearchBar, SidebarToolbar, SidebarTreeView +from aqt.scheduling_ops import ( + forget_cards, + reposition_new_cards_dialog, + set_due_date_dialog, + suspend_cards, + 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, HelpPage, + KeyboardModifiersPressed, askUser, + current_top_level_widget, disable_help_button, + ensure_editor_saved, + ensure_editor_saved_on_trigger, getTag, openHelp, 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, @@ -79,6 +91,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: @@ -91,21 +122,72 @@ class DataModel(QAbstractTableModel): ) self.cards: Sequence[int] = [] self.cardObjs: Dict[int, Card] = {} + 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) -> Card: - id = self.cards[index.row()] + def getCard(self, index: QModelIndex) -> Optional[Card]: + 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: - 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: - 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 + # 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="...")] * 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 ###################################################################### @@ -124,16 +206,13 @@ class DataModel(QAbstractTableModel): if not index.isValid(): return if role == Qt.FontRole: - if self.activeCols[index.column()] not in ("question", "answer", "noteFld"): - return - c = self.getCard(index) - 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 @@ -149,7 +228,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 @@ -193,17 +272,27 @@ 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._last_refresh = time.time() + self.dataChanged.emit(top_left, bottom_right) # type: ignore + def reset(self) -> None: self.beginReset() self.endReset() # 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() self.cardObjs = {} + self._row_cache = {} def endReset(self) -> None: self.endResetModel() @@ -211,7 +300,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() @@ -219,7 +308,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 @@ -274,6 +363,21 @@ class DataModel(QAbstractTableModel): else: 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: + # clear card cache + self.cardObjs = {} + if focused: + self.redraw_cells() + + def begin_blocking(self) -> None: + self.block_updates = True + + def end_blocking(self) -> None: + self.block_updates = False + self.redraw_cells() + # Column data ###################################################################### @@ -283,64 +387,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 "" @@ -359,30 +486,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"] @@ -399,25 +534,23 @@ 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 + row = self.model.get_row(index.row()) + cell = row.columns[index.column()] - 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"): - 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() @@ -428,8 +561,6 @@ class StatusDelegate(QItemDelegate): # Browser window ###################################################################### -# fixme: respond to reset+edit hooks - class Browser(QMainWindow): model: DataModel @@ -475,43 +606,38 @@ 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_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.begin_blocking() - 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 on_backend_did_block(self) -> None: + self.model.end_blocking() - def wrapped_op() -> None: - if reset_model: - self.model.beginReset() - self.setUpdatesEnabled(False) - op() + def on_operation_did_execute(self, changes: OpChanges) -> None: + focused = current_top_level_widget() == self + 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(): + # fixme: this will leave the splitter shown, but with no current + # note being edited + note = self.editor.note + if note: + try: + note.load() + except NotFoundError: + self.editor.set_note(None) + return + self.editor.set_note(note) - def wrapped_done(fut: Future) -> None: + self._renderPreview() + + def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: + if current_top_level_widget() == self: 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) - ) + self.model.redraw_cells() + self.sidebar.refresh_if_needed() def setupMenus(self) -> None: # pylint: disable=unnecessary-lambda @@ -532,8 +658,10 @@ class Browser(QMainWindow): f.actionRemove_Tags.triggered, lambda: self.remove_tags_from_selected_notes(), ) - qconnect(f.actionClear_Unused_Tags.triggered, self.clearUnusedTags) - qconnect(f.actionToggle_Mark.triggered, lambda: self.onMark()) + qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags) + 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) @@ -546,10 +674,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) @@ -601,7 +735,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: @@ -618,12 +752,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: @@ -689,10 +821,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) @@ -725,7 +855,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"] @@ -762,12 +892,11 @@ 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() - self.editor.setNote(None) - self.search() + self.model.reset() # Table view & editor ###################################################################### @@ -822,39 +951,33 @@ 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) - 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: + @ensure_editor_saved + def onRowChanged( + self, current: Optional[QItemSelection], previous: Optional[QItemSelection] + ) -> None: + """Update current note and hide/show editor.""" if self._closeEventHasCleanedUp: 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) + 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 - self._updateFlagsMenu() + self._update_flags_menu() 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() @@ -879,11 +1002,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: @@ -931,10 +1052,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: @@ -978,15 +1097,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) @@ -1065,13 +1182,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 @@ -1087,11 +1204,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( @@ -1108,23 +1225,23 @@ where id in %s""" def onHelp(self) -> None: openHelp(HelpPage.BROWSING) + # legacy + + selectedCards = selected_cards + selectedNotes = selected_notes + # 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) 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) @@ -1168,39 +1285,18 @@ where id in %s""" return # nothing selected? - nids = self.selectedNotes() + nids = self.selected_notes() 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) + remove_notes( + mw=self.mw, + note_ids=nids, + success=lambda _: tooltip(tr(TR.BROWSING_NOTE_DELETED, count=len(nids))), + ) # legacy @@ -1209,10 +1305,11 @@ where id in %s""" # Deck change ###################################################################### + @ensure_editor_saved_on_trigger def set_deck_of_selected_cards(self) -> None: from aqt.studydeck import StudyDeck - cids = self.selectedCards() + cids = self.selected_cards() if not cids: return @@ -1230,14 +1327,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) + set_card_deck(mw=self.mw, card_ids=cids, deck_id=did) # legacy @@ -1246,58 +1336,55 @@ 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." - 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), - ) + if not ( + 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, + 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: "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), - ) + if not ( + tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_DELETE)) + ): + return + remove_tags_for_notes( + 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 _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: - return + def _prompt_for_tags(self, prompt: str) -> Optional[str]: + (tags, ok) = getTag(self, self.col, prompt) + if not ok: + return None + else: + return tags - self.model.beginReset() - func(self.selectedNotes(), tags) - self.model.endReset() - self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) - - 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() - - self.mw.taskman.run_in_background(self.col.tags.registerNotes, on_done) + @ensure_editor_saved_on_trigger + 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 ###################################################################### @@ -1305,18 +1392,15 @@ 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() - c = self.selectedCards() + cids = self.selected_cards() + if want_suspend: - self.col.sched.suspend_cards(c) + suspend_cards(mw=self.mw, card_ids=cids) else: - self.col.sched.unsuspend_cards(c) - self.model.reset() - self.mw.requireReset(reason=ResetReason.BrowserSuspend, context=self) + unsuspend_cards(mw=self.mw, card_ids=cids) # Exporting ###################################################################### @@ -1329,28 +1413,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.saveNow(lambda: self._on_set_flag(n)) - def _on_set_flag(self, n: int) -> None: # 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() + if flag == self.card.user_flag(): + flag = 0 - def _updateFlagsMenu(self) -> None: + set_card_flag(mw=self.mw, card_ids=self.selected_cards(), flag=flag) + + 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): @@ -1358,98 +1440,49 @@ 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")) - - # Repositioning - ###################################################################### - - 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 " - + ids2str(cids) - ) - if not cids2: - showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED)) - 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() + self.add_tags_to_selected_notes(tags=MARKED_TAG) # Scheduling ###################################################################### - def _after_schedule(self) -> None: - self.model.reset() - # updates undo status - self.mw.requireReset(reason=ResetReason.BrowserReschedule, context=self) + @ensure_editor_saved_on_trigger + def reposition(self) -> None: + if self.card and self.card.queue != QUEUE_TYPE_NEW: + showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED), parent=self) + return - 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, - on_done=self._after_schedule, - ) + 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.selected_cards(), + 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(), - on_done=self._after_schedule, - ) + forget_cards( + mw=self.mw, + parent=self, + card_ids=self.selected_cards(), ) # Edit: selection ###################################################################### + @ensure_editor_saved_on_trigger def selectNotes(self) -> None: - self.editor.saveNow(self._selectNotes) - - 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() @@ -1472,24 +1505,22 @@ 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) + # 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.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) 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) gui_hooks.sidebar_should_refresh_decks.remove(self.on_item_added) gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) - - def on_unfocus_field(self, changed: bool, note: Note, field_idx: int) -> None: - self.refreshCurrentCard(note) + 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) # covers the tag, note and deck case def on_item_added(self, item: Any = None) -> None: @@ -1516,103 +1547,21 @@ 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() + 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() - nocase = 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) - - 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: - self.editor.saveNow(self._onFindDupes) + import anki.find - def _onFindDupes(self) -> None: d = QDialog(self) self.mw.garbage_collect_on_dialog_finish(d) frm = aqt.forms.finddupes.Ui_Dialog() @@ -1731,14 +1680,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) @@ -1747,7 +1696,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) @@ -1757,7 +1706,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) @@ -1807,6 +1756,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/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/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 new file mode 100644 index 000000000..60a70a49a --- /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, QWidget +from aqt.utils import tooltip, tr + + +def remove_decks( + *, + mw: AnkiQt, + parent: QWidget, + 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 e80a7953f..af6ca9cb4 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -8,10 +8,12 @@ from dataclasses import dataclass from typing import Any import aqt +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 @@ -23,7 +25,6 @@ from aqt.utils import ( shortcut, showInfo, showWarning, - tooltip, tr, ) @@ -61,6 +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 def show(self) -> None: av_player.stop_and_clear_queue() @@ -68,9 +70,24 @@ 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 refresh_if_needed(self) -> None: + if self._refresh_needed: + self.refresh() + + def op_executed(self, changes: OpChanges, focused: bool) -> bool: + if self.mw.col.op_affects_study_queue(changes): + self._refresh_needed = True + + if focused: + self.refresh_if_needed() + + return self._refresh_needed # Event handlers ########################################################################## @@ -145,7 +162,6 @@ class DeckBrowser: ], context=self, ) - self.web.key = "deckBrowser" self._drawButtons() if offset is not None: self._scrollToOffset(offset) @@ -305,15 +321,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.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/editcurrent.py b/qt/aqt/editcurrent.py index 4a540d76b..79ebd8e83 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,59 +25,53 @@ 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() def saveAndClose(self) -> None: - self.editor.saveNow(self._saveAndClose) + self.editor.call_after_note_saved(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: 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 ec2d69dd8..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,16 +27,17 @@ 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.main import ResetReason +from aqt.note_ops import update_note from aqt.qt import * from aqt.sound import av_player from aqt.theme import theme_manager from aqt.utils import ( TR, HelpPage, + KeyboardModifiersPressed, disable_help_button, getFile, openHelp, @@ -90,8 +94,17 @@ _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 +114,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() @@ -399,7 +413,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 @@ -407,7 +421,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 @@ -450,7 +464,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 @@ -459,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"): @@ -492,7 +505,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." @@ -519,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) @@ -544,7 +561,14 @@ class Editor: def _save_current_note(self) -> None: "Call after note is updated with data from webview." - self.mw.col.update_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 [ @@ -552,19 +576,34 @@ 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 self.mw.progress.timer(10, callback, False) return - self.saveTags() + self.blur_tags_if_focused() self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) - def checkValid(self) -> None: + saveNow = call_after_note_saved + + 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)});") @@ -597,16 +636,20 @@ 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 ###################################################################### 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) @@ -656,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)) ) @@ -672,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() @@ -687,9 +734,12 @@ class Editor: self.tags.setFocus() # legacy + def saveAddModeVars(self) -> None: pass + saveTags = blur_tags_if_focused + # Format buttons ###################################################################### @@ -712,7 +762,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 @@ -729,7 +779,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) @@ -1106,7 +1156,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/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/main.py b/qt/aqt/main.py index eef4e99d0..f8d09ff1d 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -14,7 +14,20 @@ 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, + Literal, + Optional, + Protocol, + Sequence, + TextIO, + Tuple, + TypeVar, + cast, +) import anki import aqt @@ -32,8 +45,11 @@ from anki.collection import ( Checkpoint, Collection, Config, + OpChanges, + OpChangesWithCount, ReviewUndo, UndoResult, + UndoStatus, ) from anki.decks import Deck from anki.hooks import runHook @@ -56,8 +72,10 @@ from aqt.theme import theme_manager from aqt.utils import ( TR, HelpPage, + KeyboardModifiersPressed, askUser, checkInvalidFilename, + current_top_level_widget, disable_help_button, getFile, getOnlyText, @@ -71,31 +89,29 @@ from aqt.utils import ( showInfo, showWarning, tooltip, + top_level_widget, tr, ) + +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() - -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" +] class AnkiQt(QMainWindow): @@ -106,7 +122,7 @@ class AnkiQt(QMainWindow): def __init__( self, - app: QApplication, + app: aqt.AnkiApp, profileManager: ProfileManagerType, backend: _RustBackend, opts: Namespace, @@ -114,7 +130,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) @@ -123,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) @@ -173,6 +187,7 @@ class AnkiQt(QMainWindow): self.setupHooks() self.setup_timers() self.updateTitleBar() + self.setup_focus() # screens self.setupDeckBrowser() self.setupOverview() @@ -201,6 +216,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 ########################################################################## @@ -650,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 @@ -694,66 +715,214 @@ class AnkiQt(QMainWindow): # Resetting state ########################################################################## - def reset(self, guiOnly: bool = False) -> None: - "Called for non-trivial edits. Rebuilds queue and updates UI." + 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], + *, + success: PerformOpOptionalSuccessCallback = None, + failure: Optional[Callable[[Exception], Any]] = None, + after_hooks: Optional[Callable[[], None]] = 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 + - 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 + + Be careful not to call any UI routines in `op`, as that may crash Qt. + This includes things select .selectedCards() in the browse screen. + + 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 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, or perform some subsequent action. + """ + + 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): + if failure: + failure(exception) + else: + showWarning(str(exception)) + return + else: + # BaseException like SystemExit; rethrow it + future.result() + + result = future.result() + try: + if success: + success(result) + finally: + # update undo status + 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, after_hooks) + + self.taskman.with_progress(op, wrapped_done) + + def _increase_background_ops(self) -> None: + if not self._background_op_count: + 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.backend_did_block() + assert self._background_op_count >= 0 + + def _fire_change_hooks_after_op_performed( + self, result: ResultWithChanges, after_hooks: Optional[Callable[[], None]] + ) -> 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() + 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, + after legacy code has called .reset()""" + 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, changes: OpChanges) -> None: + "Notify current screen of changes." + focused = current_top_level_widget() == self + if self.state == "review": + dirty = self.reviewer.op_executed(changes, focused) + elif self.state == "overview": + dirty = self.overview.op_executed(changes, focused) + elif self.state == "deckBrowser": + dirty = self.deckBrowser.op_executed(changes, 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] + ) -> 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 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. + + 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) + + # 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: str) -> 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() + def _resetRequiredState(self, oldState: MainWindowState) -> None: + pass # HTML helpers ########################################################################## @@ -812,10 +981,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) @@ -879,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() @@ -988,8 +1156,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]] @@ -1087,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: @@ -1108,6 +1277,23 @@ title="%s" %s>%s""" % ( self.form.actionUndo.setEnabled(False) gui_hooks.undo_state_did_change(False) + 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 + + 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) + + self.col.autosave() + def checkpoint(self, name: str) -> None: self.col.save(name) self.update_undo_actions() @@ -1149,7 +1335,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: @@ -1300,7 +1486,7 @@ title="%s" %s>%s""" % ( if elap > minutes * 60: self.maybe_auto_sync_media() - # Permanent libanki hooks + # Permanent hooks ########################################################################## def setupHooks(self) -> None: @@ -1310,6 +1496,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 @@ -1404,13 +1592,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") @@ -1574,7 +1763,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: @@ -1582,7 +1772,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 @@ -1645,6 +1835,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 @@ -1700,3 +1894,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/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 new file mode 100644 index 000000000..582a380b4 --- /dev/null +++ b/qt/aqt/note_ops.py @@ -0,0 +1,36 @@ +# 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.notes import Note +from aqt import AnkiQt +from aqt.main import PerformOpOptionalSuccessCallback + + +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, after_hooks: Callable[[], None]) -> None: + mw.perform_op( + lambda: mw.col.update_note(note), + after_hooks=after_hooks, + ) + + +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) diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 723528602..9bb908a48 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 OpChanges 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) + self._refresh_needed = False def show(self) -> None: av_player.stop_and_clear_queue() @@ -55,6 +57,20 @@ class Overview: self._renderBottom() self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) + self._refresh_needed = False + + def refresh_if_needed(self) -> None: + if self._refresh_needed: + self.refresh() + + def op_executed(self, changes: OpChanges, focused: bool) -> bool: + if self.mw.col.op_affects_study_queue(changes): + self._refresh_needed = True + + if focused: + self.refresh_if_needed() + + return self._refresh_needed # Handlers ############################################################ diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py index 26a55ff77..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: @@ -317,12 +326,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/progress.py b/qt/aqt/progress.py index d5002208e..9f52e26b2 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -16,10 +16,11 @@ 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 + self._busy_cursor_timer: Optional[QTimer] = None self._win: Optional[ProgressDialog] = None self._levels = 0 @@ -74,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 @@ -94,14 +95,15 @@ 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 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) @@ -120,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) @@ -136,19 +135,16 @@ 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) 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 @@ -183,14 +179,17 @@ 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 - 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: diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 420293ea3..169b4d216 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -7,19 +7,30 @@ 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 from anki import hooks from anki.cards import Card -from anki.collection import Config +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 +from aqt.note_ops import remove_notes 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.tag_ops import add_tags, remove_tags_for_notes from aqt.theme import theme_manager from aqt.toolbar import BottomBar from aqt.utils import ( @@ -33,6 +44,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 @@ -61,16 +78,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) 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.QUEUES + self.refresh_if_needed() def lastCard(self) -> Optional[Card]: if self._answeredIds: @@ -86,6 +104,42 @@ class Reviewer: gui_hooks.reviewer_will_end() self.card = None + def refresh_if_needed(self) -> None: + if self._refresh_needed is RefreshNeeded.QUEUES: + self.mw.col.reset() + self.nextCard() + 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: + self.card.load() + self._update_mark_icon() + elif changes.card and changes.kind == OpChanges.SET_CARD_FLAG: + # fixme: v3 mtime check + self.card.load() + self._update_flag_icon() + elif self.mw.col.op_affects_study_queue(changes): + self._refresh_needed = RefreshNeeded.QUEUES + elif changes.note or changes.notetype or changes.tag: + self._refresh_needed = RefreshNeeded.NOTE_TEXT + + if focused and self._refresh_needed is not RefreshNeeded.NO: + self.refresh_if_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 ########################################################################## @@ -219,7 +273,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 @@ -782,24 +836,23 @@ 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: + def set_flag_on_current_card(self, desired_flag: int) -> None: # need to toggle off? - if self.card.user_flag() == flag: + if self.card.user_flag() == desired_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() + else: + flag = desired_flag + + set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag) def toggle_mark_on_current_note(self) -> None: note = self.card.note() - if note.has_tag("marked"): - note.remove_tag("marked") + if note.has_tag(MARKED_TAG): + remove_tags_for_notes( + mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG + ) else: - note.add_tag("marked") - self.mw.col.update_note(note) - self.mw.update_undo_actions() - self._update_mark_icon() + 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: @@ -810,38 +863,52 @@ 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)) + 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.col.sched.suspend_cards([self.card.id]) - self.mw.reset() - 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)) + suspend_cards( + mw=self.mw, + card_ids=[self.card.id], + success=lambda _: tooltip(tr(TR.STUDYING_CARD_SUSPENDED)), + ) def bury_current_note(self) -> None: - self.mw.col.sched.bury_note(self.card.note()) - self.mw.reset() - tooltip(tr(TR.STUDYING_NOTE_BURIED)) + bury_note( + mw=self.mw, + note_id=self.card.nid, + 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: # need to check state because the shortcut is global to the main # 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.col.remove_notes([self.card.note().id]) - self.mw.reset() - tooltip(tr(TR.STUDYING_NOTE_AND_ITS_CARD_DELETED, count=cnt)) + + remove_notes( + mw=self.mw, + note_ids=[self.card.nid], + success=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/aqt/scheduling.py b/qt/aqt/scheduling.py deleted file mode 100644 index f16d49b1a..000000000 --- a/qt/aqt/scheduling.py +++ /dev/null @@ -1,82 +0,0 @@ -# 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 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 - - -def set_due_date_dialog( - *, - mw: aqt.AnkiQt, - 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 "" - prompt = "\n".join( - [ - tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)), - tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT_HINT), - ] - ) - (days, success) = getText( - prompt=prompt, - parent=parent, - default=default, - 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( - 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: - 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 - ) diff --git a/qt/aqt/scheduling_ops.py b/qt/aqt/scheduling_ops.py new file mode 100644 index 000000000..7468ed00a --- /dev/null +++ b/qt/aqt/scheduling_ops.py @@ -0,0 +1,175 @@ +# 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.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 disable_help_button, getText, tooltip, tr + + +def set_due_date_dialog( + *, + mw: aqt.AnkiQt, + parent: QWidget, + card_ids: List[int], + config_key: Optional[Config.String.Key.V], +) -> None: + if not card_ids: + return + + 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)), + tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT_HINT), + ] + ) + (days, success) = getText( + prompt=prompt, + parent=parent, + default=default_text, + title=tr(TR.ACTIONS_SET_DUE_DATE), + ) + if not success or not days.strip(): + return + + 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, + ), + ) + + +def forget_cards(*, mw: aqt.AnkiQt, parent: QWidget, card_ids: List[int]) -> None: + if not card_ids: + return + + 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 + ), + ) + + +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, + 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..757c090af 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 @@ -16,18 +16,18 @@ from anki.tags import TagTreeNode from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.clayout import CardLayout -from aqt.main import ResetReason +from aqt.deck_ops import remove_decks from aqt.models import Models 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, + KeyboardModifiersPressed, askUser, getOnlyText, show_invalid_search_error, - showInfo, showWarning, - tooltip, tr, ) @@ -253,9 +253,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: @@ -353,6 +351,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__() @@ -361,6 +363,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 @@ -388,6 +391,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 @@ -408,7 +415,21 @@ class SidebarTreeView(QTreeView): self.setExpandsOnDoubleClick(double_click_expands) def model(self) -> SidebarModel: - return super().model() + return cast(SidebarModel, 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 @@ -417,16 +438,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: @@ -434,9 +456,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): @@ -496,22 +521,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: @@ -602,39 +627,27 @@ 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( 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.saveNow(on_save) return True def _on_search(self, index: QModelIndex) -> None: @@ -693,7 +706,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) @@ -1166,78 +1181,40 @@ 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 ########################### def remove_tags(self, item: SidebarItem) -> None: - self.browser.editor.saveNow(lambda: self._remove_tags(item)) + tags = self.mw.col.tags.join(self._selected_tags()) + item.name = "..." - def _remove_tags(self, _item: SidebarItem) -> None: - tags = self._selected_tags() - - 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: - 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.saveNow(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) + 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/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/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/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/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/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 8d94aa5c7..ceb4db073 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, @@ -110,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)) @@ -118,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, @@ -138,17 +139,17 @@ 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[QWidget] = 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( text: str, - parent: Union[Literal[False], QDialog] = False, + parent: Optional[QWidget] = None, help: HelpPageArgument = "", type: str = "info", title: str = "Anki", @@ -157,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 @@ -213,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() @@ -221,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) @@ -262,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, @@ -295,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", ): @@ -328,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: @@ -341,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, @@ -388,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 = "", @@ -445,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 @@ -458,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) @@ -467,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]], @@ -547,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() @@ -599,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]) @@ -632,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]) @@ -718,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: @@ -729,6 +733,20 @@ def downArrow() -> str: return "â–¾" +def top_level_widget(widget: QWidget) -> QWidget: + window = None + while widget := widget.parentWidget(): + window = widget + return window + + +def current_top_level_widget() -> Optional[QWidget]: + if widget := QApplication.focusWidget(): + return top_level_widget(widget) + else: + return None + + # Tooltips ###################################################################### @@ -739,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: @@ -974,3 +992,50 @@ 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 + + +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")) diff --git a/qt/mypy.ini b/qt/mypy.ini index 63ed611ac..089683636 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -9,6 +9,19 @@ 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.find_and_replace] +no_strict_optional = false +[mypy-aqt.tag_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 4512dbbc9..5e950eb93 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 """ @@ -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"], @@ -391,7 +394,46 @@ 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_did_execute", + args=[ + "changes: anki.collection.OpChanges", + ], + 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. + """, + ), + 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.""", + ), + 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 ################### diff --git a/rslib/backend.proto b/rslib/backend.proto index 7ffc67010..443a59972 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 SortCards(SortCardsIn) returns (Empty); - rpc SortDeck(SortDeckIn) returns (Empty); + rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); + rpc SetDueDate(SetDueDateIn) returns (OpChanges); + 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); - rpc AnswerCard(AnswerCardIn) returns (Empty); + rpc AnswerCard(AnswerCardIn) returns (OpChanges); rpc UpgradeScheduler(Empty) returns (Empty); rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); } @@ -134,23 +139,21 @@ 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 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 +182,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 { @@ -212,13 +215,16 @@ 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 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 ReparentTags(ReparentTagsIn) returns (OpChangesWithCount); + rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); + rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); + rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); + rpc FindAndReplaceTag(FindAndReplaceTagIn) returns (OpChangesWithCount); } service SearchService { @@ -227,7 +233,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,9 +270,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 SetDeck(SetDeckIn) returns (OpChanges); + rpc SetFlag(SetFlagIn) returns (OpChanges); } // Protobuf stored in .anki2 files @@ -919,9 +926,14 @@ 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 { + string current_prefix = 1; + string new_prefix = 2; } message SetConfigJsonIn { @@ -970,6 +982,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; @@ -1027,16 +1044,17 @@ message AfterNoteUpdatesIn { bool generate_cards = 3; } -message AddNoteTagsIn { - repeated int64 nids = 1; +message NoteIDsAndTagsIn { + repeated int64 note_ids = 1; 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 { @@ -1442,6 +1460,24 @@ message GetQueuedCardsOut { } } +message OpChanges { + // 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; + UPDATE_NOTE = 3; + } + + Kind kind = 1; + 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; @@ -1459,4 +1495,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 8172f3f56..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(UndoableOpKind::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,11 +44,18 @@ 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 { + 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 +113,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/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/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/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/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/notes.rs b/rslib/src/backend/notes.rs index 5c82ad5a4..f147d14cd 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(UndoableOpKind::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,29 +81,9 @@ impl NotesService for Backend { .map(Into::into) .collect::>(), )?; - col.remove_notes(&nids.into_iter().collect::>())? + col.remove_notes(&nids.into_iter().collect::>()) } - Ok(().into()) - }) - } - - fn add_note_tags(&self, input: pb::AddNoteTagsIn) -> Result { - self.with_col(|col| { - col.add_tags_to_notes(&to_nids(input.nids), &input.tags) - .map(|n| n as u32) - }) - .map(Into::into) - } - - fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { - self.with_col(|col| { - col.replace_tags_for_notes( - &to_nids(input.nids), - &input.tags, - &input.replacement, - input.regex, - ) - .map(|n| (n as u32).into()) + .map(Into::into) }) } @@ -123,16 +97,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_nids(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) }) } @@ -167,6 +139,6 @@ impl NotesService for Backend { } } -fn to_nids(ids: Vec) -> Vec { +pub(super) 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 new file mode 100644 index 000000000..b25c51de5 --- /dev/null +++ b/rslib/src/backend/ops.rs @@ -0,0 +1,46 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use pb::op_changes::Kind; + +use crate::{backend_proto as pb, ops::OpChanges, prelude::*, undo::UndoStatus}; + +impl From for Kind { + fn from(o: Op) -> Self { + match o { + Op::SetFlag => Kind::SetCardFlag, + Op::UpdateTag => Kind::UpdateNoteTags, + Op::UpdateNote => Kind::UpdateNote, + _ => Kind::Other, + } + } +} + +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, + } + } +} + +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(), + } + } +} + +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..2dae46537 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,14 +111,14 @@ 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(); 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) @@ -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..f7ea19a41 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -1,13 +1,13 @@ // 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; 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))) + 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))) } fn all_tags(&self, _input: pb::Empty) -> Result { @@ -23,40 +23,66 @@ 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 { self.with_col(|col| { - col.transact(None, |col| { + col.transact_no_undo(|col| { col.set_tag_expanded(&input.name, input.expanded)?; Ok(().into()) }) }) } - fn clear_tag(&self, tag: pb::String) -> Result { - self.with_col(|col| { - col.transact(None, |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()) } - 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) } + + fn rename_tags(&self, input: pb::RenameTagsIn) -> Result { + 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 find_and_replace_tag( + &self, + input: pb::FindAndReplaceTagIn, + ) -> Result { + self.with_col(|col| { + 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/card/mod.rs b/rslib/src/card/mod.rs index dec89213a..d6fa34a85 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -6,9 +6,10 @@ pub(crate) mod undo; 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::{define_newtype, ops::StateChanges}; use crate::{deckconf::DeckConf, decks::DeckID}; use num_enum::TryFromPrimitive; @@ -110,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 { @@ -139,13 +149,31 @@ impl Card { } impl Collection { - pub(crate) fn update_card_with_op( + pub(crate) fn update_card_maybe_undoable( &mut self, card: &mut Card, - op: Option, - ) -> Result<()> { + 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)] @@ -203,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); @@ -211,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(UndoableOpKind::SetDeck), |col| { + self.transact(Op::SetDeck, |col| { for mut card in col.storage.all_searched_cards()? { if card.deck_id == deck_id { continue; @@ -224,6 +252,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(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/collection.rs b/rslib/src/collection.rs index bb701f9b6..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>( @@ -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 { @@ -80,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, { @@ -92,21 +93,56 @@ 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); } } - 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<()> { @@ -120,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 1aec6d075..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(UndoableOpKind::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 f0e80a4d7..7b12ea5bc 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, @@ -269,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 { @@ -278,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(UndoableOpKind::AddDeck), |col| { + self.transact(Op::AddDeck, |col| { let usn = col.usn()?; col.prepare_deck_for_update(deck, usn)?; deck.set_modified(usn); @@ -292,15 +290,15 @@ impl Collection { }) } - pub fn update_deck(&mut self, deck: &mut Deck) -> Result<()> { - self.transact(Some(UndoableOpKind::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(UndoableOpKind::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); @@ -466,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(UndoableOpKind::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)? { @@ -483,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 { @@ -625,9 +622,9 @@ impl Collection { &mut self, source_decks: &[DeckID], target: Option, - ) -> Result<()> { + ) -> Result> { let usn = self.usn()?; - self.transact(Some(UndoableOpKind::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/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/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 18dee8f3f..4522400f6 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,9 +15,11 @@ 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}; use std::{ borrow::Cow, collections::{HashMap, HashSet}, @@ -47,6 +48,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 { @@ -191,38 +209,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 { - 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() { @@ -305,8 +291,8 @@ impl Collection { Ok(()) } - pub fn add_note(&mut self, note: &mut Note, did: DeckID) -> Result<()> { - self.transact(Some(UndoableOpKind::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,29 +320,49 @@ impl Collection { } #[cfg(test)] - pub(crate) fn update_note(&mut self, note: &mut Note) -> Result<()> { - self.update_note_with_op(note, Some(UndoableOpKind::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( + pub(crate) fn update_note_maybe_undoable( &mut self, note: &mut Note, - op: Option, - ) -> Result<()> { + 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( @@ -392,13 +398,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(UndoableOpKind::RemoveNote), |col| { + self.transact(Op::RemoveNote, |col| { for nid in nids { let nid = *nid; if let Some(_existing_note) = col.storage.get_note(nid)? { @@ -408,27 +414,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 02bec7443..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 { @@ -21,27 +24,25 @@ 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), 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) + } } } /// 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 +58,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)?; @@ -65,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))); @@ -86,22 +121,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 == UndoableOpKind::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 new file mode 100644 index 000000000..c0bad2827 --- /dev/null +++ b/rslib/src/ops.rs @@ -0,0 +1,86 @@ +// 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, + ClearUnusedTags, + FindAndReplace, + RemoveDeck, + RemoveNote, + RemoveTag, + RenameDeck, + RenameTag, + ReparentTag, + ScheduleAsNew, + SetDeck, + SetDueDate, + SetFlag, + SortCards, + Suspend, + UnburyUnsuspend, + UpdateCard, + UpdateDeck, + UpdateNote, + UpdatePreferences, + UpdateTag, +} + +impl Op { + pub fn describe(self, i18n: &I18n) -> String { + let key = match self { + 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, + Op::SetFlag => TR::UndoSetFlag, + Op::FindAndReplace => TR::BrowsingFindAndReplace, + Op::ClearUnusedTags => TR::BrowsingClearUnusedTags, + Op::SortCards => TR::BrowsingReschedule, + Op::RenameTag => TR::ActionsRenameTag, + Op::RemoveTag => TR::ActionsRemoveTag, + Op::ReparentTag => TR::UndoReparent, + }; + + i18n.tr(key).to_string() + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct StateChanges { + pub card: bool, + pub note: bool, + pub deck: bool, + pub tag: bool, + 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 e1b4eb80f..5f607a5b4 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, }; @@ -22,17 +23,13 @@ impl Collection { }) } - pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> { - self.transact( - Some(crate::undo::UndoableOpKind::UpdatePreferences), - |col| col.set_preferences_inner(prefs), - ) + 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 2e0d589ac..949188e98 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, OpChanges, OpOutput}, 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..762d38687 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -240,10 +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(UndoableOpKind::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<()> { @@ -274,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 43f766bf3..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(UndoableOpKind::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 => UndoableOpKind::Suspend, - BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => UndoableOpKind::Bury, + 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 06691a421..c01913f40 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}; @@ -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 { @@ -103,10 +105,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(UndoableOpKind::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 +121,7 @@ impl Collection { position += 1; } col.set_next_card_position(position)?; - col.storage.clear_searched_cards_table()?; - Ok(()) + col.storage.clear_searched_cards_table() }) } @@ -131,9 +132,9 @@ impl Collection { step: u32, order: NewCardSortOrder, shift: bool, - ) -> Result<()> { + ) -> Result> { let usn = self.usn()?; - self.transact(None, |col| { + self.transact(Op::SortCards, |col| { col.sort_cards_inner(cids, starting_from, step, order, shift, usn) }) } @@ -146,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 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 cec735a4e..fdb929438 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}; @@ -94,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(UndoableOpKind::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/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/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..d8a28b630 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, @@ -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 @@ -175,18 +159,103 @@ impl super::SqliteStorage { Ok(seen) } - pub(crate) fn for_each_note_tags(&self, mut func: F) -> Result<()> + 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_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: FnMut(NoteID, String) -> Result<()>, + F: Fn(&str) -> bool, { - let mut stmt = self.db.prepare_cached("select id, tags from notes")?; - let mut rows = stmt.query(NO_PARAMS)?; + 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 id: NoteID = row.get(0)?; - let tags: String = row.get(1)?; - func(id, tags)? + let tags = row.get_raw(3).as_str()?; + if want(tags) { + output.push(row_to_note_tags(row)?) + } + } + 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(()) + } + + 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/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/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/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 c2aac651e..a84531c56 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,13 +65,24 @@ 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 ?")? - .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<()> { @@ -82,23 +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(()) - } - - /// 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/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/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/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/matcher.rs b/rslib/src/tags/matcher.rs new file mode 100644 index 000000000..e093a726e --- /dev/null +++ b/rslib/src/tags/matcher.rs @@ -0,0 +1,160 @@ +// 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 TagMatcher { + regex: Regex, + 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 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) + .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, + new_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| { + 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(); + + 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()) + } + + /// 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()) + } + + /// 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 + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn regex() -> Result<()> { + 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 = 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); + 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/mod.rs b/rslib/src/tags/mod.rs index 39985dd72..127b2c4ea 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -1,22 +1,20 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod bulkadd; +mod findreplace; +mod matcher; +mod register; +mod remove; +mod rename; +mod reparent; +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 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, @@ -50,41 +48,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) } @@ -93,236 +56,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 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; - } - self.register_tag_undoable(&tag)?; - 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) - } - - 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, - })?; - } - - Ok(()) - } - - /// 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(None, |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; @@ -334,488 +68,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(Some(UndoableOpKind::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(Some(UndoableOpKind::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::new(&format!( - r#"(?ix) - ^ - {} - # optional children - (::.+)? - $ - "#, - regex::escape(tag) - )) - .map_err(|_| AnkiError::invalid_input("invalid regex")) - .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(None, |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(()) - } -} - -#[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 cnt = col.add_tags_to_notes(&[note.id], "cee aye")?; - assert_eq!(cnt, 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); - - // 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..666a8f63d --- /dev/null +++ b/rslib/src/tags/register.rs @@ -0,0 +1,213 @@ +// 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. + /// 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, + 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)) + } + + /// 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 + /// 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..b56e73d27 --- /dev/null +++ b/rslib/src/tags/remove.rs @@ -0,0 +1,126 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::matcher::TagMatcher; +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 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()) + } +} + +impl Collection { + fn remove_tags_inner(&mut self, tags: &str) -> Result { + let usn = self.usn()?; + + // gather tags that need removing + let mut re = TagMatcher::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 remove_tags_from_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result { + let usn = self.usn()?; + + let mut re = TagMatcher::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()?; + 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; + use crate::tags::Tag; + + #[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); + + // 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 new file mode 100644 index 000000000..0547d6190 --- /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::{is_tag_separator, matcher::TagMatcher, 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(is_tag_separator) { + 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 = TagMatcher::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_new_tags() { + self.register_tag_string(tag, usn)?; + } + + Ok(match_count) + } +} 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(()) + } +} 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(()) + } +} diff --git a/rslib/src/tags/undo.rs b/rslib/src/tags/undo.rs index 907a7ea86..49062a881 100644 --- a/rslib/src/tags/undo.rs +++ b/rslib/src/tags/undo.rs @@ -13,19 +13,22 @@ 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), } } - /// 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) } - 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) + /// 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))); + Ok(()) } } diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 339db290a..c2f8eaa9f 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -2,20 +2,21 @@ // 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::*; +use crate::{ + ops::{OpChanges, StateChanges}, + prelude::*, +}; use std::collections::VecDeque; const UNDO_LIMIT: usize = 30; #[derive(Debug)] pub(crate) struct UndoableOp { - pub kind: UndoableOpKind, + pub kind: Op, pub timestamp: TimestampSecs, pub changes: Vec, } @@ -33,6 +34,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 @@ -51,7 +57,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(); @@ -76,96 +82,113 @@ 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()); } - 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 { + 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) } - 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 { - 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() } - 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) -> 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(), + pub fn undo_status(&self) -> UndoStatus { + UndoStatus { + undo: self.can_undo(), + redo: self.can_redo(), } } /// 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); } /// 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(); } @@ -174,14 +197,40 @@ 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()); } + 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)] @@ -214,7 +263,7 @@ mod test { // record a few undo steps for i in 3..=4 { - col.transact(Some(UndoableOpKind::UpdateCard), |col| { + col.transact(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = i; Ok(()) @@ -226,41 +275,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(Op::UpdateCard, |col| { col.get_and_update_card(cid, |card| { card.interval = 5; Ok(()) @@ -270,11 +319,11 @@ 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 - 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); } 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() - } -} 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 {