mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
b287cd5238
commit
9c2bff5b6d
15 changed files with 380 additions and 253 deletions
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
182
qt/aqt/find_and_replace.py
Normal 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)
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
142
rslib/src/tags/findreplace.rs
Normal file
142
rslib/src/tags/findreplace.rs
Normal 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, ®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<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!(¬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(())
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
));
|
||||
|
|
|
@ -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!(¬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(())
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue