mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
add tag drag & drop support
This commit is contained in:
parent
dd54c10e71
commit
a50601ed46
6 changed files with 243 additions and 0 deletions
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
//-------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ®exps_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 ®exps_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(())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue