mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
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:
parent
c5d507461a
commit
4da1c77220
12 changed files with 144 additions and 19 deletions
|
@ -22,6 +22,7 @@ service TagsService {
|
||||||
returns (collection.OpChangesWithCount);
|
returns (collection.OpChangesWithCount);
|
||||||
rpc FindAndReplaceTag(FindAndReplaceTagRequest)
|
rpc FindAndReplaceTag(FindAndReplaceTagRequest)
|
||||||
returns (collection.OpChangesWithCount);
|
returns (collection.OpChangesWithCount);
|
||||||
|
rpc CompleteTag(CompleteTagRequest) returns (CompleteTagResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetTagCollapsedRequest {
|
message SetTagCollapsedRequest {
|
||||||
|
@ -58,3 +59,12 @@ message FindAndReplaceTagRequest {
|
||||||
bool regex = 4;
|
bool regex = 4;
|
||||||
bool match_case = 5;
|
bool match_case = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CompleteTagRequest {
|
||||||
|
// a partial tag, optionally delimited with ::
|
||||||
|
string input = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CompleteTagResponse {
|
||||||
|
repeated string tags = 1;
|
||||||
|
}
|
||||||
|
|
|
@ -58,10 +58,11 @@ SKIP_UNROLL_INPUT = {
|
||||||
"UpdateDeckConfigs",
|
"UpdateDeckConfigs",
|
||||||
"AnswerCard",
|
"AnswerCard",
|
||||||
"ChangeNotetype",
|
"ChangeNotetype",
|
||||||
|
"CompleteTag",
|
||||||
}
|
}
|
||||||
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
|
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
|
||||||
|
|
||||||
SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo"}
|
SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo", "CompleteTag"}
|
||||||
|
|
||||||
|
|
||||||
def python_type(field):
|
def python_type(field):
|
||||||
|
|
|
@ -67,6 +67,11 @@ class TagManager:
|
||||||
"Set browser expansion state for tag, registering the tag if missing."
|
"Set browser expansion state for tag, registering the tag if missing."
|
||||||
return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed)
|
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
|
# Bulk addition/removal from specific notes
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
||||||
|
|
|
@ -351,6 +351,10 @@ def change_notetype() -> bytes:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def complete_tag() -> bytes:
|
||||||
|
return aqt.mw.col.tags.complete_tag(request.data)
|
||||||
|
|
||||||
|
|
||||||
post_handlers = {
|
post_handlers = {
|
||||||
"graphData": graph_data,
|
"graphData": graph_data,
|
||||||
"graphPreferences": graph_preferences,
|
"graphPreferences": graph_preferences,
|
||||||
|
@ -365,6 +369,7 @@ post_handlers = {
|
||||||
# pylint: disable=unnecessary-lambda
|
# pylint: disable=unnecessary-lambda
|
||||||
"i18nResources": i18n_resources,
|
"i18nResources": i18n_resources,
|
||||||
"congratsInfo": congrats_info,
|
"congratsInfo": congrats_info,
|
||||||
|
"completeTag": complete_tag,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -88,4 +88,11 @@ impl TagsService for Backend {
|
||||||
.map(Into::into)
|
.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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,9 +66,9 @@ impl SqliteStorage {
|
||||||
.map_err(Into::into)
|
.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
|
where
|
||||||
F: Fn(&str) -> bool,
|
F: FnMut(&str) -> bool,
|
||||||
{
|
{
|
||||||
let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?;
|
let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?;
|
||||||
let mut rows = query_stmt.query([])?;
|
let mut rows = query_stmt.query([])?;
|
||||||
|
|
78
rslib/src/tags/complete.rs
Normal file
78
rslib/src/tags/complete.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
mod bulkadd;
|
mod bulkadd;
|
||||||
|
mod complete;
|
||||||
mod findreplace;
|
mod findreplace;
|
||||||
mod matcher;
|
mod matcher;
|
||||||
mod register;
|
mod register;
|
||||||
|
|
|
@ -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 ButtonToolbar from "components/ButtonToolbar.svelte";
|
||||||
import { controlPressed } from "lib/keys";
|
import { controlPressed } from "lib/keys";
|
||||||
import type { Tag as TagType } from "./tags";
|
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[] = [];
|
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 let wrap = true;
|
||||||
|
|
||||||
export function resetTags(names: string[]): void {
|
export function resetTags(names: string[]): void {
|
||||||
tags = names.map(replaceWithDelimChar).map(attachId);
|
tags = names.map(replaceWithUnicodeSeparator).map(attachId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTags(): void {
|
function saveTags(): void {
|
||||||
bridgeCommand(
|
bridgeCommand(
|
||||||
`saveTags:${JSON.stringify(
|
`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 autocomplete: any;
|
||||||
let suggestionsPromise: Promise<string[]> = Promise.resolve([]);
|
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 {
|
function updateSuggestions(): void {
|
||||||
suggestionsPromise = Promise.resolve([
|
const currentInput = tags[tags.length - 1].name;
|
||||||
"en::vocabulary",
|
suggestionsPromise = fetchSuggestions(currentInput).then(
|
||||||
"en::idioms",
|
(names: string[]): string[] => names.map(replaceWithUnicodeSeparator)
|
||||||
Math.random().toString(36).substring(2),
|
);
|
||||||
]).then((names: string[]): string[] => names.map(replaceWithDelimChar));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAutocomplete(selected: string): void {
|
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() {
|
function copySelectedTags() {
|
||||||
const content = tags
|
const content = tags
|
||||||
.filter((tag) => tag.selected)
|
.filter((tag) => tag.selected)
|
||||||
.map((tag) => replaceWithColon(tag.name))
|
.map((tag) => replaceWithColons(tag.name))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
copyToClipboard(content);
|
copyToClipboard(content);
|
||||||
deselect();
|
deselect();
|
||||||
|
|
|
@ -7,8 +7,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import {
|
import {
|
||||||
normalizeTagname,
|
normalizeTagname,
|
||||||
delimChar,
|
delimChar,
|
||||||
replaceWithDelimChar,
|
replaceWithUnicodeSeparator,
|
||||||
replaceWithColon,
|
replaceWithColons,
|
||||||
} from "./tags";
|
} from "./tags";
|
||||||
|
|
||||||
export let id: string | undefined = undefined;
|
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();
|
const selection = document.getSelection();
|
||||||
event.clipboardData!.setData(
|
event.clipboardData!.setData(
|
||||||
"text/plain",
|
"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+/)
|
.split(/\s+/)
|
||||||
.map(normalizeTagname)
|
.map(normalizeTagname)
|
||||||
.filter((name: string) => name.length > 0)
|
.filter((name: string) => name.length > 0)
|
||||||
.map(replaceWithDelimChar);
|
.map(replaceWithUnicodeSeparator);
|
||||||
|
|
||||||
if (splitted.length === 0) {
|
if (splitted.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -3,11 +3,11 @@
|
||||||
|
|
||||||
export const delimChar = "\u2237";
|
export const delimChar = "\u2237";
|
||||||
|
|
||||||
export function replaceWithDelimChar(name: string): string {
|
export function replaceWithUnicodeSeparator(name: string): string {
|
||||||
return name.replace(/::/g, delimChar);
|
return name.replace(/::/g, delimChar);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceWithColon(name: string): string {
|
export function replaceWithColons(name: string): string {
|
||||||
return name.replace(/\u2237/gu, "::");
|
return name.replace(/\u2237/gu, "::");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,4 +7,5 @@ import DeckConfig = anki.deckconfig;
|
||||||
import Notetypes = anki.notetypes;
|
import Notetypes = anki.notetypes;
|
||||||
import Scheduler = anki.scheduler;
|
import Scheduler = anki.scheduler;
|
||||||
import Stats = anki.stats;
|
import Stats = anki.stats;
|
||||||
export { Stats, Cards, DeckConfig, Notetypes, Scheduler };
|
import Tags = anki.tags;
|
||||||
|
export { Stats, Cards, DeckConfig, Notetypes, Scheduler, Tags };
|
||||||
|
|
Loading…
Reference in a new issue