diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 2eab6a3c8..ec12cbf37 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -99,6 +99,11 @@ class TagManager: def remove(self, tag: str) -> None: self.col._backend.clear_tag(tag) + 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) + # legacy routines def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None: diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 85b9614d0..ca1b6be3b 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -222,6 +222,8 @@ class SidebarModel(QAbstractItemModel): if item.item_type in ( SidebarItemType.DECK, SidebarItemType.DECK_ROOT, + SidebarItemType.TAG, + SidebarItemType.TAG_ROOT, ): flags |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled @@ -382,6 +384,8 @@ class SidebarTreeView(QTreeView): def handle_drag_drop(self, sources: List[SidebarItem], target: SidebarItem) -> bool: if target.item_type in (SidebarItemType.DECK, SidebarItemType.DECK_ROOT): return self._handle_drag_drop_decks(sources, target) + if target.item_type in (SidebarItemType.TAG, SidebarItemType.TAG_ROOT): + return self._handle_drag_drop_tags(sources, target) return False def _handle_drag_drop_decks( @@ -402,6 +406,31 @@ class SidebarTreeView(QTreeView): ) return True + def _handle_drag_drop_tags( + self, sources: List[SidebarItem], target: SidebarItem + ) -> bool: + source_ids = [ + source.full_name + for source in sources + if source.item_type == SidebarItemType.TAG + ] + if not source_ids: + return False + + def on_done(fut: Future) -> None: + fut.result() + self.refresh() + + if target.item_type == SidebarItemType.TAG_ROOT: + target_name = "" + else: + target_name = target.full_name + + self.mw.taskman.with_progress( + lambda: self.col.tags.drag_drop(source_ids, target_name), on_done + ) + return True + def onClickCurrent(self) -> None: idx = self.currentIndex() if item := self.model().item_for_index(idx): diff --git a/rslib/backend.proto b/rslib/backend.proto index 196add3a3..5a450712f 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -220,6 +220,7 @@ service BackendService { rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc ClearTag(String) returns (Empty); rpc TagTree(Empty) returns (TagTreeNode); + rpc DragDropTags(DragDropTagsIn) returns (Empty); // config @@ -862,6 +863,11 @@ message TagTreeNode { bool expanded = 4; } +message DragDropTagsIn { + repeated string source_tags = 1; + string target_tag = 2; +} + message SetConfigJsonIn { string key = 1; bytes value_json = 2; diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index cce55d3d1..0a4040d5a 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -1438,6 +1438,17 @@ impl BackendService for Backend { self.with_col(|col| col.tag_tree()) } + fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> BackendResult { + let source_tags = input.source_tags; + let target_tag = if input.target_tag.is_empty() { + None + } else { + Some(input.target_tag) + }; + self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag)) + .map(Into::into) + } + // config/preferences //------------------------------------------------------------------- diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index f4bca28c6..c1a6e5aab 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -174,4 +174,19 @@ 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(()) + } } diff --git a/rslib/src/tags.rs b/rslib/src/tags.rs index 87f50438b..2a91013b9 100644 --- a/rslib/src/tags.rs +++ b/rslib/src/tags.rs @@ -90,6 +90,23 @@ fn immediate_parent_name_str(tag_name: &str) -> Option<&str> { tag_name.rsplitn(2, "::").nth(1) } +/// 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()) + } +} + /// For the given tag, check if immediate parent exists. If so, add /// tag and return. /// If the immediate parent is missing, check and add any missing parents. @@ -363,6 +380,88 @@ 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::new(&format!( + r#"(?ix) + ^ + {} + # optional children + (::.+)? + $ + "#, + regex::escape(tag) + )) + .map_err(|_| AnkiError::invalid_input("invalid regex")) + .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(None, |col| { + // clear the existing original tags + for (source_tag, _) in &source_tags_and_outputs { + col.storage.clear_tag(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(()) + } } #[cfg(test)] @@ -595,4 +694,82 @@ mod test { Ok(()) } + + 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(()) + } }