diff --git a/.bazelrc b/.bazelrc index 2e841bfbf..50abd5e9e 100644 --- a/.bazelrc +++ b/.bazelrc @@ -28,15 +28,11 @@ build --incompatible_default_to_explicit_init_py build:ci --show_timestamps --isatty=0 --color=yes --show_progress_rate_limit=5 -# incrementally compile Anki crates in fastbuild mode, but not release mode. -# The worker must be separately enabled; see docs/development.md -build -c fastbuild --@rules_rust//worker:include_regex=anki.*|rsbridge -build:opt -c opt --@rules_rust//worker:include_regex=no-crates-please -build --worker_max_instances=Rustc=HOST_CPUS*0.5 +# disable incremental compilation in release mode +build:opt -c opt --@rules_rust//:experimental_incremental_prefixes= # the TypeScript workers on Windows choke when deps are changed while they're -# still running, so shut them down at the end of the build. Also fixes issues -# with the optional Rust worker. +# still running, so shut them down at the end of the build. build:windows --worker_quit_after_build try-import %workspace%/user.bazelrc diff --git a/defs.bzl b/defs.bzl index 420a4b346..c6aca6f0f 100644 --- a/defs.bzl +++ b/defs.bzl @@ -9,6 +9,7 @@ load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install load("@io_bazel_rules_sass//:defs.bzl", "sass_repositories") load("@com_github_ali5h_rules_pip//:defs.bzl", "pip_import") load("//pip/pyqt5:defs.bzl", "install_pyqt5") +load("//ts/esbuild:esbuild_repo.bzl", "esbuild_dependencies") anki_version = "2.1.44" @@ -52,3 +53,5 @@ def setup_deps(): ) sass_repositories() + + esbuild_dependencies() diff --git a/docs/development.md b/docs/development.md index c9a53ef38..f70ad95ff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -153,7 +153,7 @@ following in your user.bazelrc file to enable incremental compilation when using ./run. ``` -build --@rules_rust//worker:cache_root=/path/to/folder/to/store/temp/files +build --@rules_rust//:experimental_incremental_base=/home/myuser/bazel/incremental ``` The worker support is experimental, so you may need to remove it in future diff --git a/ftl/qt/errors.ftl b/ftl/qt/errors.ftl index 8f7c86805..c3bca0475 100644 --- a/ftl/qt/errors.ftl +++ b/ftl/qt/errors.ftl @@ -40,3 +40,4 @@ errors-unable-open-collection = Anki was unable to open your collection file. If problems persist after restarting your computer, please use the Open Backup button in the profile manager. Debug info: +errors-windows-tts-runtime-error = The TTS service failed. Please ensure Windows updates are installed, try restarting your computer, and try using a different voice. diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 5cbf0ef75..84977650a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -385,7 +385,7 @@ class Collection: note.id = NoteId(out.note_id) return out.changes - def remove_notes(self, note_ids: Sequence[NoteId]) -> OpChanges: + def remove_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount: hooks.notes_will_be_deleted(self, note_ids) return self._backend.remove_notes(note_ids=note_ids, card_ids=[]) @@ -917,11 +917,6 @@ 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": diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 8eb6807e6..8bd047a54 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -447,30 +447,31 @@ class DeckManager: # Deck selection ############################################################# - def active(self) -> List[DeckId]: - "The currrently active dids." - return self.col.get_config("activeDecks", [1]) + def get_current(self) -> Deck: + return self.col._backend.get_current_deck() - def selected(self) -> DeckId: - "The currently selected did." - return DeckId(int(self.col.conf["curDeck"])) + def set_current(self, deck: DeckId) -> OpChanges: + return self.col._backend.set_current_deck(deck) + + def get_current_id(self) -> DeckId: + "The currently selected deck ID." + return DeckId(self.get_current().id) + + # legacy def current(self) -> DeckDict: return self.get(self.selected()) def select(self, did: DeckId) -> None: - "Select a new branch." # make sure arg is an int; legacy callers may be passing in a string did = DeckId(did) - current = self.selected() - active = self.deck_and_child_ids(did) - if current != did or active != self.active(): - self.col.conf["curDeck"] = did - self.col.conf["activeDecks"] = active + self.set_current(did) + self.col.reset() - # don't use this, it will likely go away - def update_active(self) -> None: - self.select(self.current()["id"]) + def active(self) -> List[DeckId]: + return self.col.sched.active_decks + + selected = get_current_id # Parents/children ############################################################# @@ -518,7 +519,7 @@ class DeckManager: ) def deck_and_child_ids(self, deck_id: DeckId) -> List[DeckId]: - parent_name = self.get_legacy(deck_id)["name"] + parent_name = self.col.get_deck(deck_id).name out = [deck_id] out.extend(self.child_ids(parent_name)) return out diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index 280d9a7ec..a9f8b9ea9 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -16,7 +16,7 @@ from typing import List, Optional, Sequence from anki.cards import CardId from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV from anki.decks import DeckConfigDict, DeckId, DeckTreeNode -from anki.notes import Note +from anki.notes import NoteId from anki.utils import ids2str, intTime CongratsInfo = _pb.CongratsInfoOut @@ -85,8 +85,9 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # fixme: only used by totalRevForCurrentDeck and old deck stats; # schedv2 defines separate version def _deckLimit(self) -> str: - self.col.decks.update_active() - return ids2str(self.col.decks.active()) + return ids2str( + self.col.decks.deck_and_child_ids(self.col.decks.get_current_id()) + ) # Filtered deck handling ########################################################################## @@ -123,20 +124,31 @@ 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[CardId]) -> OpChanges: + def suspend_cards(self, ids: Sequence[CardId]) -> OpChangesWithCount: return self.col._backend.bury_or_suspend_cards( - card_ids=ids, mode=BuryOrSuspend.SUSPEND + card_ids=ids, note_ids=[], mode=BuryOrSuspend.SUSPEND ) - def bury_cards(self, ids: Sequence[CardId], manual: bool = True) -> OpChanges: + def suspend_notes(self, ids: Sequence[NoteId]) -> OpChangesWithCount: + return self.col._backend.bury_or_suspend_cards( + card_ids=[], note_ids=ids, mode=BuryOrSuspend.SUSPEND + ) + + def bury_cards( + self, ids: Sequence[CardId], manual: bool = True + ) -> OpChangesWithCount: if manual: mode = BuryOrSuspend.BURY_USER else: mode = BuryOrSuspend.BURY_SCHED - return self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode) + return self.col._backend.bury_or_suspend_cards( + card_ids=ids, note_ids=[], mode=mode + ) - def bury_note(self, note: Note) -> None: - self.bury_cards(note.card_ids()) + def bury_notes(self, note_ids: Sequence[NoteId]) -> OpChangesWithCount: + return self.col._backend.bury_or_suspend_cards( + card_ids=[], note_ids=note_ids, mode=BuryOrSuspend.BURY_USER + ) # Resetting/rescheduling ########################################################################## diff --git a/pylib/anki/scheduler/v1.py b/pylib/anki/scheduler/v1.py index 1c3fcc885..74920972a 100644 --- a/pylib/anki/scheduler/v1.py +++ b/pylib/anki/scheduler/v1.py @@ -33,7 +33,7 @@ class Scheduler(V2): def __init__( # pylint: disable=super-init-not-called self, col: anki.collection.Collection ) -> None: - self.col = col.weakref() + super().__init__(col) self.queueLimit = 50 self.reportLimit = 1000 self.dynReportLimit = 99999 @@ -42,7 +42,6 @@ class Scheduler(V2): self.revCount = 0 self.newCount = 0 self._haveQueues = False - self._updateCutoff() def answerCard(self, card: Card, ease: int) -> None: self.col.log() diff --git a/pylib/anki/scheduler/v2.py b/pylib/anki/scheduler/v2.py index 22b8a7aa6..22081cf58 100644 --- a/pylib/anki/scheduler/v2.py +++ b/pylib/anki/scheduler/v2.py @@ -48,7 +48,16 @@ class Scheduler(SchedulerBaseWithLegacy): self.reps = 0 self._haveQueues = False self._lrnCutoff = 0 - self._updateCutoff() + self._active_decks: List[DeckId] = [] + self._current_deck_id = DeckId(1) + + @property + def active_decks(self) -> List[DeckId]: + "Caller must make sure to make a copy." + return self._active_decks + + def _update_active_decks(self) -> None: + self._active_decks = self.col.decks.deck_and_child_ids(self._current_deck_id) # Daily cutoff ########################################################################## @@ -65,8 +74,8 @@ class Scheduler(SchedulerBaseWithLegacy): ########################################################################## def reset(self) -> None: - self.col.decks.update_active() - self._updateCutoff() + self._current_deck_id = self.col.decks.selected() + self._update_active_decks() self._reset_counts() self._resetLrn() self._resetRev() @@ -74,10 +83,8 @@ class Scheduler(SchedulerBaseWithLegacy): self._haveQueues = True def _reset_counts(self) -> None: - tree = self.deck_due_tree(self.col.decks.selected()) - node = self.col.decks.find_deck_in_tree( - tree, DeckId(int(self.col.conf["curDeck"])) - ) + tree = self.deck_due_tree(self._current_deck_id) + node = self.col.decks.find_deck_in_tree(tree, self._current_deck_id) if not node: # current deck points to a missing deck self.newCount = 0 diff --git a/pylib/tests/test_decks.py b/pylib/tests/test_decks.py index 74f6e5589..82e0dffcf 100644 --- a/pylib/tests/test_decks.py +++ b/pylib/tests/test_decks.py @@ -18,6 +18,7 @@ def test_basic(): assert col.decks.id("new deck") == parentId # we start with the default col selected assert col.decks.selected() == 1 + col.reset() assert col.decks.active() == [1] # we can select a different col col.decks.select(parentId) diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py index ac21d7fa7..c1a4d3c89 100644 --- a/pylib/tests/test_schedv1.py +++ b/pylib/tests/test_schedv1.py @@ -501,7 +501,7 @@ def test_misc(): col.addNote(note) c = note.cards()[0] # burying - col.sched.bury_note(note) + col.sched.bury_notes([note.id]) col.reset() assert not col.sched.getCard() col.sched.unbury_cards_in_current_deck() diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index bd60b8365..04e1f16ab 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -208,9 +208,9 @@ class AddCards(QDialog): self._load_new_note(sticky_fields_from=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 - ) + add_note(parent=self, note=note, target_deck_id=target_deck_id).success( + on_success + ).run_in_background() def _note_can_be_added(self, note: Note) -> bool: result = note.duplicate_or_empty() diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 575b08382..0ad2a8f88 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -24,7 +24,6 @@ from aqt.editor import Editor from aqt.exporting import ExportDialog from aqt.find_and_replace import FindAndReplaceDialog from aqt.main import ResetReason -from aqt.operations import OpMeta from aqt.operations.card import set_card_deck, set_card_flag from aqt.operations.collection import undo from aqt.operations.note import remove_notes @@ -128,12 +127,14 @@ class Browser(QMainWindow): gui_hooks.browser_will_show(self) self.show() - def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None: + def on_operation_did_execute( + self, changes: OpChanges, handler: Optional[object] + ) -> None: focused = current_top_level_widget() == self - self.table.op_executed(changes, meta, focused) - self.sidebar.op_executed(changes, meta, focused) + self.table.op_executed(changes, handler, focused) + self.sidebar.op_executed(changes, handler, focused) if changes.note or changes.notetype: - if meta.handler is not self.editor: + if handler is not self.editor: # fixme: this will leave the splitter shown, but with no current # note being edited note = self.editor.note @@ -641,11 +642,7 @@ where id in %s""" self.focusTo = self.editor.currentField self.table.to_next_row() - remove_notes( - mw=self.mw, - note_ids=nids, - success=lambda _: tooltip(tr.browsing_note_deleted(count=len(nids))), - ) + remove_notes(parent=self, note_ids=nids).run_in_background() # legacy @@ -676,7 +673,7 @@ where id in %s""" return did = self.col.decks.id(ret.name) - set_card_deck(mw=self.mw, card_ids=cids, deck_id=did) + set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background() # legacy @@ -694,13 +691,8 @@ where id in %s""" if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())): return add_tags_to_notes( - mw=self.mw, - note_ids=self.selected_notes(), - space_separated_tags=tags, - success=lambda out: tooltip( - tr.browsing_notes_updated(count=out.count), parent=self - ), - ) + parent=self, note_ids=self.selected_notes(), space_separated_tags=tags + ).run_in_background(initiator=self) @ensure_editor_saved_on_trigger def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: @@ -709,14 +701,10 @@ where id in %s""" tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete()) ): return + remove_tags_from_notes( - mw=self.mw, - note_ids=self.selected_notes(), - space_separated_tags=tags, - success=lambda out: tooltip( - tr.browsing_notes_updated(count=out.count), parent=self - ), - ) + parent=self, note_ids=self.selected_notes(), space_separated_tags=tags + ).run_in_background(initiator=self) def _prompt_for_tags(self, prompt: str) -> Optional[str]: (tags, ok) = getTag(self, self.col, prompt) @@ -727,7 +715,7 @@ where id in %s""" @ensure_editor_saved_on_trigger def clear_unused_tags(self) -> None: - clear_unused_tags(mw=self.mw, parent=self) + clear_unused_tags(parent=self).run_in_background() addTags = add_tags_to_selected_notes deleteTags = remove_tags_from_selected_notes @@ -744,9 +732,9 @@ where id in %s""" def suspend_selected_cards(self, checked: bool) -> None: cids = self.selected_cards() if checked: - suspend_cards(mw=self.mw, card_ids=cids) + suspend_cards(parent=self, card_ids=cids).run_in_background() else: - unsuspend_cards(mw=self.mw, card_ids=cids) + unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background() # Exporting ###################################################################### @@ -768,7 +756,9 @@ where id in %s""" if flag == self.card.user_flag(): flag = 0 - set_card_flag(mw=self.mw, card_ids=self.selected_cards(), flag=flag) + set_card_flag( + parent=self, card_ids=self.selected_cards(), flag=flag + ).run_in_background() def _update_flags_menu(self) -> None: flag = self.card and self.card.user_flag() @@ -806,25 +796,23 @@ where id in %s""" return reposition_new_cards_dialog( - mw=self.mw, parent=self, card_ids=self.selected_cards() - ) + parent=self, card_ids=self.selected_cards() + ).run_in_background() @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, - ) + ).run_in_background() @ensure_editor_saved_on_trigger def forget_cards(self) -> None: forget_cards( - mw=self.mw, parent=self, card_ids=self.selected_cards(), - ) + ).run_in_background() # Edit: selection ###################################################################### @@ -867,7 +855,7 @@ where id in %s""" ###################################################################### def undo(self) -> None: - undo(mw=self.mw, parent=self) + undo(parent=self) def onUndoState(self, on: bool) -> None: self.form.actionUndo.setEnabled(on) diff --git a/qt/aqt/data/web/js/mathjax.js b/qt/aqt/data/web/js/mathjax.js index f5d99fb84..2d864715f 100644 --- a/qt/aqt/data/web/js/mathjax.js +++ b/qt/aqt/data/web/js/mathjax.js @@ -3,6 +3,7 @@ window.MathJax = { displayMath: [["\\[", "\\]"]], processRefs: false, processEnvironments: false, + processEscapes: false, packages: { "[+]": ["noerrors", "mhchem"], }, diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 34c371eb6..b795a8401 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -5,7 +5,7 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass -from typing import Any +from typing import Any, Optional import aqt from anki.collection import OpChanges @@ -17,6 +17,7 @@ from aqt.operations.deck import ( remove_decks, rename_deck, reparent_decks, + set_current_deck, set_deck_collapsed, ) from aqt.qt import * @@ -76,8 +77,10 @@ class DeckBrowser: if self._refresh_needed: self.refresh() - def op_executed(self, changes: OpChanges, focused: bool) -> bool: - if self.mw.col.op_affects_study_queue(changes): + def op_executed( + self, changes: OpChanges, handler: Optional[object], focused: bool + ) -> bool: + if changes.study_queues and handler is not self: self._refresh_needed = True if focused: @@ -94,7 +97,7 @@ class DeckBrowser: else: cmd = url if cmd == "open": - self._selDeck(arg) + self.set_current_deck(DeckId(int(arg))) elif cmd == "opts": self._showOptions(arg) elif cmd == "shared": @@ -117,9 +120,10 @@ class DeckBrowser: self.refresh() return False - def _selDeck(self, did: str) -> None: - self.mw.col.decks.select(DeckId(int(did))) - self.mw.onOverview() + def set_current_deck(self, deck_id: DeckId) -> None: + set_current_deck(parent=self.mw, deck_id=deck_id).success( + lambda _: self.mw.onOverview() + ).run_in_background(initiator=self) # HTML generation ########################################################################## @@ -276,7 +280,9 @@ class DeckBrowser: if not new_name or new_name == deck.name: return else: - rename_deck(mw=self.mw, deck_id=did, new_name=new_name) + rename_deck( + parent=self.mw, deck_id=did, new_name=new_name + ).run_in_background() self.mw.query_op(lambda: self.mw.col.get_deck(did), success=prompt) @@ -291,18 +297,20 @@ class DeckBrowser: if node: node.collapsed = not node.collapsed set_deck_collapsed( - mw=self.mw, + parent=self.mw, deck_id=did, collapsed=node.collapsed, scope=DeckCollapseScope.REVIEWER, - ) + ).run_in_background() self._renderPage(reuse=True) def _handle_drag_and_drop(self, source: DeckId, target: DeckId) -> None: - reparent_decks(mw=self.mw, parent=self.mw, deck_ids=[source], new_parent=target) + reparent_decks( + parent=self.mw, deck_ids=[source], new_parent=target + ).run_in_background() def _delete(self, did: DeckId) -> None: - remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did]) + remove_decks(parent=self.mw, deck_ids=[did]).run_in_background() # Top buttons ###################################################################### @@ -333,7 +341,8 @@ class DeckBrowser: openLink(f"{aqt.appShared}decks/") def _on_create(self) -> None: - add_deck_dialog(mw=self.mw, parent=self.mw) + if op := add_deck_dialog(parent=self.mw): + op.run_in_background() ###################################################################### diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py index 3a3a67a63..209623e37 100644 --- a/qt/aqt/editcurrent.py +++ b/qt/aqt/editcurrent.py @@ -1,11 +1,11 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from typing import Optional import aqt.editor from anki.collection import OpChanges from anki.errors import NotFoundError from aqt import gui_hooks -from aqt.operations import OpMeta from aqt.qt import * from aqt.utils import disable_help_button, restoreGeom, saveGeom, tr @@ -31,8 +31,10 @@ class EditCurrent(QDialog): gui_hooks.operation_did_execute.append(self.on_operation_did_execute) self.show() - def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None: - if changes.editor and meta.handler is not self.editor: + def on_operation_did_execute( + self, changes: OpChanges, handler: Optional[object] + ) -> None: + if changes.editor and handler is not self.editor: # reload note note = self.editor.note try: diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 82f795994..08539a8e3 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -100,7 +100,7 @@ class Editor: redrawing. The editor will cause that hook to be fired when it saves changes. To avoid - an unwanted refresh, the parent widget should check if meta.handler + an unwanted refresh, the parent widget should check if handler corresponds to this editor instance, and ignore the change if it does. """ @@ -558,7 +558,9 @@ class Editor: def _save_current_note(self) -> None: "Call after note is updated with data from webview." - update_note(mw=self.mw, note=self.note, handler=self) + update_note(parent=self.widget, note=self.note).run_in_background( + initiator=self + ) def fonts(self) -> List[Tuple[str, int, bool]]: return [ diff --git a/qt/aqt/filtered_deck.py b/qt/aqt/filtered_deck.py index fcce0af09..25af48f80 100644 --- a/qt/aqt/filtered_deck.py +++ b/qt/aqt/filtered_deck.py @@ -310,7 +310,9 @@ class FilteredDeckConfigDialog(QDialog): gui_hooks.filtered_deck_dialog_will_add_or_update_deck(self, self.deck) - add_or_update_filtered_deck(mw=self.mw, deck=self.deck, success=success) + add_or_update_filtered_deck(parent=self, deck=self.deck).success( + success + ).run_in_background() # Step load/save ######################################################## diff --git a/qt/aqt/find_and_replace.py b/qt/aqt/find_and_replace.py index d0831e2b1..4f6b14e3f 100644 --- a/qt/aqt/find_and_replace.py +++ b/qt/aqt/find_and_replace.py @@ -3,11 +3,13 @@ from __future__ import annotations -from typing import List, Optional, Sequence +from typing import List, Sequence import aqt from anki.notes import NoteId from aqt import AnkiQt, QWidget +from aqt.operations.note import find_and_replace +from aqt.operations.tag import find_and_replace_tag from aqt.qt import QDialog, Qt from aqt.utils import ( HelpPage, @@ -22,63 +24,10 @@ from aqt.utils import ( save_combo_index_for_session, save_is_checked, saveGeom, - tooltip, tr, ) -def find_and_replace( - *, - mw: AnkiQt, - parent: QWidget, - note_ids: Sequence[NoteId], - 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.findreplace_notes_updated(changed=out.count, total=len(note_ids)), - 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.findreplace_notes_updated(changed=out.count, total=len(note_ids)), - parent=parent, - ), - ) - - class FindAndReplaceDialog(QDialog): COMBO_NAME = "BrowserFindAndReplace" @@ -146,10 +95,9 @@ class FindAndReplaceDialog(QDialog): save_is_checked(self.form.re, self.COMBO_NAME + "Regex") save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") + # tags? if self.form.field.currentIndex() == 1: - # tags find_and_replace_tag( - mw=self.mw, parent=self.parentWidget(), note_ids=self.note_ids, search=search, @@ -157,23 +105,22 @@ class FindAndReplaceDialog(QDialog): 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] + # fields + 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, - ) + find_and_replace( + parent=self.parentWidget(), + note_ids=self.note_ids, + search=search, + replacement=replace, + regex=regex, + field_name=field, + match_case=match_case, + ) super().accept() diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 6611f5cd1..ed9bdb9c1 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -21,7 +21,6 @@ from typing import ( List, Literal, Optional, - Protocol, Sequence, TextIO, Tuple, @@ -40,15 +39,7 @@ import aqt.toolbar import aqt.webview from anki import hooks from anki._backend import RustBackend as _RustBackend -from anki.collection import ( - Collection, - Config, - OpChanges, - OpChangesAfterUndo, - OpChangesWithCount, - OpChangesWithId, - UndoStatus, -) +from anki.collection import Collection, Config, OpChanges, UndoStatus from anki.decks import DeckDict, DeckId from anki.hooks import runHook from anki.notes import NoteId @@ -61,8 +52,8 @@ from aqt.emptycards import show_empty_cards from aqt.legacy import install_pylib_legacy from aqt.mediacheck import check_media_db from aqt.mediasync import MediaSyncer -from aqt.operations import OpMeta from aqt.operations.collection import undo +from aqt.operations.deck import set_current_deck from aqt.profiles import ProfileManager as ProfileManagerType from aqt.qt import * from aqt.qt import sip @@ -92,30 +83,6 @@ from aqt.utils import ( 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, - OpChangesWithId, - OpChangesAfterUndo, - HasChangesProperty, - ], -) - -T = TypeVar("T") - -PerformOpOptionalSuccessCallback = Optional[Callable[[ResultWithChanges], Any]] -PerformOpOptionalFailureCallback = Optional[Callable[[Exception], Any]] - install_pylib_legacy() MainWindowState = Literal[ @@ -123,6 +90,9 @@ MainWindowState = Literal[ ] +T = TypeVar("T") + + class AnkiQt(QMainWindow): col: Collection pm: ProfileManagerType @@ -733,10 +703,9 @@ class AnkiQt(QMainWindow): ) -> 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. + 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. @@ -766,71 +735,6 @@ class AnkiQt(QMainWindow): # Resetting state ########################################################################## - def perform_op( - self, - op: Callable[[], ResultWithChanges], - *, - success: PerformOpOptionalSuccessCallback = None, - failure: PerformOpOptionalFailureCallback = None, - after_hooks: Optional[Callable[[], None]] = None, - meta: OpMeta = OpMeta(), - ) -> 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, meta) - - self.taskman.with_progress(op, wrapped_done) - def _increase_background_ops(self) -> None: if not self._background_op_count: gui_hooks.backend_will_block() @@ -842,27 +746,6 @@ class AnkiQt(QMainWindow): 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]], - meta: OpMeta, - ) -> 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, meta) - # 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()""" @@ -872,15 +755,17 @@ class AnkiQt(QMainWindow): setattr(op, field.name, True) gui_hooks.operation_did_execute(op, None) - def on_operation_did_execute(self, changes: OpChanges, meta: OpMeta) -> None: + def on_operation_did_execute( + self, changes: OpChanges, handler: Optional[object] + ) -> None: "Notify current screen of changes." focused = current_top_level_widget() == self if self.state == "review": - dirty = self.reviewer.op_executed(changes, focused) + dirty = self.reviewer.op_executed(changes, handler, focused) elif self.state == "overview": - dirty = self.overview.op_executed(changes, focused) + dirty = self.overview.op_executed(changes, handler, focused) elif self.state == "deckBrowser": - dirty = self.deckBrowser.op_executed(changes, focused) + dirty = self.deckBrowser.op_executed(changes, handler, focused) else: dirty = False @@ -908,7 +793,7 @@ class AnkiQt(QMainWindow): 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.""" + New code should use CollectionOp() instead.""" if self.col: # fire new `operation_did_execute` hook first. If the overview # or review screen are currently open, they will rebuild the study @@ -1218,7 +1103,7 @@ title="%s" %s>%s""" % ( def undo(self) -> None: "Call collection_ops.py:undo() directly instead." - undo(mw=self, parent=self) + undo(parent=self) def update_undo_actions(self, status: Optional[UndoStatus] = None) -> None: """Update menu text and enable/disable menu item as appropriate. @@ -1541,8 +1426,11 @@ title="%s" %s>%s""" % ( ret = StudyDeck(self, dyn=True, current=self.col.decks.current()["name"]) if ret.name: - self.col.decks.select(self.col.decks.id(ret.name)) - self.moveToState("overview") + # fixme: this is silly, it should be returning an ID + deck_id = self.col.decks.id(ret.name) + set_current_deck(parent=self, deck_id=deck_id).success( + lambda out: self.moveToState("overview") + ).run_in_background() def onEmptyCards(self) -> None: show_empty_cards(self) diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py index dd3d825c6..f416f1075 100644 --- a/qt/aqt/operations/__init__.py +++ b/qt/aqt/operations/__init__.py @@ -1,16 +1,142 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -from dataclasses import dataclass -from typing import Optional +from __future__ import annotations + +from concurrent.futures._base import Future +from typing import Any, Callable, Generic, Optional, Protocol, TypeVar, Union + +import aqt +from anki.collection import ( + Collection, + OpChanges, + OpChangesAfterUndo, + OpChangesWithCount, + OpChangesWithId, +) +from aqt.qt import QWidget +from aqt.utils import showWarning -@dataclass -class OpMeta: - """Metadata associated with an operation. +class HasChangesProperty(Protocol): + changes: OpChanges - The `handler` field can be used by screens to ignore change - events they initiated themselves, if they have already made - the required changes.""" - handler: Optional[object] = None +# 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, + OpChangesWithId, + OpChangesAfterUndo, + HasChangesProperty, + ], +) + + +class CollectionOp(Generic[ResultWithChanges]): + """Helper to perform a mutating DB operation on a background thread, and update UI. + + `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. + """ + + _success: Optional[Callable[[ResultWithChanges], Any]] = None + _failure: Optional[Optional[Callable[[Exception], Any]]] = None + + def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]): + self._parent = parent + self._op = op + + def success( + self, success: Optional[Callable[[ResultWithChanges], Any]] + ) -> CollectionOp[ResultWithChanges]: + self._success = success + return self + + def failure( + self, failure: Optional[Optional[Callable[[Exception], Any]]] + ) -> CollectionOp[ResultWithChanges]: + self._failure = failure + return self + + def run_in_background(self, *, initiator: Optional[object] = None) -> None: + from aqt import mw + + assert mw + + mw._increase_background_ops() + + def wrapped_op() -> ResultWithChanges: + assert mw + return self._op(mw.col) + + def wrapped_done(future: Future) -> None: + assert mw + mw._decrease_background_ops() + # did something go wrong? + if exception := future.exception(): + if isinstance(exception, Exception): + if self._failure: + self._failure(exception) + else: + showWarning(str(exception), self._parent) + return + else: + # BaseException like SystemExit; rethrow it + future.result() + + result = future.result() + try: + if self._success: + self._success(result) + finally: + # update undo status + status = mw.col.undo_status() + mw._update_undo_actions_for_status_and_save(status) + # fire change hooks + self._fire_change_hooks_after_op_performed(result, initiator) + + mw.taskman.with_progress(wrapped_op, wrapped_done) + + def _fire_change_hooks_after_op_performed( + self, + result: ResultWithChanges, + handler: Optional[object], + ) -> None: + from aqt import mw + + assert mw + + if isinstance(result, OpChanges): + changes = result + else: + changes = result.changes + + # fire new hook + print("op changes:") + print(changes) + aqt.gui_hooks.operation_did_execute(changes, handler) + # fire legacy hook so old code notices changes + if mw.col.op_made_changes(changes): + aqt.gui_hooks.state_did_reset() diff --git a/qt/aqt/operations/card.py b/qt/aqt/operations/card.py index 91c4ac0d2..2e7f0ac19 100644 --- a/qt/aqt/operations/card.py +++ b/qt/aqt/operations/card.py @@ -6,13 +6,22 @@ from __future__ import annotations from typing import Sequence from anki.cards import CardId +from anki.collection import OpChanges from anki.decks import DeckId -from aqt import AnkiQt +from aqt.operations import CollectionOp +from aqt.qt import QWidget -def set_card_deck(*, mw: AnkiQt, card_ids: Sequence[CardId], deck_id: DeckId) -> None: - mw.perform_op(lambda: mw.col.set_deck(card_ids, deck_id)) +def set_card_deck( + *, parent: QWidget, card_ids: Sequence[CardId], deck_id: DeckId +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.set_deck(card_ids, deck_id)) -def set_card_flag(*, mw: AnkiQt, card_ids: Sequence[CardId], flag: int) -> None: - mw.perform_op(lambda: mw.col.set_user_flag_for_cards(flag, card_ids)) +def set_card_flag( + *, + parent: QWidget, + card_ids: Sequence[CardId], + flag: int, +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.set_user_flag_for_cards(flag, card_ids)) diff --git a/qt/aqt/operations/collection.py b/qt/aqt/operations/collection.py index 95923c055..1eb130b8c 100644 --- a/qt/aqt/operations/collection.py +++ b/qt/aqt/operations/collection.py @@ -3,34 +3,37 @@ from __future__ import annotations -import aqt -from anki.collection import LegacyCheckpoint, LegacyReviewUndo, OpChangesAfterUndo +from anki.collection import LegacyCheckpoint, LegacyReviewUndo from anki.errors import UndoEmpty from anki.types import assert_exhaustive from aqt import gui_hooks +from aqt.operations import CollectionOp from aqt.qt import QWidget from aqt.utils import showInfo, showWarning, tooltip, tr -def undo(*, mw: aqt.AnkiQt, parent: QWidget) -> None: +def undo(*, parent: QWidget) -> None: "Undo the last operation, and refresh the UI." - def on_success(out: OpChangesAfterUndo) -> None: - mw.update_undo_actions(out.new_status) - tooltip(tr.undo_action_undone(action=out.operation), parent=parent) - def on_failure(exc: Exception) -> None: if isinstance(exc, UndoEmpty): # backend has no undo, but there may be a checkpoint # or v1/v2 review waiting - _legacy_undo(mw=mw, parent=parent) + _legacy_undo(parent=parent) else: showWarning(str(exc), parent=parent) - mw.perform_op(mw.col.undo, success=on_success, failure=on_failure) + CollectionOp(parent, lambda col: col.undo()).success( + lambda out: tooltip(tr.undo_action_undone(action=out.operation), parent=parent) + ).failure(on_failure).run_in_background() -def _legacy_undo(*, mw: aqt.AnkiQt, parent: QWidget) -> None: +def _legacy_undo(*, parent: QWidget) -> None: + from aqt import mw + + assert mw + assert mw.col + reviewing = mw.state == "review" just_refresh_reviewer = False diff --git a/qt/aqt/operations/deck.py b/qt/aqt/operations/deck.py index e32e69b0c..a8d69bd89 100644 --- a/qt/aqt/operations/deck.py +++ b/qt/aqt/operations/deck.py @@ -3,85 +3,80 @@ from __future__ import annotations -from typing import Callable, Optional, Sequence +from typing import Optional, Sequence +from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId from anki.decks import DeckCollapseScope, DeckId -from aqt import AnkiQt, QWidget -from aqt.main import PerformOpOptionalSuccessCallback -from aqt.operations import OpMeta +from aqt import QWidget +from aqt.operations import CollectionOp from aqt.utils import getOnlyText, tooltip, tr def remove_decks( *, - mw: AnkiQt, parent: QWidget, deck_ids: Sequence[DeckId], -) -> None: - mw.perform_op( - lambda: mw.col.decks.remove(deck_ids), - success=lambda out: tooltip( - tr.browsing_cards_deleted(count=out.count), parent=parent - ), +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.decks.remove(deck_ids)).success( + lambda out: tooltip(tr.browsing_cards_deleted(count=out.count), parent=parent) ) def reparent_decks( - *, mw: AnkiQt, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId -) -> None: - mw.perform_op( - lambda: mw.col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent), - success=lambda out: tooltip( + *, parent: QWidget, deck_ids: Sequence[DeckId], new_parent: DeckId +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, lambda col: col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent) + ).success( + lambda out: tooltip( tr.browsing_reparented_decks(count=out.count), parent=parent - ), + ) ) def rename_deck( *, - mw: AnkiQt, + parent: QWidget, deck_id: DeckId, new_name: str, - after_rename: Callable[[], None] = None, -) -> None: - mw.perform_op( - lambda: mw.col.decks.rename(deck_id, new_name), after_hooks=after_rename +) -> CollectionOp[OpChanges]: + return CollectionOp( + parent, + lambda col: col.decks.rename(deck_id, new_name), ) def add_deck_dialog( *, - mw: AnkiQt, parent: QWidget, default_text: str = "", - success: PerformOpOptionalSuccessCallback = None, -) -> None: +) -> Optional[CollectionOp[OpChangesWithId]]: if name := getOnlyText( tr.decks_new_deck_name(), default=default_text, parent=parent ).strip(): - add_deck(mw=mw, name=name, success=success) + return add_deck(parent=parent, name=name) + else: + return None -def add_deck( - *, mw: AnkiQt, name: str, success: PerformOpOptionalSuccessCallback = None -) -> None: - mw.perform_op( - lambda: mw.col.decks.add_normal_deck_with_name(name), - success=success, - ) +def add_deck(*, parent: QWidget, name: str) -> CollectionOp[OpChangesWithId]: + return CollectionOp(parent, lambda col: col.decks.add_normal_deck_with_name(name)) def set_deck_collapsed( *, - mw: AnkiQt, + parent: QWidget, deck_id: DeckId, collapsed: bool, scope: DeckCollapseScope.V, - handler: Optional[object] = None, -) -> None: - mw.perform_op( - lambda: mw.col.decks.set_collapsed( +) -> CollectionOp[OpChanges]: + return CollectionOp( + parent, + lambda col: col.decks.set_collapsed( deck_id=deck_id, collapsed=collapsed, scope=scope ), - meta=OpMeta(handler=handler), ) + + +def set_current_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.decks.set_current(deck_id)) diff --git a/qt/aqt/operations/note.py b/qt/aqt/operations/note.py index 4c0d8c3d2..de5be2f6b 100644 --- a/qt/aqt/operations/note.py +++ b/qt/aqt/operations/note.py @@ -5,34 +5,60 @@ from __future__ import annotations from typing import Optional, Sequence +from anki.collection import OpChanges, OpChangesWithCount from anki.decks import DeckId from anki.notes import Note, NoteId -from aqt import AnkiQt -from aqt.main import PerformOpOptionalSuccessCallback -from aqt.operations import OpMeta +from aqt.operations import CollectionOp +from aqt.qt import QWidget +from aqt.utils import tooltip, tr def add_note( *, - mw: AnkiQt, + parent: QWidget, note: Note, target_deck_id: DeckId, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op(lambda: mw.col.add_note(note, target_deck_id), success=success) +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.add_note(note, target_deck_id)) -def update_note(*, mw: AnkiQt, note: Note, handler: Optional[object]) -> None: - mw.perform_op( - lambda: mw.col.update_note(note), - meta=OpMeta(handler=handler), - ) +def update_note(*, parent: QWidget, note: Note) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.update_note(note)) def remove_notes( *, - mw: AnkiQt, + parent: QWidget, note_ids: Sequence[NoteId], - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.remove_notes(note_ids)).success( + lambda out: tooltip(tr.browsing_cards_deleted(count=out.count)), + ) + + +def find_and_replace( + *, + parent: QWidget, + note_ids: Sequence[NoteId], + search: str, + replacement: str, + regex: bool, + field_name: Optional[str], + match_case: bool, +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, + lambda col: 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.findreplace_notes_updated(changed=out.count, total=len(note_ids)), + parent=parent, + ) + ) diff --git a/qt/aqt/operations/scheduling.py b/qt/aqt/operations/scheduling.py index acb3dcdff..384aa0602 100644 --- a/qt/aqt/operations/scheduling.py +++ b/qt/aqt/operations/scheduling.py @@ -7,28 +7,33 @@ from typing import Optional, Sequence import aqt from anki.cards import CardId -from anki.collection import CARD_TYPE_NEW, Config +from anki.collection import ( + CARD_TYPE_NEW, + Config, + OpChanges, + OpChangesWithCount, + OpChangesWithId, +) from anki.decks import DeckId from anki.notes import NoteId from anki.scheduler import FilteredDeckForUpdate -from aqt import AnkiQt -from aqt.main import PerformOpOptionalSuccessCallback +from aqt.operations import CollectionOp 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: Sequence[CardId], config_key: Optional[Config.String.Key.V], -) -> None: +) -> Optional[CollectionOp[OpChanges]]: + assert aqt.mw if not card_ids: - return + return None default_text = ( - mw.col.get_config_string(config_key) if config_key is not None else "" + aqt.mw.col.get_config_string(config_key) if config_key is not None else "" ) prompt = "\n".join( [ @@ -43,35 +48,38 @@ def set_due_date_dialog( title=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.scheduling_set_due_date_done(cards=len(card_ids)), - parent=parent, - ), - ) + return None + else: + return CollectionOp( + parent, lambda col: col.sched.set_due_date(card_ids, days, config_key) + ).success( + lambda _: tooltip( + tr.scheduling_set_due_date_done(cards=len(card_ids)), + parent=parent, + ) + ) def forget_cards( - *, mw: aqt.AnkiQt, parent: QWidget, card_ids: Sequence[CardId] -) -> None: - if not card_ids: - return - - mw.perform_op( - lambda: mw.col.sched.schedule_cards_as_new(card_ids), - success=lambda _: tooltip( + *, parent: QWidget, card_ids: Sequence[CardId] +) -> CollectionOp[OpChanges]: + return CollectionOp( + parent, lambda col: col.sched.schedule_cards_as_new(card_ids) + ).success( + lambda _: tooltip( tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent - ), + ) ) def reposition_new_cards_dialog( - *, mw: AnkiQt, parent: QWidget, card_ids: Sequence[CardId] -) -> None: + *, parent: QWidget, card_ids: Sequence[CardId] +) -> Optional[CollectionOp[OpChangesWithCount]]: + from aqt import mw + + assert mw 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" ) @@ -92,15 +100,14 @@ def reposition_new_cards_dialog( frm.start.selectAll() if not d.exec_(): - return + return None start = frm.start.value() step = frm.step.value() randomize = frm.randomize.isChecked() shift = frm.shift.isChecked() - reposition_new_cards( - mw=mw, + return reposition_new_cards( parent=parent, card_ids=card_ids, starting_from=start, @@ -112,89 +119,80 @@ def reposition_new_cards_dialog( def reposition_new_cards( *, - mw: AnkiQt, parent: QWidget, card_ids: Sequence[CardId], starting_from: int, step_size: int, randomize: bool, shift_existing: bool, -) -> None: - mw.perform_op( - lambda: mw.col.sched.reposition_new_cards( +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, + lambda col: 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( + ).success( + lambda out: tooltip( tr.browsing_changed_new_position(count=out.count), parent=parent - ), + ) ) def suspend_cards( *, - mw: AnkiQt, + parent: QWidget, card_ids: Sequence[CardId], - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op(lambda: mw.col.sched.suspend_cards(card_ids), success=success) +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.sched.suspend_cards(card_ids)) def suspend_note( *, - mw: AnkiQt, - note_id: NoteId, - 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), - ) + parent: QWidget, + note_ids: Sequence[NoteId], +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.sched.suspend_notes(note_ids)) -def unsuspend_cards(*, mw: AnkiQt, card_ids: Sequence[CardId]) -> None: - mw.perform_op(lambda: mw.col.sched.unsuspend_cards(card_ids)) +def unsuspend_cards( + *, parent: QWidget, card_ids: Sequence[CardId] +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.sched.unsuspend_cards(card_ids)) def bury_cards( *, - mw: AnkiQt, + parent: QWidget, card_ids: Sequence[CardId], - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op(lambda: mw.col.sched.bury_cards(card_ids), success=success) +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.sched.bury_cards(card_ids)) -def bury_note( +def bury_notes( *, - mw: AnkiQt, - note_id: NoteId, - 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), - ) + parent: QWidget, + note_ids: Sequence[NoteId], +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.sched.bury_notes(note_ids)) -def rebuild_filtered_deck(*, mw: AnkiQt, deck_id: DeckId) -> None: - mw.perform_op(lambda: mw.col.sched.rebuild_filtered_deck(deck_id)) +def rebuild_filtered_deck( + *, parent: QWidget, deck_id: DeckId +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.sched.rebuild_filtered_deck(deck_id)) -def empty_filtered_deck(*, mw: AnkiQt, deck_id: DeckId) -> None: - mw.perform_op(lambda: mw.col.sched.empty_filtered_deck(deck_id)) +def empty_filtered_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.sched.empty_filtered_deck(deck_id)) def add_or_update_filtered_deck( *, - mw: AnkiQt, + parent: QWidget, deck: FilteredDeckForUpdate, - success: PerformOpOptionalSuccessCallback, -) -> None: - mw.perform_op( - lambda: mw.col.sched.add_or_update_filtered_deck(deck), - success=success, - ) +) -> CollectionOp[OpChangesWithId]: + return CollectionOp(parent, lambda col: col.sched.add_or_update_filtered_deck(deck)) diff --git a/qt/aqt/operations/tag.py b/qt/aqt/operations/tag.py index ee1e767cb..33ee3b9b5 100644 --- a/qt/aqt/operations/tag.py +++ b/qt/aqt/operations/tag.py @@ -3,90 +3,116 @@ from __future__ import annotations -from typing import Callable, Sequence +from typing import Sequence -from anki.collection import OpChangesWithCount +from anki.collection import OpChanges, OpChangesWithCount from anki.notes import NoteId -from aqt import AnkiQt, QWidget -from aqt.main import PerformOpOptionalSuccessCallback +from aqt import QWidget +from aqt.operations import CollectionOp from aqt.utils import showInfo, tooltip, tr def add_tags_to_notes( *, - mw: AnkiQt, + parent: QWidget, note_ids: Sequence[NoteId], space_separated_tags: str, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op( - lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, lambda col: col.tags.bulk_add(note_ids, space_separated_tags) + ).success( + lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) def remove_tags_from_notes( *, - mw: AnkiQt, + parent: QWidget, note_ids: Sequence[NoteId], space_separated_tags: str, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op( - lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, lambda col: col.tags.bulk_remove(note_ids, space_separated_tags) + ).success( + lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) -def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: - mw.perform_op( - mw.col.tags.clear_unused_tags, - success=lambda out: tooltip( +def clear_unused_tags(*, parent: QWidget) -> CollectionOp[OpChangesWithCount]: + return CollectionOp(parent, lambda col: col.tags.clear_unused_tags()).success( + lambda out: tooltip( 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: +) -> CollectionOp[OpChangesWithCount]: def success(out: OpChangesWithCount) -> None: if out.count: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) else: showInfo(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, - ) + return CollectionOp( + parent, + lambda col: col.tags.rename(old=current_name, new=new_name), + ).success(success) def remove_tags_from_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.browsing_notes_updated(count=out.count), parent=parent - ), + *, parent: QWidget, space_separated_tags: str +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, lambda col: col.tags.remove(space_separated_tags=space_separated_tags) + ).success( + lambda out: tooltip(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.browsing_notes_updated(count=out.count), parent=parent - ), + *, parent: QWidget, tags: Sequence[str], new_parent: str +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, lambda col: col.tags.reparent(tags=tags, new_parent=new_parent) + ).success( + lambda out: tooltip(tr.browsing_notes_updated(count=out.count), parent=parent) ) -def set_tag_collapsed(*, mw: AnkiQt, tag: str, collapsed: bool) -> None: - mw.perform_op(lambda: mw.col.tags.set_collapsed(tag=tag, collapsed=collapsed)) +def set_tag_collapsed( + *, parent: QWidget, tag: str, collapsed: bool +) -> CollectionOp[OpChanges]: + return CollectionOp( + parent, lambda col: col.tags.set_collapsed(tag=tag, collapsed=collapsed) + ) + + +def find_and_replace_tag( + *, + parent: QWidget, + note_ids: Sequence[int], + search: str, + replacement: str, + regex: bool, + match_case: bool, +) -> CollectionOp[OpChangesWithCount]: + return CollectionOp( + parent, + lambda col: col.tags.find_and_replace( + note_ids=note_ids, + search=search, + replacement=replacement, + regex=regex, + match_case=match_case, + ), + ).success( + lambda out: tooltip( + tr.findreplace_notes_updated(changed=out.count, total=len(note_ids)), + parent=parent, + ), + ) diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 8d2df0d76..bd98aac3e 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -64,8 +64,10 @@ class Overview: if self._refresh_needed: self.refresh() - def op_executed(self, changes: OpChanges, focused: bool) -> bool: - if self.mw.col.op_affects_study_queue(changes): + def op_executed( + self, changes: OpChanges, handler: Optional[object], focused: bool + ) -> bool: + if changes.study_queues: self._refresh_needed = True if focused: @@ -117,12 +119,14 @@ class Overview: return self.mw.col.decks.current()["dyn"] def rebuild_current_filtered_deck(self) -> None: - if self._current_deck_is_filtered(): - rebuild_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected()) + rebuild_filtered_deck( + parent=self.mw, deck_id=self.mw.col.decks.selected() + ).run_in_background() def empty_current_filtered_deck(self) -> None: - if self._current_deck_is_filtered(): - empty_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected()) + empty_filtered_deck( + parent=self.mw, deck_id=self.mw.col.decks.selected() + ).run_in_background() def onCustomStudyKey(self) -> None: if not self._current_deck_is_filtered(): diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index feb9d8e4d..0394bc8fd 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -14,7 +14,7 @@ from PyQt5.QtCore import Qt from anki import hooks from anki.cards import Card, CardId -from anki.collection import Config, OpChanges +from anki.collection import Config, OpChanges, OpChangesWithCount from anki.tags import MARKED_TAG from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks @@ -22,7 +22,7 @@ from aqt.operations.card import set_card_flag from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( bury_cards, - bury_note, + bury_notes, set_due_date_dialog, suspend_cards, suspend_note, @@ -38,7 +38,6 @@ from aqt.webview import AnkiWebView class RefreshNeeded(Enum): - NO = auto() NOTE_TEXT = auto() QUEUES = auto() @@ -71,7 +70,7 @@ class Reviewer: self._recordedAudio: Optional[str] = None self.typeCorrect: str = None # web init happens before this is set self.state: Optional[str] = None - self._refresh_needed = RefreshNeeded.NO + self._refresh_needed: Optional[RefreshNeeded] = None self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) @@ -102,29 +101,25 @@ class Reviewer: self.mw.col.reset() self.nextCard() self.mw.fade_in_webview() - self._refresh_needed = RefreshNeeded.NO + self._refresh_needed = None elif self._refresh_needed is RefreshNeeded.NOTE_TEXT: self._redraw_current_card() self.mw.fade_in_webview() - self._refresh_needed = RefreshNeeded.NO + self._refresh_needed = None - 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 + def op_executed( + self, changes: OpChanges, handler: Optional[object], focused: bool + ) -> bool: + if handler is not self: + if changes.study_queues: + self._refresh_needed = RefreshNeeded.QUEUES + elif changes.editor: + self._refresh_needed = RefreshNeeded.NOTE_TEXT - if focused and self._refresh_needed is not RefreshNeeded.NO: + if focused and self._refresh_needed: self.refresh_if_needed() - return self._refresh_needed is not RefreshNeeded.NO + return bool(self._refresh_needed) def _redraw_current_card(self) -> None: self.card.load() @@ -830,63 +825,70 @@ time = %(time)d; self.mw.onDeckConf(self.mw.col.decks.get(self.card.current_deck_id())) def set_flag_on_current_card(self, desired_flag: int) -> None: + def redraw_flag(out: OpChanges) -> None: + self.card.load() + self._update_flag_icon() + # need to toggle off? if self.card.user_flag() == desired_flag: flag = 0 else: flag = desired_flag - set_card_flag(mw=self.mw, card_ids=[self.card.id], flag=flag) + set_card_flag(parent=self.mw, card_ids=[self.card.id], flag=flag).success( + redraw_flag + ).run_in_background(initiator=self) def toggle_mark_on_current_note(self) -> None: + def redraw_mark(out: OpChangesWithCount) -> None: + self.card.load() + self._update_mark_icon() + note = self.card.note() if note.has_tag(MARKED_TAG): remove_tags_from_notes( - mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG - ) + parent=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG + ).success(redraw_mark).run_in_background(initiator=self) else: add_tags_to_notes( - mw=self.mw, note_ids=[note.id], space_separated_tags=MARKED_TAG - ) + parent=self.mw, + note_ids=[note.id], + space_separated_tags=MARKED_TAG, + ).success(redraw_mark).run_in_background(initiator=self) def on_set_due(self) -> None: if self.mw.state != "review" or not self.card: return set_due_date_dialog( - mw=self.mw, parent=self.mw, card_ids=[self.card.id], config_key=Config.String.SET_DUE_REVIEWER, - ) + ).run_in_background() def suspend_current_note(self) -> None: suspend_note( - mw=self.mw, - note_id=self.card.nid, - success=lambda _: tooltip(tr.studying_note_suspended()), - ) + parent=self.mw, + note_ids=[self.card.nid], + ).success(lambda _: tooltip(tr.studying_note_suspended())).run_in_background() def suspend_current_card(self) -> None: suspend_cards( - mw=self.mw, + parent=self.mw, card_ids=[self.card.id], - success=lambda _: tooltip(tr.studying_card_suspended()), - ) + ).success(lambda _: tooltip(tr.studying_card_suspended())).run_in_background() def bury_current_note(self) -> None: - bury_note( - mw=self.mw, - note_id=self.card.nid, - success=lambda _: tooltip(tr.studying_note_buried()), - ) + bury_notes( + parent=self.mw, + note_ids=[self.card.nid], + ).success(lambda _: tooltip(tr.studying_note_buried())).run_in_background() def bury_current_card(self) -> None: bury_cards( - mw=self.mw, + parent=self.mw, card_ids=[self.card.id], - success=lambda _: tooltip(tr.studying_card_buried()), - ) + ).success(lambda _: tooltip(tr.studying_card_buried())).run_in_background() def delete_current_note(self) -> None: # need to check state because the shortcut is global to the main @@ -894,14 +896,7 @@ time = %(time)d; 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()) - - remove_notes( - mw=self.mw, - note_ids=[self.card.nid], - success=lambda _: tooltip(tr.studying_note_and_its_card_deleted(count=cnt)), - ) + remove_notes(parent=self.mw, note_ids=[self.card.nid]).run_in_background() def onRecordVoice(self) -> None: def after_record(path: str) -> None: diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index f8d83c354..2a9c78bea 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -16,7 +16,6 @@ from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.clayout import CardLayout from aqt.models import Models -from aqt.operations import OpMeta from aqt.operations.deck import ( remove_decks, rename_deck, @@ -173,6 +172,18 @@ class SidebarItem: ) return self._search_matches_self or self._search_matches_child + def has_same_id(self, other: SidebarItem) -> bool: + "True if `other` is same type, with same id/name." + if other.item_type == self.item_type: + if self.item_type == SidebarItemType.TAG: + return self.full_name == other.full_name + elif self.item_type == SidebarItemType.SAVED_SEARCH: + return self.name == other.name + else: + return other.id == self.id + + return False + class SidebarModel(QAbstractItemModel): def __init__(self, sidebar: SidebarTreeView, root: SidebarItem) -> None: @@ -420,8 +431,10 @@ class SidebarTreeView(QTreeView): # Refreshing ########################### - def op_executed(self, changes: OpChanges, meta: OpMeta, focused: bool) -> None: - if changes.browser_sidebar and not meta.handler is self: + def op_executed( + self, changes: OpChanges, handler: Optional[object], focused: bool + ) -> None: + if changes.browser_sidebar and not handler is self: self._refresh_needed = True if focused: self.refresh_if_needed() @@ -431,13 +444,16 @@ class SidebarTreeView(QTreeView): self.refresh() self._refresh_needed = False - def refresh( - self, is_current: Optional[Callable[[SidebarItem], bool]] = None - ) -> None: + def refresh(self) -> None: "Refresh list. No-op if sidebar is not visible." if not self.isVisible(): return + if self.model() and (idx := self.currentIndex()): + current_item = self.model().item_for_index(idx) + else: + current_item = None + def on_done(root: SidebarItem) -> None: # user may have closed browser if sip.isdeleted(self): @@ -453,8 +469,8 @@ class SidebarTreeView(QTreeView): self.search_for(self.current_search) else: self._expand_where_necessary(model) - if is_current: - self.restore_current(is_current) + if current_item: + self.restore_current(current_item) self.setUpdatesEnabled(True) @@ -463,8 +479,8 @@ class SidebarTreeView(QTreeView): 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): + def restore_current(self, current: SidebarItem) -> None: + if current := self.find_item(current.has_same_id): index = self.model().index_for_item(current) self.selectionModel().setCurrentIndex( index, QItemSelectionModel.SelectCurrent @@ -615,8 +631,8 @@ class SidebarTreeView(QTreeView): new_parent = DeckId(target.id) reparent_decks( - mw=self.mw, parent=self.browser, deck_ids=deck_ids, new_parent=new_parent - ) + parent=self.browser, deck_ids=deck_ids, new_parent=new_parent + ).run_in_background() return True @@ -636,7 +652,9 @@ class SidebarTreeView(QTreeView): else: new_parent = target.full_name - reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent) + reparent_tags( + parent=self.browser, tags=tags, new_parent=new_parent + ).run_in_background() return True @@ -931,8 +949,8 @@ class SidebarTreeView(QTreeView): def toggle_expand(node: TagTreeNode) -> Callable[[bool], None]: full_name = head + node.name return lambda expanded: set_tag_collapsed( - mw=self.mw, tag=full_name, collapsed=not expanded - ) + parent=self, tag=full_name, collapsed=not expanded + ).run_in_background() for node in nodes: item = SidebarItem( @@ -977,11 +995,12 @@ class SidebarTreeView(QTreeView): ) -> None: def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]: return lambda expanded: set_deck_collapsed( - mw=self.mw, + parent=self, deck_id=DeckId(node.deck_id), collapsed=not expanded, scope=DeckCollapseScope.BROWSER, - handler=self, + ).run_in_background( + initiator=self, ) for node in nodes: @@ -1164,27 +1183,27 @@ class SidebarTreeView(QTreeView): def rename_deck(self, item: SidebarItem, new_name: str) -> None: if not new_name: return - new_name = item.name_prefix + new_name + + # update UI immediately, to avoid redraw + item.name = new_name + + full_name = item.name_prefix + new_name deck_id = DeckId(item.id) def after_fetch(deck: Deck) -> None: - if new_name == deck.name: + if full_name == deck.name: return rename_deck( - mw=self.mw, + parent=self, deck_id=deck_id, - new_name=new_name, - after_rename=lambda: self.refresh( - lambda other: other.item_type == SidebarItemType.DECK - and other.id == item.id - ), - ) + new_name=full_name, + ).run_in_background() self.mw.query_op(lambda: self.mw.col.get_deck(deck_id), success=after_fetch) def delete_decks(self, _item: SidebarItem) -> None: - remove_decks(mw=self.mw, parent=self.browser, deck_ids=self._selected_decks()) + remove_decks(parent=self, deck_ids=self._selected_decks()).run_in_background() # Tags ########################### @@ -1194,8 +1213,8 @@ class SidebarTreeView(QTreeView): item.name = "..." remove_tags_from_all_notes( - mw=self.mw, parent=self.browser, space_separated_tags=tags - ) + parent=self.browser, space_separated_tags=tags + ).run_in_background() def rename_tag(self, item: SidebarItem, new_name: str) -> None: if not new_name or new_name == item.name: @@ -1207,17 +1226,13 @@ class SidebarTreeView(QTreeView): new_name = item.name_prefix + new_name item.name = new_name_base + item.full_name = new_name 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 - ), - ) + ).run_in_background() # Saved searches #################################### @@ -1249,10 +1264,7 @@ class SidebarTreeView(QTreeView): return conf[name] = search self._set_saved_searches(conf) - self.refresh( - lambda item: item.item_type == SidebarItemType.SAVED_SEARCH - and item.name == name - ) + self.refresh() def remove_saved_searches(self, _item: SidebarItem) -> None: selected = self._selected_saved_searches() @@ -1276,10 +1288,8 @@ class SidebarTreeView(QTreeView): conf[new_name] = filt del conf[old_name] self._set_saved_searches(conf) - self.refresh( - lambda item: item.item_type == SidebarItemType.SAVED_SEARCH - and item.name == new_name - ) + item.name = new_name + self.refresh() def save_current_search(self) -> None: if (search := self._get_current_search()) is None: diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py index e028e6718..092011791 100644 --- a/qt/aqt/studydeck.py +++ b/qt/aqt/studydeck.py @@ -175,4 +175,6 @@ class StudyDeck(QDialog): QDialog.accept(self) - add_deck_dialog(mw=self.mw, parent=self, default_text=default, success=success) + add_deck_dialog(parent=self, default_text=default).success( + success + ).run_in_background() diff --git a/qt/aqt/table.py b/qt/aqt/table.py index 4fff87fb1..955ca38eb 100644 --- a/qt/aqt/table.py +++ b/qt/aqt/table.py @@ -29,7 +29,6 @@ from anki.errors import NotFoundError from anki.notes import Note, NoteId from anki.utils import ids2str, isWin from aqt import colors, gui_hooks -from aqt.operations import OpMeta from aqt.qt import * from aqt.theme import theme_manager from aqt.utils import ( @@ -180,7 +179,9 @@ class Table: def redraw_cells(self) -> None: self._model.redraw_cells() - def op_executed(self, changes: OpChanges, meta: OpMeta, focused: bool) -> None: + def op_executed( + self, changes: OpChanges, handler: Optional[object], focused: bool + ) -> None: if changes.browser_table: self._model.mark_cache_stale() if focused: diff --git a/qt/aqt/taskman.py b/qt/aqt/taskman.py index 3189024f3..a1759ed1c 100644 --- a/qt/aqt/taskman.py +++ b/qt/aqt/taskman.py @@ -4,7 +4,7 @@ """ Helper for running tasks on background threads. -See mw.query_op() and mw.perform_op() for slightly higher-level routines. +See mw.query_op() and CollectionOp() for higher-level routines. """ from __future__ import annotations diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py index 2b71a79f5..2088f639e 100644 --- a/qt/aqt/tts.py +++ b/qt/aqt/tts.py @@ -41,7 +41,7 @@ from anki.sound import AVTag, TTSTag from anki.utils import checksum, isWin, tmpdir from aqt import gui_hooks from aqt.sound import OnDoneCallback, SimpleProcessPlayer -from aqt.utils import tooltip +from aqt.utils import tooltip, tr @dataclass @@ -569,10 +569,7 @@ if isWin: try: ret.result() except RuntimeError: - # fixme: i18n if this turns out to happen frequently - tooltip( - "TTS failed to play. Please check available languages in system settings." - ) + tooltip(tr.errors_windows_tts_runtime_error()) return # inject file into the top of the audio queue diff --git a/qt/mypy.ini b/qt/mypy.ini index d14f07fba..72f33a3a4 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -9,17 +9,7 @@ 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] +[mypy-aqt.operations.*] no_strict_optional = false [mypy-aqt.winpaths] diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 55b244135..f7fdd61c9 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -26,7 +26,6 @@ from anki.hooks import runFilter, runHook from anki.models import NotetypeDict from aqt.qt import QDialog, QEvent, QMenu, QWidget from aqt.tagedit import TagEdit -import aqt.operations """ # Hook list @@ -464,14 +463,14 @@ hooks = [ Hook( name="state_did_reset", legacy_hook="reset", - doc="""Legacy 'reset' hook. Called by mw.reset() and mw.perform_op() to redraw the UI. + doc="""Legacy 'reset' hook. Called by mw.reset() and CollectionOp() to redraw the UI. New code should use `operation_did_execute` instead. """, ), Hook( name="operation_did_execute", - args=["changes: anki.collection.OpChanges", "meta: aqt.operations.OpMeta"], + args=["changes: anki.collection.OpChanges", "handler: Optional[object]"], doc="""Called after an operation completes. Changes can be inspected to determine whether the UI needs updating. @@ -489,7 +488,7 @@ hooks = [ ), Hook( name="backend_will_block", - doc="""Called before one or more operations are executed with mw.perform_op(). + doc="""Called before one or more DB tasks are run in the background. 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 @@ -498,7 +497,7 @@ hooks = [ ), Hook( name="backend_did_block", - doc="""Called after one or more operations are executed with mw.perform_op(). + doc="""Called after one or more DB tasks finish running in the background. Called regardless of the success of individual operations, and only called when there are no outstanding ops. """, diff --git a/repos.bzl b/repos.bzl index 836ac670b..2d9666768 100644 --- a/repos.bzl +++ b/repos.bzl @@ -33,11 +33,11 @@ def register_repos(): maybe( http_archive, name = "rules_rust", - strip_prefix = "rules_rust-anki-2021-03-30", + strip_prefix = "rules_rust-anki-2021-04-09", urls = [ - "https://github.com/ankitects/rules_rust/archive/anki-2021-03-30.tar.gz", + "https://github.com/ankitects/rules_rust/archive/anki-2021-04-09.tar.gz", ], - sha256 = "ad6286615fd21f71db4490207aa8d5ecdf5f526643cd65d682458d92aa84ff85", + sha256 = "2821b22e065c1b4dc73610b1d6ccbed7ed4d755b316e7e0641cd079b7abe4900", ) # python @@ -87,40 +87,8 @@ def register_repos(): http_archive( name = "build_bazel_rules_nodejs", - sha256 = "55a25a762fcf9c9b88ab54436581e671bc9f4f523cb5a1bd32459ebec7be68a8", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.2/rules_nodejs-3.2.2.tar.gz"], - ) - - _ESBUILD_VERSION = "0.8.48" # reminder: update SHAs below when changing this value - - http_archive( - name = "esbuild_darwin", - urls = [ - "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-%s.tgz" % _ESBUILD_VERSION, - ], - strip_prefix = "package", - build_file_content = """exports_files(["bin/esbuild"])""", - sha256 = "d21a722873ed24586f071973b77223553fca466946f3d7e3976eeaccb14424e6", - ) - - http_archive( - name = "esbuild_windows", - urls = [ - "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-%s.tgz" % _ESBUILD_VERSION, - ], - strip_prefix = "package", - build_file_content = """exports_files(["esbuild.exe"])""", - sha256 = "fe5dcb97b4c47f9567012f0a45c19c655f3d2e0d76932f6dd12715dbebbd6eb0", - ) - - http_archive( - name = "esbuild_linux", - urls = [ - "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-%s.tgz" % _ESBUILD_VERSION, - ], - strip_prefix = "package", - build_file_content = """exports_files(["bin/esbuild"])""", - sha256 = "60dabe141e5dfcf99e7113bded6012868132068a582a102b258fb7b1cfdac14b", + sha256 = "f533eeefc8fe1ddfe93652ec50f82373d0c431f7faabd5e6323f6903195ef227", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.3.0/rules_nodejs-3.3.0.tar.gz"], ) # sass diff --git a/rslib/backend.proto b/rslib/backend.proto index 22574daab..7a55cf2a0 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -120,7 +120,7 @@ service SchedulingService { rpc CongratsInfo(Empty) returns (CongratsInfoOut); rpc RestoreBuriedAndSuspendedCards(CardIds) returns (OpChanges); rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty); - rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges); + rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChangesWithCount); rpc EmptyFilteredDeck(DeckId) returns (OpChanges); rpc RebuildFilteredDeck(DeckId) returns (OpChangesWithCount); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); @@ -154,6 +154,8 @@ service DecksService { rpc GetOrCreateFilteredDeck(DeckId) returns (FilteredDeckForUpdate); rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate) returns (OpChangesWithId); rpc FilteredDeckOrderLabels(Empty) returns (StringList); + rpc SetCurrentDeck(DeckId) returns (OpChanges); + rpc GetCurrentDeck(Empty) returns (Deck); } service NotesService { @@ -163,7 +165,7 @@ service NotesService { rpc DefaultDeckForNotetype(NotetypeId) returns (DeckId); rpc UpdateNote(UpdateNoteIn) returns (OpChanges); rpc GetNote(NoteId) returns (Note); - rpc RemoveNotes(RemoveNotesIn) returns (OpChanges); + rpc RemoveNotes(RemoveNotesIn) returns (OpChangesWithCount); rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut); rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (OpChanges); rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut); @@ -1313,7 +1315,8 @@ message BuryOrSuspendCardsIn { BURY_USER = 2; } repeated int64 card_ids = 1; - Mode mode = 2; + repeated int64 note_ids = 2; + Mode mode = 3; } message ScheduleCardsAsNewIn { @@ -1497,26 +1500,17 @@ 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; - } + bool card = 1; + bool note = 2; + bool deck = 3; + bool tag = 4; + bool notetype = 5; + bool config = 6; - Kind kind = 1; - bool card = 2; - bool note = 3; - bool deck = 4; - bool tag = 5; - bool notetype = 6; - bool preference = 7; - - bool browser_table = 8; - bool browser_sidebar = 9; - bool editor = 10; - bool study_queues = 11; + bool browser_table = 7; + bool browser_sidebar = 8; + bool editor = 9; + bool study_queues = 10; } message UndoStatus { diff --git a/rslib/i18n/build/extract.rs b/rslib/i18n/build/extract.rs index 5c78ed2e0..dc09ed095 100644 --- a/rslib/i18n/build/extract.rs +++ b/rslib/i18n/build/extract.rs @@ -163,7 +163,7 @@ impl From for Variable { let kind = match name.as_str() { "cards" | "notes" | "count" | "amount" | "reviews" | "total" | "selected" | "kilobytes" | "daysStart" | "daysEnd" | "days" | "secs-per-card" | "remaining" - | "hourStart" | "hourEnd" | "correct" | "decks" => VariableKind::Int, + | "hourStart" | "hourEnd" | "correct" | "decks" | "changed" => VariableKind::Int, "average-seconds" | "cards-per-minute" => VariableKind::Float, "val" | "found" | "expected" | "part" | "percent" | "day" | "number" | "up" | "down" | "seconds" | "megs" => VariableKind::Any, diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index 837f30df4..a1aeaabd1 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -67,7 +67,7 @@ impl ConfigService for Backend { 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) + col.set_config(input.key.as_str(), &val).map(|_| ()) }) }) .map(Into::into) @@ -98,7 +98,7 @@ impl ConfigService for Backend { self.with_col(|col| { col.transact_no_undo(|col| col.set_bool(input.key().into(), input.value)) }) - .map(Into::into) + .map(|_| ().into()) } fn get_config_string(&self, input: pb::config::String) -> Result { @@ -113,7 +113,7 @@ impl ConfigService for Backend { self.with_col(|col| { col.transact_no_undo(|col| col.set_string(input.key().into(), &input.value)) }) - .map(Into::into) + .map(|_| ().into()) } fn get_preferences(&self, _input: pb::Empty) -> Result { diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs index a4d72b396..578d6b558 100644 --- a/rslib/src/backend/decks.rs +++ b/rslib/src/backend/decks.rs @@ -187,6 +187,16 @@ impl DecksService for Backend { }) .map(Into::into) } + + fn set_current_deck(&self, input: pb::DeckId) -> Result { + self.with_col(|col| col.set_current_deck(input.did.into())) + .map(Into::into) + } + + fn get_current_deck(&self, _input: pb::Empty) -> Result { + self.with_col(|col| col.get_current_deck()) + .map(|deck| (*deck).clone().into()) + } } impl From for DeckId { diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index 75c6edaca..864c0e5e0 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -63,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( @@ -131,7 +131,7 @@ impl NotesService for Backend { fn cards_of_note(&self, input: pb::NoteId) -> Result { self.with_col(|col| { col.storage - .all_card_ids_of_note(NoteId(input.nid)) + .all_card_ids_of_note_in_order(NoteId(input.nid)) .map(|v| pb::CardIds { cids: v.into_iter().map(Into::into).collect(), }) diff --git a/rslib/src/backend/ops.rs b/rslib/src/backend/ops.rs index d634093ab..4bbda0a1a 100644 --- a/rslib/src/backend/ops.rs +++ b/rslib/src/backend/ops.rs @@ -1,8 +1,6 @@ // 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, @@ -10,27 +8,15 @@ use crate::{ undo::{UndoOutput, 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, + config: c.changes.config, browser_table: c.requires_browser_table_redraw(), browser_sidebar: c.requires_browser_sidebar_redraw(), editor: c.requires_editor_redraw(), diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 3f612bc91..49084ad93 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -87,10 +87,18 @@ 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(); + let cids = if input.card_ids.is_empty() { + col.storage + .card_ids_of_notes(&input.note_ids.into_newtype(NoteId))? + } else { + input.card_ids.into_newtype(CardId) + }; col.bury_or_suspend_cards(&cids, mode).map(Into::into) }) } @@ -105,7 +113,7 @@ impl SchedulingService for Backend { 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 cids = input.card_ids.into_newtype(CardId); let log = input.log; col.reschedule_cards_as_new(&cids, log).map(Into::into) }) @@ -114,12 +122,12 @@ impl SchedulingService for Backend { 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(); + let cids = input.card_ids.into_newtype(CardId); self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into)) } fn sort_cards(&self, input: pb::SortCardsIn) -> Result { - let cids: Vec<_> = input.card_ids.into_iter().map(CardId).collect(); + let cids = input.card_ids.into_newtype(CardId); let (start, step, random, shift) = ( input.starting_from, input.step_size, diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 2b84e682c..6bfdf01b4 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -239,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(Op::SetDeck, |col| { + self.transact(Op::SetCardDeck, |col| { for mut card in col.storage.all_searched_cards()? { if card.deck_id == deck_id { continue; diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index 3dc0074f1..517a9d499 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -69,7 +69,7 @@ impl Collection { } } - pub(crate) fn set_bool(&mut self, key: BoolKey, value: bool) -> Result<()> { + pub(crate) fn set_bool(&mut self, key: BoolKey, value: bool) -> Result { self.set_config(key, &value) } } diff --git a/rslib/src/config/deck.rs b/rslib/src/config/deck.rs index e5ca56ef9..0eef0474c 100644 --- a/rslib/src/config/deck.rs +++ b/rslib/src/config/deck.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 super::ConfigKey; use crate::prelude::*; use strum::IntoStaticStr; @@ -20,11 +19,6 @@ impl DeckConfigKey { } impl Collection { - pub(crate) fn get_current_deck_id(&self) -> DeckId { - self.get_config_optional(ConfigKey::CurrentDeckId) - .unwrap_or(DeckId(1)) - } - pub(crate) fn clear_aux_config_for_deck(&self, ntid: DeckId) -> Result<()> { self.remove_config_prefix(&build_aux_deck_key(ntid, "")) } @@ -38,7 +32,7 @@ impl Collection { &mut self, did: DeckId, ntid: NotetypeId, - ) -> Result<()> { + ) -> Result { let key = DeckConfigKey::LastNotetype.for_deck(did); self.set_config(key.as_str(), &ntid) } diff --git a/rslib/src/config/mod.rs b/rslib/src/config/mod.rs index 3db4923e9..200d18302 100644 --- a/rslib/src/config/mod.rs +++ b/rslib/src/config/mod.rs @@ -94,7 +94,8 @@ impl Collection { self.get_config_optional(key).unwrap_or_default() } - pub(crate) fn set_config<'a, T: Serialize, K>(&mut self, key: K, val: &T) -> Result<()> + /// True if added, or new value is different. + pub(crate) fn set_config<'a, T: Serialize, K>(&mut self, key: K, val: &T) -> Result where K: Into<&'a str>, { @@ -130,6 +131,7 @@ impl Collection { self.state.scheduler_info = None; if let Some(mins) = mins { self.set_config(ConfigKey::CreationOffset, &mins) + .map(|_| ()) } else { self.remove_config(ConfigKey::CreationOffset) } @@ -141,7 +143,7 @@ impl Collection { pub(crate) fn set_configured_utc_offset(&mut self, mins: i32) -> Result<()> { self.state.scheduler_info = None; - self.set_config(ConfigKey::LocalOffset, &mins) + self.set_config(ConfigKey::LocalOffset, &mins).map(|_| ()) } pub(crate) fn get_v2_rollover(&self) -> Option { @@ -151,7 +153,7 @@ impl Collection { pub(crate) fn set_v2_rollover(&mut self, hour: u32) -> Result<()> { self.state.scheduler_info = None; - self.set_config(ConfigKey::Rollover, &hour) + self.set_config(ConfigKey::Rollover, &hour).map(|_| ()) } pub(crate) fn get_next_card_position(&self) -> u32 { @@ -168,6 +170,7 @@ impl Collection { pub(crate) fn set_next_card_position(&mut self, pos: u32) -> Result<()> { self.set_config(ConfigKey::NextNewCardPosition, &pos) + .map(|_| ()) } pub(crate) fn scheduler_version(&self) -> SchedulerVersion { @@ -179,6 +182,7 @@ impl Collection { pub(crate) fn set_scheduler_version_config_key(&mut self, ver: SchedulerVersion) -> Result<()> { self.state.scheduler_info = None; self.set_config(ConfigKey::SchedulerVersion, &ver) + .map(|_| ()) } pub(crate) fn learn_ahead_secs(&self) -> u32 { @@ -188,6 +192,7 @@ impl Collection { pub(crate) fn set_learn_ahead_secs(&mut self, secs: u32) -> Result<()> { self.set_config(ConfigKey::LearnAheadSecs, &secs) + .map(|_| ()) } pub(crate) fn get_new_review_mix(&self) -> NewReviewMix { @@ -200,6 +205,7 @@ impl Collection { pub(crate) fn set_new_review_mix(&mut self, mix: NewReviewMix) -> Result<()> { self.set_config(ConfigKey::NewReviewMix, &(mix as u8)) + .map(|_| ()) } pub(crate) fn get_first_day_of_week(&self) -> Weekday { @@ -209,6 +215,7 @@ impl Collection { pub(crate) fn set_first_day_of_week(&mut self, weekday: Weekday) -> Result<()> { self.set_config(ConfigKey::FirstDayOfWeek, &weekday) + .map(|_| ()) } pub(crate) fn get_answer_time_limit_secs(&self) -> u32 { @@ -218,6 +225,7 @@ impl Collection { pub(crate) fn set_answer_time_limit_secs(&mut self, secs: u32) -> Result<()> { self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs) + .map(|_| ()) } pub(crate) fn get_last_unburied_day(&self) -> u32 { @@ -227,6 +235,7 @@ impl Collection { pub(crate) fn set_last_unburied_day(&mut self, day: u32) -> Result<()> { self.set_config(ConfigKey::LastUnburiedDay, &day) + .map(|_| ()) } } diff --git a/rslib/src/config/notetype.rs b/rslib/src/config/notetype.rs index 6a61276b0..c672964a9 100644 --- a/rslib/src/config/notetype.rs +++ b/rslib/src/config/notetype.rs @@ -30,6 +30,7 @@ impl Collection { pub(crate) fn set_current_notetype_id(&mut self, ntid: NotetypeId) -> Result<()> { self.set_config(ConfigKey::CurrentNotetypeId, &ntid) + .map(|_| ()) } pub(crate) fn clear_aux_config_for_notetype(&self, ntid: NotetypeId) -> Result<()> { @@ -43,7 +44,7 @@ impl Collection { pub(crate) fn set_last_deck_for_notetype(&mut self, id: NotetypeId, did: DeckId) -> Result<()> { let key = NotetypeConfigKey::LastDeckAddedTo.for_notetype(id); - self.set_config(key.as_str(), &did) + self.set_config(key.as_str(), &did).map(|_| ()) } } diff --git a/rslib/src/config/string.rs b/rslib/src/config/string.rs index d5aa918f8..45bda80fb 100644 --- a/rslib/src/config/string.rs +++ b/rslib/src/config/string.rs @@ -22,7 +22,7 @@ impl Collection { .unwrap_or_else(|| default.to_string()) } - pub(crate) fn set_string(&mut self, key: StringKey, val: &str) -> Result<()> { + pub(crate) fn set_string(&mut self, key: StringKey, val: &str) -> Result { self.set_config(key, &val) } } diff --git a/rslib/src/config/undo.rs b/rslib/src/config/undo.rs index 55e14f895..15288526f 100644 --- a/rslib/src/config/undo.rs +++ b/rslib/src/config/undo.rs @@ -21,16 +21,19 @@ impl Collection { .get_config_entry(&entry.key)? .ok_or_else(|| AnkiError::invalid_input("config disappeared"))?; self.update_config_entry_undoable(entry, current) + .map(|_| ()) } UndoableConfigChange::Removed(entry) => self.add_config_entry_undoable(entry), } } - pub(super) fn set_config_undoable(&mut self, entry: Box) -> Result<()> { + /// True if added, or value changed. + pub(super) fn set_config_undoable(&mut self, entry: Box) -> Result { if let Some(original) = self.storage.get_config_entry(&entry.key)? { self.update_config_entry_undoable(entry, original) } else { - self.add_config_entry_undoable(entry) + self.add_config_entry_undoable(entry)?; + Ok(true) } } @@ -49,16 +52,19 @@ impl Collection { Ok(()) } + /// True if new value differed. fn update_config_entry_undoable( &mut self, entry: Box, original: Box, - ) -> Result<()> { + ) -> Result { if entry.value != original.value { self.save_undo(UndoableConfigChange::Updated(original)); self.storage.set_config_entry(&entry)?; + Ok(true) + } else { + Ok(false) } - Ok(()) } } diff --git a/rslib/src/decks/current.rs b/rslib/src/decks/current.rs new file mode 100644 index 000000000..3b466a052 --- /dev/null +++ b/rslib/src/decks/current.rs @@ -0,0 +1,41 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::sync::Arc; + +use crate::{config::ConfigKey, prelude::*}; + +impl Collection { + pub fn set_current_deck(&mut self, deck: DeckId) -> Result> { + self.transact(Op::SetCurrentDeck, |col| col.set_current_deck_inner(deck)) + } + + /// Fetch the current deck, falling back to the default if the previously + /// selected deck is invalid. + pub fn get_current_deck(&mut self) -> Result> { + if let Some(deck) = self.get_deck(self.get_current_deck_id())? { + return Ok(deck); + } + self.get_deck(DeckId(1))?.ok_or(AnkiError::NotFound) + } +} + +impl Collection { + /// The returned id may reference a deck that does not exist; + /// prefer using get_current_deck() instead. + pub(crate) fn get_current_deck_id(&self) -> DeckId { + self.get_config_optional(ConfigKey::CurrentDeckId) + .unwrap_or(DeckId(1)) + } + + fn set_current_deck_inner(&mut self, deck: DeckId) -> Result<()> { + if self.set_current_deck_id(deck)? { + self.state.card_queues = None; + } + Ok(()) + } + + fn set_current_deck_id(&mut self, did: DeckId) -> Result { + self.set_config(ConfigKey::CurrentDeckId, &did) + } +} diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 22a2e6db9..24fd15f5b 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod counts; +mod current; mod filtered; mod schema11; mod tree; diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 861455c93..14074d088 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -402,19 +402,21 @@ impl Collection { } /// 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(Op::RemoveNote, |col| { + let mut card_count = 0; for nid in nids { let nid = *nid; if let Some(_existing_note) = col.storage.get_note(nid)? { for card in col.storage.all_cards_of_note(nid)? { + card_count += 1; col.remove_card_and_add_grave_undoable(card, usn)?; } col.remove_note_only_undoable(nid, usn)?; } } - Ok(()) + Ok(card_count) }) } diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 90b568ced..47c2848bb 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -23,7 +23,7 @@ pub enum Op { RenameTag, ReparentTag, ScheduleAsNew, - SetDeck, + SetCardDeck, SetDueDate, SetFlag, SortCards, @@ -34,6 +34,7 @@ pub enum Op { UpdateNote, UpdatePreferences, UpdateTag, + SetCurrentDeck, } impl Op { @@ -55,7 +56,7 @@ impl Op { Op::UpdateNote => tr.undo_update_note(), Op::UpdatePreferences => tr.preferences_preferences(), Op::UpdateTag => tr.undo_update_tag(), - Op::SetDeck => tr.browsing_change_deck(), + Op::SetCardDeck => tr.browsing_change_deck(), Op::SetFlag => tr.undo_set_flag(), Op::FindAndReplace => tr.browsing_find_and_replace(), Op::ClearUnusedTags => tr.browsing_clear_unused_tags(), @@ -68,6 +69,7 @@ impl Op { Op::RebuildFilteredDeck => tr.undo_build_filtered_deck(), Op::EmptyFilteredDeck => tr.studying_empty(), Op::ExpandCollapse => tr.undo_expand_collapse(), + Op::SetCurrentDeck => tr.browsing_change_deck(), } .into() } @@ -80,7 +82,7 @@ pub struct StateChanges { pub deck: bool, pub tag: bool, pub notetype: bool, - pub preference: bool, + pub config: bool, } #[derive(Debug, Clone, Copy)] @@ -134,7 +136,12 @@ impl OpChanges { pub fn requires_study_queue_rebuild(&self) -> bool { let c = &self.changes; - !matches!(self.op, Op::AnswerCard | Op::ExpandCollapse) - && (c.card || c.deck || c.preference) + if self.op == Op::AnswerCard { + return false; + } + + c.card + || (c.deck && self.op != Op::ExpandCollapse) + || (c.config && matches!(self.op, Op::SetCurrentDeck)) } } diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs index 680066210..544c5dac3 100644 --- a/rslib/src/prelude.rs +++ b/rslib/src/prelude.rs @@ -1,6 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +pub(crate) use crate::types::IntoNewtypeVec; pub use crate::{ card::{Card, CardId}, collection::Collection, diff --git a/rslib/src/scheduler/answering/undo.rs b/rslib/src/scheduler/answering/undo.rs index 52f1885db..8d86d9bc2 100644 --- a/rslib/src/scheduler/answering/undo.rs +++ b/rslib/src/scheduler/answering/undo.rs @@ -33,7 +33,7 @@ mod test { let queued = col.next_card()?.unwrap(); let nid = note.id; let cid = queued.card.id; - let sibling_cid = col.storage.all_card_ids_of_note(nid)?[1]; + let sibling_cid = col.storage.all_card_ids_of_note_in_order(nid)?[1]; let assert_initial_state = |col: &mut Collection| -> Result<()> { let first = col.storage.get_card(cid)?.unwrap(); diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index 640e98c42..31a3771da 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -89,7 +89,8 @@ impl Collection { /// Bury/suspend cards in search table, and clear it. /// Marks the cards as modified. - fn bury_or_suspend_searched_cards(&mut self, mode: BuryOrSuspendMode) -> Result<()> { + fn bury_or_suspend_searched_cards(&mut self, mode: BuryOrSuspendMode) -> Result { + let mut count = 0; let usn = self.usn()?; let sched = self.scheduler_version(); @@ -113,18 +114,21 @@ impl Collection { card.remove_from_learning(); } card.queue = desired_queue; + count += 1; self.update_card_inner(&mut card, original, usn)?; } } - self.storage.clear_searched_cards_table() + self.storage.clear_searched_cards_table()?; + + Ok(count) } pub fn bury_or_suspend_cards( &mut self, cids: &[CardId], mode: BuryOrSuspendMode, - ) -> Result> { + ) -> Result> { let op = match mode { BuryOrSuspendMode::Suspend => Op::Suspend, BuryOrSuspendMode::BurySched | BuryOrSuspendMode::BuryUser => Op::Bury, @@ -141,7 +145,7 @@ impl Collection { nid: NoteId, include_new: bool, include_reviews: bool, - ) -> Result<()> { + ) -> Result { self.storage .search_siblings_for_bury(cid, nid, include_new, include_reviews)?; self.bury_or_suspend_searched_cards(BuryOrSuspendMode::BurySched) diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 7bd54221f..a700f7c5b 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -308,13 +308,26 @@ impl super::SqliteStorage { .collect() } - pub(crate) fn all_card_ids_of_note(&self, nid: NoteId) -> Result> { + pub(crate) fn all_card_ids_of_note_in_order(&self, nid: NoteId) -> Result> { self.db .prepare_cached("select id from cards where nid = ? order by ord")? .query_and_then(&[nid], |r| Ok(CardId(r.get(0)?)))? .collect() } + pub(crate) fn card_ids_of_notes(&self, nids: &[NoteId]) -> Result> { + let mut stmt = self + .db + .prepare_cached("select id from cards where nid = ?")?; + let mut cids = vec![]; + for nid in nids { + for cid in stmt.query_map(&[nid], |row| row.get(0))? { + cids.push(cid?); + } + } + Ok(cids) + } + /// Place matching card ids into the search table. pub(crate) fn search_siblings_for_bury( &self, diff --git a/rslib/src/types.rs b/rslib/src/types.rs index 1a14b226f..5f1a00619 100644 --- a/rslib/src/types.rs +++ b/rslib/src/types.rs @@ -68,3 +68,18 @@ macro_rules! define_newtype { } define_newtype!(Usn, i32); + +pub(crate) trait IntoNewtypeVec { + fn into_newtype(self, func: F) -> Vec + where + F: FnMut(i64) -> T; +} + +impl IntoNewtypeVec for Vec { + fn into_newtype(self, func: F) -> Vec + where + F: FnMut(i64) -> T, + { + self.into_iter().map(func).collect() + } +} diff --git a/rslib/src/undo/mod.rs b/rslib/src/undo/mod.rs index 94040ce2e..88c5e1897 100644 --- a/rslib/src/undo/mod.rs +++ b/rslib/src/undo/mod.rs @@ -126,7 +126,7 @@ impl UndoManager { UndoableChange::Tag(_) => changes.tag = true, UndoableChange::Revlog(_) => {} UndoableChange::Queue(_) => {} - UndoableChange::Config(_) => {} // fixme: preferences? + UndoableChange::Config(_) => changes.config = true, } } diff --git a/ts/congrats/BUILD.bazel b/ts/congrats/BUILD.bazel index 5d389a01e..cf72428fe 100644 --- a/ts/congrats/BUILD.bazel +++ b/ts/congrats/BUILD.bazel @@ -37,6 +37,8 @@ esbuild( args = [ "--global-name=anki", "--inject:$(location //ts:protobuf-shim.js)", + "--resolve-extensions=.mjs,.js", + "--log-level=warning", ], entry_point = "index.ts", external = [ diff --git a/ts/editor/BUILD.bazel b/ts/editor/BUILD.bazel index fb86760af..f2a6d6b0a 100644 --- a/ts/editor/BUILD.bazel +++ b/ts/editor/BUILD.bazel @@ -36,6 +36,8 @@ esbuild( name = "editor", args = [ "--loader:.svg=text", + "--resolve-extensions=.mjs,.js", + "--log-level=warning", ], entry_point = "index_wrapper.ts", visibility = ["//visibility:public"], diff --git a/ts/esbuild.bzl b/ts/esbuild.bzl index e0c3021b7..3de24a8f5 100644 --- a/ts/esbuild.bzl +++ b/ts/esbuild.bzl @@ -3,11 +3,6 @@ load("//ts/esbuild:upstream.bzl", _esbuild = "esbuild_macro") def esbuild(name, **kwargs): _esbuild( name = name, - tool = select({ - "@bazel_tools//src/conditions:darwin": "@esbuild_darwin//:bin/esbuild", - "@bazel_tools//src/conditions:windows": "@esbuild_windows//:esbuild.exe", - "@bazel_tools//src/conditions:linux_x86_64": "@esbuild_linux//:bin/esbuild", - }), minify = select({ "//:release": True, "//conditions:default": False, diff --git a/ts/esbuild/BUILD.bazel b/ts/esbuild/BUILD.bazel index e69de29bb..59a42b85e 100644 --- a/ts/esbuild/BUILD.bazel +++ b/ts/esbuild/BUILD.bazel @@ -0,0 +1,8 @@ +load(":toolchain.bzl", "define_default_toolchains", "esbuild_toolchain") + +toolchain_type( + name = "toolchain_type", + visibility = ["//visibility:public"], +) + +define_default_toolchains() diff --git a/ts/esbuild/README.md b/ts/esbuild/README.md new file mode 100644 index 000000000..4afa25887 --- /dev/null +++ b/ts/esbuild/README.md @@ -0,0 +1,4 @@ +This folder vendors the esbuild support in rules_nodejs while we wait +for some upstream changes to be applied: + +- https://github.com/bazelbuild/rules_nodejs/pull/2545 diff --git a/ts/esbuild/esbuild_repo.bzl b/ts/esbuild/esbuild_repo.bzl new file mode 100644 index 000000000..c507d9809 --- /dev/null +++ b/ts/esbuild/esbuild_repo.bzl @@ -0,0 +1,45 @@ +""" Generated code; do not edit +Update by running yarn update-esbuild-versions + +Helper macro for fetching esbuild versions for internal tests and examples in rules_nodejs +""" + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +load(":toolchain.bzl", "register_default_toolchains") + +_VERSION = "0.11.5" + +def esbuild_dependencies(): + """Helper to install required dependencies for the esbuild rules""" + + version = _VERSION + + http_archive( + name = "esbuild_darwin", + urls = [ + "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-%s.tgz" % version, + ], + strip_prefix = "package", + build_file_content = """exports_files(["bin/esbuild"])""", + sha256 = "98436890727bdb0d4beddd9c9e07d0aeff0e8dfe0169f85e568eca0dd43f665e", + ) + http_archive( + name = "esbuild_windows", + urls = [ + "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-%s.tgz" % version, + ], + strip_prefix = "package", + build_file_content = """exports_files(["esbuild.exe"])""", + sha256 = "589c8ff97210bd41de106e6317ce88f9e88d2cacfd8178ae1217f2b857ff6c3a", + ) + http_archive( + name = "esbuild_linux", + urls = [ + "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-%s.tgz" % version, + ], + strip_prefix = "package", + build_file_content = """exports_files(["bin/esbuild"])""", + sha256 = "113c2e84895f4422a3676db4e15d9f01b2b4fac041edab25284fdb9574ba58a0", + ) + + register_default_toolchains() diff --git a/ts/esbuild/toolchain.bzl b/ts/esbuild/toolchain.bzl new file mode 100644 index 000000000..dfd6d5898 --- /dev/null +++ b/ts/esbuild/toolchain.bzl @@ -0,0 +1,42 @@ +def _esbuild_toolchain_impl(ctx): + return [platform_common.ToolchainInfo( + binary = ctx.executable.binary, + )] + +esbuild_toolchain = rule( + implementation = _esbuild_toolchain_impl, + attrs = { + "binary": attr.label(allow_single_file = True, executable = True, cfg = "exec"), + }, +) + +_package_path = "@net_ankiweb_anki//ts/esbuild" + +TOOLCHAIN = _package_path + ":toolchain_type" + +_default_toolchains = [ + ["@esbuild_darwin//:bin/esbuild", "macos"], + ["@esbuild_linux//:bin/esbuild", "linux"], + ["@esbuild_windows//:esbuild.exe", "windows"], +] + +def define_default_toolchains(): + for repo_path, platform in _default_toolchains: + esbuild_toolchain( + name = "esbuild_" + platform, + binary = repo_path, + ) + + native.toolchain( + name = "esbuild_{}_toolchain".format(platform), + exec_compatible_with = [ + "@platforms//os:" + platform, + "@platforms//cpu:x86_64", + ], + toolchain = ":esbuild_" + platform, + toolchain_type = ":toolchain_type", + ) + +def register_default_toolchains(): + for _, platform in _default_toolchains: + native.register_toolchains(_package_path + ":esbuild_{}_toolchain".format(platform)) diff --git a/ts/esbuild/upstream.bzl b/ts/esbuild/upstream.bzl index 53afb7e43..190addaee 100644 --- a/ts/esbuild/upstream.bzl +++ b/ts/esbuild/upstream.bzl @@ -1,17 +1,11 @@ """ -NOTE: this file was forked from the following repo (Apache2) -https://github.com/bazelbuild/rules_nodejs/blob/c47b770a122e9614516df2e3fdca6fe0bf6e3420/packages/esbuild/esbuild.bzl - -Local changes not in upstream: -https://github.com/bazelbuild/rules_nodejs/pull/2545 -https://github.com/bazelbuild/rules_nodejs/pull/2564 - esbuild rule """ load("@build_bazel_rules_nodejs//:providers.bzl", "JSEcmaScriptModuleInfo", "JSModuleInfo", "NpmPackageInfo", "node_modules_aspect") load("@build_bazel_rules_nodejs//internal/linker:link_node_modules.bzl", "MODULE_MAPPINGS_ASPECT_RESULTS_NAME", "module_mappings_aspect") load(":helpers.bzl", "filter_files", "generate_path_mapping", "resolve_js_input", "write_jsconfig_file") +load(":toolchain.bzl", "TOOLCHAIN") def _esbuild_impl(ctx): # For each dep, JSEcmaScriptModuleInfo is used if found, then JSModuleInfo and finally @@ -35,6 +29,9 @@ def _esbuild_impl(ctx): elif hasattr(dep, "files"): deps_depsets.append(dep.files) + if DefaultInfo in dep: + deps_depsets.append(dep[DefaultInfo].data_runfiles.files) + if NpmPackageInfo in dep: deps_depsets.append(dep[NpmPackageInfo].sources) npm_workspaces.append(dep[NpmPackageInfo].workspace) @@ -61,7 +58,12 @@ def _esbuild_impl(ctx): args = ctx.actions.args() args.add("--bundle", entry_point.path) - args.add("--sourcemap") + + if len(ctx.attr.sourcemap) > 0: + args.add_joined(["--sourcemap", ctx.attr.sourcemap], join_with = "=") + else: + args.add("--sourcemap") + args.add("--preserve-symlinks") args.add_joined(["--platform", ctx.attr.platform], join_with = "=") args.add_joined(["--target", ctx.attr.target], join_with = "=") @@ -70,8 +72,8 @@ def _esbuild_impl(ctx): args.add_all(ctx.attr.define, format_each = "--define:%s") args.add_all(ctx.attr.external, format_each = "--external:%s") - # disable the error limit and show all errors - args.add_joined(["--error-limit", "0"], join_with = "=") + # disable the log limit and show all logs + args.add_joined(["--log-limit", "0"], join_with = "=") if ctx.attr.minify: args.add("--minify") @@ -94,8 +96,14 @@ def _esbuild_impl(ctx): args.add_joined(["--outdir", js_out.path], join_with = "=") else: js_out = ctx.outputs.output + outputs.append(js_out) + js_out_map = ctx.outputs.output_map - outputs.extend([js_out, js_out_map]) + if ctx.attr.sourcemap != "inline": + if js_out_map == None: + fail("output_map must be specified if sourcemap is not set to 'inline'") + outputs.append(js_out_map) + if ctx.outputs.output_css: outputs.append(ctx.outputs.output_css) @@ -110,15 +118,23 @@ def _esbuild_impl(ctx): args.add_all([ctx.expand_location(arg) for arg in ctx.attr.args]) + env = {} + if ctx.attr.max_threads > 0: + env["GOMAXPROCS"] = str(ctx.attr.max_threads) + + execution_requirements = {} + if "no-remote-exec" in ctx.attr.tags: + execution_requirements = {"no-remote-exec": "1"} + ctx.actions.run( inputs = inputs, outputs = outputs, - executable = ctx.executable.tool, + executable = ctx.toolchains[TOOLCHAIN].binary, arguments = [args], progress_message = "%s Javascript %s [esbuild]" % ("Bundling" if not ctx.attr.output_dir else "Splitting", entry_point.short_path), - execution_requirements = { - "no-remote-exec": "1", - }, + execution_requirements = execution_requirements, + mnemonic = "esbuild", + env = env, ) return [ @@ -144,6 +160,7 @@ esbuild( ], ) ``` + See https://esbuild.github.io/api/#define for more details """, ), @@ -160,6 +177,7 @@ See https://esbuild.github.io/api/#define for more details "external": attr.string_list( default = [], doc = """A list of module names that are treated as external and not included in the resulting bundle + See https://esbuild.github.io/api/#external for more details """, ), @@ -168,6 +186,7 @@ See https://esbuild.github.io/api/#external for more details mandatory = False, doc = """The output format of the bundle, defaults to iife when platform is browser and cjs when platform is node. If performing code splitting, defaults to esm. + See https://esbuild.github.io/api/#format for more details """, ), @@ -175,11 +194,20 @@ See https://esbuild.github.io/api/#format for more details doc = """Link the workspace root to the bin_dir to support absolute requires like 'my_wksp/path/to/file'. If source files need to be required then they can be copied to the bin_dir with copy_to_bin.""", ), + "max_threads": attr.int( + mandatory = False, + doc = """Sets the `GOMAXPROCS` variable to limit the number of threads that esbuild can run with. +This can be useful if running many esbuild rule invocations in parallel, which has the potential to cause slowdown. +For general use, leave this attribute unset. + """, + ), "minify": attr.bool( default = False, doc = """Minifies the bundle with the built in minification. Removes whitespace, shortens identifieres and uses equivalent but shorter syntax. + Sets all --minify-* flags + See https://esbuild.github.io/api/#minify for more details """, ), @@ -190,6 +218,7 @@ See https://esbuild.github.io/api/#minify for more details "output_dir": attr.bool( default = False, doc = """If true, esbuild produces an output directory containing all the output files from code splitting + See https://esbuild.github.io/api/#splitting for more details """, ), @@ -205,13 +234,23 @@ See https://esbuild.github.io/api/#splitting for more details default = "browser", values = ["node", "browser", "neutral", ""], doc = """The platform to bundle for. + See https://esbuild.github.io/api/#platform for more details """, ), + "sourcemap": attr.string( + values = ["external", "inline", "both", ""], + mandatory = False, + doc = """Defines where sourcemaps are output and how they are included in the bundle. By default, a separate `.js.map` file is generated and referenced by the bundle. If 'external', a separate `.js.map` file is generated but not referenced by the bundle. If 'inline', a sourcemap is generated and its contents are inlined into the bundle (and no external sourcemap file is created). If 'both', a sourcemap is inlined and a `.js.map` file is created. + +See https://esbuild.github.io/api/#sourcemap for more details + """, + ), "sources_content": attr.bool( mandatory = False, default = False, doc = """If False, omits the `sourcesContent` field from generated source maps + See https://esbuild.github.io/api/#sources-content for more details """, ), @@ -219,36 +258,37 @@ See https://esbuild.github.io/api/#sources-content for more details allow_files = True, default = [], doc = """Non-entry point JavaScript source files from the workspace. + You must not repeat file(s) passed to entry_point""", ), "target": attr.string( default = "es2015", doc = """Environment target (e.g. es2017, chrome58, firefox57, safari11, edge16, node10, default esnext) + See https://esbuild.github.io/api/#target for more details """, ), - "tool": attr.label( - allow_single_file = True, - mandatory = True, - executable = True, - cfg = "exec", - doc = "An executable for the esbuild binary", - ), }, implementation = _esbuild_impl, doc = """Runs the esbuild bundler under Bazel + For further information about esbuild, see https://esbuild.github.io/ """, + toolchains = [ + TOOLCHAIN, + ], ) def esbuild_macro(name, output_dir = False, output_css = False, **kwargs): """esbuild helper macro around the `esbuild_bundle` rule + For a full list of attributes, see the `esbuild_bundle` rule + Args: name: The name used for this rule and output files output_dir: If `True`, produce a code split bundle in an output directory - output_css: If `True`, declare a .css file will be outputted, which is the + output_css: If `True`, declare name.css as an output, which is the case when your code imports a css file. **kwargs: All other args from `esbuild_bundle` """ @@ -260,10 +300,19 @@ def esbuild_macro(name, output_dir = False, output_css = False, **kwargs): **kwargs ) else: + output = "%s.js" % name + if "output" in kwargs: + output = kwargs.pop("output") + + output_map = None + sourcemap = kwargs.get("sourcemap", None) + if sourcemap != "inline": + output_map = "%s.map" % output + esbuild( name = name, - output = "%s.js" % name, - output_map = "%s.js.map" % name, + output = output, + output_map = output_map, output_css = None if not output_css else "%s.css" % name, **kwargs ) diff --git a/ts/graphs/BUILD.bazel b/ts/graphs/BUILD.bazel index 057b572d9..aa2aea72a 100644 --- a/ts/graphs/BUILD.bazel +++ b/ts/graphs/BUILD.bazel @@ -53,6 +53,8 @@ esbuild( args = [ "--global-name=anki", "--inject:$(location //ts:protobuf-shim.js)", + "--resolve-extensions=.mjs,.js", + "--log-level=warning", ], entry_point = "index.ts", external = [