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 "", 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]) # returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
nids = self.findNotes(search, SearchNode(field_name=fieldName)) 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]: 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 # Find duplicates

View file

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

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import html import html
import time import time
from concurrent.futures import Future
from dataclasses import dataclass, field from dataclasses import dataclass, field
from operator import itemgetter from operator import itemgetter
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast 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.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor from aqt.editor import Editor
from aqt.exporting import ExportDialog from aqt.exporting import ExportDialog
from aqt.find_and_replace import FindAndReplaceDialog
from aqt.main import ResetReason from aqt.main import ResetReason
from aqt.note_ops import ( from aqt.note_ops import (
add_tags, add_tags,
clear_unused_tags, clear_unused_tags,
find_and_replace,
remove_notes, remove_notes,
remove_tags_for_notes, remove_tags_for_notes,
) )
@ -59,14 +58,12 @@ from aqt.utils import (
qtMenuShortcutWorkaround, qtMenuShortcutWorkaround,
restore_combo_history, restore_combo_history,
restore_combo_index_for_session, restore_combo_index_for_session,
restore_is_checked,
restoreGeom, restoreGeom,
restoreHeader, restoreHeader,
restoreSplitter, restoreSplitter,
restoreState, restoreState,
save_combo_history, save_combo_history,
save_combo_index_for_session, save_combo_index_for_session,
save_is_checked,
saveGeom, saveGeom,
saveHeader, saveHeader,
saveSplitter, saveSplitter,
@ -169,7 +166,7 @@ class DataModel(QAbstractTableModel):
return entry return entry
elif self.block_updates: elif self.block_updates:
# blank entry until we unblock # blank entry until we unblock
return CellRow(columns=[Cell(text="blocked")] * len(self.activeCols)) return CellRow(columns=[Cell(text="...")] * len(self.activeCols))
else: else:
# missing entry, need to build # missing entry, need to build
entry = self._build_cell_row(row) entry = self._build_cell_row(row)
@ -1559,77 +1556,16 @@ where id in %s"""
nids = self.selected_notes() nids = self.selected_notes()
if not nids: if not nids:
return return
import anki.find
def find() -> List[str]: FindAndReplaceDialog(self, mw=self.mw, note_ids=nids)
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)
# Edit: finding dupes # Edit: finding dupes
###################################################################### ######################################################################
@ensure_editor_saved @ensure_editor_saved
def onFindDupes(self) -> None: def onFindDupes(self) -> None:
import anki.find
d = QDialog(self) d = QDialog(self)
self.mw.garbage_collect_on_dialog_finish(d) self.mw.garbage_collect_on_dialog_finish(d)
frm = aqt.forms.finddupes.Ui_Dialog() 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 __future__ import annotations
from typing import Callable, Optional, Sequence from typing import Callable, Sequence
from anki.collection import OpChangesWithCount from anki.collection import OpChangesWithCount
from anki.lang import TR from anki.lang import TR
from anki.notes import Note from anki.notes import Note
from aqt import AnkiQt, QWidget from aqt import AnkiQt, QWidget
from aqt.main import PerformOpOptionalSuccessCallback 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( def add_note(
@ -102,31 +102,3 @@ def remove_tags_for_all_notes(
tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent 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 no_strict_optional = false
[mypy-aqt.deck_ops] [mypy-aqt.deck_ops]
no_strict_optional = false no_strict_optional = false
[mypy-aqt.find_and_replace]
no_strict_optional = false
[mypy-aqt.winpaths] [mypy-aqt.winpaths]
disallow_untyped_defs=false disallow_untyped_defs=false

View file

@ -224,7 +224,7 @@ service TagsService {
rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount);
rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount);
rpc UpdateNoteTags(UpdateNoteTagsIn) returns (OpChangesWithCount); rpc FindAndReplaceTag(FindAndReplaceTagIn) returns (OpChangesWithCount);
} }
service SearchService { service SearchService {
@ -1049,11 +1049,12 @@ message NoteIDsAndTagsIn {
string tags = 2; string tags = 2;
} }
message UpdateNoteTagsIn { message FindAndReplaceTagIn {
repeated int64 nids = 1; repeated int64 note_ids = 1;
string tags = 2; string search = 2;
string replacement = 3; string replacement = 3;
bool regex = 4; bool regex = 4;
bool match_case = 5;
} }
message CheckDatabaseOut { 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| { self.with_col(|col| {
col.replace_tags_for_notes( col.find_and_replace_tag(
&to_note_ids(input.nids), &to_note_ids(input.note_ids),
&input.tags, &input.search,
&input.replacement, &input.replacement,
input.regex, input.regex,
input.match_case,
) )
.map(Into::into) .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 bulkadd;
mod dragdrop; mod dragdrop;
mod findreplace;
mod prefix_replacer; mod prefix_replacer;
mod register; mod register;
mod remove; mod remove;
mod rename; mod rename;
mod selectednotes;
mod tree; mod tree;
pub(crate) mod undo; pub(crate) mod undo;

View file

@ -10,6 +10,8 @@ use unicase::UniCase;
impl Collection { impl Collection {
/// Given a list of tags, fix case, ordering and duplicates. /// Given a list of tags, fix case, ordering and duplicates.
/// Returns true if any new tags were added. /// 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( pub(crate) fn canonify_tags(
&mut self, &mut self,
tags: Vec<String>, tags: Vec<String>,

View file

@ -97,6 +97,7 @@ impl Collection {
mod test { mod test {
use super::*; use super::*;
use crate::collection::open_test_collection; use crate::collection::open_test_collection;
use crate::tags::Tag;
#[test] #[test]
fn clearing() -> Result<()> { 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("one")?.unwrap().expanded, true);
assert_eq!(col.storage.get_tag("two")?.unwrap().expanded, false); 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(()) Ok(())
} }
} }

View file

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