diff --git a/ftl/core/actions.ftl b/ftl/core/actions.ftl index 392ee383a..dd15e114c 100644 --- a/ftl/core/actions.ftl +++ b/ftl/core/actions.ftl @@ -1,4 +1,6 @@ actions-add = Add +actions-all-selected = All selected +actions-any-selected = Any selected actions-blue-flag = Blue Flag actions-cancel = Cancel actions-choose = Choose @@ -30,6 +32,7 @@ actions-replay-audio = Replay Audio actions-reposition = Reposition actions-save = Save actions-search = Search +actions-select = Select actions-shortcut-key = Shortcut key: { $val } actions-suspend-card = Suspend Card actions-set-due-date = Set Due Date diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index f7e803bff..cb676d53c 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -14,6 +14,11 @@ browsing-card = Card browsing-card-list = Card List browsing-card-state = Card State browsing-cards-cant-be-manually-moved-into = Cards can't be manually moved into a filtered deck. +browsing-cards-deleted = + { $count -> + [one] { $count } card deleted. + *[other] { $count } cards deleted. + } browsing-change-deck = Change Deck browsing-change-deck2 = Change Deck... browsing-change-note-type = Change Note Type @@ -21,6 +26,7 @@ browsing-change-note-type2 = Change Note Type... browsing-change-to = Change { $val } to: browsing-clear-unused = Clear Unused browsing-clear-unused-tags = Clear Unused Tags +browsing-confirm-saved-search-overwrite = A saved search with the name { $name } already exists. Do you want to overwrite it? browsing-created = Created browsing-ctrlandshiftande = Ctrl+Shift+E browsing-current-deck = Current Deck @@ -70,14 +76,11 @@ browsing-question = Question browsing-queue-bottom = Queue bottom: { $val } browsing-queue-top = Queue top: { $val } browsing-randomize-order = Randomize order -browsing-remove-current-filter = Remove Current Filter... -browsing-remove-from-your-saved-searches = Remove { $val } from your saved searches? browsing-remove-tags = Remove Tags... browsing-replace-with = Replace With: browsing-reposition = Reposition... browsing-reposition-new-cards = Reposition New Cards browsing-reschedule = Reschedule -browsing-save-current-filter = Save Current Filter... browsing-search-bar-hint = Search cards/notes (type text, then press Enter) browsing-search-in = Search in: browsing-search-within-formatting-slow = Search within formatting (slow) @@ -112,7 +115,14 @@ browsing-note-deleted = [one] { $count } note deleted. *[other] { $count } notes deleted. } +browsing-notes-updated = + { $count -> + [one] { $count } note updated. + *[other] { $count } notes updated. + } browsing-window-title = Browse ({ $selected } of { $total } cards selected) +browsing-sidebar-expand = Expand +browsing-sidebar-collapse = Collapse browsing-sidebar-expand-children = Expand Children browsing-sidebar-collapse-children = Collapse Children browsing-sidebar-decks = Decks diff --git a/ftl/core/decks.ftl b/ftl/core/decks.ftl index 3ffe2a470..fb7451edf 100644 --- a/ftl/core/decks.ftl +++ b/ftl/core/decks.ftl @@ -1,5 +1,4 @@ decks-add-new-deck-ctrlandn = Add New Deck (Ctrl+N) -decks-are-you-sure-you-wish-to = Are you sure you wish to delete { $val }? decks-build = Build decks-cards-selected-by = cards selected by decks-create-deck = Create Deck @@ -32,8 +31,3 @@ decks-study = Study decks-study-deck = Study Deck decks-the-provided-search-did-not-match = The provided search did not match any cards. Would you like to revise it? decks-unmovable-cards = Show any excluded cards -decks-it-has-card = - { $count -> - [one] It has { $count } card. - *[other] It has { $count } cards. - } diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 4f9f336ff..853ba991a 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -13,7 +13,7 @@ import anki # pylint: disable=unused-import import anki._backend.backend_pb2 as _pb from anki.consts import * from anki.errors import NotFoundError -from anki.utils import from_json_bytes, ids2str, intTime, to_json_bytes +from anki.utils import from_json_bytes, ids2str, intTime, legacy_func, to_json_bytes # public exports DeckTreeNode = _pb.DeckTreeNode @@ -130,12 +130,16 @@ class DeckManager: return deck["id"] + @legacy_func(sub="remove") def rem(self, did: int, cardsToo: bool = True, childrenToo: bool = True) -> None: "Remove the deck. If cardsToo, delete any cards inside." if isinstance(did, str): did = int(did) assert cardsToo and childrenToo - self.col._backend.remove_deck(did) + self.remove([did]) + + def remove(self, dids: List[int]) -> int: + return self.col._backend.remove_decks(dids) def all_names_and_ids( self, skip_empty_default: bool = False, include_filtered: bool = True @@ -212,10 +216,15 @@ class DeckManager: def count(self) -> int: return len(self.all_names_and_ids()) - def card_count(self, did: int, include_subdecks: bool) -> Any: - dids: List[int] = [did] + def card_count( + self, dids: Union[int, Iterable[int]], include_subdecks: bool + ) -> Any: + if isinstance(dids, int): + dids = {dids} + else: + dids = set(dids) if include_subdecks: - dids += [r[1] for r in self.children(did)] + dids.update([child[1] for did in dids for child in self.children(did)]) count = self.col.db.scalar( "select count() from cards where did in {0} or " "odid in {0}".format(ids2str(dids)) diff --git a/pylib/anki/utils.py b/pylib/anki/utils.py index 330797c0f..7322f8002 100644 --- a/pylib/anki/utils.py +++ b/pylib/anki/utils.py @@ -18,7 +18,7 @@ import traceback from contextlib import contextmanager from hashlib import sha1 from html.entities import name2codepoint -from typing import Any, Iterable, Iterator, List, Match, Optional, Union +from typing import Any, Callable, Iterable, Iterator, List, Match, Optional, Union from anki.dbproxy import DBProxy @@ -372,3 +372,26 @@ def pointVersion() -> int: from anki.buildinfo import version return int(version.split(".")[-1]) + + +# Legacy support +############################################################################## + + +def legacy_func(sub: Optional[str] = None) -> Callable: + """Print a deprecation warning for the decorated callable recommending the use of + 'sub' instead, if provided. + """ + if sub: + hint = f", use '{sub}' instead" + else: + hint = "" + + def decorater(func: Callable) -> Callable: + def decorated_func(*args: Any, **kwargs: Any) -> Any: + print(f"'{func.__name__}' is deprecated{hint}.") + return func(*args, **kwargs) + + return decorated_func + + return decorater diff --git a/qt/aqt/about.py b/qt/aqt/about.py index 04d577c2d..da8efe13b 100644 --- a/qt/aqt/about.py +++ b/qt/aqt/about.py @@ -205,6 +205,7 @@ def show(mw: aqt.AnkiQt) -> QDialog: "Gustavo Costa", "余时行", "叶峻峣", + "RumovZ", ) ) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 839bbde47..d5970c032 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -28,7 +28,7 @@ from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer from aqt.qt import * from aqt.scheduling import forget_cards, set_due_date_dialog -from aqt.sidebar import SidebarSearchBar, SidebarTreeView +from aqt.sidebar import SidebarSearchBar, SidebarToolbar, SidebarTreeView from aqt.theme import theme_manager from aqt.utils import ( TR, @@ -941,18 +941,20 @@ QTableView {{ gridline-color: {grid} }} self.sidebar = SidebarTreeView(self) self.sidebarTree = self.sidebar # legacy alias dw.setWidget(self.sidebar) + self.sidebar.toolbar = toolbar = SidebarToolbar(self.sidebar) self.sidebar.searchBar = searchBar = SidebarSearchBar(self.sidebar) qconnect( self.form.actionSidebarFilter.triggered, self.focusSidebarSearchBar, ) - l = QVBoxLayout() - l.addWidget(searchBar) - l.addWidget(self.sidebar) - l.setContentsMargins(0, 0, 0, 0) - l.setSpacing(0) + grid = QGridLayout() + grid.addWidget(searchBar, 0, 0) + grid.addWidget(toolbar, 0, 1) + grid.addWidget(self.sidebar, 1, 0, 1, 2) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(0) w = QWidget() - w.setLayout(l) + w.setLayout(grid) dw.setWidget(w) self.sidebarDockWidget.setFloating(False) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 6525f0d08..e80a7953f 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -23,6 +23,7 @@ from aqt.utils import ( shortcut, showInfo, showWarning, + tooltip, tr, ) @@ -303,34 +304,16 @@ class DeckBrowser: self.mw.taskman.with_progress(process, on_done) - def ask_delete_deck(self, did: int) -> bool: - deck = self.mw.col.decks.get(did) - if deck["dyn"]: - return True - - count = self.mw.col.decks.card_count(did, include_subdecks=True) - if not count: - return True - - extra = tr(TR.DECKS_IT_HAS_CARD, count=count) - if askUser( - f"{tr(TR.DECKS_ARE_YOU_SURE_YOU_WISH_TO, val=deck['name'])} {extra}" - ): - return True - return False - def _delete(self, did: int) -> None: - if self.ask_delete_deck(did): + def do_delete() -> int: + return self.mw.col.decks.remove([did]) - def do_delete() -> None: - return self.mw.col.decks.rem(did, True) + def on_done(fut: Future) -> None: + self.mw.update_undo_actions() + self.show() + tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result())) - def on_done(fut: Future) -> None: - self.mw.update_undo_actions() - self.show() - res = fut.result() # Required to check for errors - - self.mw.taskman.with_progress(do_delete, on_done) + self.mw.taskman.with_progress(do_delete, on_done) # Top buttons ###################################################################### diff --git a/qt/aqt/forms/icons.qrc b/qt/aqt/forms/icons.qrc index 277e01c9a..23dd5f5c5 100644 --- a/qt/aqt/forms/icons.qrc +++ b/qt/aqt/forms/icons.qrc @@ -10,5 +10,7 @@ icons/clock.svg icons/card-state.svg icons/flag.svg + icons/select.svg + icons/magnifying_glass.svg diff --git a/qt/aqt/forms/icons/magnifying_glass.svg b/qt/aqt/forms/icons/magnifying_glass.svg new file mode 100644 index 000000000..5cf295b2b --- /dev/null +++ b/qt/aqt/forms/icons/magnifying_glass.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/qt/aqt/forms/icons/select.svg b/qt/aqt/forms/icons/select.svg new file mode 100644 index 000000000..fe4ee8c67 --- /dev/null +++ b/qt/aqt/forms/icons/select.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 1d5544250..da4905b20 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -5,15 +5,17 @@ from __future__ import annotations from concurrent.futures import Future from enum import Enum, auto -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast +from typing import Dict, Iterable, List, Optional, Tuple, cast import aqt -from anki.collection import Config, SearchNode +from anki.collection import Config, SearchJoiner, SearchNode from anki.decks import DeckTreeNode from anki.errors import DeckIsFilteredError, InvalidInput +from anki.notes import Note from anki.tags import TagTreeNode from anki.types import assert_exhaustive from aqt import colors, gui_hooks +from aqt.clayout import CardLayout from aqt.main import ResetReason from aqt.models import Models from aqt.qt import * @@ -25,10 +27,16 @@ from aqt.utils import ( show_invalid_search_error, showInfo, showWarning, + tooltip, tr, ) +class SidebarTool(Enum): + SELECT = auto() + SEARCH = auto() + + class SidebarItemType(Enum): ROOT = auto() SAVED_SEARCH_ROOT = auto() @@ -40,6 +48,7 @@ class SidebarItemType(Enum): CARD_STATE_ROOT = auto() CARD_STATE = auto() DECK_ROOT = auto() + DECK_CURRENT = auto() DECK = auto() NOTETYPE_ROOT = auto() NOTETYPE = auto() @@ -57,6 +66,20 @@ class SidebarItemType(Enum): def is_section_root(self) -> bool: return self in self.section_roots() + def is_editable(self) -> bool: + return self in ( + SidebarItemType.SAVED_SEARCH, + SidebarItemType.DECK, + SidebarItemType.TAG, + ) + + def is_deletable(self) -> bool: + return self in ( + SidebarItemType.SAVED_SEARCH, + SidebarItemType.DECK, + SidebarItemType.TAG, + ) + class SidebarStage(Enum): ROOT = auto() @@ -74,26 +97,25 @@ class SidebarItem: self, name: str, icon: Union[str, ColoredIcon], - on_click: Callable[[], None] = None, + search_node: Optional[SearchNode] = None, on_expanded: Callable[[bool], None] = None, expanded: bool = False, item_type: SidebarItemType = SidebarItemType.CUSTOM, id: int = 0, - full_name: str = None, + name_prefix: str = "", ) -> None: self.name = name - if not full_name: - full_name = name - self.full_name = full_name + self.name_prefix = name_prefix + self.full_name = name_prefix + name self.icon = icon self.item_type = item_type self.id = id - self.on_click = on_click + self.search_node = search_node self.on_expanded = on_expanded self.children: List["SidebarItem"] = [] self.tooltip: Optional[str] = None self._parent_item: Optional["SidebarItem"] = None - self._is_expanded = expanded + self._expanded = expanded self._row_in_parent: Optional[int] = None self._search_matches_self = False self._search_matches_child = False @@ -107,7 +129,7 @@ class SidebarItem: name: Union[str, TR.V], icon: Union[str, ColoredIcon], type: SidebarItemType, - on_click: Callable[[], None], + search_node: Optional[SearchNode], ) -> SidebarItem: "Add child sidebar item, and return it." if not isinstance(name, str): @@ -115,20 +137,30 @@ class SidebarItem: item = SidebarItem( name=name, icon=icon, - on_click=on_click, + search_node=search_node, item_type=type, ) self.add_child(item) return item - def is_expanded(self, searching: bool) -> bool: + @property + def expanded(self) -> bool: + return self._expanded + + @expanded.setter + def expanded(self, expanded: bool) -> None: + if self.expanded != expanded: + self._expanded = expanded + if self.on_expanded: + self.on_expanded(expanded) + + def show_expanded(self, searching: bool) -> bool: if not searching: - return self._is_expanded - else: - if self._search_matches_child: - return True - # if search matches top level, expand children one level - return self._search_matches_self and self.item_type.is_section_root() + return self.expanded + if self._search_matches_child: + return True + # if search matches top level, expand children one level + return self._search_matches_self and self.item_type.is_section_root() def is_highlighted(self) -> bool: return self._search_matches_self @@ -143,8 +175,9 @@ class SidebarItem: class SidebarModel(QAbstractItemModel): - def __init__(self, root: SidebarItem) -> None: + def __init__(self, sidebar: SidebarTreeView, root: SidebarItem) -> None: super().__init__() + self.sidebar = sidebar self.root = root self._cache_rows(root) @@ -157,6 +190,9 @@ class SidebarModel(QAbstractItemModel): def item_for_index(self, idx: QModelIndex) -> SidebarItem: return idx.internalPointer() + def index_for_item(self, item: SidebarItem) -> QModelIndex: + return self.createIndex(item._row_in_parent, 0, item) + def search(self, text: str) -> bool: return self.root.search(text.lower()) @@ -206,17 +242,21 @@ class SidebarModel(QAbstractItemModel): if not index.isValid(): return QVariant() - if role not in (Qt.DisplayRole, Qt.DecorationRole, Qt.ToolTipRole): + if role not in (Qt.DisplayRole, Qt.DecorationRole, Qt.ToolTipRole, Qt.EditRole): return QVariant() item: SidebarItem = index.internalPointer() - if role == Qt.DisplayRole: + if role in (Qt.DisplayRole, Qt.EditRole): return QVariant(item.name) - elif role == Qt.ToolTipRole: + if role == Qt.ToolTipRole: return QVariant(item.tooltip) - else: - return QVariant(theme_manager.icon_from_resources(item.icon)) + return QVariant(theme_manager.icon_from_resources(item.icon)) + + def setData( + self, index: QModelIndex, text: QVariant, _role: int = Qt.EditRole + ) -> bool: + return self.sidebar.rename_node(index.internalPointer(), text) def supportedDropActions(self) -> Qt.DropActions: return cast(Qt.DropActions, Qt.MoveAction) @@ -224,20 +264,50 @@ class SidebarModel(QAbstractItemModel): def flags(self, index: QModelIndex) -> Qt.ItemFlags: if not index.isValid(): return cast(Qt.ItemFlags, Qt.ItemIsEnabled) - flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable - + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled item: SidebarItem = index.internalPointer() - if item.item_type in ( - SidebarItemType.DECK, - SidebarItemType.DECK_ROOT, - SidebarItemType.TAG, - SidebarItemType.TAG_ROOT, - ): - flags |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled + if item.item_type in self.sidebar.valid_drop_types: + flags |= Qt.ItemIsDropEnabled + if item.item_type.is_editable(): + flags |= Qt.ItemIsEditable return cast(Qt.ItemFlags, flags) +class SidebarToolbar(QToolBar): + _tools: Tuple[Tuple[SidebarTool, str, TR.V], ...] = ( + (SidebarTool.SEARCH, ":/icons/magnifying_glass.svg", TR.ACTIONS_SEARCH), + (SidebarTool.SELECT, ":/icons/select.svg", TR.ACTIONS_SELECT), + ) + + def __init__(self, sidebar: SidebarTreeView) -> None: + super().__init__() + self.sidebar = sidebar + self._action_group = QActionGroup(self) + qconnect(self._action_group.triggered, self._on_action_group_triggered) + self._setup_tools() + self.setIconSize(QSize(16, 16)) + self.setStyle(QStyleFactory.create("fusion")) + + def _setup_tools(self) -> None: + for row, tool in enumerate(self._tools): + action = self.addAction( + theme_manager.icon_from_resources(tool[1]), tr(tool[2]) + ) + action.setCheckable(True) + action.setShortcut(f"Alt+{row + 1}") + self._action_group.addAction(action) + saved = self.sidebar.col.get_config("sidebarTool", 0) + active = saved if saved < len(self._tools) else 0 + self._action_group.actions()[active].setChecked(True) + self.sidebar.tool = self._tools[active][0] + + def _on_action_group_triggered(self, action: QAction) -> None: + index = self._action_group.actions().index(action) + self.sidebar.col.set_config("sidebarTool", index) + self.sidebar.tool = self._tools[index][0] + + class SidebarSearchBar(QLineEdit): def __init__(self, sidebar: SidebarTreeView) -> None: QLineEdit.__init__(self, sidebar) @@ -290,37 +360,16 @@ class SidebarTreeView(QTreeView): self.mw = browser.mw self.col = self.mw.col self.current_search: Optional[str] = None + self.valid_drop_types: Tuple[SidebarItemType, ...] = () self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.onContextMenu) # type: ignore - self.context_menus: Dict[SidebarItemType, Sequence[Tuple[str, Callable]]] = { - SidebarItemType.DECK: ( - (tr(TR.ACTIONS_RENAME), self.rename_deck), - (tr(TR.ACTIONS_DELETE), self.delete_deck), - ), - SidebarItemType.TAG: ( - (tr(TR.ACTIONS_RENAME), self.rename_tag), - (tr(TR.ACTIONS_DELETE), self.remove_tag), - ), - SidebarItemType.SAVED_SEARCH: ( - (tr(TR.ACTIONS_RENAME), self.rename_saved_search), - (tr(TR.ACTIONS_DELETE), self.remove_saved_search), - ), - SidebarItemType.NOTETYPE: ((tr(TR.ACTIONS_MANAGE), self.manage_notetype),), - SidebarItemType.SAVED_SEARCH_ROOT: ( - (tr(TR.BROWSING_SIDEBAR_SAVE_CURRENT_SEARCH), self.save_current_search), - ), - } - self.setUniformRowHeights(True) self.setHeaderHidden(True) self.setIndentation(15) self.setAutoExpandDelay(600) - # pylint: disable=no-member - # mode = QAbstractItemView.SelectionMode.ExtendedSelection # type: ignore - # self.setSelectionMode(mode) - self.setDragDropMode(QAbstractItemView.InternalMove) self.setDragDropOverwriteMode(False) + self.setEditTriggers(QAbstractItemView.EditKeyPressed) qconnect(self.expanded, self._on_expansion) qconnect(self.collapsed, self._on_collapse) @@ -339,29 +388,79 @@ class SidebarTreeView(QTreeView): self.setStyleSheet("QTreeView { %s }" % ";".join(styles)) + @property + def tool(self) -> SidebarTool: + return self._tool + + @tool.setter + def tool(self, tool: SidebarTool) -> None: + self._tool = tool + if tool == SidebarTool.SEARCH: + selection_mode = QAbstractItemView.SingleSelection + drag_drop_mode = QAbstractItemView.NoDragDrop + double_click_expands = False + else: + selection_mode = QAbstractItemView.ExtendedSelection + drag_drop_mode = QAbstractItemView.InternalMove + double_click_expands = True + self.setSelectionMode(selection_mode) + self.setDragDropMode(drag_drop_mode) + self.setExpandsOnDoubleClick(double_click_expands) + def model(self) -> SidebarModel: return super().model() - def refresh(self) -> None: + def refresh( + self, is_current: Optional[Callable[[SidebarItem], bool]] = None + ) -> None: "Refresh list. No-op if sidebar is not visible." if not self.isVisible(): return def on_done(fut: Future) -> None: + self.setUpdatesEnabled(True) root = fut.result() - model = SidebarModel(root) + model = SidebarModel(self, root) # from PyQt5.QtTest import QAbstractItemModelTester # tester = QAbstractItemModelTester(model) self.setModel(model) + qconnect(self.selectionModel().selectionChanged, self._on_selection_changed) if self.current_search: self.search_for(self.current_search) else: self._expand_where_necessary(model) + if is_current: + self.restore_current(is_current) + # block repainting during refreshing to avoid flickering + self.setUpdatesEnabled(False) self.mw.taskman.run_in_background(self._root_tree, on_done) + def restore_current(self, is_current: Callable[[SidebarItem], bool]) -> None: + if current := self.find_item(is_current): + index = self.model().index_for_item(current) + self.selectionModel().setCurrentIndex( + index, QItemSelectionModel.SelectCurrent + ) + self.scrollTo(index) + + def find_item( + self, + is_target: Callable[[SidebarItem], bool], + parent: Optional[SidebarItem] = None, + ) -> Optional[SidebarItem]: + def find_item_rec(parent: SidebarItem) -> Optional[SidebarItem]: + if is_target(parent): + return parent + for child in parent.children: + if item := find_item_rec(child): + return item + return None + + return find_item_rec(parent or self.model().root) + def search_for(self, text: str) -> None: self.showColumn(0) if not text.strip(): @@ -388,14 +487,18 @@ class SidebarTreeView(QTreeView): continue self._expand_where_necessary(model, idx, searching) if item := model.item_for_index(idx): - if item.is_expanded(searching): + if item.show_expanded(searching): self.setExpanded(idx, True) - def update_search(self, *terms: Union[str, SearchNode]) -> None: + def update_search( + self, + *terms: Union[str, SearchNode], + joiner: SearchJoiner = "AND", + ) -> None: """Modify the current search string based on modifier keys, then refresh.""" mods = self.mw.app.keyboardModifiers() previous = SearchNode(parsable_text=self.browser.current_search()) - current = self.mw.col.group_searches(*terms) + current = self.mw.col.group_searches(*terms, joiner=joiner) # if Alt pressed, invert if mods & Qt.AltModifier: @@ -434,27 +537,37 @@ class SidebarTreeView(QTreeView): def dropEvent(self, event: QDropEvent) -> None: model = self.model() - source_items = [model.item_for_index(idx) for idx in self.selectedIndexes()] target_item = model.item_for_index(self.indexAt(event.pos())) - if self.handle_drag_drop(source_items, target_item): + if self.handle_drag_drop(self._selected_items(), target_item): event.acceptProposedAction() def mouseReleaseEvent(self, event: QMouseEvent) -> None: super().mouseReleaseEvent(event) - if event.button() == Qt.LeftButton: - idx = self.indexAt(event.pos()) - if idx == self.currentIndex(): - self._on_click_index(idx) + if self.tool == SidebarTool.SEARCH and event.button() == Qt.LeftButton: + if (index := self.currentIndex()) == self.indexAt(event.pos()): + self._on_search(index) def keyPressEvent(self, event: QKeyEvent) -> None: + index = self.currentIndex() if event.key() in (Qt.Key_Return, Qt.Key_Enter): - idx = self.currentIndex() - self._on_click_index(idx) + if not self.isPersistentEditorOpen(index): + self._on_search(index) + elif event.key() == Qt.Key_Delete: + self._on_delete(index) else: super().keyPressEvent(event) ########### + def _on_selection_changed(self, _new: QItemSelection, _old: QItemSelection) -> None: + selected_types = [item.item_type for item in self._selected_items()] + if all(item_type == SidebarItemType.DECK for item_type in selected_types): + self.valid_drop_types = (SidebarItemType.DECK, SidebarItemType.DECK_ROOT) + elif all(item_type == SidebarItemType.TAG for item_type in selected_types): + self.valid_drop_types = (SidebarItemType.TAG, SidebarItemType.TAG_ROOT) + else: + self.valid_drop_types = () + def handle_drag_drop(self, sources: List[SidebarItem], target: SidebarItem) -> bool: if target.item_type in (SidebarItemType.DECK, SidebarItemType.DECK_ROOT): return self._handle_drag_drop_decks(sources, target) @@ -523,27 +636,31 @@ class SidebarTreeView(QTreeView): self.browser.editor.saveNow(on_save) return True - def _on_click_index(self, idx: QModelIndex) -> None: - if item := self.model().item_for_index(idx): - if item.on_click: - item.on_click() + def _on_search(self, index: QModelIndex) -> None: + if item := self.model().item_for_index(index): + if search_node := item.search_node: + self.update_search(search_node) + + def _on_delete(self, index: QModelIndex) -> None: + if item := self.model().item_for_index(index): + if item.item_type == SidebarItemType.SAVED_SEARCH: + self.remove_saved_searches(item) + elif item.item_type == SidebarItemType.DECK: + self.delete_decks(item) + elif item.item_type == SidebarItemType.TAG: + self.remove_tags(item) def _on_expansion(self, idx: QModelIndex) -> None: if self.current_search: return - self._on_expand_or_collapse(idx, True) + if item := self.model().item_for_index(idx): + item.expanded = True def _on_collapse(self, idx: QModelIndex) -> None: if self.current_search: return - self._on_expand_or_collapse(idx, False) - - def _on_expand_or_collapse(self, idx: QModelIndex, expanded: bool) -> None: - item = self.model().item_for_index(idx) - if item and item._is_expanded != expanded: - item._is_expanded = expanded - if item.on_expanded: - item.on_expanded(expanded) + if item := self.model().item_for_index(idx): + item.expanded = False # Tree building ########################### @@ -603,9 +720,6 @@ class SidebarTreeView(QTreeView): return top - def _filter_func(self, *terms: Union[str, SearchNode]) -> Callable: - return lambda: self.update_search(*terms) - # Tree: Saved Searches ########################### @@ -625,7 +739,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( name, icon, - self._filter_func(filt), + search_node=SearchNode(parsable_text=filt), item_type=SidebarItemType.SAVED_SEARCH, ) root.add_child(item) @@ -643,47 +757,44 @@ class SidebarTreeView(QTreeView): type=SidebarItemType.TODAY_ROOT, ) type = SidebarItemType.TODAY - search = self._filter_func root.add_simple( name=TR.BROWSING_SIDEBAR_DUE_TODAY, icon=icon, type=type, - on_click=search(SearchNode(due_on_day=0)), + search_node=SearchNode(due_on_day=0), ) root.add_simple( name=TR.BROWSING_ADDED_TODAY, icon=icon, type=type, - on_click=search(SearchNode(added_in_days=1)), + search_node=SearchNode(added_in_days=1), ) root.add_simple( name=TR.BROWSING_EDITED_TODAY, icon=icon, type=type, - on_click=search(SearchNode(edited_in_days=1)), + search_node=SearchNode(edited_in_days=1), ) root.add_simple( name=TR.BROWSING_STUDIED_TODAY, icon=icon, type=type, - on_click=search(SearchNode(rated=SearchNode.Rated(days=1))), + search_node=SearchNode(rated=SearchNode.Rated(days=1)), ) root.add_simple( name=TR.BROWSING_AGAIN_TODAY, icon=icon, type=type, - on_click=search( - SearchNode( - rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN) - ) + search_node=SearchNode( + rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN) ), ) root.add_simple( name=TR.BROWSING_SIDEBAR_OVERDUE, icon=icon, type=type, - on_click=search( + search_node=self.col.group_searches( SearchNode(card_state=SearchNode.CARD_STATE_DUE), SearchNode(negated=SearchNode(due_on_day=0)), ), @@ -702,38 +813,37 @@ class SidebarTreeView(QTreeView): type=SidebarItemType.CARD_STATE_ROOT, ) type = SidebarItemType.CARD_STATE - search = self._filter_func root.add_simple( TR.ACTIONS_NEW, icon=icon.with_color(colors.NEW_COUNT), type=type, - on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_NEW)), + search_node=SearchNode(card_state=SearchNode.CARD_STATE_NEW), ) root.add_simple( name=TR.SCHEDULING_LEARNING, icon=icon.with_color(colors.LEARN_COUNT), type=type, - on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_LEARN)), + search_node=SearchNode(card_state=SearchNode.CARD_STATE_LEARN), ) root.add_simple( name=TR.SCHEDULING_REVIEW, icon=icon.with_color(colors.REVIEW_COUNT), type=type, - on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_REVIEW)), + search_node=SearchNode(card_state=SearchNode.CARD_STATE_REVIEW), ) root.add_simple( name=TR.BROWSING_SUSPENDED, icon=icon.with_color(colors.SUSPENDED_FG), type=type, - on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED)), + search_node=SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED), ) root.add_simple( name=TR.BROWSING_BURIED, icon=icon.with_color(colors.BURIED_FG), type=type, - on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_BURIED)), + search_node=SearchNode(card_state=SearchNode.CARD_STATE_BURIED), ) # Tree: Flags @@ -741,7 +851,6 @@ class SidebarTreeView(QTreeView): def _flags_tree(self, root: SidebarItem) -> None: icon = ColoredIcon(path=":/icons/flag.svg", color=colors.DISABLED) - search = self._filter_func root = self._section_root( root=root, name=TR.BROWSING_SIDEBAR_FLAGS, @@ -749,38 +858,38 @@ class SidebarTreeView(QTreeView): collapse_key=Config.Bool.COLLAPSE_FLAGS, type=SidebarItemType.FLAG_ROOT, ) - root.on_click = search(SearchNode(flag=SearchNode.FLAG_ANY)) + root.search_node = SearchNode(flag=SearchNode.FLAG_ANY) type = SidebarItemType.FLAG root.add_simple( TR.ACTIONS_RED_FLAG, icon=icon.with_color(colors.FLAG1_FG), type=type, - on_click=search(SearchNode(flag=SearchNode.FLAG_RED)), + search_node=SearchNode(flag=SearchNode.FLAG_RED), ) root.add_simple( TR.ACTIONS_ORANGE_FLAG, icon=icon.with_color(colors.FLAG2_FG), type=type, - on_click=search(SearchNode(flag=SearchNode.FLAG_ORANGE)), + search_node=SearchNode(flag=SearchNode.FLAG_ORANGE), ) root.add_simple( TR.ACTIONS_GREEN_FLAG, icon=icon.with_color(colors.FLAG3_FG), type=type, - on_click=search(SearchNode(flag=SearchNode.FLAG_GREEN)), + search_node=SearchNode(flag=SearchNode.FLAG_GREEN), ) root.add_simple( TR.ACTIONS_BLUE_FLAG, icon=icon.with_color(colors.FLAG4_FG), type=type, - on_click=search(SearchNode(flag=SearchNode.FLAG_BLUE)), + search_node=SearchNode(flag=SearchNode.FLAG_BLUE), ) root.add_simple( TR.BROWSING_NO_FLAG, icon=icon.with_color(colors.DISABLED), type=type, - on_click=search(SearchNode(flag=SearchNode.FLAG_NONE)), + search_node=SearchNode(flag=SearchNode.FLAG_NONE), ) # Tree: Tags @@ -801,13 +910,13 @@ class SidebarTreeView(QTreeView): ) item = SidebarItem( - node.name, - icon, - self._filter_func(SearchNode(tag=head + node.name)), - toggle_expand(), - node.expanded, + name=node.name, + icon=icon, + search_node=SearchNode(tag=head + node.name), + on_expanded=toggle_expand(), + expanded=node.expanded, item_type=SidebarItemType.TAG, - full_name=head + node.name, + name_prefix=head, ) root.add_child(item) newhead = f"{head + node.name}::" @@ -821,12 +930,12 @@ class SidebarTreeView(QTreeView): collapse_key=Config.Bool.COLLAPSE_TAGS, type=SidebarItemType.TAG_ROOT, ) - root.on_click = self._filter_func(SearchNode(negated=SearchNode(tag="none"))) + root.search_node = SearchNode(negated=SearchNode(tag="none")) root.add_simple( name=tr(TR.BROWSING_SIDEBAR_UNTAGGED), icon=icon, type=SidebarItemType.TAG_NONE, - on_click=self._filter_func(SearchNode(tag="none")), + search_node=SearchNode(tag="none"), ) render(root, tree.children) @@ -847,14 +956,14 @@ class SidebarTreeView(QTreeView): return lambda _: self.mw.col.decks.collapseBrowser(did) item = SidebarItem( - node.name, - icon, - self._filter_func(SearchNode(deck=head + node.name)), - toggle_expand(), - not node.collapsed, + name=node.name, + icon=icon, + search_node=SearchNode(deck=head + node.name), + on_expanded=toggle_expand(), + expanded=not node.collapsed, item_type=SidebarItemType.DECK, id=node.deck_id, - full_name=head + node.name, + name_prefix=head, ) root.add_child(item) newhead = f"{head + node.name}::" @@ -868,12 +977,12 @@ class SidebarTreeView(QTreeView): collapse_key=Config.Bool.COLLAPSE_DECKS, type=SidebarItemType.DECK_ROOT, ) - root.on_click = self._filter_func(SearchNode(deck="*")) + root.search_node = SearchNode(deck="*") current = root.add_simple( name=tr(TR.BROWSING_CURRENT_DECK), icon=icon, - type=SidebarItemType.DECK, - on_click=self._filter_func(SearchNode(deck="current")), + type=SidebarItemType.DECK_CURRENT, + search_node=SearchNode(deck="current"), ) current.id = self.mw.col.decks.selected() @@ -896,7 +1005,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( nt["name"], icon, - self._filter_func(SearchNode(note=nt["name"])), + search_node=SearchNode(note=nt["name"]), item_type=SidebarItemType.NOTETYPE, id=nt["id"], ) @@ -905,11 +1014,12 @@ class SidebarTreeView(QTreeView): child = SidebarItem( tmpl["name"], icon, - self._filter_func( + search_node=self.col.group_searches( SearchNode(note=nt["name"]), SearchNode(template=c) ), item_type=SidebarItemType.NOTETYPE_TEMPLATE, - full_name=f"{nt['name']}::{tmpl['name']}", + name_prefix=f"{nt['name']}::", + id=tmpl["ord"], ) item.add_child(child) @@ -919,153 +1029,201 @@ class SidebarTreeView(QTreeView): ########################### def onContextMenu(self, point: QPoint) -> None: - idx: QModelIndex = self.indexAt(point) - item = self.model().item_for_index(idx) - if not item: - return - self.show_context_menu(item, idx) + index: QModelIndex = self.indexAt(point) + item = self.model().item_for_index(index) + if item and self.selectionModel().isSelected(index): + self.show_context_menu(item, index) - # idx is only None when triggering the context menu from a left click on - # saved searches - perhaps there is a better way to handle that? - def show_context_menu(self, item: SidebarItem, idx: Optional[QModelIndex]) -> None: - m = QMenu() + def show_context_menu(self, item: SidebarItem, index: QModelIndex) -> None: + menu = QMenu() + self._maybe_add_type_specific_actions(menu, item) + self._maybe_add_delete_action(menu, item, index) + self._maybe_add_rename_action(menu, item, index) + self._maybe_add_search_actions(menu) + self._maybe_add_tree_actions(menu) + if menu.children(): + menu.exec_(QCursor.pos()) - if item.item_type in self.context_menus: - for action in self.context_menus[item.item_type]: - act_name = action[0] - act_func = action[1] - a = m.addAction(act_name) - qconnect(a.triggered, lambda _, func=act_func: func(item)) - - if idx: - self.maybe_add_tree_actions(m, item, idx) - - if not m.children(): - return - - # until we support multiple selection, show user that only the current - # item is being operated on by clearing the selection - if idx: - sm = self.selectionModel() - sm.clear() - sm.select( - idx, - cast( - QItemSelectionModel.SelectionFlag, - QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows, - ), + def _maybe_add_type_specific_actions(self, menu: QMenu, item: SidebarItem) -> None: + if item.item_type in (SidebarItemType.NOTETYPE, SidebarItemType.NOTETYPE_ROOT): + menu.addAction( + tr(TR.BROWSING_MANAGE_NOTE_TYPES), lambda: self.manage_notetype(item) + ) + elif item.item_type == SidebarItemType.NOTETYPE_TEMPLATE: + menu.addAction(tr(TR.NOTETYPES_CARDS), lambda: self.manage_template(item)) + elif item.item_type == SidebarItemType.SAVED_SEARCH_ROOT: + menu.addAction( + tr(TR.BROWSING_SIDEBAR_SAVE_CURRENT_SEARCH), self.save_current_search ) - m.exec_(QCursor.pos()) - - def maybe_add_tree_actions( - self, menu: QMenu, item: SidebarItem, parent: QModelIndex + def _maybe_add_delete_action( + self, menu: QMenu, item: SidebarItem, index: QModelIndex ) -> None: + if item.item_type.is_deletable() and all( + s.item_type == item.item_type for s in self._selected_items() + ): + menu.addAction(tr(TR.ACTIONS_DELETE), lambda: self._on_delete(index)) + + def _maybe_add_rename_action( + self, menu: QMenu, item: SidebarItem, index: QModelIndex + ) -> None: + if item.item_type.is_editable() and len(self._selected_items()) == 1: + menu.addAction(tr(TR.ACTIONS_RENAME), lambda: self.edit(index)) + + def _maybe_add_search_actions(self, menu: QMenu) -> None: + nodes = [ + item.search_node for item in self._selected_items() if item.search_node + ] + if not nodes: + return + menu.addSeparator() + if len(nodes) == 1: + menu.addAction(tr(TR.ACTIONS_SEARCH), lambda: self.update_search(*nodes)) + return + sub_menu = menu.addMenu(tr(TR.ACTIONS_SEARCH)) + sub_menu.addAction( + tr(TR.ACTIONS_ALL_SELECTED), lambda: self.update_search(*nodes) + ) + sub_menu.addAction( + tr(TR.ACTIONS_ANY_SELECTED), + lambda: self.update_search(*nodes, joiner="OR"), + ) + + def _maybe_add_tree_actions(self, menu: QMenu) -> None: + def set_expanded(expanded: bool) -> None: + for index in self.selectedIndexes(): + self.setExpanded(index, expanded) + + def set_children_expanded(expanded: bool) -> None: + for index in self.selectedIndexes(): + self.setExpanded(index, True) + for row in range(self.model().rowCount(index)): + self.setExpanded(self.model().index(row, 0, index), expanded) + if self.current_search: return - if not any(bool(c.children) for c in item.children): - return - def set_children_collapsed(collapsed: bool) -> None: - m = self.model() - self.setExpanded(parent, True) - for row in range(m.rowCount(parent)): - idx = m.index(row, 0, parent) - self.setExpanded(idx, not collapsed) + selected_items = self._selected_items() + if not any(item.children for item in selected_items): + return menu.addSeparator() - menu.addAction( - tr(TR.BROWSING_SIDEBAR_EXPAND_CHILDREN), - lambda: set_children_collapsed(False), - ) - menu.addAction( - tr(TR.BROWSING_SIDEBAR_COLLAPSE_CHILDREN), - lambda: set_children_collapsed(True), - ) + if any(not item.expanded for item in selected_items if item.children): + menu.addAction(tr(TR.BROWSING_SIDEBAR_EXPAND), lambda: set_expanded(True)) + if any(item.expanded for item in selected_items if item.children): + menu.addAction( + tr(TR.BROWSING_SIDEBAR_COLLAPSE), lambda: set_expanded(False) + ) + if any( + not c.expanded for i in selected_items for c in i.children if c.children + ): + menu.addAction( + tr(TR.BROWSING_SIDEBAR_EXPAND_CHILDREN), + lambda: set_children_expanded(True), + ) + if any(c.expanded for i in selected_items for c in i.children if c.children): + menu.addAction( + tr(TR.BROWSING_SIDEBAR_COLLAPSE_CHILDREN), + lambda: set_children_expanded(False), + ) - def rename_deck(self, item: SidebarItem) -> None: + def rename_deck(self, item: SidebarItem, new_name: str) -> None: deck = self.mw.col.decks.get(item.id) - old_name = deck["name"] - new_name = getOnlyText(tr(TR.DECKS_NEW_DECK_NAME), default=old_name) - new_name = new_name.replace('"', "") - if not new_name or new_name == old_name: - return + new_name = item.name_prefix + new_name try: self.mw.col.decks.rename(deck, new_name) except DeckIsFilteredError as err: showWarning(str(err)) return - self.refresh() + self.refresh( + lambda other: other.item_type == SidebarItemType.DECK + and other.id == item.id + ) self.mw.deckBrowser.refresh() self.mw.update_undo_actions() - def remove_tag(self, item: SidebarItem) -> None: - self.browser.editor.saveNow(lambda: self._remove_tag(item)) + def remove_tags(self, item: SidebarItem) -> None: + self.browser.editor.saveNow(lambda: self._remove_tags(item)) - def _remove_tag(self, item: SidebarItem) -> None: - old_name = item.full_name + def _remove_tags(self, _item: SidebarItem) -> None: + tags = self._selected_tags() - def do_remove() -> None: - self.mw.col.tags.remove(old_name) - self.col.tags.rename(old_name, "") + def do_remove() -> int: + return self.col._backend.expunge_tags(" ".join(tags)) def on_done(fut: Future) -> None: self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self) self.browser.model.endReset() - fut.result() + tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=fut.result()), parent=self) self.refresh() self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG)) self.browser.model.beginReset() - self.mw.taskman.run_in_background(do_remove, on_done) + self.mw.taskman.with_progress(do_remove, on_done) - def rename_tag(self, item: SidebarItem) -> None: - self.browser.editor.saveNow(lambda: self._rename_tag(item)) + def rename_tag(self, item: SidebarItem, new_name: str) -> None: + new_name = new_name.replace(" ", "") + if new_name and new_name != item.name: + # block repainting until collection is updated + self.setUpdatesEnabled(False) + self.browser.editor.saveNow(lambda: self._rename_tag(item, new_name)) - def _rename_tag(self, item: SidebarItem) -> None: + def _rename_tag(self, item: SidebarItem, new_name: str) -> None: old_name = item.full_name - new_name = getOnlyText(tr(TR.ACTIONS_NEW_NAME), default=old_name) - if new_name == old_name or not new_name: - return + new_name = item.name_prefix + new_name def do_rename() -> int: self.mw.col.tags.remove(old_name) return self.col.tags.rename(old_name, new_name) def on_done(fut: Future) -> None: + self.setUpdatesEnabled(True) self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) self.browser.model.endReset() count = fut.result() if not count: showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY)) - return - - self.refresh() + else: + tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=count), parent=self) + self.refresh( + lambda item: item.item_type == SidebarItemType.TAG + and item.full_name == new_name + ) self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG)) self.browser.model.beginReset() - self.mw.taskman.run_in_background(do_rename, on_done) + self.mw.taskman.with_progress(do_rename, on_done) - def delete_deck(self, item: SidebarItem) -> None: - self.browser.editor.saveNow(lambda: self._delete_deck(item)) + def delete_decks(self, _item: SidebarItem) -> None: + self.browser.editor.saveNow(self._delete_decks) - def _delete_deck(self, item: SidebarItem) -> None: - did = item.id - if self.mw.deckBrowser.ask_delete_deck(did): + def _delete_decks(self) -> None: + def do_delete() -> int: + return self.mw.col.decks.remove(dids) - def do_delete() -> None: - return self.mw.col.decks.rem(did, True) + def on_done(fut: Future) -> None: + self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self) + self.browser.search() + self.browser.model.endReset() + tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()), parent=self) + self.refresh() - def on_done(fut: Future) -> None: - self.mw.requireReset(reason=ResetReason.BrowserDeleteDeck, context=self) - self.browser.search() - self.browser.model.endReset() - self.refresh() - res = fut.result() # Required to check for errors + dids = self._selected_decks() + self.browser.model.beginReset() + self.mw.taskman.with_progress(do_delete, on_done) - self.browser.model.beginReset() - self.mw.taskman.run_in_background(do_delete, on_done) + def rename_node(self, item: SidebarItem, text: str) -> bool: + new_name = text.replace('"', "") + if new_name and new_name != item.name: + if item.item_type == SidebarItemType.DECK: + self.rename_deck(item, new_name) + elif item.item_type == SidebarItemType.SAVED_SEARCH: + self.rename_saved_search(item, new_name) + elif item.item_type == SidebarItemType.TAG: + self.rename_tag(item, new_name) + # renaming may be asynchronous so always return False + return False # Saved searches ################## @@ -1078,47 +1236,88 @@ class SidebarTreeView(QTreeView): def _set_saved_searches(self, searches: Dict[str, str]) -> None: self.col.set_config(self._saved_searches_key, searches) - def remove_saved_search(self, item: SidebarItem) -> None: - name = item.name - if not askUser(tr(TR.BROWSING_REMOVE_FROM_YOUR_SAVED_SEARCHES, val=name)): - return + def remove_saved_searches(self, _item: SidebarItem) -> None: + selected = self._selected_saved_searches() conf = self._get_saved_searches() - del conf[name] + for name in selected: + del conf[name] self._set_saved_searches(conf) self.refresh() - def rename_saved_search(self, item: SidebarItem) -> None: - old = item.name + def rename_saved_search(self, item: SidebarItem, new_name: str) -> None: + old_name = item.name conf = self._get_saved_searches() try: - filt = conf[old] + filt = conf[old_name] except KeyError: return - new = getOnlyText(tr(TR.ACTIONS_NEW_NAME), default=old) - if new == old or not new: + if new_name in conf and not askUser( + tr(TR.BROWSING_CONFIRM_SAVED_SEARCH_OVERWRITE, name=new_name) + ): return - conf[new] = filt - del conf[old] + conf[new_name] = filt + del conf[old_name] self._set_saved_searches(conf) - self.refresh() + self.refresh( + lambda item: item.item_type == SidebarItemType.SAVED_SEARCH + and item.name == new_name + ) - def save_current_search(self, _item: Any = None) -> None: + def save_current_search(self) -> None: try: filt = self.col.build_search_string( self.browser.form.searchEdit.lineEdit().text() ) except InvalidInput as e: show_invalid_search_error(e) - else: - name = getOnlyText(tr(TR.BROWSING_PLEASE_GIVE_YOUR_FILTER_A_NAME)) - if not name: - return - conf = self._get_saved_searches() - conf[name] = filt - self._set_saved_searches(conf) - self.refresh() + return + name = getOnlyText(tr(TR.BROWSING_PLEASE_GIVE_YOUR_FILTER_A_NAME)) + if not name: + return + conf = self._get_saved_searches() + if name in conf and not askUser( + tr(TR.BROWSING_CONFIRM_SAVED_SEARCH_OVERWRITE, name=name) + ): + return + conf[name] = filt + self._set_saved_searches(conf) + self.refresh( + lambda item: item.item_type == SidebarItemType.SAVED_SEARCH + and item.name == name + ) def manage_notetype(self, item: SidebarItem) -> None: Models( self.mw, parent=self.browser, fromMain=True, selected_notetype_id=item.id ) + + def manage_template(self, item: SidebarItem) -> None: + note = Note(self.col, self.col.models.get(item._parent_item.id)) + CardLayout(self.mw, note, ord=item.id, parent=self, fill_empty=True) + + # Helpers + ################## + + def _selected_items(self) -> List[SidebarItem]: + return [self.model().item_for_index(idx) for idx in self.selectedIndexes()] + + def _selected_decks(self) -> List[int]: + return [ + item.id + for item in self._selected_items() + if item.item_type == SidebarItemType.DECK + ] + + def _selected_saved_searches(self) -> List[str]: + return [ + item.name + for item in self._selected_items() + if item.item_type == SidebarItemType.SAVED_SEARCH + ] + + def _selected_tags(self) -> List[str]: + return [ + item.full_name + for item in self._selected_items() + if item.item_type == SidebarItemType.TAG + ] diff --git a/rslib/backend.proto b/rslib/backend.proto index f9bec2995..a18f34dc6 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -68,6 +68,10 @@ message DeckID { int64 did = 1; } +message DeckIDs { + repeated int64 dids = 1; +} + message DeckConfigID { int64 dcid = 1; } @@ -130,7 +134,7 @@ service DecksService { rpc GetDeckLegacy(DeckID) returns (Json); rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames); rpc NewDeckLegacy(Bool) returns (Json); - rpc RemoveDeck(DeckID) returns (Empty); + rpc RemoveDecks(DeckIDs) returns (UInt32); rpc DragDropDecks(DragDropDecksIn) returns (Empty); rpc RenameDeck(RenameDeckIn) returns (Empty); } @@ -210,6 +214,7 @@ service DeckConfigService { service TagsService { rpc ClearUnusedTags(Empty) returns (Empty); rpc AllTags(Empty) returns (StringList); + rpc ExpungeTags(String) returns (UInt32); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc ClearTag(String) returns (Empty); rpc TagTree(Empty) returns (TagTreeNode); diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs index 7b2c8d613..ab1fae037 100644 --- a/rslib/src/backend/decks.rs +++ b/rslib/src/backend/decks.rs @@ -109,8 +109,8 @@ impl DecksService for Backend { .map(Into::into) } - fn remove_deck(&self, input: pb::DeckId) -> Result { - self.with_col(|col| col.remove_deck_and_child_decks(input.into())) + fn remove_decks(&self, input: pb::DeckIDs) -> Result { + self.with_col(|col| col.remove_decks_and_child_decks(&Into::>::into(input))) .map(Into::into) } @@ -137,6 +137,12 @@ impl From for DeckID { } } +impl From for Vec { + fn from(dids: pb::DeckIDs) -> Self { + dids.dids.into_iter().map(DeckID).collect() + } +} + impl From for pb::DeckId { fn from(did: DeckID) -> Self { pb::DeckId { did: did.0 } diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs index 1919901a9..3aae3b721 100644 --- a/rslib/src/backend/generic.rs +++ b/rslib/src/backend/generic.rs @@ -33,6 +33,12 @@ impl From for pb::UInt32 { } } +impl From for pb::UInt32 { + fn from(val: usize) -> Self { + pb::UInt32 { val: val as u32 } + } +} + impl From<()> for pb::Empty { fn from(_val: ()) -> Self { pb::Empty {} diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 8175cdf6c..3d08e0f0d 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -23,6 +23,10 @@ impl TagsService for Backend { }) } + fn expunge_tags(&self, tags: pb::String) -> Result { + self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into)) + } + fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result { self.with_col(|col| { col.transact(None, |col| { diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index ea0d5d71b..df16a066e 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -466,44 +466,53 @@ impl Collection { self.storage.get_deck_id(&machine_name) } - pub fn remove_deck_and_child_decks(&mut self, did: DeckID) -> Result<()> { - self.transact(Some(UndoableOpKind::RemoveDeck), |col| { + pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result { + let mut card_count = 0; + self.transact(None, |col| { let usn = col.usn()?; - if let Some(deck) = col.storage.get_deck(did)? { - let child_decks = col.storage.child_decks(&deck)?; + for did in dids { + if let Some(deck) = col.storage.get_deck(*did)? { + let child_decks = col.storage.child_decks(&deck)?; - // top level - col.remove_single_deck(&deck, usn)?; + // top level + card_count += col.remove_single_deck(&deck, usn)?; - // remove children - for deck in child_decks { - col.remove_single_deck(&deck, usn)?; + // remove children + for deck in child_decks { + card_count += col.remove_single_deck(&deck, usn)?; + } } } Ok(()) - }) + })?; + Ok(card_count) } - pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<()> { - match deck.kind { + pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result { + let card_count = match deck.kind { DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, - DeckKind::Filtered(_) => self.return_all_cards_in_filtered_deck(deck.id)?, - } + DeckKind::Filtered(_) => { + self.return_all_cards_in_filtered_deck(deck.id)?; + 0 + } + }; self.clear_aux_config_for_deck(deck.id)?; if deck.id.0 == 1 { // if deleting the default deck, ensure there's a new one, and avoid the grave let mut deck = deck.to_owned(); deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into(); deck.set_modified(usn); - self.add_or_update_single_deck_with_existing_id(&mut deck, usn) + self.add_or_update_single_deck_with_existing_id(&mut deck, usn)?; } else { - self.remove_deck_and_add_grave_undoable(deck.clone(), usn) + self.remove_deck_and_add_grave_undoable(deck.clone(), usn)?; } + Ok(card_count) } - fn delete_all_cards_in_normal_deck(&mut self, did: DeckID) -> Result<()> { + fn delete_all_cards_in_normal_deck(&mut self, did: DeckID) -> Result { let cids = self.storage.all_cards_in_single_deck(did)?; - self.remove_cards_and_orphaned_notes(&cids) + self.remove_cards_and_orphaned_notes(&cids)?; + Ok(cids.len()) } pub fn get_all_deck_names(&self, skip_empty_default: bool) -> Result> { @@ -820,7 +829,7 @@ mod test { // delete top level let top = col.get_or_create_normal_deck("one")?; - col.remove_deck_and_child_decks(top.id)?; + col.remove_decks_and_child_decks(&[top.id])?; // should have come back as "Default+" due to conflict assert_eq!(sorted_names(&col), vec!["default", "Default+"]); diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 77f714599..0849adb59 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -488,3 +488,11 @@ impl From for AnkiError { AnkiError::ParseNumError } } + +impl From for AnkiError { + fn from(_err: regex::Error) -> Self { + AnkiError::InvalidInput { + info: "invalid regex".into(), + } + } +} diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index c2a66246b..18dee8f3f 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -191,6 +191,12 @@ impl Note { .collect() } + pub(crate) fn remove_tags(&mut self, re: &Regex) -> bool { + let old_len = self.tags.len(); + self.tags.retain(|tag| !re.is_match(tag)); + old_len > self.tags.len() + } + pub(crate) fn replace_tags(&mut self, re: &Regex, mut repl: T) -> bool { let mut changed = false; for tag in &mut self.tags { diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index f07c4f99d..c2aac651e 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -90,6 +90,15 @@ impl SqliteStorage { Ok(()) } + /// Clear all matching tags where tag_group is a regexp group that should not match whitespace. + pub(crate) fn clear_tag_group(&self, tag_group: &str) -> Result<()> { + self.db + .prepare_cached("delete from tags where tag regexp ?")? + .execute(&[format!("(?i)^{}($|::)", tag_group)])?; + + Ok(()) + } + pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> { self.db .prepare_cached("update tags set collapsed = ? where tag = ?")? diff --git a/rslib/src/sync/mod.rs b/rslib/src/sync/mod.rs index 81115e923..dbfa87d0c 100644 --- a/rslib/src/sync/mod.rs +++ b/rslib/src/sync/mod.rs @@ -1532,7 +1532,7 @@ mod test { col1.remove_cards_and_orphaned_notes(&[cardid])?; let usn = col1.usn()?; col1.remove_note_only_undoable(noteid, usn)?; - col1.remove_deck_and_child_decks(deckid)?; + col1.remove_decks_and_child_decks(&[deckid])?; let out = ctx.normal_sync(&mut col1).await; assert_eq!(out.required, SyncActionRequired::NoChanges); diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 6f0fc6860..39985dd72 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -292,6 +292,37 @@ impl Collection { Ok(()) } + /// Take tags as a whitespace-separated string and remove them from all notes and the storage. + pub fn expunge_tags(&mut self, tags: &str) -> Result { + let tag_group = format!("({})", regex::escape(tags.trim()).replace(' ', "|")); + let nids = self.nids_for_tags(&tag_group)?; + let re = Regex::new(&format!("(?i)^{}(::.*)?$", tag_group))?; + self.transact(None, |col| { + col.storage.clear_tag_group(&tag_group)?; + col.transform_notes(&nids, |note, _nt| { + Ok(TransformNoteOutput { + changed: note.remove_tags(&re), + generate_cards: false, + mark_modified: true, + }) + }) + }) + } + + /// Take tags as a regexp group, i.e. separated with pipes and wrapped in brackets, and return + /// the ids of all notes with one of them. + fn nids_for_tags(&mut self, tag_group: &str) -> Result> { + let mut stmt = self + .storage + .db + .prepare("select id from notes where tags regexp ?")?; + let args = format!("(?i).* {}(::| ).*", tag_group); + let nids = stmt + .query_map(&[args], |row| row.get(0))? + .collect::>()?; + Ok(nids) + } + pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> { let mut name = name; let tag;