From 9c2bff5b6d729f0f4f94c39ca0a3418e9587e73a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 19 Mar 2021 16:55:10 +1000 Subject: [PATCH] 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. --- pylib/anki/collection.py | 3 + pylib/anki/find.py | 2 +- pylib/anki/tags.py | 22 ++-- qt/aqt/browser.py | 74 +------------ qt/aqt/find_and_replace.py | 182 ++++++++++++++++++++++++++++++++ qt/aqt/note_ops.py | 32 +----- qt/mypy.ini | 2 + rslib/backend.proto | 9 +- rslib/src/backend/tags.rs | 12 ++- rslib/src/tags/findreplace.rs | 142 +++++++++++++++++++++++++ rslib/src/tags/mod.rs | 2 +- rslib/src/tags/register.rs | 2 + rslib/src/tags/remove.rs | 9 ++ rslib/src/tags/rename.rs | 4 +- rslib/src/tags/selectednotes.rs | 136 ------------------------ 15 files changed, 380 insertions(+), 253 deletions(-) create mode 100644 qt/aqt/find_and_replace.py create mode 100644 rslib/src/tags/findreplace.rs delete mode 100644 rslib/src/tags/selectednotes.rs diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 3bdf63574..01b0fb3c4 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -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)) diff --git a/pylib/anki/find.py b/pylib/anki/find.py index 346eff311..14692531a 100644 --- a/pylib/anki/find.py +++ b/pylib/anki/find.py @@ -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 diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index fa0800e7d..4e4eaed22 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -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 diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 8640694b9..729b0ca73 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -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() diff --git a/qt/aqt/find_and_replace.py b/qt/aqt/find_and_replace.py new file mode 100644 index 000000000..5a57d134f --- /dev/null +++ b/qt/aqt/find_and_replace.py @@ -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) diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index db8fd0751..2e29eb977 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -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), - ) diff --git a/qt/mypy.ini b/qt/mypy.ini index 3df1f2a6a..01549f4e7 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -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 diff --git a/rslib/backend.proto b/rslib/backend.proto index 523cd0272..7f90453eb 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -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 { diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 28d72dd8c..bb20d8cf9 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -70,13 +70,17 @@ impl TagsService for Backend { }) } - fn update_note_tags(&self, input: pb::UpdateNoteTagsIn) -> Result { + fn find_and_replace_tag( + &self, + input: pb::FindAndReplaceTagIn, + ) -> Result { 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) }) diff --git a/rslib/src/tags/findreplace.rs b/rslib/src/tags/findreplace.rs new file mode 100644 index 000000000..746d4b423 --- /dev/null +++ b/rslib/src/tags/findreplace.rs @@ -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> { + 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( + &mut self, + nids: &[NoteID], + regex: Regex, + mut repl: R, + ) -> Result { + 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, ®ex, 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(¬e, 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(tags: &str, regex: &Regex, mut repl: R) -> Option> +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!(¬e.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!(¬e.tags, &["cee"]); + + Ok(()) + } +} diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 289d97525..836a0981e 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -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; diff --git a/rslib/src/tags/register.rs b/rslib/src/tags/register.rs index 7be6f475b..666a8f63d 100644 --- a/rslib/src/tags/register.rs +++ b/rslib/src/tags/register.rs @@ -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, diff --git a/rslib/src/tags/remove.rs b/rslib/src/tags/remove.rs index aacb42fd9..1bcaaf5db 100644 --- a/rslib/src/tags/remove.rs +++ b/rslib/src/tags/remove.rs @@ -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(()) } } diff --git a/rslib/src/tags/rename.rs b/rslib/src/tags/rename.rs index d75805c8a..b33b99716 100644 --- a/rslib/src/tags/rename.rs +++ b/rslib/src/tags/rename.rs @@ -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 { - if new_prefix.contains(' ') { + if new_prefix.contains(is_tag_separator) { return Err(AnkiError::invalid_input( "replacement name can not contain a space", )); diff --git a/rslib/src/tags/selectednotes.rs b/rslib/src/tags/selectednotes.rs deleted file mode 100644 index cc0008458..000000000 --- a/rslib/src/tags/selectednotes.rs +++ /dev/null @@ -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( - &mut self, - nids: &[NoteID], - tags: &[Regex], - mut repl: R, - ) -> Result> { - 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> { - // 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::>>()?; - 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!(¬e.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!(¬e.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!(¬e.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!(¬e.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(()) - } -}