# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations import json import math import re from typing import Callable, Sequence import aqt import aqt.browser import aqt.editor import aqt.forms import aqt.operations from anki._legacy import deprecated from anki.cards import Card, CardId from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.errors import NotFoundError from anki.lang import without_unicode_isolation from anki.notes import NoteId from anki.scheduler.base import ScheduleCardsAsNew from anki.tags import MARKED_TAG from anki.utils import is_mac from aqt import AnkiQt, gui_hooks from aqt.editor import Editor from aqt.exporting import ExportDialog as LegacyExportDialog from aqt.import_export.exporting import ExportDialog from aqt.operations.card import set_card_deck, set_card_flag from aqt.operations.collection import redo, undo from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( bury_cards, forget_cards, reposition_new_cards_dialog, set_due_date_dialog, suspend_cards, unbury_cards, unsuspend_cards, ) from aqt.operations.tag import ( add_tags_to_notes, clear_unused_tags, remove_tags_from_notes, ) from aqt.qt import * from aqt.sound import av_player from aqt.switch import Switch from aqt.undo import UndoActionsInfo from aqt.utils import ( HelpPage, KeyboardModifiersPressed, add_ellipsis_to_action_label, current_window, ensure_editor_saved, getTag, no_arg_trigger, openHelp, qtMenuShortcutWorkaround, restoreGeom, restoreSplitter, restoreState, saveGeom, saveSplitter, saveState, showWarning, skip_if_selection_is_empty, tooltip, tr, ) from ..changenotetype import change_notetype_dialog from .card_info import BrowserCardInfo from .find_and_replace import FindAndReplaceDialog from .layout import BrowserLayout, QSplitterHandleEventFilter from .previewer import BrowserPreviewer as PreviewDialog from .previewer import Previewer from .sidebar import SidebarTreeView from .table import Table class MockModel: """This class only exists to support some legacy aliases.""" def __init__(self, browser: aqt.browser.Browser) -> None: self.browser = browser @deprecated(replaced_by=aqt.operations.CollectionOp) def beginReset(self) -> None: self.browser.begin_reset() @deprecated(replaced_by=aqt.operations.CollectionOp) def endReset(self) -> None: self.browser.end_reset() @deprecated(replaced_by=aqt.operations.CollectionOp) def reset(self) -> None: self.browser.begin_reset() self.browser.end_reset() class Browser(QMainWindow): mw: AnkiQt col: Collection editor: Editor | None table: Table def __init__( self, mw: AnkiQt, card: Card | None = None, search: tuple[str | SearchNode] | None = None, ) -> None: """ card -- try to select the provided card after executing "search" or "deck:current" (if "search" was None) search -- set and perform search; caller must ensure validity """ QMainWindow.__init__(self, None, Qt.WindowType.Window) self.mw = mw self.col = self.mw.col self.lastFilter = "" self.focusTo: int | None = None self._previewer: Previewer | None = None self._card_info = BrowserCardInfo(self.mw) self._closeEventHasCleanedUp = False self.form = aqt.forms.browser.Ui_Dialog() self.form.setupUi(self) self.form.splitter.setChildrenCollapsible(False) splitter_handle_event_filter = QSplitterHandleEventFilter(self.form.splitter) self.form.splitter.handle(1).installEventFilter(splitter_handle_event_filter) # set if exactly 1 row is selected; used by the previewer self.card: Card | None = None self.current_card: Card | None = None self.setupSidebar() self.setup_table() self.setupMenus() self.setupHooks() self.setupEditor() gui_hooks.browser_will_show(self) # restoreXXX() should be called after all child widgets have been created # and attached to QMainWindow self._editor_state_key = ( "editorRTL" if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else "editor" ) restoreGeom(self, self._editor_state_key) restoreSplitter(self.form.splitter, "editor3") restoreState(self, self._editor_state_key) # responsive layout self.aspect_ratio = self.width() / self.height() if self.height() != 0 else 0 self.set_layout(self.mw.pm.browser_layout(), True) # disable undo/redo self.on_undo_state_change(mw.undo_actions_info()) # legacy alias self.model = MockModel(self) self.setupSearch(card, search) self.show() def on_operation_did_execute( self, changes: OpChanges, handler: object | None ) -> None: focused = current_window() == self self.table.op_executed(changes, handler, focused) self.sidebar.op_executed(changes, handler, focused) if changes.note_text: if handler is not self.editor: # fixme: this will leave the splitter shown, but with no current # note being edited note = self.editor.note if note: try: note.load() except NotFoundError: self.editor.set_note(None) return self.editor.set_note(note) if changes.browser_table and changes.card: self.card = self.table.get_single_selected_card() self.current_card = self.table.get_current_card() self._update_card_info() self._update_current_actions() # changes.card is required for updating flag icon if changes.note_text or changes.card: self._renderPreview() def on_focus_change(self, new: QWidget | None, old: QWidget | None) -> None: if current_window() == self: self.setUpdatesEnabled(True) self.table.redraw_cells() self.sidebar.refresh_if_needed() def set_layout(self, mode: BrowserLayout, init: bool = False) -> None: self.mw.pm.set_browser_layout(mode) if mode == BrowserLayout.AUTO: self.auto_layout = True self.maybe_update_layout(self.aspect_ratio, True) self.form.actionLayoutAuto.setChecked(True) self.form.actionLayoutVertical.setChecked(False) self.form.actionLayoutHorizontal.setChecked(False) if not init: tooltip(tr.qt_misc_layout_auto_enabled()) else: self.auto_layout = False self.form.actionLayoutAuto.setChecked(False) if mode == BrowserLayout.VERTICAL: self.form.splitter.setOrientation(Qt.Orientation.Vertical) self.form.actionLayoutVertical.setChecked(True) self.form.actionLayoutHorizontal.setChecked(False) if not init: tooltip(tr.qt_misc_layout_vertical_enabled()) elif mode == BrowserLayout.HORIZONTAL: self.form.splitter.setOrientation(Qt.Orientation.Horizontal) self.form.actionLayoutHorizontal.setChecked(True) self.form.actionLayoutVertical.setChecked(False) if not init: tooltip(tr.qt_misc_layout_horizontal_enabled()) def maybe_update_layout(self, aspect_ratio: float, force: bool = False) -> None: if force or math.floor(aspect_ratio) != math.floor(self.aspect_ratio): if aspect_ratio < 1: self.form.splitter.setOrientation(Qt.Orientation.Vertical) else: self.form.splitter.setOrientation(Qt.Orientation.Horizontal) def resizeEvent(self, event: QResizeEvent) -> None: if self.height() != 0: aspect_ratio = self.width() / self.height() if self.auto_layout: self.maybe_update_layout(aspect_ratio) self.aspect_ratio = aspect_ratio QMainWindow.resizeEvent(self, event) def setupMenus(self) -> None: # actions f = self.form # edit qconnect(f.actionUndo.triggered, self.undo) qconnect(f.actionRedo.triggered, self.redo) qconnect(f.actionInvertSelection.triggered, self.table.invert_selection) qconnect(f.actionSelectNotes.triggered, self.selectNotes) if not is_mac: f.actionClose.setVisible(False) qconnect(f.actionCreateFilteredDeck.triggered, self.createFilteredDeck) f.actionCreateFilteredDeck.setShortcuts(["Ctrl+G", "Ctrl+Alt+G"]) # view qconnect(f.actionFullScreen.triggered, self.mw.on_toggle_full_screen) qconnect( f.actionZoomIn.triggered, lambda: self.editor.web.setZoomFactor(self.editor.web.zoomFactor() + 0.1), ) qconnect( f.actionZoomOut.triggered, lambda: self.editor.web.setZoomFactor(self.editor.web.zoomFactor() - 0.1), ) qconnect( f.actionResetZoom.triggered, lambda: self.editor.web.setZoomFactor(1), ) qconnect( self.form.actionLayoutAuto.triggered, lambda: self.set_layout(BrowserLayout.AUTO), ) qconnect( self.form.actionLayoutVertical.triggered, lambda: self.set_layout(BrowserLayout.VERTICAL), ) qconnect( self.form.actionLayoutHorizontal.triggered, lambda: self.set_layout(BrowserLayout.HORIZONTAL), ) # notes qconnect(f.actionAdd.triggered, self.mw.onAddCard) qconnect(f.actionCopy.triggered, self.on_create_copy) qconnect(f.actionAdd_Tags.triggered, self.add_tags_to_selected_notes) qconnect(f.actionRemove_Tags.triggered, self.remove_tags_from_selected_notes) qconnect(f.actionClear_Unused_Tags.triggered, self.clear_unused_tags) qconnect(f.actionToggle_Mark.triggered, self.toggle_mark_of_selected_notes) qconnect(f.actionChangeModel.triggered, self.onChangeModel) qconnect(f.actionFindDuplicates.triggered, self.onFindDupes) qconnect(f.actionFindReplace.triggered, self.onFindReplace) qconnect(f.actionManage_Note_Types.triggered, self.mw.onNoteTypes) qconnect(f.actionDelete.triggered, self.delete_selected_notes) # cards qconnect(f.actionChange_Deck.triggered, self.set_deck_of_selected_cards) qconnect(f.action_Info.triggered, self.showCardInfo) qconnect(f.actionReposition.triggered, self.reposition) qconnect(f.action_set_due_date.triggered, self.set_due_date) qconnect(f.action_forget.triggered, self.forget_cards) qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards) qconnect(f.action_toggle_bury.triggered, self.bury_selected_cards) def set_flag_func(desired_flag: int) -> Callable: return lambda: self.set_flag_of_selected_cards(desired_flag) for flag in self.mw.flags.all(): qconnect( getattr(self.form, flag.action).triggered, set_flag_func(flag.index) ) self._update_flag_labels() qconnect(f.actionExport.triggered, self._on_export_notes) # jumps qconnect(f.actionPreviousCard.triggered, self.onPreviousCard) qconnect(f.actionNextCard.triggered, self.onNextCard) qconnect(f.actionFirstCard.triggered, self.onFirstCard) qconnect(f.actionLastCard.triggered, self.onLastCard) qconnect(f.actionFind.triggered, self.onFind) qconnect(f.actionNote.triggered, self.onNote) qconnect(f.actionSidebar.triggered, self.focusSidebar) qconnect(f.actionCardList.triggered, self.onCardList) # help qconnect(f.actionGuide.triggered, self.onHelp) # keyboard shortcut for shift+home/end self.pgUpCut = QShortcut(QKeySequence("Shift+Home"), self) qconnect(self.pgUpCut.activated, self.onFirstCard) self.pgDownCut = QShortcut(QKeySequence("Shift+End"), self) qconnect(self.pgDownCut.activated, self.onLastCard) # add-on hook gui_hooks.browser_menus_did_init(self) self.mw.maybeHideAccelerators(self) add_ellipsis_to_action_label(f.actionCopy) add_ellipsis_to_action_label(f.action_forget) def closeEvent(self, evt: QCloseEvent) -> None: if self._closeEventHasCleanedUp: evt.accept() return self.editor.call_after_note_saved(self._closeWindow) evt.ignore() def _closeWindow(self) -> None: self._cleanup_preview() self._card_info.close() self.editor.cleanup() self.table.cleanup() self.sidebar.cleanup() saveSplitter(self.form.splitter, "editor3") saveGeom(self, self._editor_state_key) saveState(self, self._editor_state_key) self.teardownHooks() self.mw.maybeReset() aqt.dialogs.markClosed("Browser") self._closeEventHasCleanedUp = True self.mw.deferred_delete_and_garbage_collect(self) self.close() @ensure_editor_saved def closeWithCallback(self, onsuccess: Callable) -> None: self._closeWindow() onsuccess() def keyPressEvent(self, evt: QKeyEvent) -> None: if evt.key() == Qt.Key.Key_Escape: self.close() else: super().keyPressEvent(evt) def reopen( self, _mw: AnkiQt, card: Card | None = None, search: tuple[str | SearchNode] | None = None, ) -> None: if search is not None: self.search_for_terms(*search) self.form.searchEdit.setFocus() if card is not None: if search is None: # implicitly assume 'card' is in the current deck self._default_search(card) self.form.searchEdit.setFocus() self.table.select_single_card(card.id) # Searching ###################################################################### def setupSearch( self, card: Card | None = None, search: tuple[str | SearchNode] | None = None, ) -> None: qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated) self.form.searchEdit.setCompleter(None) self.form.searchEdit.lineEdit().setPlaceholderText( tr.browsing_search_bar_hint() ) self.form.searchEdit.lineEdit().setMaxLength(2000000) self.form.searchEdit.addItems( [""] + self.mw.pm.profile.get("searchHistory", []) ) if search is not None: self.search_for_terms(*search) else: self._default_search(card) self.form.searchEdit.setFocus() if card: self.table.select_single_card(card.id) # search triggered by user @ensure_editor_saved def onSearchActivated(self) -> None: text = self.current_search() try: normed = self.col.build_search_string(text) except Exception as err: showWarning(str(err)) else: self.search_for(normed) self.update_history() def search_for(self, search: str, prompt: str | None = None) -> None: """Keep track of search string so that we reuse identical search when refreshing, rather than whatever is currently in the search field. Optionally set the search bar to a different text than the actual search. """ self._lastSearchTxt = search prompt = search if prompt is None else prompt self.form.searchEdit.setCurrentIndex(-1) self.form.searchEdit.lineEdit().setText(prompt) self.search() def current_search(self) -> str: return self.form.searchEdit.lineEdit().text() def search(self) -> None: """Search triggered programmatically. Caller must have saved note first.""" try: self.table.search(self._lastSearchTxt) except Exception as err: showWarning(str(err)) def update_history(self) -> None: sh = self.mw.pm.profile.get("searchHistory", []) if self._lastSearchTxt in sh: sh.remove(self._lastSearchTxt) sh.insert(0, self._lastSearchTxt) sh = sh[:30] self.form.searchEdit.clear() self.form.searchEdit.addItems(sh) self.mw.pm.profile["searchHistory"] = sh def updateTitle(self) -> None: selected = self.table.len_selection() cur = self.table.len() tr_title = ( tr.browsing_window_title_notes if self.table.is_notes_mode() else tr.browsing_window_title ) self.setWindowTitle( without_unicode_isolation(tr_title(total=cur, selected=selected)) ) def search_for_terms(self, *search_terms: str | SearchNode) -> None: search = self.col.build_search_string(*search_terms) self.form.searchEdit.setEditText(search) self.onSearchActivated() def _default_search(self, card: Card | None = None) -> None: default = self.col.get_config_string(Config.String.DEFAULT_SEARCH_TEXT) if default.strip(): search = default prompt = default else: search = self.col.build_search_string(SearchNode(deck="current")) prompt = "" if card is not None: search = gui_hooks.default_search(search, card) self.search_for(search, prompt) def onReset(self) -> None: self.sidebar.refresh() self.begin_reset() self.end_reset() # caller must have called editor.saveNow() before calling this or .reset() def begin_reset(self) -> None: self.editor.set_note(None, hide=False) self.mw.progress.start() self.table.begin_reset() def end_reset(self) -> None: self.table.end_reset() self.mw.progress.finish() # Table & Editor ###################################################################### def setup_table(self) -> None: self.table = Table(self) self.table.set_view(self.form.tableView) switch = Switch(12, tr.browsing_cards(), tr.browsing_notes()) switch.setChecked(self.table.is_notes_mode()) switch.setToolTip(tr.browsing_toggle_showing_cards_notes()) qconnect(self.form.action_toggle_mode.triggered, switch.toggle) qconnect(switch.toggled, self.on_table_state_changed) self.form.gridLayout.addWidget(switch, 0, 0) def setupEditor(self) -> None: QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.onTogglePreview) def add_preview_button(editor: Editor) -> None: editor._links["preview"] = lambda _editor: self.onTogglePreview() gui_hooks.editor_did_init.append(add_preview_button) self.editor = aqt.editor.Editor( self.mw, self.form.fieldsArea, self, editor_mode=aqt.editor.EditorMode.BROWSER, ) gui_hooks.editor_did_init.remove(add_preview_button) @ensure_editor_saved def on_all_or_selected_rows_changed(self) -> None: """Called after the selected or all rows (searching, toggling mode) have changed. Update window title, card preview, context actions, and editor. """ if self._closeEventHasCleanedUp: return self.updateTitle() # if there is only one selected card, use it in the editor # it might differ from the current card self.card = self.table.get_single_selected_card() self.singleCard = bool(self.card) self.form.splitter.widget(1).setVisible(self.singleCard) if self.singleCard: self.editor.set_note(self.card.note(), focusTo=self.focusTo) self.focusTo = None self.editor.card = self.card else: self.editor.set_note(None) self._renderPreview() self._update_row_actions() self._update_selection_actions() gui_hooks.browser_did_change_row(self) @deprecated(info="please use on_all_or_selected_rows_changed() instead.") def onRowChanged(self, *args: Any) -> None: self.on_all_or_selected_rows_changed() def on_current_row_changed(self) -> None: """Called after the row of the current element has changed.""" if self._closeEventHasCleanedUp: return self.current_card = self.table.get_current_card() self._update_current_actions() self._update_card_info() def _update_row_actions(self) -> None: has_rows = bool(self.table.len()) self.form.actionSelectAll.setEnabled(has_rows) self.form.actionInvertSelection.setEnabled(has_rows) self.form.actionFirstCard.setEnabled(has_rows) self.form.actionLastCard.setEnabled(has_rows) def _update_selection_actions(self) -> None: has_selection = bool(self.table.len_selection()) self.form.actionSelectNotes.setEnabled(has_selection) self.form.actionExport.setEnabled(has_selection) self.form.actionAdd_Tags.setEnabled(has_selection) self.form.actionRemove_Tags.setEnabled(has_selection) self.form.actionToggle_Mark.setEnabled(has_selection) self.form.actionChangeModel.setEnabled(has_selection) self.form.actionDelete.setEnabled(has_selection) self.form.actionChange_Deck.setEnabled(has_selection) self.form.action_set_due_date.setEnabled(has_selection) self.form.action_forget.setEnabled(has_selection) self.form.actionReposition.setEnabled(has_selection) self.form.actionToggle_Suspend.setEnabled(has_selection) self.form.action_toggle_bury.setEnabled(has_selection) self.form.menuFlag.setEnabled(has_selection) def _update_current_actions(self) -> None: self._update_flags_menu() self._update_toggle_bury_action() self._update_toggle_mark_action() self._update_toggle_suspend_action() self.form.actionCopy.setEnabled(self.table.has_current()) self.form.action_Info.setEnabled(self.table.has_current()) self.form.actionPreviousCard.setEnabled(self.table.has_previous()) self.form.actionNextCard.setEnabled(self.table.has_next()) @ensure_editor_saved def on_table_state_changed(self, checked: bool) -> None: self.mw.progress.start() self.table.toggle_state(checked, self._lastSearchTxt) self.mw.progress.finish() # Sidebar ###################################################################### def setupSidebar(self) -> None: dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self) dw.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) dw.setObjectName("Sidebar") dock_area = ( Qt.DockWidgetArea.RightDockWidgetArea if self.layoutDirection() == Qt.LayoutDirection.RightToLeft else Qt.DockWidgetArea.LeftDockWidgetArea ) dw.setAllowedAreas(dock_area) self.sidebar = SidebarTreeView(self) self.sidebarTree = self.sidebar # legacy alias dw.setWidget(self.sidebar) qconnect( self.form.actionSidebarFilter.triggered, self.focusSidebarSearchBar, ) grid = QGridLayout() grid.addWidget(self.sidebar.searchBar, 0, 0) grid.addWidget(self.sidebar.toolbar, 0, 1) grid.addWidget(self.sidebar, 1, 0, 1, 2) grid.setContentsMargins(8, 4, 0, 0) grid.setSpacing(0) w = QWidget() w.setLayout(grid) dw.setWidget(w) self.sidebarDockWidget.setFloating(False) self.sidebarDockWidget.setTitleBarWidget(QWidget()) self.addDockWidget(dock_area, dw) # schedule sidebar to refresh after browser window has loaded, so the # UI is more responsive self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar) def showSidebar(self) -> None: self.sidebarDockWidget.setVisible(True) def focusSidebar(self) -> None: self.showSidebar() self.sidebar.setFocus() def focusSidebarSearchBar(self) -> None: self.showSidebar() self.sidebar.searchBar.setFocus() def toggle_sidebar(self) -> None: want_visible = not self.sidebarDockWidget.isVisible() self.sidebarDockWidget.setVisible(want_visible) if want_visible: self.sidebar.refresh() # legacy def setFilter(self, *terms: str) -> None: self.sidebar.update_search(*terms) # Info ###################################################################### def showCardInfo(self) -> None: self._card_info.toggle() def _update_card_info(self) -> None: self._card_info.set_card(self.current_card) # Menu helpers ###################################################################### def selected_cards(self) -> Sequence[CardId]: return self.table.get_selected_card_ids() def selected_notes(self) -> Sequence[NoteId]: return self.table.get_selected_note_ids() def selectedNotesAsCards(self) -> Sequence[CardId]: return self.table.get_card_ids_from_selected_note_ids() def onHelp(self) -> None: openHelp(HelpPage.BROWSING) # legacy selectedCards = selected_cards selectedNotes = selected_notes # Misc menu options ###################################################################### def on_create_copy(self) -> None: if note := self.table.get_current_note(): deck_id = self.table.get_current_card().did aqt.dialogs.open("AddCards", self.mw).set_note(note, deck_id) @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def onChangeModel(self) -> None: ids = self.selected_notes() change_notetype_dialog(parent=self, note_ids=ids) def createFilteredDeck(self) -> None: search = self.current_search() if self.mw.col.sched_ver() != 1 and KeyboardModifiersPressed().alt: aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search_2=search) else: aqt.dialogs.open("FilteredDeckConfigDialog", self.mw, search=search) # Preview ###################################################################### def onTogglePreview(self) -> None: if self._previewer: self._previewer.close() elif self.editor.note: self._previewer = PreviewDialog(self, self.mw, self._on_preview_closed) self._previewer.open() self.toggle_preview_button_state(True) def _renderPreview(self) -> None: if self._previewer: if self.singleCard: self._previewer.render_card() else: self.onTogglePreview() def toggle_preview_button_state(self, active: bool) -> None: if self.editor.web: self.editor.web.eval(f"togglePreviewButtonState({json.dumps(active)});") def _cleanup_preview(self) -> None: if self._previewer: self._previewer.cancel_timer() self._previewer.close() def _on_preview_closed(self) -> None: av_player.stop_and_clear_queue() self.toggle_preview_button_state(False) self._previewer = None # Card deletion ###################################################################### @no_arg_trigger @skip_if_selection_is_empty def delete_selected_notes(self) -> None: # ensure deletion is not accidentally triggered when the user is focused # in the editing screen or search bar focus = self.focusWidget() if focus != self.form.tableView: return self.editor.set_note(None) nids = self.table.to_row_of_unselected_note() remove_notes(parent=self, note_ids=nids).run_in_background() # legacy deleteNotes = delete_selected_notes # Deck change ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def set_deck_of_selected_cards(self) -> None: from aqt.studydeck import StudyDeck cids = self.table.get_selected_card_ids() did = self.mw.col.db.scalar("select did from cards where id = ?", cids[0]) current = self.mw.col.decks.get(did)["name"] def callback(ret: StudyDeck) -> None: if not ret.name: return did = self.col.decks.id(ret.name) set_card_deck(parent=self, card_ids=cids, deck_id=did).run_in_background() StudyDeck( self.mw, current=current, accept=tr.browsing_move_cards(), title=tr.browsing_change_deck(), help=HelpPage.BROWSING, parent=self, callback=callback, ) # legacy setDeck = set_deck_of_selected_cards # Tags ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def add_tags_to_selected_notes( self, tags: str | None = None, ) -> None: "Shows prompt if tags not provided." if not (tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_add())): return space_separated_tags = re.sub(r"[ \n\t\v]+", " ", tags) add_tags_to_notes( parent=self, note_ids=self.selected_notes(), space_separated_tags=space_separated_tags, ).run_in_background(initiator=self) @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def remove_tags_from_selected_notes(self, tags: str | None = None) -> None: "Shows prompt if tags not provided." if not ( tags := tags or self._prompt_for_tags(tr.browsing_enter_tags_to_delete()) ): return remove_tags_from_notes( parent=self, note_ids=self.selected_notes(), space_separated_tags=tags ).run_in_background(initiator=self) def _prompt_for_tags(self, prompt: str) -> str | None: (tags, ok) = getTag(self, self.col, prompt) if not ok: return None else: return tags @no_arg_trigger @ensure_editor_saved def clear_unused_tags(self) -> None: clear_unused_tags(parent=self).run_in_background() addTags = add_tags_to_selected_notes deleteTags = remove_tags_from_selected_notes clearUnusedTags = clear_unused_tags # Suspending ###################################################################### def _update_toggle_suspend_action(self) -> None: is_suspended = bool( self.current_card and self.current_card.queue == QUEUE_TYPE_SUSPENDED ) self.form.actionToggle_Suspend.setChecked(is_suspended) @skip_if_selection_is_empty @ensure_editor_saved def suspend_selected_cards(self, checked: bool) -> None: cids = self.selected_cards() if checked: suspend_cards(parent=self, card_ids=cids).run_in_background() else: unsuspend_cards(parent=self.mw, card_ids=cids).run_in_background() # Burying ###################################################################### def _update_toggle_bury_action(self) -> None: is_buried = bool( self.current_card and self.current_card.queue in (QUEUE_TYPE_MANUALLY_BURIED, QUEUE_TYPE_SIBLING_BURIED) ) self.form.action_toggle_bury.setChecked(is_buried) @skip_if_selection_is_empty @ensure_editor_saved def bury_selected_cards(self, checked: bool) -> None: cids = self.selected_cards() if checked: bury_cards(parent=self, card_ids=cids).run_in_background() else: unbury_cards(parent=self.mw, card_ids=cids).run_in_background() # Exporting ###################################################################### @no_arg_trigger @skip_if_selection_is_empty def _on_export_notes(self) -> None: if not self.mw.pm.legacy_import_export(): nids = self.selected_notes() ExportDialog(self.mw, nids=nids) else: cids = self.selectedNotesAsCards() LegacyExportDialog(self.mw, cids=list(cids)) # Flags & Marking ###################################################################### @skip_if_selection_is_empty @ensure_editor_saved def set_flag_of_selected_cards(self, flag: int) -> None: if not self.current_card: return # flag needs toggling off? if flag == self.current_card.user_flag(): flag = 0 set_card_flag( parent=self, card_ids=self.selected_cards(), flag=flag ).run_in_background() def _update_flags_menu(self) -> None: flag = self.current_card and self.current_card.user_flag() flag = flag or 0 for f in self.mw.flags.all(): getattr(self.form, f.action).setChecked(flag == f.index) qtMenuShortcutWorkaround(self.form.menuFlag) def _update_flag_labels(self) -> None: for flag in self.mw.flags.all(): getattr(self.form, flag.action).setText(flag.label) def toggle_mark_of_selected_notes(self, checked: bool) -> None: if checked: self.add_tags_to_selected_notes(tags=MARKED_TAG) else: self.remove_tags_from_selected_notes(tags=MARKED_TAG) def _update_toggle_mark_action(self) -> None: is_marked = bool( self.current_card and self.current_card.note().has_tag(MARKED_TAG) ) self.form.actionToggle_Mark.setChecked(is_marked) # Scheduling ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def reposition(self) -> None: if op := reposition_new_cards_dialog( parent=self, card_ids=self.selected_cards() ): op.run_in_background() @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def set_due_date(self) -> None: if op := set_due_date_dialog( parent=self, card_ids=self.selected_cards(), config_key=Config.String.SET_DUE_BROWSER, ): op.run_in_background() @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def forget_cards(self) -> None: if op := forget_cards( parent=self, card_ids=self.selected_cards(), context=ScheduleCardsAsNew.Context.BROWSER, ): op.run_in_background() # Edit: selection ###################################################################### @no_arg_trigger @skip_if_selection_is_empty @ensure_editor_saved def selectNotes(self) -> None: nids = self.selected_notes() # clear the selection so we don't waste energy preserving it self.table.clear_selection() search = self.col.build_search_string( SearchNode(nids=SearchNode.IdList(ids=nids)) ) self.search_for(search) self.table.select_all() # Hooks ###################################################################### def setupHooks(self) -> None: gui_hooks.undo_state_did_change.append(self.on_undo_state_change) gui_hooks.backend_will_block.append(self.table.on_backend_will_block) gui_hooks.backend_did_block.append(self.table.on_backend_did_block) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.focus_did_change.append(self.on_focus_change) gui_hooks.flag_label_did_change.append(self._update_flag_labels) gui_hooks.collection_will_temporarily_close.append(self._on_temporary_close) def teardownHooks(self) -> None: gui_hooks.undo_state_did_change.remove(self.on_undo_state_change) gui_hooks.backend_will_block.remove(self.table.on_backend_will_block) gui_hooks.backend_did_block.remove(self.table.on_backend_did_block) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.focus_did_change.remove(self.on_focus_change) gui_hooks.flag_label_did_change.remove(self._update_flag_labels) gui_hooks.collection_will_temporarily_close.remove(self._on_temporary_close) def _on_temporary_close(self, col: Collection) -> None: # we could reload browser columns in the future; for now we just close self.close() # Undo ###################################################################### def undo(self) -> None: undo(parent=self) def redo(self) -> None: redo(parent=self) def on_undo_state_change(self, info: UndoActionsInfo) -> None: self.form.actionUndo.setText(info.undo_text) self.form.actionUndo.setEnabled(info.can_undo) self.form.actionRedo.setText(info.redo_text) self.form.actionRedo.setEnabled(info.can_redo) self.form.actionRedo.setVisible(info.show_redo) # Edit: replacing ###################################################################### @no_arg_trigger @ensure_editor_saved def onFindReplace(self) -> None: FindAndReplaceDialog(self, mw=self.mw, note_ids=self.selected_notes()) # Edit: finding dupes ###################################################################### @no_arg_trigger @ensure_editor_saved def onFindDupes(self) -> None: from aqt.browser.find_duplicates import FindDuplicatesDialog FindDuplicatesDialog(browser=self, mw=self.mw) # Jumping ###################################################################### def has_previous_card(self) -> bool: return self.table.has_previous() def has_next_card(self) -> bool: return self.table.has_next() def onPreviousCard(self) -> None: self.focusTo = self.editor.currentField self.editor.call_after_note_saved(self.table.to_previous_row) def onNextCard(self) -> None: self.focusTo = self.editor.currentField self.editor.call_after_note_saved(self.table.to_next_row) def onFirstCard(self) -> None: self.table.to_first_row() def onLastCard(self) -> None: self.table.to_last_row() def onFind(self) -> None: self.form.searchEdit.setFocus() self.form.searchEdit.lineEdit().selectAll() def onNote(self) -> None: self.editor.web.setFocus() self.editor.loadNote(focusTo=0) def onCardList(self) -> None: self.form.tableView.setFocus()