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-update-deck = Update Deck
undo-forget-card = Forget Card undo-forget-card = Forget Card
undo-set-flag = Set Flag 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." "Remove the provided tag(s) and their children from notes and the tag list."
return self.col._backend.remove_tags(val=space_separated_tags) return self.col._backend.remove_tags(val=space_separated_tags)
def drag_drop(self, source_tags: List[str], target_tag: str) -> None: def reparent(self, tags: Sequence[str], new_parent: str) -> OpChangesWithCount:
"""Rename one or more source tags that were dropped on `target_tag`. """Change the parent of the provided tags.
If target_tag is "", tags will be placed at the top level.""" If new_parent is empty, tags will be reparented to the top-level."""
self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag) return self.col._backend.reparent_tags(tags=tags, new_parent=new_parent)
# String-based utilities # String-based utilities
########################################################################## ##########################################################################

View file

@ -26,12 +26,7 @@ from aqt.editor import Editor
from aqt.exporting import ExportDialog from aqt.exporting import ExportDialog
from aqt.find_and_replace import FindAndReplaceDialog 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 remove_notes
add_tags,
clear_unused_tags,
remove_notes,
remove_tags_for_notes,
)
from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import BrowserPreviewer as PreviewDialog
from aqt.previewer import Previewer from aqt.previewer import Previewer
from aqt.qt import * from aqt.qt import *
@ -43,6 +38,7 @@ from aqt.scheduling_ops import (
unsuspend_cards, unsuspend_cards,
) )
from aqt.sidebar import SidebarTreeView 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.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,

View file

@ -5,12 +5,9 @@ from __future__ import annotations
from typing import Callable, Sequence from typing import Callable, Sequence
from anki.collection import OpChangesWithCount
from anki.lang import TR
from anki.notes import Note from anki.notes import Note
from aqt import AnkiQt, QWidget from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback from aqt.main import PerformOpOptionalSuccessCallback
from aqt.utils import showInfo, tooltip, tr
def add_note( def add_note(
@ -37,68 +34,3 @@ def remove_notes(
success: PerformOpOptionalSuccessCallback = None, success: PerformOpOptionalSuccessCallback = None,
) -> None: ) -> None:
mw.perform_op(lambda: mw.col.remove_notes(note_ids), success=success) 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 anki.utils import stripHTML
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.card_ops import set_card_flag 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.profiles import VideoDriver
from aqt.qt import * from aqt.qt import *
from aqt.scheduling_ops import ( from aqt.scheduling_ops import (
@ -30,6 +30,7 @@ from aqt.scheduling_ops import (
suspend_note, suspend_note,
) )
from aqt.sound import av_player, play_clicked_audio, record_audio 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.theme import theme_manager
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
from aqt.utils import ( from aqt.utils import (

View file

@ -17,10 +17,9 @@ from anki.types import assert_exhaustive
from aqt import colors, gui_hooks from aqt import colors, gui_hooks
from aqt.clayout import CardLayout from aqt.clayout import CardLayout
from aqt.deck_ops import remove_decks from aqt.deck_ops import remove_decks
from aqt.main import ResetReason
from aqt.models import Models from aqt.models import Models
from aqt.note_ops import remove_tags_for_all_notes, rename_tag
from aqt.qt import * 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.theme import ColoredIcon, theme_manager
from aqt.utils import ( from aqt.utils import (
TR, TR,
@ -634,33 +633,21 @@ class SidebarTreeView(QTreeView):
def _handle_drag_drop_tags( def _handle_drag_drop_tags(
self, sources: List[SidebarItem], target: SidebarItem self, sources: List[SidebarItem], target: SidebarItem
) -> bool: ) -> bool:
source_ids = [ tags = [
source.full_name source.full_name
for source in sources for source in sources
if source.item_type == SidebarItemType.TAG if source.item_type == SidebarItemType.TAG
] ]
if not source_ids: if not tags:
return False 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: if target.item_type == SidebarItemType.TAG_ROOT:
target_name = "" new_parent = ""
else: else:
target_name = target.full_name new_parent = target.full_name
def on_save() -> None: reparent_tags(mw=self.mw, parent=self.browser, tags=tags, new_parent=new_parent)
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
)
self.browser.editor.call_after_note_saved(on_save)
return True return True
def _on_search(self, index: QModelIndex) -> None: 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 no_strict_optional = false
[mypy-aqt.find_and_replace] [mypy-aqt.find_and_replace]
no_strict_optional = false no_strict_optional = false
[mypy-aqt.tag_ops]
no_strict_optional = false
[mypy-aqt.winpaths] [mypy-aqt.winpaths]
disallow_untyped_defs=false disallow_untyped_defs=false

View file

@ -220,7 +220,7 @@ service TagsService {
rpc RemoveTags(String) returns (OpChangesWithCount); rpc RemoveTags(String) returns (OpChangesWithCount);
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty); rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode); rpc TagTree(Empty) returns (TagTreeNode);
rpc DragDropTags(DragDropTagsIn) returns (Empty); rpc ReparentTags(ReparentTagsIn) returns (OpChangesWithCount);
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);
@ -926,9 +926,9 @@ message TagTreeNode {
bool expanded = 4; bool expanded = 4;
} }
message DragDropTagsIn { message ReparentTagsIn {
repeated string source_tags = 1; repeated string tags = 1;
string target_tag = 2; string new_parent = 2;
} }
message RenameTagsIn { message RenameTagsIn {

View file

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

View file

@ -20,7 +20,6 @@ use crate::{
}; };
use itertools::Itertools; use itertools::Itertools;
use num_integer::Integer; use num_integer::Integer;
use regex::{Regex, Replacer};
use std::{ use std::{
borrow::Cow, borrow::Cow,
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@ -210,32 +209,6 @@ impl Note {
.collect() .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. /// Pad or merge fields to match note type.
pub(crate) fn fix_field_count(&mut self, nt: &NoteType) { pub(crate) fn fix_field_count(&mut self, nt: &NoteType) {
while self.fields.len() < nt.fields.len() { while self.fields.len() < nt.fields.len() {

View file

@ -13,9 +13,10 @@ pub enum Op {
FindAndReplace, FindAndReplace,
RemoveDeck, RemoveDeck,
RemoveNote, RemoveNote,
RemoveTag,
RenameDeck, RenameDeck,
RenameTag, RenameTag,
RemoveTag, ReparentTag,
ScheduleAsNew, ScheduleAsNew,
SetDeck, SetDeck,
SetDueDate, SetDueDate,
@ -56,6 +57,7 @@ impl Op {
Op::SortCards => TR::BrowsingReschedule, Op::SortCards => TR::BrowsingReschedule,
Op::RenameTag => TR::ActionsRenameTag, Op::RenameTag => TR::ActionsRenameTag,
Op::RemoveTag => TR::ActionsRemoveTag, Op::RemoveTag => TR::ActionsRemoveTag,
Op::ReparentTag => TR::UndoReparent,
}; };
i18n.tr(key).to_string() i18n.tr(key).to_string()

View file

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

View file

@ -93,14 +93,6 @@ impl SqliteStorage {
Ok(()) 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<()> { pub(crate) fn set_tag_collapsed(&self, tag: &str, collapsed: bool) -> Result<()> {
self.db self.db
.prepare_cached("update tags set collapsed = ? where tag = ?")? .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 super::{join_tags, split_tags};
use crate::prelude::*; use crate::prelude::*;
pub(crate) struct PrefixReplacer { pub(crate) struct TagMatcher {
regex: Regex, regex: Regex,
seen_tags: HashSet<String>, new_tags: HashSet<String>,
} }
/// Helper to match any of the provided space-separated tags in a space- /// Helper to match any of the provided space-separated tags in a space-
/// separated list of tags, and replace the prefix. /// separated list of tags, and replace the prefix.
/// ///
/// Tracks seen tags during replacement, so the tag list can be updated as well. /// 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> { pub fn new(space_separated_tags: &str) -> Result<Self> {
// convert "fo*o bar" into "fo\*o|bar" // convert "fo*o bar" into "fo\*o|bar"
let tags: Vec<_> = split_tags(space_separated_tags) let tags: Vec<_> = split_tags(space_separated_tags)
@ -43,7 +43,7 @@ impl PrefixReplacer {
Ok(Self { Ok(Self {
regex, 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 { pub fn replace(&mut self, space_separated_tags: &str, replacement: &str) -> String {
let tags: Vec<_> = split_tags(space_separated_tags) let tags: Vec<_> = split_tags(space_separated_tags)
.map(|tag| { .map(|tag| {
self.regex let out = self.regex.replace(tag, |caps: &Captures| {
.replace(tag, |caps: &Captures| {
// if we captured the child separator, add it to the replacement // if we captured the child separator, add it to the replacement
if caps.get(2).is_some() { if caps.get(2).is_some() {
Cow::Owned(format!("{}::", replacement)) Cow::Owned(format!("{}::", replacement))
} else { } else {
Cow::Borrowed(replacement) 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(); .collect();
for tag in &tags { join_tags(tags.as_slice())
// sadly HashSet doesn't have an entry API at the moment
if !self.seen_tags.contains(tag) {
self.seen_tags.insert(tag.clone());
} }
/// 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()) join_tags(tags.as_slice())
} }
@ -87,8 +116,10 @@ impl PrefixReplacer {
join_tags(tags.as_slice()) join_tags(tags.as_slice())
} }
pub fn into_seen_tags(self) -> HashSet<String> { /// Returns all replaced values that were used, so they can be registered
self.seen_tags /// into the tag list.
pub fn into_new_tags(self) -> HashSet<String> {
self.new_tags
} }
} }
@ -98,12 +129,12 @@ mod test {
#[test] #[test]
fn regex() -> Result<()> { 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 "), false);
assert_eq!(re.is_match(" foo one "), true); assert_eq!(re.is_match(" foo one "), true);
assert_eq!(re.is_match(" two foo "), 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(" foo "), true); assert_eq!(re.is_match(" foo "), true);
assert_eq!(re.is_match(" bar foo baz "), 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 // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod bulkadd; mod bulkadd;
mod dragdrop;
mod findreplace; mod findreplace;
mod prefix_replacer; mod matcher;
mod register; mod register;
mod remove; mod remove;
mod rename; mod rename;
mod reparent;
mod tree; mod tree;
pub(crate) mod undo; pub(crate) mod undo;

View file

@ -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; use super::matcher::TagMatcher;
use crate::prelude::*; use crate::prelude::*;
impl Collection { impl Collection {
@ -32,7 +32,7 @@ impl Collection {
let usn = self.usn()?; let usn = self.usn()?;
// gather tags that need removing // gather tags that need removing
let mut re = PrefixReplacer::new(tags)?; let mut re = TagMatcher::new(tags)?;
let matched_notes = self let matched_notes = self
.storage .storage
.get_note_tags_by_predicate(|tags| re.is_match(tags))?; .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> { fn remove_tags_from_notes_inner(&mut self, nids: &[NoteID], tags: &str) -> Result<usize> {
let usn = self.usn()?; let usn = self.usn()?;
let mut re = PrefixReplacer::new(tags)?; let mut re = TagMatcher::new(tags)?;
let mut match_count = 0; let mut match_count = 0;
let notes = self.storage.get_note_tags_by_id_list(nids)?; let notes = self.storage.get_note_tags_by_id_list(nids)?;

View file

@ -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::{is_tag_separator, prefix_replacer::PrefixReplacer, Tag}; use super::{is_tag_separator, matcher::TagMatcher, Tag};
use crate::prelude::*; use crate::prelude::*;
impl Collection { impl Collection {
@ -35,7 +35,7 @@ impl Collection {
let new_prefix = &tag.name; let new_prefix = &tag.name;
// gather tags that need replacing // gather tags that need replacing
let mut re = PrefixReplacer::new(old_prefix)?; let mut re = TagMatcher::new(old_prefix)?;
let matched_notes = self let matched_notes = self
.storage .storage
.get_note_tags_by_predicate(|tags| re.is_match(tags))?; .get_note_tags_by_predicate(|tags| re.is_match(tags))?;
@ -59,7 +59,7 @@ impl Collection {
} }
// update tag list // update tag list
for tag in re.into_seen_tags() { for tag in re.into_new_tags() {
self.register_tag_string(tag, usn)?; 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(())
}
}