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 @@
+
+
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 @@
+
+
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;