mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 07:22:23 -04:00
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:
parent
9c2bff5b6d
commit
4c61c92806
20 changed files with 377 additions and 387 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
##########################################################################
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
88
qt/aqt/tag_ops.py
Normal 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
|
||||
),
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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: ®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() {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")))?
|
||||
|
|
|
@ -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 = ?")?
|
||||
|
|
|
@ -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 ®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<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(())
|
||||
}
|
||||
}
|
|
@ -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| {
|
||||
// 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<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);
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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)?;
|
||||
|
||||
|
|
|
@ -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
195
rslib/src/tags/reparent.rs
Normal 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(¬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<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(())
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue