diff --git a/proto/anki/tags.proto b/proto/anki/tags.proto index a1597f7d6..d178a4da6 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,12 @@ message FindAndReplaceTagRequest { bool regex = 4; bool match_case = 5; } + +message CompleteTagRequest { + // a partial tag, optionally delimited with :: + string input = 1; +} + +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/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..e6c770bd1 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)?; + 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..23e86a0ad --- /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) -> Result> { + let filters: Vec<_> = input + .split("::") + .map(component_to_regex) + .collect::>()?; + let mut tags = vec![]; + self.storage.get_tags_by_predicate(|tag| { + if 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/editor/TagEditor.svelte b/ts/editor/TagEditor.svelte index afc3fc40e..c9d9a6d41 100644 --- a/ts/editor/TagEditor.svelte +++ b/ts/editor/TagEditor.svelte @@ -16,7 +16,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ButtonToolbar from "components/ButtonToolbar.svelte"; import { controlPressed } from "lib/keys"; import type { Tag as TagType } from "./tags"; - import { attachId, getName, replaceWithDelimChar, replaceWithColon } from "./tags"; + import { + attachId, + getName, + replaceWithUnicodeSeparator, + replaceWithColons, + } from "./tags"; + import { Tags } from "lib/proto"; + import { postRequest } from "lib/postrequest"; export let tags: TagType[] = []; @@ -24,13 +31,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let wrap = true; export function resetTags(names: string[]): void { - tags = names.map(replaceWithDelimChar).map(attachId); + tags = names.map(replaceWithUnicodeSeparator).map(attachId); } function saveTags(): void { bridgeCommand( `saveTags:${JSON.stringify( - tags.map((tag) => tag.name).map(replaceWithColon) + tags.map((tag) => tag.name).map(replaceWithColons) )}` ); } @@ -43,12 +50,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let autocomplete: any; let suggestionsPromise: Promise = Promise.resolve([]); + async function fetchSuggestions(input: string): Promise { + const data = await postRequest( + "/_anki/completeTag", + Tags.CompleteTagRequest.encode( + Tags.CompleteTagRequest.create({ input: replaceWithColons(input) }) + ).finish() + ); + const response = Tags.CompleteTagResponse.decode(data); + return response.tags; + } + function updateSuggestions(): void { - suggestionsPromise = Promise.resolve([ - "en::vocabulary", - "en::idioms", - Math.random().toString(36).substring(2), - ]).then((names: string[]): string[] => names.map(replaceWithDelimChar)); + const currentInput = tags[tags.length - 1].name; + suggestionsPromise = fetchSuggestions(currentInput).then( + (names: string[]): string[] => names.map(replaceWithUnicodeSeparator) + ); } function onAutocomplete(selected: string): void { @@ -351,7 +368,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html function copySelectedTags() { const content = tags .filter((tag) => tag.selected) - .map((tag) => replaceWithColon(tag.name)) + .map((tag) => replaceWithColons(tag.name)) .join("\n"); copyToClipboard(content); deselect(); diff --git a/ts/editor/TagInput.svelte b/ts/editor/TagInput.svelte index 910c14292..2bdaf9b13 100644 --- a/ts/editor/TagInput.svelte +++ b/ts/editor/TagInput.svelte @@ -7,8 +7,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { normalizeTagname, delimChar, - replaceWithDelimChar, - replaceWithColon, + replaceWithUnicodeSeparator, + replaceWithColons, } from "./tags"; export let id: string | undefined = undefined; @@ -195,7 +195,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const selection = document.getSelection(); event.clipboardData!.setData( "text/plain", - replaceWithColon(selection!.toString()) + replaceWithColons(selection!.toString()) ); } @@ -209,7 +209,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html .split(/\s+/) .map(normalizeTagname) .filter((name: string) => name.length > 0) - .map(replaceWithDelimChar); + .map(replaceWithUnicodeSeparator); if (splitted.length === 0) { return; diff --git a/ts/editor/tags.ts b/ts/editor/tags.ts index 44e3f21e4..b1f6076a1 100644 --- a/ts/editor/tags.ts +++ b/ts/editor/tags.ts @@ -3,11 +3,11 @@ export const delimChar = "\u2237"; -export function replaceWithDelimChar(name: string): string { +export function replaceWithUnicodeSeparator(name: string): string { return name.replace(/::/g, delimChar); } -export function replaceWithColon(name: string): string { +export function replaceWithColons(name: string): string { return name.replace(/\u2237/gu, "::"); } diff --git a/ts/lib/proto.ts b/ts/lib/proto.ts index dbba70dbb..19f916e2c 100644 --- a/ts/lib/proto.ts +++ b/ts/lib/proto.ts @@ -7,4 +7,5 @@ import DeckConfig = anki.deckconfig; import Notetypes = anki.notetypes; import Scheduler = anki.scheduler; import Stats = anki.stats; -export { Stats, Cards, DeckConfig, Notetypes, Scheduler }; +import Tags = anki.tags; +export { Stats, Cards, DeckConfig, Notetypes, Scheduler, Tags };