change bulk_update() into find_and_replace_tag()

Now behaves the same way as standard find&replace:
- Will match substrings
- Regexs can be used to match multiple items; we no longer split
input on spaces.
- The find&replace dialog has been updated to add tags to the field
list.
This commit is contained in:
Damien Elmes 2021-03-19 16:55:10 +10:00
parent b287cd5238
commit 9c2bff5b6d
15 changed files with 380 additions and 253 deletions

View file

@ -558,6 +558,9 @@ class Collection:
field_name=field_name or "",
)
def field_names_for_note_ids(self, nids: Sequence[int]) -> Sequence[str]:
return self._backend.field_names_for_notes(nids)
# returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
nids = self.findNotes(search, SearchNode(field_name=fieldName))

View file

@ -49,7 +49,7 @@ def findReplace(
def fieldNamesForNotes(col: Collection, nids: List[int]) -> List[str]:
return list(col._backend.field_names_for_notes(nids))
return list(col.field_names_for_note_ids(nids))
# Find duplicates

View file

@ -78,13 +78,23 @@ class TagManager:
# Find&replace
#############################################################
def bulk_update(
self, nids: Sequence[int], tags: str, replacement: str, regex: bool
def find_and_replace(
self,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> OpChangesWithCount:
"""Replace space-separated tags, returning changed count.
Tags replaced with an empty string will be removed."""
return self.col._backend.update_note_tags(
nids=nids, tags=tags, replacement=replacement, regex=regex
"""Replace instances of 'search' with 'replacement' in tags.
Each tag is matched separately. If the replacement results in an empty string,
the tag will be removed."""
return self.col._backend.find_and_replace_tag(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
)
# Bulk addition/removal based on tag

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import html
import time
from concurrent.futures import Future
from dataclasses import dataclass, field
from operator import itemgetter
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast
@ -25,11 +24,11 @@ from aqt import AnkiQt, colors, gui_hooks
from aqt.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor
from aqt.exporting import ExportDialog
from aqt.find_and_replace import FindAndReplaceDialog
from aqt.main import ResetReason
from aqt.note_ops import (
add_tags,
clear_unused_tags,
find_and_replace,
remove_notes,
remove_tags_for_notes,
)
@ -59,14 +58,12 @@ from aqt.utils import (
qtMenuShortcutWorkaround,
restore_combo_history,
restore_combo_index_for_session,
restore_is_checked,
restoreGeom,
restoreHeader,
restoreSplitter,
restoreState,
save_combo_history,
save_combo_index_for_session,
save_is_checked,
saveGeom,
saveHeader,
saveSplitter,
@ -169,7 +166,7 @@ class DataModel(QAbstractTableModel):
return entry
elif self.block_updates:
# blank entry until we unblock
return CellRow(columns=[Cell(text="blocked")] * len(self.activeCols))
return CellRow(columns=[Cell(text="...")] * len(self.activeCols))
else:
# missing entry, need to build
entry = self._build_cell_row(row)
@ -1559,77 +1556,16 @@ where id in %s"""
nids = self.selected_notes()
if not nids:
return
import anki.find
def find() -> List[str]:
return anki.find.fieldNamesForNotes(self.mw.col, nids)
def on_done(fut: Future) -> None:
self._on_find_replace_diag(fut.result(), nids)
self.mw.taskman.with_progress(find, on_done, self)
def _on_find_replace_diag(self, fields: List[str], nids: List[int]) -> None:
d = QDialog(self)
disable_help_button(d)
frm = aqt.forms.findreplace.Ui_Dialog()
frm.setupUi(d)
d.setWindowModality(Qt.WindowModal)
combo = "BrowserFindAndReplace"
findhistory = restore_combo_history(frm.find, combo + "Find")
frm.find._completer().setCaseSensitivity(True)
replacehistory = restore_combo_history(frm.replace, combo + "Replace")
frm.replace._completer().setCaseSensitivity(True)
restore_is_checked(frm.re, combo + "Regex")
restore_is_checked(frm.ignoreCase, combo + "ignoreCase")
frm.find.setFocus()
allfields = [tr(TR.BROWSING_ALL_FIELDS)] + fields
frm.field.addItems(allfields)
restore_combo_index_for_session(frm.field, allfields, combo + "Field")
qconnect(frm.buttonBox.helpRequested, self.onFindReplaceHelp)
restoreGeom(d, "findreplace")
r = d.exec_()
saveGeom(d, "findreplace")
if not r:
return
save_combo_index_for_session(frm.field, combo + "Field")
if frm.field.currentIndex() == 0:
field = None
else:
field = fields[frm.field.currentIndex() - 1]
search = save_combo_history(frm.find, findhistory, combo + "Find")
replace = save_combo_history(frm.replace, replacehistory, combo + "Replace")
regex = frm.re.isChecked()
match_case = not frm.ignoreCase.isChecked()
save_is_checked(frm.re, combo + "Regex")
save_is_checked(frm.ignoreCase, combo + "ignoreCase")
find_and_replace(
mw=self.mw,
parent=self,
note_ids=nids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
def onFindReplaceHelp(self) -> None:
openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)
FindAndReplaceDialog(self, mw=self.mw, note_ids=nids)
# Edit: finding dupes
######################################################################
@ensure_editor_saved
def onFindDupes(self) -> None:
import anki.find
d = QDialog(self)
self.mw.garbage_collect_on_dialog_finish(d)
frm = aqt.forms.finddupes.Ui_Dialog()

182
qt/aqt/find_and_replace.py Normal file
View file

@ -0,0 +1,182 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import List, Optional, Sequence
import aqt
from anki.lang import TR
from aqt import AnkiQt, QWidget
from aqt.qt import QDialog, Qt
from aqt.utils import (
HelpPage,
disable_help_button,
openHelp,
qconnect,
restore_combo_history,
restore_combo_index_for_session,
restore_is_checked,
restoreGeom,
save_combo_history,
save_combo_index_for_session,
save_is_checked,
saveGeom,
show_invalid_search_error,
tooltip,
tr,
)
def find_and_replace(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
field_name: Optional[str],
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
field_name=field_name,
match_case=match_case,
),
success=lambda out: tooltip(
tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
parent=parent,
),
failure=lambda exc: show_invalid_search_error(exc, parent=parent),
)
def find_and_replace_tag(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.tags.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
match_case=match_case,
),
success=lambda out: tooltip(
tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
parent=parent,
),
failure=lambda exc: show_invalid_search_error(exc, parent=parent),
)
class FindAndReplaceDialog(QDialog):
COMBO_NAME = "BrowserFindAndReplace"
def __init__(self, parent: QWidget, *, mw: AnkiQt, note_ids: Sequence[int]) -> None:
super().__init__(parent)
self.mw = mw
self.note_ids = note_ids
self.field_names: List[str] = []
# fetch field names and then show
mw.query_op(
lambda: mw.col.field_names_for_note_ids(note_ids),
success=self._show,
)
def _show(self, field_names: Sequence[str]) -> None:
# add "all fields" and "tags" to the top of the list
self.field_names = [
tr(TR.BROWSING_ALL_FIELDS),
tr(TR.EDITING_TAGS),
] + list(field_names)
disable_help_button(self)
self.form = aqt.forms.findreplace.Ui_Dialog()
self.form.setupUi(self)
self.setWindowModality(Qt.WindowModal)
self._find_history = restore_combo_history(
self.form.find, self.COMBO_NAME + "Find"
)
self.form.find.completer().setCaseSensitivity(True)
self._replace_history = restore_combo_history(
self.form.replace, self.COMBO_NAME + "Replace"
)
self.form.replace.completer().setCaseSensitivity(True)
restore_is_checked(self.form.re, self.COMBO_NAME + "Regex")
restore_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
self.form.field.addItems(self.field_names)
restore_combo_index_for_session(
self.form.field, self.field_names, self.COMBO_NAME + "Field"
)
qconnect(self.form.buttonBox.helpRequested, self.show_help)
restoreGeom(self, "findreplace")
self.show()
self.form.find.setFocus()
def accept(self) -> None:
saveGeom(self, "findreplace")
save_combo_index_for_session(self.form.field, self.COMBO_NAME + "Field")
search = save_combo_history(
self.form.find, self._find_history, self.COMBO_NAME + "Find"
)
replace = save_combo_history(
self.form.replace, self._replace_history, self.COMBO_NAME + "Replace"
)
regex = self.form.re.isChecked()
match_case = not self.form.ignoreCase.isChecked()
save_is_checked(self.form.re, self.COMBO_NAME + "Regex")
save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase")
if self.form.field.currentIndex() == 1:
# tags
find_and_replace_tag(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
regex=regex,
match_case=match_case,
)
return
if self.form.field.currentIndex() == 0:
field = None
else:
field = self.field_names[self.form.field.currentIndex() - 2]
find_and_replace(
mw=self.mw,
parent=self.parentWidget(),
note_ids=self.note_ids,
search=search,
replacement=replace,
regex=regex,
field_name=field,
match_case=match_case,
)
super().accept()
def show_help(self) -> None:
openHelp(HelpPage.BROWSING_FIND_AND_REPLACE)

View file

@ -3,14 +3,14 @@
from __future__ import annotations
from typing import Callable, Optional, Sequence
from typing import Callable, Sequence
from anki.collection import OpChangesWithCount
from anki.lang import TR
from anki.notes import Note
from aqt import AnkiQt, QWidget
from aqt.main import PerformOpOptionalSuccessCallback
from aqt.utils import show_invalid_search_error, showInfo, tooltip, tr
from aqt.utils import showInfo, tooltip, tr
def add_note(
@ -102,31 +102,3 @@ def remove_tags_for_all_notes(
tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent
),
)
def find_and_replace(
*,
mw: AnkiQt,
parent: QWidget,
note_ids: Sequence[int],
search: str,
replacement: str,
regex: bool,
field_name: Optional[str],
match_case: bool,
) -> None:
mw.perform_op(
lambda: mw.col.find_and_replace(
note_ids=note_ids,
search=search,
replacement=replacement,
regex=regex,
field_name=field_name,
match_case=match_case,
),
success=lambda out: showInfo(
tr(TR.FINDREPLACE_NOTES_UPDATED, changed=out.count, total=len(note_ids)),
parent=parent,
),
failure=lambda exc: show_invalid_search_error(exc, parent=parent),
)

View file

@ -17,6 +17,8 @@ no_strict_optional = false
no_strict_optional = false
[mypy-aqt.deck_ops]
no_strict_optional = false
[mypy-aqt.find_and_replace]
no_strict_optional = false
[mypy-aqt.winpaths]
disallow_untyped_defs=false

View file

@ -224,7 +224,7 @@ service TagsService {
rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount);
rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount);
rpc FindAndReplaceTag(FindAndReplaceTagIn) returns (OpChangesWithCount);
}
service SearchService {
@ -1049,11 +1049,12 @@ message NoteIDsAndTagsIn {
string tags = 2;
}
message UpdateNoteTagsIn {
repeated int64 nids = 1;
string tags = 2;
message FindAndReplaceTagIn {
repeated int64 note_ids = 1;
string search = 2;
string replacement = 3;
bool regex = 4;
bool match_case = 5;
}
message CheckDatabaseOut {

View file

@ -70,13 +70,17 @@ impl TagsService for Backend {
})
}
fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result<pb::OpChangesWithCount> {
fn find_and_replace_tag(
&self,
input: pb::FindAndReplaceTagIn,
) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| {
col.replace_tags_for_notes(
&to_note_ids(input.nids),
&input.tags,
col.find_and_replace_tag(
&to_note_ids(input.note_ids),
&input.search,
&input.replacement,
input.regex,
input.match_case,
)
.map(Into::into)
})

View file

@ -0,0 +1,142 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::borrow::Cow;
use regex::{NoExpand, Regex, Replacer};
use super::{is_tag_separator, join_tags, split_tags};
use crate::{notes::NoteTags, prelude::*};
impl Collection {
/// Replace occurences of a search with a new value in tags.
pub fn find_and_replace_tag(
&mut self,
nids: &[NoteID],
search: &str,
replacement: &str,
regex: bool,
match_case: bool,
) -> Result<OpOutput<usize>> {
if replacement.contains(is_tag_separator) {
return Err(AnkiError::invalid_input(
"replacement name can not contain a space",
));
}
let mut search = if regex {
Cow::from(search)
} else {
Cow::from(regex::escape(search))
};
if !match_case {
search = format!("(?i){}", search).into();
}
self.transact(Op::UpdateTag, |col| {
if regex {
col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, replacement)
} else {
col.replace_tags_for_notes_inner(nids, Regex::new(&search)?, NoExpand(replacement))
}
})
}
}
impl Collection {
fn replace_tags_for_notes_inner<R: Replacer>(
&mut self,
nids: &[NoteID],
regex: Regex,
mut repl: R,
) -> Result<usize> {
let usn = self.usn()?;
let mut match_count = 0;
let notes = self.storage.get_note_tags_by_id_list(nids)?;
for original in notes {
if let Some(updated_tags) = replace_tags(&original.tags, &regex, repl.by_ref()) {
let (tags, _) = self.canonify_tags(updated_tags, usn)?;
match_count += 1;
let mut note = NoteTags {
tags: join_tags(&tags),
..original
};
note.set_modified(usn);
self.update_note_tags_undoable(&note, original)?;
}
}
Ok(match_count)
}
}
/// If any tags are changed, return the new tags list.
/// The returned tags will need to be canonified.
fn replace_tags<R>(tags: &str, regex: &Regex, mut repl: R) -> Option<Vec<String>>
where
R: Replacer,
{
let maybe_replaced: Vec<_> = split_tags(tags)
.map(|tag| regex.replace_all(tag, repl.by_ref()))
.collect();
if maybe_replaced
.iter()
.any(|cow| matches!(cow, Cow::Owned(_)))
{
Some(maybe_replaced.into_iter().map(|s| s.to_string()).collect())
} else {
// nothing matched
None
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{collection::open_test_collection, decks::DeckID};
#[test]
fn find_replace() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("test".into());
col.add_note(&mut note, DeckID(1))?;
col.find_and_replace_tag(&[note.id], "foo|test", "bar", true, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.find_and_replace_tag(&[note.id], "BAR", "baz", false, true)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.find_and_replace_tag(&[note.id], "b.r", "baz", false, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.find_and_replace_tag(&[note.id], "b.r", "baz", true, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "baz");
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(out.output, 1);
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["aye", "baz", "cee"]);
// if all tags already on note, it doesn't get updated
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(out.output, 0);
// empty replacement deletes tag
col.find_and_replace_tag(&[note.id], "b.*|.*ye", "", true, false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["cee"]);
Ok(())
}
}

View file

@ -3,11 +3,11 @@
mod bulkadd;
mod dragdrop;
mod findreplace;
mod prefix_replacer;
mod register;
mod remove;
mod rename;
mod selectednotes;
mod tree;
pub(crate) mod undo;

View file

@ -10,6 +10,8 @@ use unicase::UniCase;
impl Collection {
/// Given a list of tags, fix case, ordering and duplicates.
/// Returns true if any new tags were added.
/// Each tag is split on spaces, so if you have a &str, you
/// can pass that in as a one-element vec.
pub(crate) fn canonify_tags(
&mut self,
tags: Vec<String>,

View file

@ -97,6 +97,7 @@ impl Collection {
mod test {
use super::*;
use crate::collection::open_test_collection;
use crate::tags::Tag;
#[test]
fn clearing() -> Result<()> {
@ -112,6 +113,14 @@ mod test {
assert_eq!(col.storage.get_tag("one")?.unwrap().expanded, true);
assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false);
// tag children are also cleared when clearing their parent
col.storage.clear_all_tags()?;
for name in vec!["a", "a::b", "A::b::c"] {
col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?;
}
col.remove_tags("a")?;
assert_eq!(col.storage.all_tags()?, vec![]);
Ok(())
}
}

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{prefix_replacer::PrefixReplacer, Tag};
use super::{is_tag_separator, prefix_replacer::PrefixReplacer, Tag};
use crate::prelude::*;
impl Collection {
@ -16,7 +16,7 @@ impl Collection {
impl Collection {
fn rename_tag_inner(&mut self, old_prefix: &str, new_prefix: &str) -> Result<usize> {
if new_prefix.contains(' ') {
if new_prefix.contains(is_tag_separator) {
return Err(AnkiError::invalid_input(
"replacement name can not contain a space",
));

View file

@ -1,136 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
//! Add/update/remove tags on selected notes
use crate::{notes::TransformNoteOutput, prelude::*, text::to_re};
use regex::{NoExpand, Regex, Replacer};
use super::split_tags;
impl Collection {
fn replace_tags_for_notes_inner<R: Replacer>(
&mut self,
nids: &[NoteID],
tags: &[Regex],
mut repl: R,
) -> Result<OpOutput<usize>> {
self.transact(Op::UpdateTag, |col| {
col.transform_notes(nids, |note, _nt| {
let mut changed = false;
for re in tags {
if note.replace_tags(re, repl.by_ref()) {
changed = true;
}
}
Ok(TransformNoteOutput {
changed,
generate_cards: false,
mark_modified: true,
})
})
})
}
/// Apply the provided list of regular expressions to note tags,
/// saving any modified notes.
pub fn replace_tags_for_notes(
&mut self,
nids: &[NoteID],
tags: &str,
repl: &str,
regex: bool,
) -> Result<OpOutput<usize>> {
// generate regexps
let tags = split_tags(tags)
.map(|tag| {
let tag = if regex { tag.into() } else { to_re(tag) };
Regex::new(&format!("(?i)^{}(::.*)?$", tag))
.map_err(|_| AnkiError::invalid_input("invalid regex"))
})
.collect::<Result<Vec<Regex>>>()?;
if !regex {
self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl))
} else {
self.replace_tags_for_notes_inner(nids, &tags, repl)
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::tags::Tag;
use crate::{collection::open_test_collection, decks::DeckID};
#[test]
fn bulk() -> Result<()> {
let mut col = open_test_collection();
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.tags.push("test".into());
col.add_note(&mut note, DeckID(1))?;
col.replace_tags_for_notes(&[note.id], "foo test", "bar", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.replace_tags_for_notes(&[note.id], "b.r", "baz", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "bar");
col.replace_tags_for_notes(&[note.id], "b*r", "baz", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "baz");
col.replace_tags_for_notes(&[note.id], "b.r", "baz", true)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(note.tags[0], "baz");
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(out.output, 1);
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["aye", "baz", "cee"]);
// if all tags already on note, it doesn't get updated
let out = col.add_tags_to_notes(&[note.id], "cee aye")?;
assert_eq!(out.output, 0);
// empty replacement deletes tag
col.replace_tags_for_notes(&[note.id], "b.* .*ye", "", true)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["cee"]);
let mut note = col.storage.get_note(note.id)?.unwrap();
note.tags = vec![
"foo::bar".into(),
"foo::bar::foo".into(),
"bar::foo".into(),
"bar::foo::bar".into(),
];
col.update_note(&mut note)?;
col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]);
// ensure replacements fully match
let mut note = col.storage.get_note(note.id)?.unwrap();
note.tags = vec!["foobar".into(), "barfoo".into(), "foo".into()];
col.update_note(&mut note)?;
col.replace_tags_for_notes(&[note.id], "foo", "", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["barfoo", "foobar"]);
// tag children are also cleared when clearing their parent
col.storage.clear_all_tags()?;
for name in vec!["a", "a::b", "A::b::c"] {
col.register_tag(&mut Tag::new(name.to_string(), Usn(0)))?;
}
col.storage.clear_tag_and_children("a")?;
assert_eq!(col.storage.all_tags()?, vec![]);
Ok(())
}
}