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);
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
#############################################################
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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([])?;
|
||||
|
|
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
|
||||
|
||||
mod bulkadd;
|
||||
mod complete;
|
||||
mod findreplace;
|
||||
mod matcher;
|
||||
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 { 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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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, "::");
|
||||
}
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in a new issue