Improve tags suggestions

Now tags support two methods of matching:
 * static - which loads all tags when requested and keeps them in cache until next reload
 * dynamic - which loads tags from backend

Other than that it also supports two methods of matching tags:
 * stars_with
 * contains
This commit is contained in:
Andrzej Budzanowski 2024-10-10 10:33:18 +02:00
parent 621aedd8eb
commit 251cd323ba
14 changed files with 229 additions and 58 deletions

View file

@ -1,7 +1,11 @@
import logging
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
@ -140,7 +144,30 @@ class TagViewSet(
def get_queryset(self):
user = self.request.user
return Tag.objects.all().filter(owner=user)
queryset = Tag.objects.filter(owner=user)
word = self.request.query_params.get("word", None)
if word:
min_length = getattr(settings, "LD_TAG_MINIMUM_CHARACTER", 1)
if len(word) < min_length:
raise ValidationError(
{"word": f"Word must be at least {min_length} characters long."}
)
match_type = getattr(settings, "LD_TAG_MATCH_TYPE", "starts_with")
if match_type == "contains":
queryset = queryset.filter(name__icontains=word)
elif match_type == "starts_with":
queryset = queryset.filter(name__istartswith=word)
else:
# Handle unexpected match_type values
raise ImproperlyConfigured(
{
"match_type": f"Invalid LD_TAG_MATCH_TYPE setting: '{match_type}'. "
"Expected 'contains' or 'starts_with'."
}
)
return queryset
def get_serializer_context(self):
return {"user": self.request.user}

View file

@ -19,8 +19,11 @@ export class Api {
.then((data) => data.results);
}
getTags(options = { limit: 100, offset: 0 }) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;
getTags(options = { limit: 100, offset: 0, word: undefined }) {
let url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;
if (options.word) {
url += `&word=${options.word}`;
}
return fetch(url)
.then((response) => response.json())

View file

@ -25,6 +25,8 @@ class SearchAutocomplete extends Behavior {
shared: input.dataset.shared,
unread: input.dataset.unread,
},
fetchType: input.getAttribute("data-fetch-type") || "static",
matchType: input.getAttribute("data-match-type") || "starts_with",
},
});

View file

@ -20,6 +20,8 @@ class TagAutocomplete extends Behavior {
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
variant: input.getAttribute("variant"),
fetchType: container.getAttribute("data-fetch-type") || "static",
matchType: container.getAttribute("data-match-type") || "starts_with",
},
});

View file

@ -1,35 +0,0 @@
import { api } from "./api.js";
class Cache {
constructor(api) {
this.api = api;
// Reset cached tags after a form submission
document.addEventListener("turbo:submit-end", () => {
this.tagsPromise = null;
});
}
getTags() {
if (!this.tagsPromise) {
this.tagsPromise = this.api
.getTags({
limit: 5000,
offset: 0,
})
.then((tags) =>
tags.sort((left, right) =>
left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
),
)
.catch((e) => {
console.warn("Cache: Error loading tags", e);
return [];
});
}
return this.tagsPromise;
}
}
export const cache = new Cache(api);

View file

@ -1,7 +1,7 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {tags as cache} from "../tags";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
@ -12,6 +12,8 @@
export let mode = '';
export let search;
export let linkTarget = '_blank';
export let fetchType = 'static';
export let matchType = 'starts_with';
let isFocus = false;
let isOpen = false;
@ -88,19 +90,20 @@
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tag => ({
type: 'tag',
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name
}))
tagSuggestions = (await cache.getTags(fetchType, matchType, searchTag))
.slice(0, 5)
.map((tag) => {
return {
type: 'tag',
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name
}
})
}
// Recent search suggestions

View file

@ -1,6 +1,6 @@
<script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds} from "../util";
import {tags as cache} from "../tags";
import {debounce, getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
@ -8,6 +8,9 @@
export let placeholder;
export let variant = 'default';
export let fetchType = 'static';
export let matchType = 'starts_with';
let isFocus = false;
let isOpen = false;
let input = null;
@ -28,12 +31,12 @@
async function handleInput(e) {
input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input);
debouncedFetchTags(input);
}
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
async function fetchTags(input) {
const word = getCurrentWord(input);
suggestions = await cache.getTags(fetchType, matchType, word);
if (word && suggestions.length > 0) {
open();
@ -42,6 +45,8 @@
}
}
const debouncedFetchTags = debounce(fetchTags);
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
@ -101,6 +106,7 @@
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>

View file

@ -14,4 +14,4 @@ import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { api } from "./api";
export { cache } from "./cache";
export { tags } from "./tags";

124
bookmarks/frontend/tags.js Normal file
View file

@ -0,0 +1,124 @@
import { api } from "./api";
class Tags {
api;
staticTable;
constructor(api) {
this.api = api;
// Reset cached tags after a form submission
document.addEventListener("turbo:submit-end", () => {
this.staticTable = null;
});
}
async getTags(
fetchType /* : 'static' | 'dynamic' */,
matchType /*: 'starts_with' | 'contains'*/,
word /*: string */,
) {
if (fetchType !== "static" && fetchType !== "dynamic") {
if (fetchType !== undefined) {
console.warn(`Invalid fetch type passed as fetch type:`, fetchType);
}
fetchType = "static";
}
if (matchType !== "starts_with" && matchType !== "contains") {
if (matchType !== undefined) {
console.warn(`Invalid match type passed as match type:`, matchType);
}
matchType = "starts_with";
}
switch (fetchType) {
case "static":
return this._getTypesWithStaticTable(matchType, word);
case "dynamic":
return this._getTypesWithDynamicTable(matchType, word);
default:
console.error(`unreachable`);
}
}
async _getTypesWithStaticTable(
matchType /*: 'starts_with' | 'contains'*/,
word /*: string */,
) {
if (!this.staticTable) {
this.staticTable = await this._getAllTags();
}
return this._matchTags(this.staticTable, matchType, word);
}
async _getTypesWithDynamicTable(
matchType /*: 'starts_with' | 'contains'*/,
word /*: string */,
) {
const table = await this._getSpecificTags(word);
return this._matchTags(table, matchType, word);
}
async _getAllTags() {
return this.api
.getTags({
offset: 0,
limit: 5000,
})
.catch((e) => {
console.error(`Tags: Error fetching tags:`, e);
return [];
});
}
async _getSpecificTags(word /*: string */) {
if (word) {
return this.api
.getTags({
offset: 0,
limit: 50,
word: word,
})
.catch((e) => {
console.error(`Tags: Error fetching specific ${word} tags:`, e);
return [];
});
} else {
return this.api
.getTags({
offset: 0,
limit: 50,
})
.catch((e) => {
console.error(`Tags: Error fetching specific ${word} tags:`, e);
return [];
});
}
}
_matchTags(
tags,
matchType /*: 'starts_with' | 'contains'*/,
word /*: string */,
) {
if (!Array.isArray(tags)) return [];
word = word.toLocaleLowerCase();
return tags.filter((tag) => {
const lower = tag.name.toLocaleLowerCase();
return matchType === "starts_with"
? lower.startsWith(word)
: lower.includes(word);
});
}
}
export const tags = new Tags(api);

View file

@ -20,7 +20,7 @@
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
</div>
</div>
<div class="form-group" ld-tag-autocomplete>
<div class="form-group" ld-tag-autocomplete data-match-type="{{ tag_match_type }}" data-fetch-type="{{ tag_fetch_type }}">
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
<div class="form-input-hint">

View file

@ -8,7 +8,9 @@
data-mode="{{ mode }}"
data-user="{{ search.user }}"
data-shared="{{ search.shared }}"
data-unread="{{ search.unread }}">
data-unread="{{ search.unread }}"
data-match-type="{{ tag_match_type }}"
data-fetch-type="{{ tag_fetch_type }}">
<input type="submit" value="Search" class="d-none">
{% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }}

View file

@ -1,6 +1,7 @@
from typing import List
from django import template
from django.conf import settings
from bookmarks.models import (
BookmarkForm,
@ -26,6 +27,8 @@ def bookmark_form(
"auto_close": auto_close,
"bookmark_id": bookmark_id,
"cancel_url": cancel_url,
"tag_match_type": settings.LD_TAG_MATCH_TYPE,
"tag_fetch_type": settings.LD_TAG_FETCH_TYPE,
}
@ -47,6 +50,8 @@ def bookmark_search(context, search: BookmarkSearch, mode: str = ""):
"search_form": search_form,
"preferences_form": preferences_form,
"mode": mode,
"tag_match_type": settings.LD_TAG_MATCH_TYPE,
"tag_fetch_type": settings.LD_TAG_FETCH_TYPE,
}

View file

@ -268,3 +268,31 @@ When creating HTML archive snapshots, pass additional options to the `single-fil
See `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js
Example: `LD_SINGLEFILE_OPTIONS=--user-agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"`
LD_TAG_FETCH_TYPE
LD_TAG_MATCH_TYPE
LD_TAG_MINIMUM_CHARACTER
### `LD_TAG_FETCH_TYPE`
Values: `static` or `dynamic` | Default = `static`
How tag autocompletion should fetch data. There are two supported methods:
* `static` - frontend will fetch all tags from backend but only once, and will match them in browser.
* `dynamic` - frontend will fetch tags from backend when searching for them, matching will be performed at database level.
### `LD_TAG_MATCH_TYPE`
Values: `starts_with` or `contains` | Default = `starts_with`
How tags should be matched against string. There are two supported methods:
* `starts_with` - will match tags that starts with searched string
* `contains` - will match tags that contain searched string
### `LD_TAG_MINIMUM_CHARACTER`
Values `Int` | Default = 1
How many characters should be required when searching for tag.
This settings works only on backend side (when `LD_TAG_FETCH_TYPE` = `dynamic`).

View file

@ -308,3 +308,7 @@ LD_SINGLEFILE_TIMEOUT_SEC = float(os.getenv("LD_SINGLEFILE_TIMEOUT_SEC", 120))
# it turns out to be useful in the future.
LD_MONOLITH_PATH = os.getenv("LD_MONOLITH_PATH", "monolith")
LD_MONOLITH_OPTIONS = os.getenv("LD_MONOLITH_OPTIONS", "-a -v -s")
LD_TAG_FETCH_TYPE = os.getenv("LD_TAG_FETCH_TYPE", "static")
LD_TAG_MATCH_TYPE = os.getenv("LD_TAG_MATCH_TYPE", "starts_with")
LD_TAG_MINIMUM_CHARACTER = int(os.getenv("LD_TAG_MINIMUM_CHARACTER", 1))