diff --git a/ftl/core/actions.ftl b/ftl/core/actions.ftl index a6917f03c..8b7a6d8ef 100644 --- a/ftl/core/actions.ftl +++ b/ftl/core/actions.ftl @@ -23,6 +23,7 @@ actions-rebuild = Rebuild actions-rename = Rename actions-rename-deck = Rename Deck actions-rename-tag = Rename Tag +actions-rename-with-parents = Rename with Parents actions-remove-tag = Remove Tag actions-replay-audio = Replay Audio actions-reposition = Reposition diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 190ba2739..c2e69950f 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -92,6 +92,7 @@ browsing-reschedule = Reschedule browsing-search-bar-hint = Search cards/notes (type text, then press Enter) browsing-search-in = Search in: browsing-search-within-formatting-slow = Search within formatting (slow) +browsing-selected-notes-only = Selected notes only browsing-shift-position-of-existing-cards = Shift position of existing cards browsing-sidebar = Sidebar browsing-sidebar-filter = Sidebar filter diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index d5877b18e..b4fafd151 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -835,7 +835,6 @@ class Browser(QMainWindow): ###################################################################### @no_arg_trigger - @skip_if_selection_is_empty @ensure_editor_saved def onFindReplace(self) -> None: FindAndReplaceDialog(self, mw=self.mw, note_ids=self.selected_notes()) diff --git a/qt/aqt/browser/find_and_replace.py b/qt/aqt/browser/find_and_replace.py index 2abb814d4..254c18be1 100644 --- a/qt/aqt/browser/find_and_replace.py +++ b/qt/aqt/browser/find_and_replace.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import List, Sequence +from typing import List, Optional, Sequence import aqt from anki.notes import NoteId @@ -25,6 +25,7 @@ from aqt.utils import ( save_combo_index_for_session, save_is_checked, saveGeom, + tooltip, tr, ) @@ -33,19 +34,34 @@ class FindAndReplaceDialog(QDialog): COMBO_NAME = "BrowserFindAndReplace" def __init__( - self, parent: QWidget, *, mw: AnkiQt, note_ids: Sequence[NoteId] + self, + parent: QWidget, + *, + mw: AnkiQt, + note_ids: Sequence[NoteId], + field: Optional[str] = None, ) -> None: + """ + If 'field' is passed, only this is added to the field selector. + Otherwise, the fields belonging to the 'note_ids' are added. + """ super().__init__(parent) self.mw = mw self.note_ids = note_ids self.field_names: List[str] = [] + self._field = field - # fetch field names and then show - QueryOp( - parent=mw, - op=lambda col: col.field_names_for_note_ids(note_ids), - success=self._show, - ).run_in_background() + if field: + self._show([field]) + elif note_ids: + # fetch field names and then show + QueryOp( + parent=mw, + op=lambda col: col.field_names_for_note_ids(note_ids), + success=self._show, + ).run_in_background() + else: + self._show([]) def _show(self, field_names: Sequence[str]) -> None: # add "all fields" and "tags" to the top of the list @@ -68,13 +84,23 @@ class FindAndReplaceDialog(QDialog): ) self.form.replace.completer().setCaseSensitivity(Qt.CaseSensitive) + if not self.note_ids: + # no selected notes to affect + self.form.selected_notes.setChecked(False) + self.form.selected_notes.setEnabled(False) + elif self._field: + self.form.selected_notes.setChecked(False) + 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" - ) + if self._field: + self.form.field.setCurrentIndex(self.field_names.index(self._field)) + else: + restore_combo_index_for_session( + self.form.field, self.field_names, self.COMBO_NAME + "Field" + ) qconnect(self.form.buttonBox.helpRequested, self.show_help) @@ -97,16 +123,20 @@ class FindAndReplaceDialog(QDialog): save_is_checked(self.form.re, self.COMBO_NAME + "Regex") save_is_checked(self.form.ignoreCase, self.COMBO_NAME + "ignoreCase") + if not self.form.selected_notes.isChecked(): + # an empty list means *all* notes + self.note_ids = [] + # tags? if self.form.field.currentIndex() == 1: - find_and_replace_tag( + op = find_and_replace_tag( parent=self.parentWidget(), note_ids=self.note_ids, search=search, replacement=replace, regex=regex, match_case=match_case, - ).run_in_background() + ) else: # fields if self.form.field.currentIndex() == 0: @@ -114,7 +144,7 @@ class FindAndReplaceDialog(QDialog): else: field = self.field_names[self.form.field.currentIndex()] - find_and_replace( + op = find_and_replace( parent=self.parentWidget(), note_ids=self.note_ids, search=search, @@ -122,7 +152,16 @@ class FindAndReplaceDialog(QDialog): regex=regex, field_name=field, match_case=match_case, - ).run_in_background() + ) + + if not self.note_ids: + op.success( + lambda out: tooltip( + tr.browsing_notes_updated(count=out.count), + parent=self.parentWidget(), + ) + ) + op.run_in_background() super().accept() diff --git a/qt/aqt/browser/sidebar/tree.py b/qt/aqt/browser/sidebar/tree.py index be0544a0a..595298f27 100644 --- a/qt/aqt/browser/sidebar/tree.py +++ b/qt/aqt/browser/sidebar/tree.py @@ -19,6 +19,7 @@ from anki.notes import Note from anki.tags import TagTreeNode from anki.types import assert_exhaustive from aqt import colors, gui_hooks +from aqt.browser.find_and_replace import FindAndReplaceDialog from aqt.browser.sidebar import _want_right_border from aqt.browser.sidebar.item import SidebarItem, SidebarItemType from aqt.browser.sidebar.model import SidebarModel @@ -836,7 +837,6 @@ class SidebarTreeView(QTreeView): SearchNode(note=nt["name"]), SearchNode(template=c) ), item_type=SidebarItemType.NOTETYPE_TEMPLATE, - name_prefix=f"{nt['name']}::", id=tmpl["ord"], ) item.add_child(child) @@ -868,7 +868,8 @@ class SidebarTreeView(QTreeView): menu = QMenu() self._maybe_add_type_specific_actions(menu, item) self._maybe_add_delete_action(menu, item, index) - self._maybe_add_rename_action(menu, item, index) + self._maybe_add_rename_actions(menu, item, index) + self._maybe_add_find_and_replace_action(menu, item, index) self._maybe_add_search_actions(menu) self._maybe_add_tree_actions(menu) gui_hooks.browser_sidebar_will_show_context_menu(self, menu, item, index) @@ -900,11 +901,27 @@ class SidebarTreeView(QTreeView): if self._enable_delete(item): menu.addAction(tr.actions_delete(), lambda: self._on_delete(item)) - def _maybe_add_rename_action( + def _maybe_add_rename_actions( self, menu: QMenu, item: SidebarItem, index: QModelIndex ) -> None: if item.item_type.is_editable() and len(self._selected_items()) == 1: menu.addAction(tr.actions_rename(), lambda: self.edit(index)) + if item.item_type in (SidebarItemType.TAG, SidebarItemType.DECK): + menu.addAction( + tr.actions_rename_with_parents(), + lambda: self._on_rename_with_parents(item), + ) + + def _maybe_add_find_and_replace_action( + self, menu: QMenu, item: SidebarItem, index: QModelIndex + ) -> None: + if ( + len(self._selected_items()) == 1 + and item.item_type is SidebarItemType.NOTETYPE_FIELD + ): + menu.addAction( + tr.browsing_find_and_replace(), lambda: self._on_find_and_replace(item) + ) def _maybe_add_search_actions(self, menu: QMenu) -> None: nodes = [ @@ -961,6 +978,51 @@ class SidebarTreeView(QTreeView): lambda: set_children_expanded(False), ) + def _on_rename_with_parents(self, item: SidebarItem) -> None: + title = "Anki" + if item.item_type is SidebarItemType.TAG: + title = tr.actions_rename_tag() + elif item.item_type is SidebarItemType.DECK: + title = tr.actions_rename_deck() + + new_name = getOnlyText( + tr.actions_new_name(), title=title, default=item.full_name + ).replace('"', "") + if not new_name or new_name == item.full_name: + return + + if item.item_type is SidebarItemType.TAG: + + def success(out: OpChangesWithCount) -> None: + if out.count: + tooltip(tr.browsing_notes_updated(count=out.count), parent=self) + else: + showInfo(tr.browsing_tag_rename_warning_empty(), parent=self) + + rename_tag( + parent=self, + current_name=item.full_name, + new_name=new_name, + ).success(success).run_in_background() + + elif item.item_type is SidebarItemType.DECK: + rename_deck( + parent=self, + deck_id=DeckId(item.id), + new_name=new_name, + ).run_in_background() + + def _on_find_and_replace(self, item: SidebarItem) -> None: + field = None + if item.item_type is SidebarItemType.NOTETYPE_FIELD: + field = item.name + FindAndReplaceDialog( + self, + mw=self.mw, + note_ids=self.browser.selected_notes(), + field=field, + ) + # Flags ########################### @@ -972,29 +1034,16 @@ class SidebarTreeView(QTreeView): ########################### def rename_deck(self, item: SidebarItem, new_name: str) -> None: - if not new_name: + if not new_name or new_name == item.name: return # update UI immediately, to avoid redraw item.name = new_name - full_name = item.name_prefix + new_name - deck_id = DeckId(item.id) - - def after_fetch(name: str) -> None: - if full_name == name: - return - - rename_deck( - parent=self, - deck_id=deck_id, - new_name=full_name, - ).run_in_background() - - QueryOp( - parent=self.browser, - op=lambda col: col.decks.name(deck_id), - success=after_fetch, + rename_deck( + parent=self, + deck_id=DeckId(item.id), + new_name=item.name_prefix + new_name, ).run_in_background() def delete_decks(self, _item: SidebarItem) -> None: diff --git a/qt/aqt/forms/findreplace.ui b/qt/aqt/forms/findreplace.ui index c763fc4ac..8d08770ab 100644 --- a/qt/aqt/forms/findreplace.ui +++ b/qt/aqt/forms/findreplace.ui @@ -6,7 +6,7 @@ 0 0 - 367 + 377 224 @@ -16,15 +16,8 @@ - - - - browsing_find - - - - - + + 9 @@ -49,8 +42,46 @@ - - + + + + browsing_in + + + + + + + browsing_find + + + + + + + QComboBox::AdjustToMinimumContentsLength + + + + + + + browsing_treat_input_as_regular_expression + + + + + + + browsing_ignore_case + + + true + + + + + 9 @@ -68,31 +99,10 @@ - - - - browsing_in - - - - - - - QComboBox::AdjustToMinimumContentsLength - - - - - - - browsing_treat_input_as_regular_expression - - - - + - browsing_ignore_case + browsing_selected_notes_only true diff --git a/rslib/src/backend/search/mod.rs b/rslib/src/backend/search/mod.rs index 7201b4cf0..22fc04274 100644 --- a/rslib/src/backend/search/mod.rs +++ b/rslib/src/backend/search/mod.rs @@ -6,7 +6,7 @@ mod search_node; use std::{convert::TryInto, str::FromStr, sync::Arc}; -use super::Backend; +use super::{notes::to_note_ids, Backend}; pub(super) use crate::backend_proto::search_service::Service as SearchService; use crate::{ backend_proto as pb, @@ -74,7 +74,7 @@ impl SearchService for Backend { if !input.match_case { search = format!("(?i){}", search); } - let nids = input.nids.into_iter().map(NoteId).collect(); + let mut nids = to_note_ids(input.nids); let field_name = if input.field_name.is_empty() { None } else { @@ -82,6 +82,9 @@ impl SearchService for Backend { }; let repl = input.replacement; self.with_col(|col| { + if nids.is_empty() { + nids = col.search_notes_unordered("")? + }; col.find_and_replace(nids, &search, &repl, field_name) .map(Into::into) }) diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 75201e618..5f91c248d 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -73,8 +73,13 @@ impl TagsService for Backend { input: pb::FindAndReplaceTagRequest, ) -> Result { self.with_col(|col| { + let note_ids = if input.note_ids.is_empty() { + col.search_notes_unordered("")? + } else { + to_note_ids(input.note_ids) + }; col.find_and_replace_tag( - &to_note_ids(input.note_ids), + ¬e_ids, &input.search, &input.replacement, input.regex, diff --git a/rslib/src/findreplace.rs b/rslib/src/findreplace.rs index 165fb5131..c1b0c0d66 100644 --- a/rslib/src/findreplace.rs +++ b/rslib/src/findreplace.rs @@ -20,6 +20,12 @@ pub struct FindReplaceContext { field_name: Option, } +enum FieldForNotetype { + Any, + Index(usize), + None, +} + impl FindReplaceContext { pub fn new( nids: Vec, @@ -62,17 +68,22 @@ impl Collection { fn find_and_replace_inner(&mut self, ctx: FindReplaceContext) -> Result { let mut last_ntid = None; - let mut field_ord = None; + let mut field_for_notetype = FieldForNotetype::None; self.transform_notes(&ctx.nids, |note, nt| { if last_ntid != Some(nt.id) { - field_ord = ctx.field_name.as_ref().and_then(|n| nt.get_field_ord(n)); + field_for_notetype = match ctx.field_name.as_ref() { + None => FieldForNotetype::Any, + Some(name) => match nt.get_field_ord(name) { + None => FieldForNotetype::None, + Some(ord) => FieldForNotetype::Index(ord), + }, + }; last_ntid = Some(nt.id); } let mut changed = false; - match field_ord { - None => { - // all fields + match field_for_notetype { + FieldForNotetype::Any => { for txt in note.fields_mut() { if let Cow::Owned(otxt) = ctx.replace_text(txt) { changed = true; @@ -80,8 +91,7 @@ impl Collection { } } } - Some(ord) => { - // single field + FieldForNotetype::Index(ord) => { if let Some(txt) = note.fields_mut().get_mut(ord) { if let Cow::Owned(otxt) = ctx.replace_text(txt) { changed = true; @@ -89,6 +99,7 @@ impl Collection { } } } + FieldForNotetype::None => (), } Ok(TransformNoteOutput { @@ -142,12 +153,11 @@ mod test { ] ); let out = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?; - // still 2, as the caller is expected to provide only note ids that have - // that field, and if we can't find the field we fall back on all fields - assert_eq!(out.output, 2); + // 1, because notes without the specified field should be skipped + assert_eq!(out.output, 1); let note = col.storage.get_note(note.id)?.unwrap(); - // but the update should be limited to the specified field when it was available + // the update should be limited to the specified field when it was available assert_eq!(¬e.fields()[..], &["one ccc", "two BBB"]); Ok(())