diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index 61d60d945..89c92c6b5 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -19,3 +19,5 @@ undo-update-card = Update Card undo-update-deck = Update Deck undo-forget-card = Forget Card undo-set-flag = Set Flag +# when dragging/dropping tags and decks in the sidebar +undo-reparent = Change Parent diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 4e4eaed22..d139d9f23 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -108,10 +108,10 @@ class TagManager: "Remove the provided tag(s) and their children from notes and the tag list." return self.col._backend.remove_tags(val=space_separated_tags) - def drag_drop(self, source_tags: List[str], target_tag: str) -> None: - """Rename one or more source tags that were dropped on `target_tag`. - If target_tag is "", tags will be placed at the top level.""" - self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag) + def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount: + """Change the parent of the provided tags. + If new_parent is empty, tags will be reparented to the top-level.""" + return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent) # String-based utilities ########################################################################## diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 729b0ca73..d4f25c774 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -26,12 +26,7 @@ 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, - remove_notes, - remove_tags_for_notes, -) +from aqt.note_ops import remove_notes from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer from aqt.qt import * @@ -43,6 +38,7 @@ from aqt.scheduling_ops import ( unsuspend_cards, ) from aqt.sidebar import SidebarTreeView +from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes from aqt.theme import theme_manager from aqt.utils import ( TR, diff --git a/qt/aqt/note_ops.py b/qt/aqt/note_ops.py index 2e29eb977..582a380b4 100644 --- a/qt/aqt/note_ops.py +++ b/qt/aqt/note_ops.py @@ -5,12 +5,9 @@ from __future__ import annotations 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 import AnkiQt from aqt.main import PerformOpOptionalSuccessCallback -from aqt.utils import showInfo, tooltip, tr def add_note( @@ -37,68 +34,3 @@ def remove_notes( success: PerformOpOptionalSuccessCallback = None, ) -> None: mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) - - -def add_tags( - *, - mw: AnkiQt, - note_ids: Sequence[int], - space_separated_tags: str, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op( - lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success - ) - - -def remove_tags_for_notes( - *, - mw: AnkiQt, - note_ids: Sequence[int], - space_separated_tags: str, - success: PerformOpOptionalSuccessCallback = None, -) -> None: - mw.perform_op( - lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success - ) - - -def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: - mw.perform_op( - mw.col.tags.clear_unused_tags, - success=lambda out: tooltip( - tr(TR.BROWSING_REMOVED_UNUSED_TAGS_COUNT, count=out.count), parent=parent - ), - ) - - -def rename_tag( - *, - mw: AnkiQt, - parent: QWidget, - current_name: str, - new_name: str, - after_rename: Callable[[], None], -) -> None: - def success(out: OpChangesWithCount) -> None: - if out.count: - tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent) - else: - showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY), parent=parent) - - mw.perform_op( - lambda: mw.col.tags.rename(old=current_name, new=new_name), - success=success, - after_hooks=after_rename, - ) - - -def remove_tags_for_all_notes( - *, mw: AnkiQt, parent: QWidget, space_separated_tags: str -) -> None: - mw.perform_op( - lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), - success=lambda out: tooltip( - tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent - ), - ) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 4dc6c00f5..169b4d216 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -19,7 +19,7 @@ from anki.tags import MARKED_TAG from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.card_ops import set_card_flag -from aqt.note_ops import add_tags, remove_notes, remove_tags_for_notes +from aqt.note_ops import remove_notes from aqt.profiles import VideoDriver from aqt.qt import * from aqt.scheduling_ops import ( @@ -30,6 +30,7 @@ from aqt.scheduling_ops import ( suspend_note, ) from aqt.sound import av_player, play_clicked_audio, record_audio +from aqt.tag_ops import add_tags, remove_tags_for_notes from aqt.theme import theme_manager from aqt.toolbar import BottomBar from aqt.utils import ( diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 9e0637f4e..757c090af 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -17,10 +17,9 @@ from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.clayout import CardLayout from aqt.deck_ops import remove_decks -from aqt.main import ResetReason from aqt.models import Models -from aqt.note_ops import remove_tags_for_all_notes, rename_tag from aqt.qt import * +from aqt.tag_ops import remove_tags_for_all_notes, rename_tag, reparent_tags from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( TR, @@ -634,33 +633,21 @@ class SidebarTreeView(QTreeView): def _handle_drag_drop_tags( self, sources: List[SidebarItem], target: SidebarItem ) -> bool: - source_ids = [ + tags = [ source.full_name for source in sources if source.item_type == SidebarItemType.TAG ] - if not source_ids: + if not tags: return False - def on_done(fut: Future) -> None: - self.mw.requireReset(reason=ResetReason.BrowserAddTags, context=self) - self.browser.model.endReset() - fut.result() - self.refresh() - if target.item_type == SidebarItemType.TAG_ROOT: - target_name = "" + new_parent = "" else: - target_name = target.full_name + new_parent = target.full_name - def on_save() -> None: - self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG)) - self.browser.model.beginReset() - self.mw.taskman.with_progress( - lambda: self.col.tags.drag_drop(source_ids, target_name), on_done - ) + reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent) - self.browser.editor.call_after_note_saved(on_save) return True def _on_search(self, index: QModelIndex) -> None: diff --git a/qt/aqt/tag_ops.py b/qt/aqt/tag_ops.py new file mode 100644 index 000000000..f5f68abf4 --- /dev/null +++ b/qt/aqt/tag_ops.py @@ -0,0 +1,88 @@ +# 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 Callable, Sequence + +from anki.collection import OpChangesWithCount +from anki.lang import TR +from aqt import AnkiQt, QWidget +from aqt.main import PerformOpOptionalSuccessCallback +from aqt.utils import showInfo, tooltip, tr + + +def add_tags( + *, + mw: AnkiQt, + note_ids: Sequence[int], + space_separated_tags: str, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op( + lambda: mw.col.tags.bulk_add(note_ids, space_separated_tags), success=success + ) + + +def remove_tags_for_notes( + *, + mw: AnkiQt, + note_ids: Sequence[int], + space_separated_tags: str, + success: PerformOpOptionalSuccessCallback = None, +) -> None: + mw.perform_op( + lambda: mw.col.tags.bulk_remove(note_ids, space_separated_tags), success=success + ) + + +def clear_unused_tags(*, mw: AnkiQt, parent: QWidget) -> None: + mw.perform_op( + mw.col.tags.clear_unused_tags, + success=lambda out: tooltip( + tr(TR.BROWSING_REMOVED_UNUSED_TAGS_COUNT, count=out.count), parent=parent + ), + ) + + +def rename_tag( + *, + mw: AnkiQt, + parent: QWidget, + current_name: str, + new_name: str, + after_rename: Callable[[], None], +) -> None: + def success(out: OpChangesWithCount) -> None: + if out.count: + tooltip(tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent) + else: + showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY), parent=parent) + + mw.perform_op( + lambda: mw.col.tags.rename(old=current_name, new=new_name), + success=success, + after_hooks=after_rename, + ) + + +def remove_tags_for_all_notes( + *, mw: AnkiQt, parent: QWidget, space_separated_tags: str +) -> None: + mw.perform_op( + lambda: mw.col.tags.remove(space_separated_tags=space_separated_tags), + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent + ), + ) + + +def reparent_tags( + *, mw: AnkiQt, parent: QWidget, tags: Sequence[str], new_parent: str +) -> None: + mw.perform_op( + lambda: mw.col.tags.reparent(tags=tags, new_parent=new_parent), + success=lambda out: tooltip( + tr(TR.BROWSING_NOTES_UPDATED, count=out.count), parent=parent + ), + ) diff --git a/qt/mypy.ini b/qt/mypy.ini index 01549f4e7..089683636 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -19,6 +19,8 @@ no_strict_optional = false no_strict_optional = false [mypy-aqt.find_and_replace] no_strict_optional = false +[mypy-aqt.tag_ops] +no_strict_optional = false [mypy-aqt.winpaths] disallow_untyped_defs=false diff --git a/rslib/backend.proto b/rslib/backend.proto index 7f90453eb..443a59972 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -220,7 +220,7 @@ service TagsService { rpc RemoveTags(String) returns (OpChangesWithCount); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc TagTree(Empty) returns (TagTreeNode); - rpc DragDropTags(DragDropTagsIn) returns (Empty); + rpc ReparentTags(ReparentTagsIn) returns (OpChangesWithCount); rpc RenameTags(RenameTagsIn) returns (OpChangesWithCount); rpc AddNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); rpc RemoveNoteTags(NoteIDsAndTagsIn) returns (OpChangesWithCount); @@ -926,9 +926,9 @@ message TagTreeNode { bool expanded = 4; } -message DragDropTagsIn { - repeated string source_tags = 1; - string target_tag = 2; +message ReparentTagsIn { + repeated string tags = 1; + string new_parent = 2; } message RenameTagsIn { diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index bb20d8cf9..f7ea19a41 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -40,14 +40,14 @@ impl TagsService for Backend { self.with_col(|col| col.tag_tree()) } - fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result { - let source_tags = input.source_tags; - let target_tag = if input.target_tag.is_empty() { + fn reparent_tags(&self, input: pb::ReparentTagsIn) -> Result { + let source_tags = input.tags; + let target_tag = if input.new_parent.is_empty() { None } else { - Some(input.target_tag) + Some(input.new_parent) }; - self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag)) + self.with_col(|col| col.reparent_tags(&source_tags, target_tag)) .map(Into::into) } diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 4feeec601..4522400f6 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -20,7 +20,6 @@ use crate::{ }; use itertools::Itertools; use num_integer::Integer; -use regex::{Regex, Replacer}; use std::{ borrow::Cow, collections::{HashMap, HashSet}, @@ -210,32 +209,6 @@ impl Note { .collect() } - pub(crate) fn replace_tags(&mut self, re: &Regex, mut repl: T) -> bool { - let mut changed = false; - for tag in &mut self.tags { - if let Cow::Owned(rep) = re.replace_all(tag, |caps: ®ex::Captures| { - if let Some(expanded) = repl.by_ref().no_expansion() { - if expanded.trim().is_empty() { - "".to_string() - } else { - // include "::" if it was matched - format!( - "{}{}", - expanded, - caps.get(caps.len() - 1).map_or("", |m| m.as_str()) - ) - } - } else { - tag.to_string() - } - }) { - *tag = rep; - changed = true; - } - } - changed - } - /// Pad or merge fields to match note type. pub(crate) fn fix_field_count(&mut self, nt: &NoteType) { while self.fields.len() < nt.fields.len() { diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index dd5208d00..c0bad2827 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -13,9 +13,10 @@ pub enum Op { FindAndReplace, RemoveDeck, RemoveNote, + RemoveTag, RenameDeck, RenameTag, - RemoveTag, + ReparentTag, ScheduleAsNew, SetDeck, SetDueDate, @@ -56,6 +57,7 @@ impl Op { Op::SortCards => TR::BrowsingReschedule, Op::RenameTag => TR::ActionsRenameTag, Op::RemoveTag => TR::ActionsRemoveTag, + Op::ReparentTag => TR::UndoReparent, }; i18n.tr(key).to_string() diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index 434c625b0..d8a28b630 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -159,21 +159,6 @@ impl super::SqliteStorage { Ok(seen) } - pub(crate) fn for_each_note_tags(&self, mut func: F) -> Result<()> - where - F: FnMut(NoteID, String) -> Result<()>, - { - let mut stmt = self.db.prepare_cached("select id, tags from notes")?; - let mut rows = stmt.query(NO_PARAMS)?; - while let Some(row) = rows.next()? { - let id: NoteID = row.get(0)?; - let tags: String = row.get(1)?; - func(id, tags)? - } - - Ok(()) - } - pub(crate) fn get_note_tags_by_id(&mut self, note_id: NoteID) -> Result> { self.db .prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))? diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index 097da8f31..a84531c56 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -93,14 +93,6 @@ impl SqliteStorage { Ok(()) } - pub(crate) fn clear_tag_and_children(&self, tag: &str) -> Result<()> { - self.db - .prepare_cached("delete from tags where tag regexp ?")? - .execute(&[format!("(?i)^{}($|::)", regex::escape(tag))])?; - - Ok(()) - } - pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> { self.db .prepare_cached("update tags set collapsed = ? where tag = ?")? diff --git a/rslib/src/tags/dragdrop.rs b/rslib/src/tags/dragdrop.rs deleted file mode 100644 index f541b31b6..000000000 --- a/rslib/src/tags/dragdrop.rs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use regex::{NoExpand, Regex, Replacer}; - -use super::split_tags; -use crate::{notes::TransformNoteOutput, prelude::*}; - -impl Collection { - pub fn drag_drop_tags( - &mut self, - source_tags: &[String], - target_tag: Option, - ) -> Result<()> { - let source_tags_and_outputs: Vec<_> = source_tags - .iter() - // generate resulting names and filter out invalid ones - .flat_map(|source_tag| { - if let Some(output_name) = drag_drop_tag_name(source_tag, target_tag.as_deref()) { - Some((source_tag, output_name)) - } else { - // invalid rename, ignore this tag - None - } - }) - .collect(); - - let regexps_and_replacements = source_tags_and_outputs - .iter() - // convert the names into regexps/replacements - .map(|(tag, output)| { - regex_matching_tag_and_children_in_single_tag(tag).map(|regex| (regex, output)) - }) - .collect::>>()?; - - // locate notes that match them - let mut nids = vec![]; - self.storage.for_each_note_tags(|nid, tags| { - for tag in split_tags(&tags) { - for (regex, _) in ®exps_and_replacements { - if regex.is_match(&tag) { - nids.push(nid); - break; - } - } - } - - Ok(()) - })?; - - if nids.is_empty() { - return Ok(()); - } - - // update notes - self.transact_no_undo(|col| { - // clear the existing original tags - for (source_tag, _) in &source_tags_and_outputs { - col.storage.clear_tag_and_children(source_tag)?; - } - - col.transform_notes(&nids, |note, _nt| { - let mut changed = false; - for (re, repl) in ®exps_and_replacements { - if note.replace_tags(re, NoExpand(&repl).by_ref()) { - changed = true; - } - } - - Ok(TransformNoteOutput { - changed, - generate_cards: false, - mark_modified: true, - }) - }) - })?; - - Ok(()) - } -} - -/// Arguments are expected in 'human' form with an :: separator. -pub(crate) fn drag_drop_tag_name(dragged: &str, dropped: Option<&str>) -> Option { - let dragged_base = dragged.rsplit("::").next().unwrap(); - if let Some(dropped) = dropped { - if dropped.starts_with(dragged) { - // foo onto foo::bar, or foo onto itself -> no-op - None - } else { - // foo::bar onto baz -> baz::bar - Some(format!("{}::{}", dropped, dragged_base)) - } - } else { - // foo::bar onto top level -> bar - Some(dragged_base.into()) - } -} - -/// A regex that will match a string tag that has been split from a list. -fn regex_matching_tag_and_children_in_single_tag(tag: &str) -> Result { - Regex::new(&format!( - r#"(?ix) - ^ - {} - # optional children - (::.+)? - $ - "#, - regex::escape(tag) - )) - .map_err(Into::into) -} - -#[cfg(test)] -mod test { - use super::*; - use crate::collection::open_test_collection; - - fn alltags(col: &Collection) -> Vec { - col.storage - .all_tags() - .unwrap() - .into_iter() - .map(|t| t.name) - .collect() - } - - #[test] - fn dragdrop() -> Result<()> { - let mut col = open_test_collection(); - let nt = col.get_notetype_by_name("Basic")?.unwrap(); - for tag in &[ - "another", - "parent1::child1::grandchild1", - "parent1::child1", - "parent1", - "parent2", - "yet::another", - ] { - let mut note = nt.new_note(); - note.tags.push(tag.to_string()); - col.add_note(&mut note, DeckID(1))?; - } - - // two decks with the same base name; they both get mapped - // to parent1::another - col.drag_drop_tags( - &["another".to_string(), "yet::another".to_string()], - Some("parent1".to_string()), - )?; - - assert_eq!( - alltags(&col), - &[ - "parent1", - "parent1::another", - "parent1::child1", - "parent1::child1::grandchild1", - "parent2", - ] - ); - - // child and children moved to parent2 - col.drag_drop_tags( - &["parent1::child1".to_string()], - Some("parent2".to_string()), - )?; - - assert_eq!( - alltags(&col), - &[ - "parent1", - "parent1::another", - "parent2", - "parent2::child1", - "parent2::child1::grandchild1", - ] - ); - - // empty target reparents to root - col.drag_drop_tags(&["parent1::another".to_string()], None)?; - - assert_eq!( - alltags(&col), - &[ - "another", - "parent1", - "parent2", - "parent2::child1", - "parent2::child1::grandchild1", - ] - ); - - Ok(()) - } -} diff --git a/rslib/src/tags/prefix_replacer.rs b/rslib/src/tags/matcher.rs similarity index 57% rename from rslib/src/tags/prefix_replacer.rs rename to rslib/src/tags/matcher.rs index e2edb4369..e093a726e 100644 --- a/rslib/src/tags/prefix_replacer.rs +++ b/rslib/src/tags/matcher.rs @@ -6,16 +6,16 @@ use std::{borrow::Cow, collections::HashSet}; use super::{join_tags, split_tags}; use crate::prelude::*; -pub(crate) struct PrefixReplacer { +pub(crate) struct TagMatcher { regex: Regex, - seen_tags: HashSet, + new_tags: HashSet, } /// Helper to match any of the provided space-separated tags in a space- /// separated list of tags, and replace the prefix. /// /// Tracks seen tags during replacement, so the tag list can be updated as well. -impl PrefixReplacer { +impl TagMatcher { pub fn new(space_separated_tags: &str) -> Result { // convert "fo*o bar" into "fo\*o|bar" let tags: Vec<_> = split_tags(space_separated_tags) @@ -43,7 +43,7 @@ impl PrefixReplacer { Ok(Self { regex, - seen_tags: HashSet::new(), + new_tags: HashSet::new(), }) } @@ -54,25 +54,54 @@ impl PrefixReplacer { pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String { let tags: Vec<_> = split_tags(space_separated_tags) .map(|tag| { - self.regex - .replace(tag, |caps: &Captures| { - // if we captured the child separator, add it to the replacement - if caps.get(2).is_some() { - Cow::Owned(format!("{}::", replacement)) - } else { - Cow::Borrowed(replacement) - } - }) - .to_string() + let out = self.regex.replace(tag, |caps: &Captures| { + // if we captured the child separator, add it to the replacement + if caps.get(2).is_some() { + Cow::Owned(format!("{}::", replacement)) + } else { + Cow::Borrowed(replacement) + } + }); + if let Cow::Owned(out) = out { + if !self.new_tags.contains(&out) { + self.new_tags.insert(out.clone()); + } + out + } else { + out.to_string() + } }) .collect(); - for tag in &tags { - // sadly HashSet doesn't have an entry API at the moment - if !self.seen_tags.contains(tag) { - self.seen_tags.insert(tag.clone()); - } - } + join_tags(tags.as_slice()) + } + + /// The `replacement` function should return the text to use as a replacement. + pub fn replace_with_fn(&mut self, space_separated_tags: &str, replacer: F) -> String + where + F: Fn(&str) -> String, + { + let tags: Vec<_> = split_tags(space_separated_tags) + .map(|tag| { + let out = self.regex.replace(tag, |caps: &Captures| { + let replacement = replacer(caps.get(1).unwrap().as_str()); + // if we captured the child separator, add it to the replacement + if caps.get(2).is_some() { + format!("{}::", replacement) + } else { + replacement + } + }); + if let Cow::Owned(out) = out { + if !self.new_tags.contains(&out) { + self.new_tags.insert(out.clone()); + } + out + } else { + out.to_string() + } + }) + .collect(); join_tags(tags.as_slice()) } @@ -87,8 +116,10 @@ impl PrefixReplacer { join_tags(tags.as_slice()) } - pub fn into_seen_tags(self) -> HashSet { - self.seen_tags + /// Returns all replaced values that were used, so they can be registered + /// into the tag list. + pub fn into_new_tags(self) -> HashSet { + self.new_tags } } @@ -98,12 +129,12 @@ mod test { #[test] fn regex() -> Result<()> { - let re = PrefixReplacer::new("one two")?; + let re = TagMatcher::new("one two")?; assert_eq!(re.is_match(" foo "), false); assert_eq!(re.is_match(" foo one "), true); assert_eq!(re.is_match(" two foo "), true); - let mut re = PrefixReplacer::new("foo")?; + let mut re = TagMatcher::new("foo")?; assert_eq!(re.is_match("foo"), true); assert_eq!(re.is_match(" foo "), true); assert_eq!(re.is_match(" bar foo baz "), true); diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index 836a0981e..127b2c4ea 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -2,12 +2,12 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod bulkadd; -mod dragdrop; mod findreplace; -mod prefix_replacer; +mod matcher; mod register; mod remove; mod rename; +mod reparent; mod tree; pub(crate) mod undo; diff --git a/rslib/src/tags/remove.rs b/rslib/src/tags/remove.rs index 1bcaaf5db..b56e73d27 100644 --- a/rslib/src/tags/remove.rs +++ b/rslib/src/tags/remove.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; +use super::matcher::TagMatcher; use crate::prelude::*; impl Collection { @@ -32,7 +32,7 @@ impl Collection { let usn = self.usn()?; // gather tags that need removing - let mut re = PrefixReplacer::new(tags)?; + let mut re = TagMatcher::new(tags)?; let matched_notes = self .storage .get_note_tags_by_predicate(|tags| re.is_match(tags))?; @@ -57,7 +57,7 @@ impl Collection { fn remove_tags_from_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result { let usn = self.usn()?; - let mut re = PrefixReplacer::new(tags)?; + let mut re = TagMatcher::new(tags)?; let mut match_count = 0; let notes = self.storage.get_note_tags_by_id_list(nids)?; diff --git a/rslib/src/tags/rename.rs b/rslib/src/tags/rename.rs index b33b99716..0547d6190 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::{is_tag_separator, prefix_replacer::PrefixReplacer, Tag}; +use super::{is_tag_separator, matcher::TagMatcher, Tag}; use crate::prelude::*; impl Collection { @@ -35,7 +35,7 @@ impl Collection { let new_prefix = &tag.name; // gather tags that need replacing - let mut re = PrefixReplacer::new(old_prefix)?; + let mut re = TagMatcher::new(old_prefix)?; let matched_notes = self .storage .get_note_tags_by_predicate(|tags| re.is_match(tags))?; @@ -59,7 +59,7 @@ impl Collection { } // update tag list - for tag in re.into_seen_tags() { + for tag in re.into_new_tags() { self.register_tag_string(tag, usn)?; } diff --git a/rslib/src/tags/reparent.rs b/rslib/src/tags/reparent.rs new file mode 100644 index 000000000..915b6d1ee --- /dev/null +++ b/rslib/src/tags/reparent.rs @@ -0,0 +1,195 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::collections::HashMap; + +use super::{join_tags, matcher::TagMatcher}; +use crate::prelude::*; + +impl Collection { + /// Reparent the provided tags under a new parent. + /// + /// Parents of the provided tags are left alone - only the final component + /// and its children are moved. If a source tag is the parent of the target + /// tag, it will remain unchanged. If `new_parent` is not provided, tags + /// will be reparented to the root element. When reparenting tags, any + /// children they have are reparented as well. + /// + /// For example: + /// - foo, bar -> bar::foo + /// - foo::bar, baz -> baz::bar + /// - foo, foo::bar -> no action + /// - foo::bar, none -> bar + pub fn reparent_tags( + &mut self, + tags_to_reparent: &[String], + new_parent: Option, + ) -> Result> { + self.transact(Op::ReparentTag, |col| { + col.reparent_tags_inner(tags_to_reparent, new_parent) + }) + } + + pub fn reparent_tags_inner( + &mut self, + tags_to_reparent: &[String], + new_parent: Option, + ) -> Result { + let usn = self.usn()?; + let mut matcher = TagMatcher::new(&join_tags(tags_to_reparent))?; + let old_to_new_names = old_to_new_names(tags_to_reparent, new_parent); + + let matched_notes = self + .storage + .get_note_tags_by_predicate(|tags| matcher.is_match(tags))?; + let match_count = matched_notes.len(); + if match_count == 0 { + // no matches; exit early so we don't clobber the empty tag entries + return Ok(0); + } + + // remove old prefixes from the tag list + for tag in self + .storage + .get_tags_by_predicate(|tag| matcher.is_match(tag))? + { + self.remove_single_tag_undoable(tag)?; + } + + // replace tags + for mut note in matched_notes { + let original = note.clone(); + note.tags = matcher + .replace_with_fn(¬e.tags, |cap| old_to_new_names.get(cap).unwrap().clone()); + note.set_modified(usn); + self.update_note_tags_undoable(¬e, original)?; + } + + // update tag list + for tag in matcher.into_new_tags() { + self.register_tag_string(tag, usn)?; + } + + Ok(match_count) + } +} + +fn old_to_new_names( + tags_to_reparent: &[String], + new_parent: Option, +) -> HashMap<&str, String> { + tags_to_reparent + .iter() + // generate resulting names and filter out invalid ones + .flat_map(|source_tag| { + if let Some(output_name) = reparented_name(source_tag, new_parent.as_deref()) { + Some((source_tag.as_str(), output_name)) + } else { + // invalid rename, ignore this tag + None + } + }) + .collect() +} + +/// Arguments are expected in 'human' form with a :: separator. +/// Returns None if new parent is a child of the tag to be reparented. +fn reparented_name(existing_name: &str, new_parent: Option<&str>) -> Option { + let existing_base = existing_name.rsplit("::").next().unwrap(); + if let Some(new_parent) = new_parent { + if new_parent.starts_with(existing_name) { + // foo onto foo::bar, or foo onto itself -> no-op + None + } else { + // foo::bar onto baz -> baz::bar + Some(format!("{}::{}", new_parent, existing_base)) + } + } else { + // foo::bar onto top level -> bar + Some(existing_base.into()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::collection::open_test_collection; + + fn alltags(col: &Collection) -> Vec { + col.storage + .all_tags() + .unwrap() + .into_iter() + .map(|t| t.name) + .collect() + } + + #[test] + fn dragdrop() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + for tag in &[ + "another", + "parent1::child1::grandchild1", + "parent1::child1", + "parent1", + "parent2", + "yet::another", + ] { + let mut note = nt.new_note(); + note.tags.push(tag.to_string()); + col.add_note(&mut note, DeckID(1))?; + } + + // two decks with the same base name; they both get mapped + // to parent1::another + col.reparent_tags( + &["another".to_string(), "yet::another".to_string()], + Some("parent1".to_string()), + )?; + + assert_eq!( + alltags(&col), + &[ + "parent1", + "parent1::another", + "parent1::child1", + "parent1::child1::grandchild1", + "parent2", + ] + ); + + // child and children moved to parent2 + col.reparent_tags( + &["parent1::child1".to_string()], + Some("parent2".to_string()), + )?; + + assert_eq!( + alltags(&col), + &[ + "parent1", + "parent1::another", + "parent2", + "parent2::child1", + "parent2::child1::grandchild1", + ] + ); + + // empty target reparents to root + col.reparent_tags(&["parent1::another".to_string()], None)?; + + assert_eq!( + alltags(&col), + &[ + "another", + "parent1", + "parent2", + "parent2::child1", + "parent2::child1::grandchild1", + ] + ); + + Ok(()) + } +}