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-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

View file

@ -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 = <b>Replace With</b>:
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

View file

@ -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.
}

View file

@ -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))

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -10,5 +10,7 @@
<file>icons/clock.svg</file>
<file>icons/card-state.svg</file>
<file>icons/flag.svg</file>
<file>icons/select.svg</file>
<file>icons/magnifying_glass.svg</file>
</qresource>
</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;
}
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);

View file

@ -109,8 +109,8 @@ impl DecksService for Backend {
.map(Into::into)
}
fn remove_deck(&self, input: pb::DeckId) -> Result<pb::Empty> {
self.with_col(|col| col.remove_deck_and_child_decks(input.into()))
fn remove_decks(&self, input: pb::DeckIDs) -> Result<pb::UInt32> {
self.with_col(|col| col.remove_decks_and_child_decks(&Into::<Vec<DeckID>>::into(input)))
.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 {
fn from(did: DeckID) -> Self {
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 {
fn from(_val: ()) -> Self {
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> {
self.with_col(|col| {
col.transact(None, |col| {

View file

@ -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<usize> {
let mut card_count = 0;
self.transact(None, |col| {
let usn = col.usn()?;
if let Some(deck) = col.storage.get_deck(did)? {
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)?;
card_count += col.remove_single_deck(&deck, usn)?;
// remove children
for deck in child_decks {
col.remove_single_deck(&deck, usn)?;
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<usize> {
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<usize> {
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)>> {
@ -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+"]);

View file

@ -488,3 +488,11 @@ impl From<ParseIntError> for AnkiError {
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()
}
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 {
let mut changed = false;
for tag in &mut self.tags {

View file

@ -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 = ?")?

View file

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

View file

@ -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<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<()> {
let mut name = name;
let tag;