add basic tag completion to backend

Matches should arrive in alphabetical order. Currently results are not
capped (JS should be able to handle ~1k tags without too much hassle),
and no reordering based on match location is done. Matches are substring
based, and multiple can be provided, eg "foo::bar" will match
"foof::baz::abbar".

This is not hooked up properly on the frontend at the moment -
updateSuggestions() seems to be missing the most recently typed character,
and is not updating the list of completions half the time.
This commit is contained in:
Damien Elmes 2021-09-08 17:22:30 +10:00 committed by Henrik Giesel
parent c5d507461a
commit 4da1c77220
12 changed files with 144 additions and 19 deletions

View file

@ -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;
}

View file

@ -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):

View file

@ -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
#############################################################

View file

@ -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,
}

View file

@ -88,4 +88,11 @@ impl TagsService for Backend {
.map(Into::into)
})
}
fn complete_tag(&self, input: pb::CompleteTagRequest) -> Result<pb::CompleteTagResponse> {
self.with_col(|col| {
let tags = col.complete_tag(&input.input)?;
Ok(pb::CompleteTagResponse { tags })
})
}
}

View file

@ -66,9 +66,9 @@ impl SqliteStorage {
.map_err(Into::into)
}
pub(crate) fn get_tags_by_predicate<F>(&self, want: F) -> Result<Vec<Tag>>
pub(crate) fn get_tags_by_predicate<F>(&self, mut want: F) -> Result<Vec<Tag>>
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([])?;

View file

@ -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<Vec<String>> {
let filters: Vec<_> = input
.split("::")
.map(component_to_regex)
.collect::<Result<_, _>>()?;
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> {
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(())
}
}

View file

@ -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;

View file

@ -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<string[]> = Promise.resolve([]);
async function fetchSuggestions(input: string): Promise<string[]> {
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();

View file

@ -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;

View file

@ -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, "::");
}

View file

@ -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 };