mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-24 20:33:04 +00:00
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:
parent
621aedd8eb
commit
251cd323ba
14 changed files with 229 additions and 58 deletions
|
@ -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}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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
|
||||
|
|
|
@ -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'}>
|
||||
|
|
|
@ -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
124
bookmarks/frontend/tags.js
Normal 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);
|
|
@ -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">
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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`).
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue