add tag drag & drop support

This commit is contained in:
Damien Elmes 2021-02-02 20:14:04 +10:00
parent dd54c10e71
commit a50601ed46
6 changed files with 243 additions and 0 deletions

View file

@ -99,6 +99,11 @@ class TagManager:
def remove(self, tag: str) -> None:
self.col._backend.clear_tag(tag)
def drag_drop(self, source_tags: List[str], target_tag: str) -> None:
"""Rename one or more source tags that were dropped on `target_tag`.
If target_tag is "", tags will be placed at the top level."""
self.col._backend.drag_drop_tags(source_tags=source_tags, target_tag=target_tag)
# legacy routines
def bulkAdd(self, ids: List[int], tags: str, add: bool = True) -> None:

View file

@ -222,6 +222,8 @@ class SidebarModel(QAbstractItemModel):
if item.item_type in (
SidebarItemType.DECK,
SidebarItemType.DECK_ROOT,
SidebarItemType.TAG,
SidebarItemType.TAG_ROOT,
):
flags |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
@ -382,6 +384,8 @@ class SidebarTreeView(QTreeView):
def handle_drag_drop(self, sources: List[SidebarItem], target: SidebarItem) -> bool:
if target.item_type in (SidebarItemType.DECK, SidebarItemType.DECK_ROOT):
return self._handle_drag_drop_decks(sources, target)
if target.item_type in (SidebarItemType.TAG, SidebarItemType.TAG_ROOT):
return self._handle_drag_drop_tags(sources, target)
return False
def _handle_drag_drop_decks(
@ -402,6 +406,31 @@ class SidebarTreeView(QTreeView):
)
return True
def _handle_drag_drop_tags(
self, sources: List[SidebarItem], target: SidebarItem
) -> bool:
source_ids = [
source.full_name
for source in sources
if source.item_type == SidebarItemType.TAG
]
if not source_ids:
return False
def on_done(fut: Future) -> None:
fut.result()
self.refresh()
if target.item_type == SidebarItemType.TAG_ROOT:
target_name = ""
else:
target_name = target.full_name
self.mw.taskman.with_progress(
lambda: self.col.tags.drag_drop(source_ids, target_name), on_done
)
return True
def onClickCurrent(self) -> None:
idx = self.currentIndex()
if item := self.model().item_for_index(idx):

View file

@ -220,6 +220,7 @@ service BackendService {
rpc SetTagExpanded(SetTagExpandedIn) returns (Empty);
rpc ClearTag(String) returns (Empty);
rpc TagTree(Empty) returns (TagTreeNode);
rpc DragDropTags(DragDropTagsIn) returns (Empty);
// config
@ -862,6 +863,11 @@ message TagTreeNode {
bool expanded = 4;
}
message DragDropTagsIn {
repeated string source_tags = 1;
string target_tag = 2;
}
message SetConfigJsonIn {
string key = 1;
bytes value_json = 2;

View file

@ -1438,6 +1438,17 @@ impl BackendService for Backend {
self.with_col(|col| col.tag_tree())
}
fn drag_drop_tags(&self, input: pb::DragDropTagsIn) -> BackendResult<Empty> {
let source_tags = input.source_tags;
let target_tag = if input.target_tag.is_empty() {
None
} else {
Some(input.target_tag)
};
self.with_col(|col| col.drag_drop_tags(&source_tags, target_tag))
.map(Into::into)
}
// config/preferences
//-------------------------------------------------------------------

View file

@ -174,4 +174,19 @@ 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(())
}
}

View file

@ -90,6 +90,23 @@ fn immediate_parent_name_str(tag_name: &str) -> Option<&str> {
tag_name.rsplitn(2, "::").nth(1)
}
/// Arguments are expected in 'human' form with an :: separator.
pub(crate) fn drag_drop_tag_name(dragged: &str, dropped: Option<&str>) -> Option<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())
}
}
/// For the given tag, check if immediate parent exists. If so, add
/// tag and return.
/// If the immediate parent is missing, check and add any missing parents.
@ -363,6 +380,88 @@ impl Collection {
})
})
}
pub fn drag_drop_tags(
&mut self,
source_tags: &[String],
target_tag: Option<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::new(&format!(
r#"(?ix)
^
{}
# optional children
(::.+)?
$
"#,
regex::escape(tag)
))
.map_err(|_| AnkiError::invalid_input("invalid regex"))
.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(None, |col| {
// clear the existing original tags
for (source_tag, _) in &source_tags_and_outputs {
col.storage.clear_tag(source_tag)?;
}
col.transform_notes(&nids, |note, _nt| {
let mut changed = false;
for (re, repl) in &regexps_and_replacements {
if note.replace_tags(re, NoExpand(&repl).by_ref()) {
changed = true;
}
}
Ok(TransformNoteOutput {
changed,
generate_cards: false,
mark_modified: true,
})
})
})?;
Ok(())
}
}
#[cfg(test)]
@ -595,4 +694,82 @@ mod test {
Ok(())
}
fn alltags(col: &Collection) -> Vec<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(())
}
}