mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 15:32: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-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
|
||||||
|
|
|
@ -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
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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
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
|
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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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: ®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.
|
/// 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() {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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")))?
|
||||||
|
|
|
@ -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 = ?")?
|
||||||
|
|
|
@ -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 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);
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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)?;
|
||||||
|
|
||||||
|
|
|
@ -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
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