mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12: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 "",
|
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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
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 __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),
|
|
||||||
)
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
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 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;
|
||||||
|
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
));
|
));
|
||||||
|
|
|
@ -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