diff --git a/proto/anki/tags.proto b/proto/anki/tags.proto index a1597f7d6..c2ff124cb 100644 --- a/proto/anki/tags.proto +++ b/proto/anki/tags.proto @@ -22,6 +22,7 @@ service TagsService { returns (collection.OpChangesWithCount); rpc FindAndReplaceTag(FindAndReplaceTagRequest) returns (collection.OpChangesWithCount); + rpc CompleteTag(CompleteTagRequest) returns (CompleteTagResponse); } message SetTagCollapsedRequest { @@ -58,3 +59,13 @@ message FindAndReplaceTagRequest { bool regex = 4; bool match_case = 5; } + +message CompleteTagRequest { + // a partial tag, optionally delimited with :: + string input = 1; + uint32 match_limit = 2; +} + +message CompleteTagResponse { + repeated string tags = 1; +} diff --git a/pylib/anki/_backend/genbackend.py b/pylib/anki/_backend/genbackend.py index e8ed939d8..9c4e7795f 100755 --- a/pylib/anki/_backend/genbackend.py +++ b/pylib/anki/_backend/genbackend.py @@ -58,10 +58,11 @@ SKIP_UNROLL_INPUT = { "UpdateDeckConfigs", "AnswerCard", "ChangeNotetype", + "CompleteTag", } SKIP_UNROLL_OUTPUT = {"GetPreferences"} -SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo"} +SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo", "CompleteTag"} def python_type(field): diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 757d102b9..c1fd64b2a 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -67,6 +67,11 @@ class TagManager: "Set browser expansion state for tag, registering the tag if missing." return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed) + def complete_tag(self, input_bytes: bytes) -> bytes: + input = tags_pb2.CompleteTagRequest() + input.ParseFromString(input_bytes) + return self.col._backend.complete_tag(input) + # Bulk addition/removal from specific notes ############################################################# diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index ac0f65319..ba8f5a82c 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -226,9 +226,6 @@ class AddCards(QDialog): self.addHistory(note) - # workaround for PyQt focus bug - self.editor.hideCompleters() - tooltip(tr.adding_added(), period=500) av_player.stop_and_clear_queue() self._load_new_note(sticky_fields_from=note) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 6c847b300..6a285ebd4 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -84,6 +84,7 @@ _html = """ %s
+
""" @@ -114,7 +115,6 @@ class Editor: self.setupOuter() self.setupWeb() self.setupShortcuts() - self.setupTags() gui_hooks.editor_did_init(self) # Initial setup @@ -302,9 +302,7 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ def setupShortcuts(self) -> None: # if a third element is provided, enable shortcut even when no field selected - cuts: List[Tuple] = [ - ("Ctrl+Shift+T", self.onFocusTags, True), - ] + cuts: List[Tuple] = [] gui_hooks.editor_did_init_shortcuts(cuts, self) for row in cuts: if len(row) == 2: @@ -430,6 +428,14 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ (_, highlightColor) = cmd.split(":", 1) self.mw.pm.profile["lastHighlightColor"] = highlightColor + elif cmd.startswith("saveTags"): + (type, tagsJson) = cmd.split(":", 1) + self.note.tags = json.loads(tagsJson) + + gui_hooks.editor_did_update_tags(self.note) + if not self.addMode: + self._save_current_note() + elif cmd in self._links: self._links[cmd](self) @@ -450,10 +456,8 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ self.currentField = None if self.note: self.loadNote(focusTo=focusTo) - else: - self.hideCompleters() - if hide: - self.widget.hide() + elif hide: + self.widget.hide() def loadNoteKeepingFocus(self) -> None: self.loadNote(self.currentField) @@ -467,7 +471,6 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ for fld, val in self.note.items() ] self.widget.show() - self.updateTags() note_fields_status = self.note.fields_check() @@ -485,12 +488,13 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ text_color = self.mw.pm.profile.get("lastTextColor", "#00f") highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#00f") - js = "setFields(%s); setFonts(%s); focusField(%s); setNoteId(%s); setColorButtons(%s);" % ( + js = "setFields(%s); setFonts(%s); focusField(%s); setNoteId(%s); setColorButtons(%s); setTags(%s); " % ( json.dumps(data), json.dumps(self.fonts()), json.dumps(focusTo), json.dumps(self.note.id), json.dumps([text_color, highlight_color]), + json.dumps(self.mw.col.tags.canonify(self.note.tags)), ) if self.addMode: @@ -520,7 +524,6 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{ # calling code may not expect the callback to fire immediately self.mw.progress.timer(10, callback, False) return - self.blur_tags_if_focused() self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) saveNow = call_after_note_saved diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index a4448bfc6..09e094fce 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -351,6 +351,10 @@ def change_notetype() -> bytes: return b"" +def complete_tag() -> bytes: + return aqt.mw.col.tags.complete_tag(request.data) + + post_handlers = { "graphData": graph_data, "graphPreferences": graph_preferences, @@ -365,6 +369,7 @@ post_handlers = { # pylint: disable=unnecessary-lambda "i18nResources": i18n_resources, "congratsInfo": congrats_info, + "completeTag": complete_tag, } diff --git a/rslib/src/backend/tags.rs b/rslib/src/backend/tags.rs index 5f91c248d..5601efcc4 100644 --- a/rslib/src/backend/tags.rs +++ b/rslib/src/backend/tags.rs @@ -88,4 +88,11 @@ impl TagsService for Backend { .map(Into::into) }) } + + fn complete_tag(&self, input: pb::CompleteTagRequest) -> Result { + self.with_col(|col| { + let tags = col.complete_tag(&input.input, input.match_limit as usize)?; + Ok(pb::CompleteTagResponse { tags }) + }) + } } diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index 3686bfce7..38e5a07bb 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -66,9 +66,9 @@ impl SqliteStorage { .map_err(Into::into) } - pub(crate) fn get_tags_by_predicate(&self, want: F) -> Result> + pub(crate) fn get_tags_by_predicate(&self, mut want: F) -> Result> where - F: Fn(&str) -> bool, + F: FnMut(&str) -> bool, { let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?; let mut rows = query_stmt.query([])?; diff --git a/rslib/src/tags/complete.rs b/rslib/src/tags/complete.rs new file mode 100644 index 000000000..1093017b0 --- /dev/null +++ b/rslib/src/tags/complete.rs @@ -0,0 +1,78 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use regex::Regex; + +use crate::prelude::*; + +impl Collection { + pub fn complete_tag(&self, input: &str, limit: usize) -> Result> { + let filters: Vec<_> = input + .split("::") + .map(component_to_regex) + .collect::>()?; + let mut tags = vec![]; + self.storage.get_tags_by_predicate(|tag| { + if tags.len() <= limit && filters_match(&filters, tag) { + tags.push(tag.to_string()); + } + // we only need the tag name + false + })?; + Ok(tags) + } +} + +fn component_to_regex(component: &str) -> Result { + Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into) +} + +fn filters_match(filters: &[Regex], tag: &str) -> bool { + let mut remaining_tag_components = tag.split("::"); + 'outer: for filter in filters { + loop { + if let Some(component) = remaining_tag_components.next() { + if filter.is_match(component) { + continue 'outer; + } + } else { + return false; + } + } + } + true +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn matching() -> Result<()> { + let filters = &[component_to_regex("b")?]; + assert!(filters_match(filters, "ABC")); + assert!(filters_match(filters, "ABC::def")); + assert!(filters_match(filters, "def::abc")); + assert!(!filters_match(filters, "def")); + + let filters = &[component_to_regex("b")?, component_to_regex("E")?]; + assert!(!filters_match(filters, "ABC")); + assert!(filters_match(filters, "ABC::def")); + assert!(!filters_match(filters, "def::abc")); + assert!(!filters_match(filters, "def")); + + let filters = &[ + component_to_regex("a")?, + component_to_regex("c")?, + component_to_regex("e")?, + ]; + assert!(!filters_match(filters, "ace")); + assert!(!filters_match(filters, "a::c")); + assert!(!filters_match(filters, "c::e")); + assert!(filters_match(filters, "a::c::e")); + assert!(filters_match(filters, "a::b::c::d::e")); + assert!(filters_match(filters, "1::a::b::c::d::e::f")); + + Ok(()) + } +} diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index b954d4f9e..b99728f55 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod bulkadd; +mod complete; mod findreplace; mod matcher; mod register; diff --git a/ts/change-notetype/SaveButton.svelte b/ts/change-notetype/SaveButton.svelte index c3bd7a8f0..52bf31451 100644 --- a/ts/change-notetype/SaveButton.svelte +++ b/ts/change-notetype/SaveButton.svelte @@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/ts/components/DropdownMenu.svelte b/ts/components/DropdownMenu.svelte index e960ef943..830f19009 100644 --- a/ts/components/DropdownMenu.svelte +++ b/ts/components/DropdownMenu.svelte @@ -7,16 +7,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { dropdownKey } from "./context-keys"; export let id: string | undefined = undefined; + let className: string = ""; + export { className as class }; + + export let labelledby: string | undefined = undefined; + export let show = false; setContext(dropdownKey, null); -