speed up tag drag&drop and finish tag tidyup

approx 4x speedup when reparenting 10-15 tags and their children at once
This commit is contained in:
Damien Elmes 2021-03-19 19:15:17 +10:00
parent 9c2bff5b6d
commit 4c61c92806
20 changed files with 377 additions and 387 deletions

View file

@ -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

View file

@ -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
##########################################################################

View file

@ -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,

View file

@ -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
),
)

View file

@ -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 (

View file

@ -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:

88
qt/aqt/tag_ops.py Normal file
View file

@ -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
),
)

View file

@ -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

View file

@ -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 {

View file

@ -40,14 +40,14 @@ impl TagsService for Backend {
self.with_col(|col| col.tag_tree())
}
fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> Result<pb::Empty> {
let source_tags = input.source_tags;
let target_tag = if input.target_tag.is_empty() {
fn reparent_tags(&self, input: pb::ReparentTagsIn) -> Result<pb::OpChangesWithCount> {
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)
}

View file

@ -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<T: Replacer>(&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: &regex::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() {

View file

@ -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()

View file

@ -159,21 +159,6 @@ impl super::SqliteStorage {
Ok(seen)
}
pub(crate) fn for_each_note_tags<F>(&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<Option<NoteTags>> {
self.db
.prepare_cached(&format!("{} where id = ?", include_str!("get_tags.sql")))?

View file

@ -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 = ?")?

View file

@ -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<String>,
) -> 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::<Result<Vec<_>>>()?;
// 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 &regexps_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 &regexps_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<String> {
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> {
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<String> {
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(())
}
}

View file

@ -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<String>,
new_tags: HashSet<String>,
}
/// 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<Self> {
// 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| {
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)
}
})
.to_string()
});
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<F>(&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<String> {
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<String> {
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);

View file

@ -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;

View file

@ -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<usize> {
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)?;

View file

@ -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)?;
}

195
rslib/src/tags/reparent.rs Normal file
View file

@ -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<String>,
) -> Result<OpOutput<usize>> {
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<String>,
) -> Result<usize> {
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(&note.tags, |cap| old_to_new_names.get(cap).unwrap().clone());
note.set_modified(usn);
self.update_note_tags_undoable(&note, 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<String>,
) -> 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<String> {
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<String> {
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(())
}
}