Merge pull request #1044 from RumovZ/sidebar-tools

Add sidebar modes for different click behaviour
This commit is contained in:
Damien Elmes 2021-03-11 21:57:53 +10:00 committed by GitHub
commit 71789eb51a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 877 additions and 315 deletions

View file

@ -1,4 +1,6 @@
actions-add = Add actions-add = Add
actions-all-selected = All selected
actions-any-selected = Any selected
actions-blue-flag = Blue Flag actions-blue-flag = Blue Flag
actions-cancel = Cancel actions-cancel = Cancel
actions-choose = Choose actions-choose = Choose
@ -30,6 +32,7 @@ actions-replay-audio = Replay Audio
actions-reposition = Reposition actions-reposition = Reposition
actions-save = Save actions-save = Save
actions-search = Search actions-search = Search
actions-select = Select
actions-shortcut-key = Shortcut key: { $val } actions-shortcut-key = Shortcut key: { $val }
actions-suspend-card = Suspend Card actions-suspend-card = Suspend Card
actions-set-due-date = Set Due Date actions-set-due-date = Set Due Date

View file

@ -14,6 +14,11 @@ browsing-card = Card
browsing-card-list = Card List browsing-card-list = Card List
browsing-card-state = Card State browsing-card-state = Card State
browsing-cards-cant-be-manually-moved-into = Cards can't be manually moved into a filtered deck. 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-deck = Change Deck
browsing-change-deck2 = Change Deck... browsing-change-deck2 = Change Deck...
browsing-change-note-type = Change Note Type 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-change-to = Change { $val } to:
browsing-clear-unused = Clear Unused browsing-clear-unused = Clear Unused
browsing-clear-unused-tags = Clear Unused Tags 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-created = Created
browsing-ctrlandshiftande = Ctrl+Shift+E browsing-ctrlandshiftande = Ctrl+Shift+E
browsing-current-deck = Current Deck browsing-current-deck = Current Deck
@ -70,14 +76,11 @@ browsing-question = Question
browsing-queue-bottom = Queue bottom: { $val } browsing-queue-bottom = Queue bottom: { $val }
browsing-queue-top = Queue top: { $val } browsing-queue-top = Queue top: { $val }
browsing-randomize-order = Randomize order 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-remove-tags = Remove Tags...
browsing-replace-with = <b>Replace With</b>: browsing-replace-with = <b>Replace With</b>:
browsing-reposition = Reposition... browsing-reposition = Reposition...
browsing-reposition-new-cards = Reposition New Cards browsing-reposition-new-cards = Reposition New Cards
browsing-reschedule = Reschedule browsing-reschedule = Reschedule
browsing-save-current-filter = Save Current Filter...
browsing-search-bar-hint = Search cards/notes (type text, then press Enter) browsing-search-bar-hint = Search cards/notes (type text, then press Enter)
browsing-search-in = Search in: browsing-search-in = Search in:
browsing-search-within-formatting-slow = Search within formatting (slow) browsing-search-within-formatting-slow = Search within formatting (slow)
@ -112,7 +115,14 @@ browsing-note-deleted =
[one] { $count } note deleted. [one] { $count } note deleted.
*[other] { $count } notes 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-window-title = Browse ({ $selected } of { $total } cards selected)
browsing-sidebar-expand = Expand
browsing-sidebar-collapse = Collapse
browsing-sidebar-expand-children = Expand Children browsing-sidebar-expand-children = Expand Children
browsing-sidebar-collapse-children = Collapse Children browsing-sidebar-collapse-children = Collapse Children
browsing-sidebar-decks = Decks browsing-sidebar-decks = Decks

View file

@ -1,5 +1,4 @@
decks-add-new-deck-ctrlandn = Add New Deck (Ctrl+N) 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-build = Build
decks-cards-selected-by = cards selected by decks-cards-selected-by = cards selected by
decks-create-deck = Create Deck decks-create-deck = Create Deck
@ -32,8 +31,3 @@ decks-study = Study
decks-study-deck = Study Deck 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-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-unmovable-cards = Show any excluded cards
decks-it-has-card =
{ $count ->
[one] It has { $count } card.
*[other] It has { $count } cards.
}

View file

@ -13,7 +13,7 @@ import anki # pylint: disable=unused-import
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
from anki.consts import * from anki.consts import *
from anki.errors import NotFoundError 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 # public exports
DeckTreeNode = _pb.DeckTreeNode DeckTreeNode = _pb.DeckTreeNode
@ -130,12 +130,16 @@ class DeckManager:
return deck["id"] return deck["id"]
@legacy_func(sub="remove")
def rem(self, did: int, cardsToo: bool = True, childrenToo: bool = True) -> None: def rem(self, did: int, cardsToo: bool = True, childrenToo: bool = True) -> None:
"Remove the deck. If cardsToo, delete any cards inside." "Remove the deck. If cardsToo, delete any cards inside."
if isinstance(did, str): if isinstance(did, str):
did = int(did) did = int(did)
assert cardsToo and childrenToo 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( def all_names_and_ids(
self, skip_empty_default: bool = False, include_filtered: bool = True self, skip_empty_default: bool = False, include_filtered: bool = True
@ -212,10 +216,15 @@ class DeckManager:
def count(self) -> int: def count(self) -> int:
return len(self.all_names_and_ids()) return len(self.all_names_and_ids())
def card_count(self, did: int, include_subdecks: bool) -> Any: def card_count(
dids: List[int] = [did] self, dids: Union[int, Iterable[int]], include_subdecks: bool
) -> Any:
if isinstance(dids, int):
dids = {dids}
else:
dids = set(dids)
if include_subdecks: 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( count = self.col.db.scalar(
"select count() from cards where did in {0} or " "select count() from cards where did in {0} or "
"odid in {0}".format(ids2str(dids)) "odid in {0}".format(ids2str(dids))

View file

@ -18,7 +18,7 @@ import traceback
from contextlib import contextmanager from contextlib import contextmanager
from hashlib import sha1 from hashlib import sha1
from html.entities import name2codepoint 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 from anki.dbproxy import DBProxy
@ -372,3 +372,26 @@ def pointVersion() -> int:
from anki.buildinfo import version from anki.buildinfo import version
return int(version.split(".")[-1]) 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

View file

@ -205,6 +205,7 @@ def show(mw: aqt.AnkiQt) -> QDialog:
"Gustavo Costa", "Gustavo Costa",
"余时行", "余时行",
"叶峻峣", "叶峻峣",
"RumovZ",
) )
) )

View file

@ -28,7 +28,7 @@ from aqt.previewer import BrowserPreviewer as PreviewDialog
from aqt.previewer import Previewer from aqt.previewer import Previewer
from aqt.qt import * from aqt.qt import *
from aqt.scheduling import forget_cards, set_due_date_dialog 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.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,
@ -941,18 +941,20 @@ QTableView {{ gridline-color: {grid} }}
self.sidebar = SidebarTreeView(self) self.sidebar = SidebarTreeView(self)
self.sidebarTree = self.sidebar # legacy alias self.sidebarTree = self.sidebar # legacy alias
dw.setWidget(self.sidebar) dw.setWidget(self.sidebar)
self.sidebar.toolbar = toolbar = SidebarToolbar(self.sidebar)
self.sidebar.searchBar = searchBar = SidebarSearchBar(self.sidebar) self.sidebar.searchBar = searchBar = SidebarSearchBar(self.sidebar)
qconnect( qconnect(
self.form.actionSidebarFilter.triggered, self.form.actionSidebarFilter.triggered,
self.focusSidebarSearchBar, self.focusSidebarSearchBar,
) )
l = QVBoxLayout() grid = QGridLayout()
l.addWidget(searchBar) grid.addWidget(searchBar, 0, 0)
l.addWidget(self.sidebar) grid.addWidget(toolbar, 0, 1)
l.setContentsMargins(0, 0, 0, 0) grid.addWidget(self.sidebar, 1, 0, 1, 2)
l.setSpacing(0) grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(0)
w = QWidget() w = QWidget()
w.setLayout(l) w.setLayout(grid)
dw.setWidget(w) dw.setWidget(w)
self.sidebarDockWidget.setFloating(False) self.sidebarDockWidget.setFloating(False)

View file

@ -23,6 +23,7 @@ from aqt.utils import (
shortcut, shortcut,
showInfo, showInfo,
showWarning, showWarning,
tooltip,
tr, tr,
) )
@ -303,34 +304,16 @@ class DeckBrowser:
self.mw.taskman.with_progress(process, on_done) 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: 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: def on_done(fut: Future) -> None:
return self.mw.col.decks.rem(did, True) self.mw.update_undo_actions()
self.show()
tooltip(tr(TR.BROWSING_CARDS_DELETED, count=fut.result()))
def on_done(fut: Future) -> None: self.mw.taskman.with_progress(do_delete, on_done)
self.mw.update_undo_actions()
self.show()
res = fut.result() # Required to check for errors
self.mw.taskman.with_progress(do_delete, on_done)
# Top buttons # Top buttons
###################################################################### ######################################################################

View file

@ -10,5 +10,7 @@
<file>icons/clock.svg</file> <file>icons/clock.svg</file>
<file>icons/card-state.svg</file> <file>icons/card-state.svg</file>
<file>icons/flag.svg</file> <file>icons/flag.svg</file>
<file>icons/select.svg</file>
<file>icons/magnifying_glass.svg</file>
</qresource> </qresource>
</RCC> </RCC>

View file

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg8"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)"
sodipodi:docname="magnifying_glass.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="8"
inkscape:cx="10.039334"
inkscape:cy="35.645602"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
gridtolerance="10000"
objecttolerance="51"
guidetolerance="51"
inkscape:snap-global="false">
<inkscape:grid
type="xygrid"
id="grid833" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.38115;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal"
id="path835"
cx="5.5429349"
cy="5.5176048"
r="4.7567849" />
<g
id="path837"
style="opacity:1"
transform="translate(0.280633,0.25724692)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.1866;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 9.270412,9.1682417 5.763677,5.8797903"
id="path3348" />
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.997025;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="M 8.519367,8.5427531 C 7.9111727,9.1535363 7.8640343,9.5551464 8.1618931,9.8774543 l 5.8029559,6.2792797 c 0.603423,0.638261 1.591613,0.648031 2.206659,0.0218 0.614172,-0.625577 0.623586,-1.648878 0.02103,-2.286493 0,0 -6.025394,-5.3742675 -6.3649177,-5.6724746 C 9.4880962,7.9213592 9.1275613,7.9319698 8.519367,8.5427531 Z"
id="path3350"
sodipodi:nodetypes="zzccczz" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg8"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)"
sodipodi:docname="select.svg">
<defs
id="defs2">
<inkscape:path-effect
effect="powermask"
id="path-effect4000"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect4000"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<inkscape:path-effect
effect="powermask"
id="path-effect3981"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect3981"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<inkscape:path-effect
effect="powermask"
id="path-effect3966"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect3966"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<inkscape:path-effect
effect="powermask"
id="path-effect2895"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect2895"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<linearGradient
id="linearGradient866"
osb:paint="solid">
<stop
style="stop-color:#838799;stop-opacity:1;"
offset="0"
id="stop864" />
</linearGradient>
<marker
style="overflow:visible"
id="Arrow1Lstart"
refX="0.0"
refY="0.0"
orient="auto"
inkscape:stockid="Arrow1Lstart"
inkscape:isstock="true">
<path
transform="scale(0.8) translate(12.5,0)"
style="fill-rule:evenodd;stroke:#000000;stroke-width:1.0pt"
d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z "
id="path2747" />
</marker>
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 8.466667 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="16.933333 : 8.466667 : 1"
inkscape:persp3d-origin="8.4666665 : 5.6444447 : 1"
id="perspective2694" />
<filter
id="mask-powermask-path-effect4000_inverse"
inkscape:label="filtermask-powermask-path-effect4000"
style="color-interpolation-filters:sRGB"
height="100"
width="100"
x="-50"
y="-50">
<feColorMatrix
id="mask-powermask-path-effect4000_primitive1"
values="1"
type="saturate"
result="fbSourceGraphic" />
<feColorMatrix
id="mask-powermask-path-effect4000_primitive2"
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0 "
in="fbSourceGraphic" />
</filter>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="9.1371454"
inkscape:cx="33.803843"
inkscape:cy="32.605832"
inkscape:document-units="mm"
inkscape:current-layer="layer2"
inkscape:document-rotation="0"
showgrid="true"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid833" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Back"
style="display:inline;opacity:0.997">
<path
id="rect2692"
transform="translate(0.26458378,0.26458346)"
mask="none"
d="m 7.4083329,10.847917 -6.87916626,0 V 0.52916664 H 14.022917 l 0,5.29166656"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.165;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:2.33,2.33;stroke-dashoffset:8.621;stroke-opacity:1;paint-order:normal"
sodipodi:nodetypes="ccccc" />
</g>
<g
inkscape:label="Front"
inkscape:groupmode="layer"
id="layer1"
style="display:inline">
<path
style="opacity:1;fill:#000000;fill-opacity:0.997319;stroke:#000000;stroke-width:0.535;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 10.433094,5.9254024 v 8.8238546 l 2.129895,-1.217083 1.217083,3.042708 1.521355,-0.608542 -1.217083,-3.042708 h 2.434166 z"
id="path2710"
sodipodi:nodetypes="cccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,10 @@ message DeckID {
int64 did = 1; int64 did = 1;
} }
message DeckIDs {
repeated int64 dids = 1;
}
message DeckConfigID { message DeckConfigID {
int64 dcid = 1; int64 dcid = 1;
} }
@ -130,7 +134,7 @@ service DecksService {
rpc GetDeckLegacy(DeckID) returns (Json); rpc GetDeckLegacy(DeckID) returns (Json);
rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames); rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames);
rpc NewDeckLegacy(Bool) returns (Json); rpc NewDeckLegacy(Bool) returns (Json);
rpc RemoveDeck(DeckID) returns (Empty); rpc RemoveDecks(DeckIDs) returns (UInt32);
rpc DragDropDecks(DragDropDecksIn) returns (Empty); rpc DragDropDecks(DragDropDecksIn) returns (Empty);
rpc RenameDeck(RenameDeckIn) returns (Empty); rpc RenameDeck(RenameDeckIn) returns (Empty);
} }
@ -210,6 +214,7 @@ service DeckConfigService {
service TagsService { service TagsService {
rpc ClearUnusedTags(Empty) returns (Empty); rpc ClearUnusedTags(Empty) returns (Empty);
rpc AllTags(Empty) returns (StringList); rpc AllTags(Empty) returns (StringList);
rpc ExpungeTags(String) returns (UInt32);
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
rpc ClearTag(String) returns (Empty); rpc ClearTag(String) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode); rpc TagTree(Empty) returns (TagTreeNode);

View file

@ -109,8 +109,8 @@ impl DecksService for Backend {
.map(Into::into) .map(Into::into)
} }
fn remove_deck(&self, input: pb::DeckId) -> Result<pb::Empty> { fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::UInt32> {
self.with_col(|col| col.remove_deck_and_child_decks(input.into())) self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input)))
.map(Into::into) .map(Into::into)
} }
@ -137,6 +137,12 @@ impl From<pb::DeckId> for DeckID {
} }
} }
impl From<pb::DeckIDs> for Vec<DeckID> {
fn from(dids: pb::DeckIDs) -> Self {
dids.dids.into_iter().map(DeckID).collect()
}
}
impl From<DeckID> for pb::DeckId { impl From<DeckID> for pb::DeckId {
fn from(did: DeckID) -> Self { fn from(did: DeckID) -> Self {
pb::DeckId { did: did.0 } pb::DeckId { did: did.0 }

View file

@ -33,6 +33,12 @@ impl From<u32> for pb::UInt32 {
} }
} }
impl From<usize> for pb::UInt32 {
fn from(val: usize) -> Self {
pb::UInt32 { val: val as u32 }
}
}
impl From<()> for pb::Empty { impl From<()> for pb::Empty {
fn from(_val: ()) -> Self { fn from(_val: ()) -> Self {
pb::Empty {} pb::Empty {}

View file

@ -23,6 +23,10 @@ impl TagsService for Backend {
}) })
} }
fn expunge_tags(&self, tags: pb::String) -> Result<pb::UInt32> {
self.with_col(|col| col.expunge_tags(tags.val.as_str()).map(Into::into))
}
fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> { fn set_tag_expanded(&self, input: pb::SetTagExpandedIn) -> Result<pb::Empty> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |col| { col.transact(None, |col| {

View file

@ -466,44 +466,53 @@ impl Collection {
self.storage.get_deck_id(&machine_name) self.storage.get_deck_id(&machine_name)
} }
pub fn remove_deck_and_child_decks(&mut self, did: DeckID) -> Result<()> { pub fn remove_decks_and_child_decks(&mut self, dids: &[DeckID]) -> Result<usize> {
self.transact(Some(UndoableOpKind::RemoveDeck), |col| { let mut card_count = 0;
self.transact(None, |col| {
let usn = col.usn()?; let usn = col.usn()?;
if let Some(deck) = col.storage.get_deck(did)? { for did in dids {
let child_decks = col.storage.child_decks(&deck)?; if let Some(deck) = col.storage.get_deck(*did)? {
let child_decks = col.storage.child_decks(&deck)?;
// top level // top level
col.remove_single_deck(&deck, usn)?; card_count += col.remove_single_deck(&deck, usn)?;
// remove children // remove children
for deck in child_decks { for deck in child_decks {
col.remove_single_deck(&deck, usn)?; card_count += col.remove_single_deck(&deck, usn)?;
}
} }
} }
Ok(()) Ok(())
}) })?;
Ok(card_count)
} }
pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<()> { pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<usize> {
match deck.kind { let card_count = match deck.kind {
DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, 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)?; self.clear_aux_config_for_deck(deck.id)?;
if deck.id.0 == 1 { if deck.id.0 == 1 {
// if deleting the default deck, ensure there's a new one, and avoid the grave // if deleting the default deck, ensure there's a new one, and avoid the grave
let mut deck = deck.to_owned(); let mut deck = deck.to_owned();
deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into(); deck.name = self.i18n.tr(TR::DeckConfigDefaultName).into();
deck.set_modified(usn); 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 { } 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<usize> {
let cids = self.storage.all_cards_in_single_deck(did)?; 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<Vec<(DeckID, String)>> { pub fn get_all_deck_names(&self, skip_empty_default: bool) -> Result<Vec<(DeckID, String)>> {
@ -820,7 +829,7 @@ mod test {
// delete top level // delete top level
let top = col.get_or_create_normal_deck("one")?; 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 // should have come back as "Default+" due to conflict
assert_eq!(sorted_names(&col), vec!["default", "Default+"]); assert_eq!(sorted_names(&col), vec!["default", "Default+"]);

View file

@ -488,3 +488,11 @@ impl From<ParseIntError> for AnkiError {
AnkiError::ParseNumError AnkiError::ParseNumError
} }
} }
impl From<regex::Error> for AnkiError {
fn from(_err: regex::Error) -> Self {
AnkiError::InvalidInput {
info: "invalid regex".into(),
}
}
}

View file

@ -191,6 +191,12 @@ impl Note {
.collect() .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<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool { pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
let mut changed = false; let mut changed = false;
for tag in &mut self.tags { for tag in &mut self.tags {

View file

@ -90,6 +90,15 @@ impl SqliteStorage {
Ok(()) 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<()> { pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> {
self.db self.db
.prepare_cached("update tags set collapsed = ? where tag = ?")? .prepare_cached("update tags set collapsed = ? where tag = ?")?

View file

@ -1532,7 +1532,7 @@ mod test {
col1.remove_cards_and_orphaned_notes(&[cardid])?; col1.remove_cards_and_orphaned_notes(&[cardid])?;
let usn = col1.usn()?; let usn = col1.usn()?;
col1.remove_note_only_undoable(noteid, 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; let out = ctx.normal_sync(&mut col1).await;
assert_eq!(out.required, SyncActionRequired::NoChanges); assert_eq!(out.required, SyncActionRequired::NoChanges);

View file

@ -292,6 +292,37 @@ impl Collection {
Ok(()) 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<usize> {
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<Vec<NoteID>> {
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::<std::result::Result<_, _>>()?;
Ok(nids)
}
pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> { pub(crate) fn set_tag_expanded(&self, name: &str, expanded: bool) -> Result<()> {
let mut name = name; let mut name = name;
let tag; let tag;