mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-04-20 10:13:59 +02:00
Completions overhaul and search history support
This commit is contained in:
parent
64f954e686
commit
ef5dd9a379
44 changed files with 3161 additions and 829 deletions
assets
css/views
eslint.config.jsjs
__tests__
autocomplete.tsautocomplete
query
settings.tstagsinput.tsutils
test
lib/philomena_web
controllers
plugs
templates
filter
layout
profile/artist_link
search
setting
tag
|
@ -24,27 +24,97 @@
|
|||
}
|
||||
|
||||
/* Autocomplete */
|
||||
.autocomplete__list {
|
||||
.autocomplete {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
z-index: 999;
|
||||
font-family: var(--font-family-monospace);
|
||||
background: var(--background-color);
|
||||
|
||||
/* Borders */
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-top-width: 0;
|
||||
border-color: var(--meta-border-color);
|
||||
|
||||
/* Poor man's hack to make sure autocomplete doesn't grow beyond the viewport */
|
||||
max-width: 70vw;
|
||||
}
|
||||
|
||||
.autocomplete__separator {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.autocomplete__item {
|
||||
background: var(--base-color);
|
||||
color: var(--link-light-color);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.autocomplete__item--selected {
|
||||
background: var(--link-light-color);
|
||||
color: var(--base-color);
|
||||
.autocomplete__item__content {
|
||||
/* Squash overly long suggestions */
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.autocomplete__item__tag {
|
||||
color: var(--foreground-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.autocomplete__item__history {
|
||||
color: var(--block-header-link-text-color);
|
||||
}
|
||||
|
||||
.autocomplete__item__history__icon {
|
||||
/*
|
||||
Makes the history icon aligned in width with the autocomplete__item__tag's icon.
|
||||
Yes, it's a dirty hack, don't look at me like that >_<, but turns out font-awesome
|
||||
icons aren't actually all of the same size!
|
||||
*/
|
||||
font-size: 11.38px;
|
||||
}
|
||||
|
||||
.autocomplete__item__history__match {
|
||||
font-weight: bold;
|
||||
|
||||
/* Use a lighter color to highlight the matched part of the query */
|
||||
color: lch(from var(--block-header-link-text-color) calc(l + 20) c h);
|
||||
}
|
||||
|
||||
.autocomplete__item__tag__match {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.autocomplete__item__tag__match:not(.autocomplete__item--selected) {
|
||||
/* Use a lighter color to highlight the matched part of the query */
|
||||
color: lch(from var(--foreground-color) calc(l + 20) c h);
|
||||
}
|
||||
|
||||
.autocomplete__item__tag__count {
|
||||
color: var(--foreground-half-color);
|
||||
|
||||
/*
|
||||
Reduce the space size between groups of 3 digits in big numbers like "1 000 000".
|
||||
This way the number is more compact and easier to read.
|
||||
*/
|
||||
word-spacing: -3px;
|
||||
}
|
||||
|
||||
.autocomplete__item:hover:not(.autocomplete__item--selected) {
|
||||
background: lch(from var(--background-color) calc(l + 10) c h);
|
||||
}
|
||||
|
||||
.autocomplete__item--selected,
|
||||
.autocomplete__item--selected .autocomplete__item__history__match,
|
||||
.autocomplete__item--selected .autocomplete__item__tag__match {
|
||||
background: var(--foreground-color);
|
||||
color: var(--background-color);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
|
|
@ -265,6 +265,10 @@ export default tsEslint.config(
|
|||
'no-unused-vars': 'off',
|
||||
'no-redeclare': 'off',
|
||||
'no-shadow': 'off',
|
||||
|
||||
// Often conflicts with prettier. In fact, prettier should just be enforced by CI.
|
||||
'no-extra-parens': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
2,
|
||||
{ vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*' },
|
||||
|
@ -285,6 +289,13 @@ export default tsEslint.config(
|
|||
'no-unused-expressions': 0,
|
||||
'vitest/valid-expect': 0,
|
||||
'@typescript-eslint/no-unused-expressions': 0,
|
||||
'vitest/expect-expect': [
|
||||
'error',
|
||||
{
|
||||
// Custom `expectStuff()` functions must also count as assertions.
|
||||
assertFunctionNames: ['expect*', '*.expect*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { $, $$, hideEl } from '../utils/dom';
|
||||
import { assertNotNull } from '../utils/assert';
|
||||
import { TermSuggestion } from '../utils/suggestions';
|
||||
import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput';
|
||||
|
||||
const formData = `<form class="tags-form">
|
||||
|
@ -96,7 +95,7 @@ describe('Fancy tags input', () => {
|
|||
|
||||
it('should respond to autocomplete events', () => {
|
||||
setupTagsInput(tagBlock);
|
||||
fancyText.dispatchEvent(new CustomEvent<TermSuggestion>('autocomplete', { detail: { value: 'a', label: 'a' } }));
|
||||
fancyText.dispatchEvent(new CustomEvent<string>('autocomplete', { detail: 'a' }));
|
||||
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,292 +0,0 @@
|
|||
/**
|
||||
* Autocomplete.
|
||||
*/
|
||||
|
||||
import { LocalAutocompleter } from './utils/local-autocompleter';
|
||||
import { getTermContexts } from './match_query';
|
||||
import store from './utils/store';
|
||||
import { TermContext } from './query/lex';
|
||||
import { $$ } from './utils/dom';
|
||||
import {
|
||||
formatLocalAutocompleteResult,
|
||||
fetchLocalAutocomplete,
|
||||
fetchSuggestions,
|
||||
SuggestionsPopup,
|
||||
TermSuggestion,
|
||||
} from './utils/suggestions';
|
||||
|
||||
type AutocompletableInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function hasAutocompleteEnabled(element: unknown): element is AutocompletableInputElement {
|
||||
return (
|
||||
(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) &&
|
||||
Boolean(element.dataset.autocomplete)
|
||||
);
|
||||
}
|
||||
|
||||
let inputField: AutocompletableInputElement | null = null;
|
||||
let originalTerm: string | undefined;
|
||||
let originalQuery: string | undefined;
|
||||
let selectedTerm: TermContext | null = null;
|
||||
|
||||
const popup = new SuggestionsPopup();
|
||||
|
||||
function isSearchField(targetInput: HTMLElement): boolean {
|
||||
return targetInput.dataset.autocompleteMode === 'search';
|
||||
}
|
||||
|
||||
function restoreOriginalValue() {
|
||||
if (!inputField) return;
|
||||
|
||||
if (isSearchField(inputField) && originalQuery) {
|
||||
inputField.value = originalQuery;
|
||||
|
||||
if (selectedTerm) {
|
||||
const [, selectedTermEnd] = selectedTerm[0];
|
||||
|
||||
inputField.setSelectionRange(selectedTermEnd, selectedTermEnd);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalTerm) {
|
||||
inputField.value = originalTerm;
|
||||
}
|
||||
}
|
||||
|
||||
function applySelectedValue(selection: string) {
|
||||
if (!inputField) return;
|
||||
|
||||
if (!isSearchField(inputField)) {
|
||||
let resultValue = selection;
|
||||
|
||||
if (originalTerm?.startsWith('-')) {
|
||||
resultValue = `-${selection}`;
|
||||
}
|
||||
|
||||
inputField.value = resultValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTerm && originalQuery) {
|
||||
const [startIndex, endIndex] = selectedTerm[0];
|
||||
inputField.value = originalQuery.slice(0, startIndex) + selection + originalQuery.slice(endIndex);
|
||||
inputField.setSelectionRange(startIndex + selection.length, startIndex + selection.length);
|
||||
inputField.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectionOutsideCurrentTerm(): boolean {
|
||||
if (!inputField || !selectedTerm) return true;
|
||||
if (inputField.selectionStart === null || inputField.selectionEnd === null) return true;
|
||||
|
||||
const selectionIndex = Math.min(inputField.selectionStart, inputField.selectionEnd);
|
||||
const [startIndex, endIndex] = selectedTerm[0];
|
||||
|
||||
return startIndex > selectionIndex || endIndex < selectionIndex;
|
||||
}
|
||||
|
||||
function keydownHandler(event: KeyboardEvent) {
|
||||
if (inputField !== event.currentTarget) return;
|
||||
|
||||
if (inputField && isSearchField(inputField)) {
|
||||
// Prevent submission of the search field when Enter was hit
|
||||
if (popup.selectedTerm && event.keyCode === 13) event.preventDefault(); // Enter
|
||||
|
||||
// Close autocompletion popup when text cursor is outside current tag
|
||||
if (selectedTerm && (event.keyCode === 37 || event.keyCode === 39)) {
|
||||
// ArrowLeft || ArrowRight
|
||||
requestAnimationFrame(() => {
|
||||
if (isSelectionOutsideCurrentTerm()) popup.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!popup.isActive) return;
|
||||
|
||||
if (event.keyCode === 38) popup.selectPrevious(); // ArrowUp
|
||||
if (event.keyCode === 40) popup.selectNext(); // ArrowDown
|
||||
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) popup.hide(); // Enter || Esc || Comma
|
||||
if (event.keyCode === 38 || event.keyCode === 40) {
|
||||
// ArrowUp || ArrowDown
|
||||
if (popup.selectedTerm) {
|
||||
applySelectedValue(popup.selectedTerm);
|
||||
} else {
|
||||
restoreOriginalValue();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectedTerm(targetInput: AutocompletableInputElement, searchQuery: string): TermContext | null {
|
||||
if (targetInput.selectionStart === null || targetInput.selectionEnd === null) return null;
|
||||
|
||||
const selectionIndex = Math.min(targetInput.selectionStart, targetInput.selectionEnd);
|
||||
|
||||
// Multi-line textarea elements should treat each line as the different search queries. Here we're looking for the
|
||||
// actively edited line and use it instead of the whole value.
|
||||
const activeLineStart = searchQuery.slice(0, selectionIndex).lastIndexOf('\n') + 1;
|
||||
const lengthAfterSelectionIndex = Math.max(searchQuery.slice(selectionIndex).indexOf('\n'), 0);
|
||||
const targetQuery = searchQuery.slice(activeLineStart, selectionIndex + lengthAfterSelectionIndex);
|
||||
|
||||
const terms = getTermContexts(targetQuery);
|
||||
const searchIndex = selectionIndex - activeLineStart;
|
||||
const term = terms.find(([range]) => range[0] < searchIndex && range[1] >= searchIndex) ?? null;
|
||||
|
||||
// Converting line-specific indexes back to absolute ones.
|
||||
if (term) {
|
||||
const [range] = term;
|
||||
|
||||
range[0] += activeLineStart;
|
||||
range[1] += activeLineStart;
|
||||
}
|
||||
|
||||
return term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our custom autocomplete isn't compatible with the native browser autocomplete,
|
||||
* so we have to turn it off if our autocomplete is enabled, or turn it back on
|
||||
* if it's disabled.
|
||||
*/
|
||||
function toggleSearchNativeAutocomplete() {
|
||||
const enable = store.get('enable_search_ac');
|
||||
|
||||
const searchFields = $$<AutocompletableInputElement>(
|
||||
'input[data-autocomplete][data-autocomplete-mode=search], textarea[data-autocomplete][data-autocomplete-mode=search]',
|
||||
);
|
||||
|
||||
for (const searchField of searchFields) {
|
||||
if (enable) {
|
||||
searchField.autocomplete = 'off';
|
||||
} else {
|
||||
searchField.removeAttribute('data-autocomplete');
|
||||
searchField.autocomplete = 'on';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimPrefixes(targetTerm: string): string {
|
||||
return targetTerm.trim().replace(/^-/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* We control the autocomplete with `data-autocomplete*` attributes in HTML, and subscribe
|
||||
* event listeners to the `document`. This pattern is described in more detail
|
||||
* here: https://javascript.info/event-delegation
|
||||
*/
|
||||
export function listenAutocomplete() {
|
||||
let serverSideSuggestionsTimeout: number | undefined;
|
||||
|
||||
let localAutocomplete: LocalAutocompleter | null = null;
|
||||
|
||||
document.addEventListener('focusin', loadAutocompleteFromEvent);
|
||||
|
||||
document.addEventListener('input', event => {
|
||||
popup.hide();
|
||||
loadAutocompleteFromEvent(event);
|
||||
window.clearTimeout(serverSideSuggestionsTimeout);
|
||||
|
||||
if (!hasAutocompleteEnabled(event.target)) return;
|
||||
|
||||
const targetedInput = event.target;
|
||||
|
||||
targetedInput.addEventListener('keydown', keydownHandler as EventListener);
|
||||
|
||||
if (localAutocomplete !== null) {
|
||||
inputField = targetedInput;
|
||||
let suggestionsCount = 5;
|
||||
|
||||
if (isSearchField(inputField)) {
|
||||
originalQuery = inputField.value;
|
||||
selectedTerm = findSelectedTerm(inputField, originalQuery);
|
||||
suggestionsCount = 10;
|
||||
|
||||
// We don't need to run auto-completion if user is not selecting tag at all
|
||||
if (!selectedTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalTerm = selectedTerm[1].toLowerCase();
|
||||
} else {
|
||||
originalTerm = inputField.value.toLowerCase();
|
||||
}
|
||||
|
||||
const suggestions = localAutocomplete
|
||||
.matchPrefix(trimPrefixes(originalTerm), suggestionsCount)
|
||||
.map(formatLocalAutocompleteResult);
|
||||
|
||||
if (suggestions.length) {
|
||||
popup.renderSuggestions(suggestions).showForField(targetedInput);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { autocompleteMinLength: minTermLength, autocompleteSource: endpointUrl } = targetedInput.dataset;
|
||||
|
||||
if (!endpointUrl) return;
|
||||
|
||||
// Use a timeout to delay requests until the user has stopped typing
|
||||
serverSideSuggestionsTimeout = window.setTimeout(() => {
|
||||
inputField = targetedInput;
|
||||
originalTerm = inputField.value;
|
||||
|
||||
const fetchedTerm = trimPrefixes(inputField.value);
|
||||
|
||||
if (minTermLength && fetchedTerm.length < parseInt(minTermLength, 10)) return;
|
||||
|
||||
fetchSuggestions(endpointUrl, fetchedTerm).then(suggestions => {
|
||||
// inputField could get overwritten while the suggestions are being fetched - use previously targeted input
|
||||
if (fetchedTerm === trimPrefixes(targetedInput.value)) {
|
||||
popup.renderSuggestions(suggestions).showForField(targetedInput);
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// If there's a click outside the inputField, remove autocomplete
|
||||
document.addEventListener('click', event => {
|
||||
if (event.target && event.target !== inputField) popup.hide();
|
||||
if (inputField && event.target === inputField && isSearchField(inputField) && isSelectionOutsideCurrentTerm()) {
|
||||
popup.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Lazy-load the local autocomplete index from the server only once.
|
||||
let localAutocompleteFetchNeeded = true;
|
||||
|
||||
async function loadAutocompleteFromEvent(event: Event) {
|
||||
if (!localAutocompleteFetchNeeded || !hasAutocompleteEnabled(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
localAutocompleteFetchNeeded = false;
|
||||
localAutocomplete = await fetchLocalAutocomplete();
|
||||
}
|
||||
|
||||
toggleSearchNativeAutocomplete();
|
||||
|
||||
popup.onItemSelected((event: CustomEvent<TermSuggestion>) => {
|
||||
if (!event.detail || !inputField) return;
|
||||
|
||||
const originalSuggestion = event.detail;
|
||||
applySelectedValue(originalSuggestion.value);
|
||||
|
||||
if (originalTerm?.startsWith('-')) {
|
||||
originalSuggestion.value = `-${originalSuggestion.value}`;
|
||||
}
|
||||
|
||||
inputField.dispatchEvent(
|
||||
new CustomEvent<TermSuggestion>('autocomplete', {
|
||||
detail: Object.assign(
|
||||
{
|
||||
type: 'click',
|
||||
},
|
||||
originalSuggestion,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
269
assets/js/autocomplete/__tests__/context.ts
Normal file
269
assets/js/autocomplete/__tests__/context.ts
Normal file
|
@ -0,0 +1,269 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetchMock } from '../../../test/fetch-mock';
|
||||
import { listenAutocomplete } from '..';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { assertNotNull } from '../../utils/assert';
|
||||
import { TextInputElement } from '../input';
|
||||
import store from '../../utils/store';
|
||||
import { GetTagSuggestionsResponse } from 'autocomplete/client';
|
||||
|
||||
/**
|
||||
* A reusable test environment for autocompletion tests. Note that it does no
|
||||
* attempt to provide environment cleanup functionality. Yes, if you use this
|
||||
* in several tests in one file, then tests will conflict with each other.
|
||||
*
|
||||
* The main problem of implementing the cleanup here is that autocomplete code
|
||||
* adds event listeners to the `document` object. Some of them could be moved
|
||||
* to the `<body>` element, but events such as `'storage'` are only available
|
||||
* on the document object.
|
||||
*
|
||||
* Unfortunately, there isn't a good easy way to reload the DOM completely in
|
||||
* `jsdom`, so it's expected that you define a single test per file so that
|
||||
* `vitest` runs every test in an isolated process, where no cleanup is needed.
|
||||
*
|
||||
* I wish `vitest` actually did that by default, because cleanup logic and test
|
||||
* in-process test isolation is just boilerplate that we could avoid at this
|
||||
* scale at least.
|
||||
*/
|
||||
export class TestContext {
|
||||
private input: TextInputElement;
|
||||
private popup: HTMLElement;
|
||||
readonly fakeAutocompleteResponse: Response;
|
||||
|
||||
constructor(fakeAutocompleteResponse: Response) {
|
||||
this.fakeAutocompleteResponse = fakeAutocompleteResponse;
|
||||
|
||||
vi.useFakeTimers().setSystemTime(0);
|
||||
fetchMock.enableMocks();
|
||||
|
||||
// Our mock backend implementation.
|
||||
fetchMock.mockResponse(request => {
|
||||
if (request.url.includes('/autocomplete/compiled')) {
|
||||
return this.fakeAutocompleteResponse;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('term')?.toLowerCase() !== 'mar') {
|
||||
const suggestions: GetTagSuggestionsResponse = { suggestions: [] };
|
||||
return JSON.stringify(suggestions);
|
||||
}
|
||||
|
||||
const suggestions: GetTagSuggestionsResponse = {
|
||||
suggestions: [
|
||||
{
|
||||
alias: 'marvelous',
|
||||
canonical: 'beautiful',
|
||||
images: 30,
|
||||
},
|
||||
{
|
||||
canonical: 'mare',
|
||||
images: 20,
|
||||
},
|
||||
{
|
||||
canonical: 'market',
|
||||
images: 10,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return JSON.stringify(suggestions);
|
||||
});
|
||||
|
||||
store.set('enable_search_ac', true);
|
||||
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<input
|
||||
class="test-input"
|
||||
data-autocomplete="multi-tags"
|
||||
data-autocomplete-condition="enable_search_ac"
|
||||
data-autocomplete-history-id="search-history"
|
||||
/>
|
||||
</form>
|
||||
`;
|
||||
|
||||
listenAutocomplete();
|
||||
|
||||
this.input = assertNotNull(document.querySelector('.test-input'));
|
||||
this.popup = assertNotNull(document.querySelector('.autocomplete'));
|
||||
|
||||
expect(fetch).not.toBeCalled();
|
||||
}
|
||||
|
||||
async submitForm(input?: string) {
|
||||
if (input) {
|
||||
await this.setInput(input);
|
||||
}
|
||||
|
||||
this.input.form!.submit();
|
||||
|
||||
await this.setInput('');
|
||||
}
|
||||
|
||||
async focusInput() {
|
||||
this.input.focus();
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the input to `value`. Allows for a special `<>` syntax. These characters
|
||||
* are removed from the input. Their position is used to set the selection.
|
||||
*
|
||||
* - `<` denotes the `selectionStart`
|
||||
* - `>` denotes the `selectionEnd`.
|
||||
*/
|
||||
async setInput(value: string) {
|
||||
if (document.activeElement !== this.input) {
|
||||
await this.focusInput();
|
||||
}
|
||||
|
||||
const valueChars = [...value];
|
||||
|
||||
const selectionStart = valueChars.indexOf('<');
|
||||
if (selectionStart >= 0) {
|
||||
valueChars.splice(selectionStart, 1);
|
||||
}
|
||||
|
||||
const selectionEnd = valueChars.indexOf('>');
|
||||
if (selectionEnd >= 0) {
|
||||
valueChars.splice(selectionEnd, 1);
|
||||
}
|
||||
|
||||
this.input.value = valueChars.join('');
|
||||
if (selectionStart >= 0) {
|
||||
this.input.selectionStart = selectionStart;
|
||||
}
|
||||
if (selectionEnd >= 0) {
|
||||
this.input.selectionEnd = selectionEnd;
|
||||
}
|
||||
|
||||
fireEvent.input(this.input, { target: { value: this.input.value } });
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
async keyDown(code: string, params?: { ctrlKey?: boolean }) {
|
||||
fireEvent.keyDown(this.input, { code, ...(params ?? {}) });
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
expectRequests() {
|
||||
const snapshot = vi.mocked(fetch).mock.calls.map(([input]) => {
|
||||
const request = input as unknown as Request;
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const methodAndUrl = `${request.method} ${url}`;
|
||||
|
||||
if (request.credentials !== 'same-origin') {
|
||||
meta.credentials = request.credentials;
|
||||
}
|
||||
|
||||
if (request.cache !== 'default') {
|
||||
meta.cache = request.cache;
|
||||
}
|
||||
|
||||
if (Object.getOwnPropertyNames(meta).length === 0) {
|
||||
return methodAndUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
dest: methodAndUrl,
|
||||
meta,
|
||||
};
|
||||
});
|
||||
|
||||
return expect(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot of the UI uses some special syntax like `<>` to denote the
|
||||
* selection start (`<`) and end (`>`), as well as some markers for the
|
||||
* currently selected item and history suggestions.
|
||||
*/
|
||||
expectUi() {
|
||||
const input = this.inputSnapshot();
|
||||
const suggestions = this.suggestionsSnapshot();
|
||||
|
||||
return expect({ input, suggestions });
|
||||
}
|
||||
|
||||
suggestionsSnapshot() {
|
||||
const { popup } = this;
|
||||
|
||||
if (popup.classList.contains('hidden')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...popup.children].map(el => {
|
||||
if (el.tagName === 'HR') {
|
||||
return '-----------';
|
||||
}
|
||||
|
||||
let content = el.textContent!.trim();
|
||||
|
||||
if (el.classList.contains('autocomplete__item__history')) {
|
||||
content = `(history) ${content}`;
|
||||
}
|
||||
|
||||
if (el.classList.contains('autocomplete__item--selected')) {
|
||||
return `👉 ${content}`;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
inputSnapshot() {
|
||||
const { input } = this;
|
||||
|
||||
const value = [...input.value];
|
||||
|
||||
if (input.selectionStart) {
|
||||
value.splice(input.selectionStart, 0, '<');
|
||||
}
|
||||
|
||||
if (input.selectionEnd) {
|
||||
const shift = input.selectionStart && input.selectionStart <= input.selectionEnd ? 1 : 0;
|
||||
|
||||
value.splice(input.selectionEnd + shift, 0, '>');
|
||||
}
|
||||
|
||||
return value.join('');
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<TestContext> {
|
||||
const fakeAutocompleteBuffer = await fs.promises
|
||||
.readFile(path.join(__dirname, '../../utils/__tests__/autocomplete-compiled-v2.bin'))
|
||||
.then(({ buffer }) => new Response(buffer));
|
||||
|
||||
const ctx = new TestContext(fakeAutocompleteBuffer);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
// Initialize the lazy autocomplete index cache
|
||||
await ctx.focusInput();
|
||||
|
||||
ctx.expectRequests().toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"dest": "GET http://localhost:3000/autocomplete/compiled?vsn=2&key=1970-0-1",
|
||||
"meta": {
|
||||
"cache": "force-cache",
|
||||
"credentials": "omit",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
|
||||
return ctx;
|
||||
}
|
102
assets/js/autocomplete/__tests__/history.spec.ts
Normal file
102
assets/js/autocomplete/__tests__/history.spec.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { init } from './context';
|
||||
|
||||
it('records search history', async () => {
|
||||
const ctx = await init();
|
||||
|
||||
await ctx.submitForm('foo1');
|
||||
|
||||
// Empty input should show all latest history items
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.submitForm('foo2');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) foo2",
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.submitForm('a complex OR (query AND bar)');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) a complex OR (query AND bar)",
|
||||
"(history) foo2",
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
// Last recently used item should be on top
|
||||
await ctx.submitForm('foo2');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "",
|
||||
"suggestions": [
|
||||
"(history) foo2",
|
||||
"(history) a complex OR (query AND bar)",
|
||||
"(history) foo1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('a com');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "a com<>",
|
||||
"suggestions": [
|
||||
"(history) a complex OR (query AND bar)",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('f');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "f<>",
|
||||
"suggestions": [
|
||||
"(history) foo2",
|
||||
"(history) foo1",
|
||||
"-----------",
|
||||
"forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
// History items must be selectable
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "foo2<>",
|
||||
"suggestions": [
|
||||
"👉 (history) foo2",
|
||||
"(history) foo1",
|
||||
"-----------",
|
||||
"forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
114
assets/js/autocomplete/__tests__/keyboard.spec.ts
Normal file
114
assets/js/autocomplete/__tests__/keyboard.spec.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { init } from './context';
|
||||
|
||||
it('supports navigation via keyboard', async () => {
|
||||
const ctx = await init();
|
||||
|
||||
await ctx.setInput('f');
|
||||
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest<>",
|
||||
"suggestions": [
|
||||
"👉 forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "fog<>",
|
||||
"suggestions": [
|
||||
"forest 3",
|
||||
"👉 fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowDown', { ctrlKey: true });
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "flower<>",
|
||||
"suggestions": [
|
||||
"forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"👉 flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowUp', { ctrlKey: true });
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest<>",
|
||||
"suggestions": [
|
||||
"👉 forest 3",
|
||||
"fog 1",
|
||||
"force field 1",
|
||||
"flower 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('Enter');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest<>",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('forest, t<>, safe');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, t<>, safe",
|
||||
"suggestions": [
|
||||
"artist:test 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('ArrowDown');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, artist:test<>, safe",
|
||||
"suggestions": [
|
||||
"👉 artist:test 1",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.keyDown('Escape');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, artist:test<>, safe",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('forest, t<>, safe');
|
||||
await ctx.keyDown('ArrowDown');
|
||||
await ctx.keyDown('Enter');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "forest, artist:test<>, safe",
|
||||
"suggestions": [],
|
||||
}
|
||||
`);
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
import { init } from './context';
|
||||
|
||||
it('requests server-side autocomplete if local autocomplete returns no results', async () => {
|
||||
const ctx = await init();
|
||||
|
||||
await ctx.setInput('mar');
|
||||
|
||||
// 1. Request the local autocomplete index.
|
||||
// 2. Request the server-side suggestions.
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": "mar<>",
|
||||
"suggestions": [
|
||||
"marvelous → beautiful 30",
|
||||
"mare 20",
|
||||
"market 10",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await ctx.setInput('');
|
||||
|
||||
// Make sure the response caching is insensitive to term case and leading whitespace.
|
||||
await ctx.setInput('mar');
|
||||
await ctx.setInput(' mar');
|
||||
await ctx.setInput(' Mar');
|
||||
await ctx.setInput(' MAR');
|
||||
|
||||
ctx.expectUi().toMatchInlineSnapshot(`
|
||||
{
|
||||
"input": " MAR<>",
|
||||
"suggestions": [
|
||||
"marvelous → beautiful 30",
|
||||
"mare 20",
|
||||
"market 10",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Trailing whitespace is still significant because terms may have internal spaces.
|
||||
await ctx.setInput('mar ');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
77
assets/js/autocomplete/client.ts
Normal file
77
assets/js/autocomplete/client.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { HttpClient } from '../utils/http-client.ts';
|
||||
|
||||
export interface TagSuggestion {
|
||||
/**
|
||||
* If present, then this suggestion is for a tag alias.
|
||||
* If absent, then this suggestion is for the `canonical` tag name.
|
||||
*/
|
||||
alias?: null | string;
|
||||
|
||||
/**
|
||||
* The canonical name of the tag (non-alias).
|
||||
*/
|
||||
canonical: string;
|
||||
|
||||
/**
|
||||
* Number of images tagged with this tag.
|
||||
*/
|
||||
images: number;
|
||||
}
|
||||
|
||||
export interface GetTagSuggestionsResponse {
|
||||
suggestions: TagSuggestion[];
|
||||
}
|
||||
|
||||
export interface GetTagSuggestionsRequest {
|
||||
/**
|
||||
* Term to complete.
|
||||
*/
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* Maximum number of suggestions to return.
|
||||
*/
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete API client for Philomena backend.
|
||||
*/
|
||||
export class AutocompleteClient {
|
||||
private http: HttpClient = new HttpClient();
|
||||
|
||||
/**
|
||||
* Fetches server-side tag suggestions for the given term. The provided incomplete
|
||||
* term is expected to be normalized by the caller (i.e. lowercased and trimmed).
|
||||
* This is because the caller is responsible for caching the normalized term.
|
||||
*/
|
||||
async getTagSuggestions(
|
||||
request: GetTagSuggestionsRequest,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<GetTagSuggestionsResponse> {
|
||||
return this.http.fetchJson('/autocomplete/tags', {
|
||||
query: {
|
||||
vsn: '2',
|
||||
term: request.term,
|
||||
limit: request.limit.toString(),
|
||||
},
|
||||
signal: abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a GET request to fetch the compiled autocomplete index.
|
||||
*/
|
||||
async getCompiledAutocomplete(): Promise<ArrayBuffer> {
|
||||
const now = new Date();
|
||||
const key = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||
|
||||
const response = await this.http.fetch(`/autocomplete/compiled`, {
|
||||
query: { vsn: '2', key },
|
||||
credentials: 'omit',
|
||||
cache: 'force-cache',
|
||||
});
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
}
|
94
assets/js/autocomplete/history/history.ts
Normal file
94
assets/js/autocomplete/history/history.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { HistoryStore } from './store';
|
||||
|
||||
/**
|
||||
* Maximum number of records we keep in the history. If the limit is reached,
|
||||
* the least popular records will be removed to make space for new ones.
|
||||
*/
|
||||
const maxRecords = 1000;
|
||||
|
||||
/**
|
||||
* Maximum length of the input content we store in the history. If the input
|
||||
* exceeds this value it won't be saved in the history.
|
||||
*/
|
||||
const maxInputLength = 256;
|
||||
|
||||
/**
|
||||
* Input history is a mini DB limited in size and stored in the `localStorage`.
|
||||
* It provides a simple CRUD API for the search history data.
|
||||
*
|
||||
* Note that `localStorage` is not transactional. Other browser tabs may modify
|
||||
* it concurrently, which may lead to version mismatches and potential TOCTOU
|
||||
* issues. However, search history data is not critical, and the probability of
|
||||
* concurrent usage patterns is almost 0. The worst thing that can happen in
|
||||
* such a rare scenario is that a search query may not be saved to the storage
|
||||
* or the search history may be temporarily disabled for the current session
|
||||
* until the page is reloaded with a newer version of the frontend code.
|
||||
*/
|
||||
export class InputHistory {
|
||||
private readonly store: HistoryStore;
|
||||
|
||||
/**
|
||||
* The list of history records sorted from the last recently used to the oldest unused.
|
||||
*/
|
||||
private records: string[];
|
||||
|
||||
constructor(store: HistoryStore) {
|
||||
this.store = store;
|
||||
|
||||
const parsing = performance.now();
|
||||
this.records = store.read();
|
||||
|
||||
const end = performance.now();
|
||||
console.debug(`Loading input history took ${end - parsing}ms. Records: ${this.records.length}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the input into the history and commit it to the `localStorage`.
|
||||
* Expects a value trimmed from whitespace by the caller.
|
||||
*/
|
||||
write(input: string) {
|
||||
if (input === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.length > maxInputLength) {
|
||||
console.warn(`The input is too long to be saved in the search history (length: ${input.length}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.records.findIndex(historyRecord => historyRecord === input);
|
||||
|
||||
if (index >= 0) {
|
||||
this.records.splice(index, 1);
|
||||
} else if (this.records.length >= maxRecords) {
|
||||
// Bye-bye, the oldest unused record! 👋 Nopony will miss you 🔪🩸
|
||||
this.records.pop();
|
||||
}
|
||||
|
||||
// Put the record on the top of the list as the last recently used.
|
||||
this.records.unshift(input);
|
||||
|
||||
this.store.write(this.records);
|
||||
}
|
||||
|
||||
listSuggestions(query: string, limit: number): string[] {
|
||||
// Waiting for iterator combinators such as `Iterator.prototype.filter()`
|
||||
// and `Iterator.prototype.take()` to reach a greater availability 🙏:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/filter
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/take
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const record of this.records) {
|
||||
if (results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (record.startsWith(query)) {
|
||||
results.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
74
assets/js/autocomplete/history/index.ts
Normal file
74
assets/js/autocomplete/history/index.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { HistorySuggestion } from '../../utils/suggestions';
|
||||
import { InputHistory } from './history';
|
||||
import { HistoryStore } from './store';
|
||||
import { AutocompletableInput } from '../input';
|
||||
|
||||
/**
|
||||
* Stores a set of histories identified by their unique IDs.
|
||||
*/
|
||||
class InputHistoriesPool {
|
||||
private histories = new Map<string, InputHistory>();
|
||||
|
||||
load(historyId: string): InputHistory {
|
||||
const existing = this.histories.get(historyId);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const store = new HistoryStore(historyId);
|
||||
const newHistory = new InputHistory(store);
|
||||
this.histories.set(historyId, newHistory);
|
||||
|
||||
return newHistory;
|
||||
}
|
||||
}
|
||||
|
||||
const histories = new InputHistoriesPool();
|
||||
|
||||
export function listen() {
|
||||
// Only load the history for the input element when it gets focused.
|
||||
document.addEventListener('focusin', event => {
|
||||
const input = AutocompletableInput.fromElement(event.target);
|
||||
|
||||
if (!input?.historyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
histories.load(input.historyId);
|
||||
});
|
||||
|
||||
document.addEventListener('submit', event => {
|
||||
if (!(event.target instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = [...event.target.elements]
|
||||
.map(elem => AutocompletableInput.fromElement(elem))
|
||||
.find(it => it !== null && it.hasHistory());
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
histories.load(input.historyId).write(input.snapshot.trimmedValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns suggestions based on history for the input. Unless the `limit` is
|
||||
* specified as an argument, it will return the maximum number of suggestions
|
||||
* allowed by the input.
|
||||
*/
|
||||
export function listSuggestions(input: AutocompletableInput, limit?: number): HistorySuggestion[] {
|
||||
if (!input.hasHistory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const value = input.snapshot.trimmedValue.toLowerCase();
|
||||
|
||||
return histories
|
||||
.load(input.historyId)
|
||||
.listSuggestions(value, limit ?? input.maxSuggestions)
|
||||
.map(content => new HistorySuggestion(content, value.length));
|
||||
}
|
95
assets/js/autocomplete/history/store.ts
Normal file
95
assets/js/autocomplete/history/store.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import store from '../../utils/store';
|
||||
/**
|
||||
* The root JSON object that contains the history records and is persisted to disk.
|
||||
*/
|
||||
interface History {
|
||||
/**
|
||||
* Used to track the version of the schema layout just in case we do any
|
||||
* breaking changes to this schema so that we can properly migrate old
|
||||
* search history data. It's also used to prevent older versions of
|
||||
* the frontend code from trying to use the newer incompatible schema they
|
||||
* know nothing about (extremely improbable, but just in case).
|
||||
*/
|
||||
schemaVersion: 1;
|
||||
|
||||
/**
|
||||
* The list of history records sorted from the last recently used to the oldest unused.
|
||||
*/
|
||||
records: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* History store backend is responsible for parsing and serializing the data
|
||||
* to/from `localStorage`. It handles versioning of the schema, and transparently
|
||||
* disables writing to the storage if the schema version is unknown to prevent
|
||||
* data loss (extremely improbable, but just in case).
|
||||
*/
|
||||
export class HistoryStore {
|
||||
private writable: boolean = true;
|
||||
private readonly key: string;
|
||||
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
read(): string[] {
|
||||
return this.extractRecords(store.get<History>(this.key));
|
||||
}
|
||||
|
||||
write(records: string[]): void {
|
||||
if (!this.writable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history: History = {
|
||||
schemaVersion: 1,
|
||||
records,
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
store.set(this.key, history);
|
||||
|
||||
const end = performance.now();
|
||||
console.debug(
|
||||
`Writing ${records.length} history records to the localStorage took ${end - start}ms. ` +
|
||||
`Records: ${records.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the records from the history. To do this, we first need to migrate
|
||||
* the history object to the latest schema version if necessary.
|
||||
*/
|
||||
private extractRecords(history: History | null): string[] {
|
||||
// `null` here means we are starting from the initial state (empty list of records).
|
||||
if (history === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// We have only one version at the time of this writing, so we don't need
|
||||
// to do any migration yet. Hopefully we never need to do a breaking change
|
||||
// and this stays at version `1` forever.
|
||||
const latestSchemaVersion = 1;
|
||||
|
||||
switch (history.schemaVersion) {
|
||||
case latestSchemaVersion:
|
||||
return history.records;
|
||||
default:
|
||||
// It's very unlikely that we ever hit this branch.
|
||||
console.warn(
|
||||
`Unknown search history schema version: '${history.schemaVersion}'. ` +
|
||||
`This frontend code was built with the maximum supported schema version ` +
|
||||
`'${latestSchemaVersion}'. The search history will be disabled for this ` +
|
||||
`session to prevent potential history data loss. The cause of the version ` +
|
||||
`mismatch may be that a newer version of the frontend code is running in a ` +
|
||||
`separate tab, or you were mistakenly served with an older version of the ` +
|
||||
`frontend code.`,
|
||||
);
|
||||
|
||||
// Disallow writing to the storage to prevent data loss.
|
||||
this.writable = false;
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
410
assets/js/autocomplete/index.ts
Normal file
410
assets/js/autocomplete/index.ts
Normal file
|
@ -0,0 +1,410 @@
|
|||
import { LocalAutocompleter } from '../utils/local-autocompleter';
|
||||
import * as history from './history';
|
||||
import { AutocompletableInput, TextInputElement } from './input';
|
||||
import {
|
||||
SuggestionsPopup,
|
||||
Suggestions,
|
||||
TagSuggestion,
|
||||
Suggestion,
|
||||
HistorySuggestion,
|
||||
ItemSelectedEvent,
|
||||
} from '../utils/suggestions';
|
||||
import { $$ } from '../utils/dom';
|
||||
import { AutocompleteClient, GetTagSuggestionsRequest } from './client';
|
||||
import { DebouncedCache } from '../utils/debounced-cache';
|
||||
import store from '../utils/store';
|
||||
|
||||
// This lint is dumb, especially in this case because this type alias depends on
|
||||
// the `Autocomplete` symbol, and methods on the `Autocomplete` class depend on
|
||||
// this type alias, so either way there is a circular dependency in type annotations
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
type ActiveAutocomplete = Autocomplete & { input: AutocompletableInput };
|
||||
|
||||
function readHistoryConfig() {
|
||||
if (store.get<boolean>('autocomplete_search_history_hidden')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
maxSuggestionsWhenTyping: store.get<number>('autocomplete_search_history_max_suggestions_when_typing') ?? 3,
|
||||
};
|
||||
}
|
||||
|
||||
class Autocomplete {
|
||||
index: null | 'fetching' | 'unavailable' | LocalAutocompleter = null;
|
||||
input: AutocompletableInput | null = null;
|
||||
popup = new SuggestionsPopup();
|
||||
client = new AutocompleteClient();
|
||||
serverSideTagSuggestions = new DebouncedCache(this.client.getTagSuggestions.bind(this.client));
|
||||
|
||||
constructor() {
|
||||
this.popup.onItemSelected(this.confirmSuggestion.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load the local autocomplete data.
|
||||
*/
|
||||
async fetchLocalAutocomplete() {
|
||||
if (this.index) {
|
||||
// The index is already either fetching or initialized/unavailable, so nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
// Indicate that the index is in the process of fetching so that
|
||||
// we don't try to fetch it again while it's still loading.
|
||||
this.index = 'fetching';
|
||||
|
||||
try {
|
||||
const index = await this.client.getCompiledAutocomplete();
|
||||
this.index = new LocalAutocompleter(index);
|
||||
this.refresh();
|
||||
} catch (error) {
|
||||
this.index = 'unavailable';
|
||||
console.error('Failed to fetch local autocomplete data', error);
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.serverSideTagSuggestions.abortLastSchedule('[Autocomplete] A new user input was received');
|
||||
|
||||
this.input = AutocompletableInput.fromElement(document.activeElement);
|
||||
if (!this.isActive()) {
|
||||
this.popup.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const { input } = this;
|
||||
|
||||
// Initiate the lazy local autocomplete fetch on background if it hasn't been done yet.
|
||||
this.fetchLocalAutocomplete();
|
||||
|
||||
const historyConfig = readHistoryConfig();
|
||||
|
||||
// Show all history suggestions if the input is empty.
|
||||
if (historyConfig && input.snapshot.trimmedValue === '') {
|
||||
this.showSuggestions({
|
||||
history: history.listSuggestions(input),
|
||||
tags: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// When the input is not empty the history suggestions take up
|
||||
// only a small portion of the suggestions.
|
||||
const suggestions: Suggestions = {
|
||||
history: historyConfig ? history.listSuggestions(input, historyConfig.maxSuggestionsWhenTyping) : [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
// There are several scenarios where we don't try to fetch server-side suggestions,
|
||||
// even if we could.
|
||||
//
|
||||
// 1. The `index` is still `fetching`.
|
||||
// We should wait until it's done. Doing concurrent server-side suggestions
|
||||
// request in this case would be optimistically wasteful.
|
||||
//
|
||||
// 2. The `index` is `unavailable`.
|
||||
// We shouldn't fetch server suggestions either because there may be something
|
||||
// horribly wrong on the backend, so we don't want to spam it with even more
|
||||
// requests. This scenario should be extremely rare though.
|
||||
if (
|
||||
!input.snapshot.activeTerm ||
|
||||
!(this.index instanceof LocalAutocompleter) ||
|
||||
suggestions.history.length === this.input.maxSuggestions
|
||||
) {
|
||||
this.showSuggestions(suggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTerm = input.snapshot.activeTerm.term;
|
||||
|
||||
suggestions.tags = this.index
|
||||
.matchPrefix(activeTerm, input.maxSuggestions - suggestions.history.length)
|
||||
.map(result => new TagSuggestion({ ...result, matchLength: activeTerm.length }));
|
||||
|
||||
// Used for debugging server-side completions, to ensure local autocomplete
|
||||
// doesn't prevent sever-side completions from being shown. Use these console
|
||||
// commands to enable/disable server-side completions:
|
||||
// ```js
|
||||
// localStorage.setItem('SERVER_SIDE_COMPLETIONS_ONLY', true)
|
||||
// localStorage.removeItem('SERVER_SIDE_COMPLETIONS_ONLY')
|
||||
// ```
|
||||
if (store.get('SERVER_SIDE_COMPLETIONS_ONLY')) {
|
||||
suggestions.tags = [];
|
||||
}
|
||||
|
||||
// Show suggestions that we already have early without waiting for a potential
|
||||
// server-side suggestions request.
|
||||
this.showSuggestions(suggestions);
|
||||
|
||||
// Only if the index had its chance to provide suggestions
|
||||
// and produced nothing, do we try to fetch server-side suggestions.
|
||||
if (suggestions.tags.length > 0 || activeTerm.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleServerSideSuggestions(activeTerm, suggestions.history);
|
||||
}
|
||||
|
||||
scheduleServerSideSuggestions(this: ActiveAutocomplete, term: string, historySuggestions: HistorySuggestion[]) {
|
||||
const request: GetTagSuggestionsRequest = {
|
||||
term,
|
||||
|
||||
// We always use the `maxSuggestions` value for the limit, because it's a
|
||||
// reasonably small and limited value. Yes, we may overfetch in some cases,
|
||||
// but otherwise the cache hits rate of `DebouncedCache` also increases due
|
||||
// to the less variation in the cache key (request params).
|
||||
limit: this.input.maxSuggestions,
|
||||
};
|
||||
|
||||
this.serverSideTagSuggestions.schedule(request, response => {
|
||||
if (!this.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Truncate the suggestions to the leftover space shared with history suggestions.
|
||||
const maxTags = this.input.maxSuggestions - historySuggestions.length;
|
||||
|
||||
const tags = response.suggestions.slice(0, maxTags).map(
|
||||
suggestion =>
|
||||
new TagSuggestion({
|
||||
...suggestion,
|
||||
matchLength: term.length,
|
||||
}),
|
||||
);
|
||||
|
||||
this.showSuggestions({
|
||||
history: historySuggestions,
|
||||
tags,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showSuggestions(this: ActiveAutocomplete, suggestions: Suggestions) {
|
||||
this.popup.setSuggestions(suggestions).showForElement(this.input.element);
|
||||
}
|
||||
|
||||
onFocusIn() {
|
||||
// The purpose of `focusin` subscription is to bring up the popup with the
|
||||
// initial history suggestions if there is no popup yet. If there is a popup
|
||||
// already, e.g. when we are re-focusing back to the input after the user
|
||||
// selected some suggestion then there is no need to refresh the popup.
|
||||
if (!this.popup.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The event we are processing comes before the input's selection is updated.
|
||||
// Defer the refresh to the next frame to get the updated selection.
|
||||
requestAnimationFrame(() => {
|
||||
// Double-check the popup is still hidden on a new spin of the event loop.
|
||||
// Just in case =)
|
||||
if (!this.popup.isHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
onClick(event: MouseEvent) {
|
||||
if (this.input?.isEnabled() && this.input.element !== event.target) {
|
||||
// We lost focus. Hide the popup.
|
||||
// We use this method instead of the `focusout` event because this way it's
|
||||
// easier to work in the developer tools when you want to inspect the element.
|
||||
// When you inspect it, a `focusout` happens.
|
||||
this.popup.hide();
|
||||
this.input = null;
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (!this.isActive() || this.input.element !== event.target) {
|
||||
return;
|
||||
}
|
||||
if ((event.key === ',' || event.code === 'Enter') && this.input.type === 'single-tag') {
|
||||
// Coma means the end of input for the current tag in single-tag mode.
|
||||
this.popup.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Enter': {
|
||||
const { selectedSuggestion } = this.popup;
|
||||
if (!selectedSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent submission of the form when Enter was hit.
|
||||
// Note, however, that `confirmSuggestion` may still submit the form
|
||||
// manually if the selected suggestion is a history suggestion and
|
||||
// no `Shift` key was pressed.
|
||||
event.preventDefault();
|
||||
|
||||
this.confirmSuggestion({
|
||||
suggestion: selectedSuggestion,
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'Escape': {
|
||||
this.popup.hide();
|
||||
return;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight': {
|
||||
// The event we are processing comes before the input's selection is updated.
|
||||
// Defer the refresh to the next frame to get the updated selection.
|
||||
requestAnimationFrame(() => this.refresh());
|
||||
return;
|
||||
}
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown': {
|
||||
if (event.code === 'ArrowUp') {
|
||||
if (event.ctrlKey) {
|
||||
this.popup.selectCtrlUp();
|
||||
} else {
|
||||
this.popup.selectUp();
|
||||
}
|
||||
} else {
|
||||
if (event.ctrlKey) {
|
||||
this.popup.selectCtrlDown();
|
||||
} else {
|
||||
this.popup.selectDown();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.popup.selectedSuggestion) {
|
||||
this.updateInputWithSelectedValue(this.popup.selectedSuggestion);
|
||||
} else {
|
||||
this.updateInputWithOriginalValue();
|
||||
}
|
||||
|
||||
// Prevent the cursor from moving to the start or end of the input field,
|
||||
// which is the default behavior of the arrow keys are used in a text input.
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
updateInputWithOriginalValue(this: ActiveAutocomplete) {
|
||||
const { element, snapshot } = this.input;
|
||||
const { selection } = snapshot;
|
||||
element.value = snapshot.origValue;
|
||||
element.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
|
||||
}
|
||||
|
||||
confirmSuggestion({ suggestion, shiftKey, ctrlKey }: ItemSelectedEvent) {
|
||||
this.assertActive();
|
||||
|
||||
this.updateInputWithSelectedValue(suggestion);
|
||||
|
||||
const prefix = this.input.snapshot.activeTerm?.prefix ?? '';
|
||||
|
||||
const detail = `${prefix}${suggestion.value()}`;
|
||||
|
||||
const newEvent = new CustomEvent<string>('autocomplete', { detail });
|
||||
|
||||
this.input.element.dispatchEvent(newEvent);
|
||||
|
||||
if (ctrlKey || (suggestion instanceof HistorySuggestion && !shiftKey)) {
|
||||
// We use `requestSubmit()` instead of `submit()` because it triggers the
|
||||
// 'submit' event on the form. We have a handler subscribed to that event
|
||||
// that records the input's value for history tracking.
|
||||
this.input.element.form?.requestSubmit();
|
||||
}
|
||||
|
||||
// XXX: it's important to focus the input element first before hiding the popup,
|
||||
// because if we do it the other way around our `onFocusIn` handler will refresh
|
||||
// the popup and bring it back up, which is not what we want. We want to give a
|
||||
// brief moment of silence for the user without the popup before they type
|
||||
// something else, otherwise we'd show some more completions for the current term.
|
||||
this.input.element.focus();
|
||||
this.popup.hide();
|
||||
}
|
||||
|
||||
updateInputWithSelectedValue(this: ActiveAutocomplete, suggestion: Suggestion) {
|
||||
const {
|
||||
element,
|
||||
snapshot: { activeTerm, origValue },
|
||||
} = this.input;
|
||||
|
||||
const value = suggestion.value();
|
||||
|
||||
if (!activeTerm || suggestion instanceof HistorySuggestion) {
|
||||
element.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const { range, prefix } = activeTerm;
|
||||
|
||||
element.value = origValue.slice(0, range.start) + prefix + value + origValue.slice(range.end);
|
||||
|
||||
const newCursorIndex = range.start + value.length;
|
||||
element.setSelectionRange(newCursorIndex, newCursorIndex);
|
||||
}
|
||||
|
||||
isActive(): this is ActiveAutocomplete {
|
||||
return Boolean(this.input?.isEnabled());
|
||||
}
|
||||
|
||||
assertActive(): asserts this is ActiveAutocomplete {
|
||||
if (this.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Current input when the error happened', this.input);
|
||||
throw new Error(`BUG: expected autocomplete to be active, but it isn't`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Our custom autocomplete isn't compatible with the native browser autocomplete,
|
||||
* so we have to turn it off if our autocomplete is enabled, or turn it back on
|
||||
* if it's disabled.
|
||||
*/
|
||||
function refreshNativeAutocomplete() {
|
||||
const elements = $$<TextInputElement>(
|
||||
'input[data-autocomplete][data-autocomplete-condition], ' +
|
||||
'textarea[data-autocomplete][data-autocomplete-condition]',
|
||||
);
|
||||
|
||||
for (const element of elements) {
|
||||
const input = AutocompletableInput.fromElement(element);
|
||||
if (!input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.autocomplete = input.isEnabled() ? 'off' : 'on';
|
||||
}
|
||||
}
|
||||
|
||||
export function listenAutocomplete() {
|
||||
history.listen();
|
||||
|
||||
const autocomplete = new Autocomplete();
|
||||
|
||||
// Refresh all the state in case any autocomplete settings change.
|
||||
store.watchAll(key => {
|
||||
if (key && key !== 'enable_search_ac' && !key.startsWith('autocomplete')) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshNativeAutocomplete();
|
||||
autocomplete.refresh();
|
||||
});
|
||||
|
||||
refreshNativeAutocomplete();
|
||||
|
||||
// By the time this script loads, the input elements may already be focused,
|
||||
// so we refresh the autocomplete state immediately to trigger the initial completions.
|
||||
autocomplete.refresh();
|
||||
|
||||
document.addEventListener('focusin', autocomplete.onFocusIn.bind(autocomplete));
|
||||
document.addEventListener('input', autocomplete.refresh.bind(autocomplete));
|
||||
document.addEventListener('click', autocomplete.onClick.bind(autocomplete));
|
||||
document.addEventListener('keydown', autocomplete.onKeyDown.bind(autocomplete));
|
||||
}
|
199
assets/js/autocomplete/input.ts
Normal file
199
assets/js/autocomplete/input.ts
Normal file
|
@ -0,0 +1,199 @@
|
|||
import store from '../utils/store';
|
||||
import { getTermContexts } from '../match_query';
|
||||
import { Range } from '../query/lex';
|
||||
|
||||
export type TextInputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
/**
|
||||
* Describes the term, that the cursor is currently on, which is known as "active".
|
||||
* If any tag completion is accepted, this term will be overwritten in the input.
|
||||
* The rest of the input will be left untouched.
|
||||
*/
|
||||
interface ActiveTerm {
|
||||
range: Range;
|
||||
|
||||
/**
|
||||
* The term itself. Stripped from the `prefix` if it's present, and also lowercased.
|
||||
*/
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* Optional `-` prefix is only relevant for the `single-tag` autocompletion type.
|
||||
* This prefix is extracted automatically from the `term` value and is used to
|
||||
* signal that the tag should be removed from the list.
|
||||
*/
|
||||
prefix: '-' | '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the value of the input at the time when the `AutocompletableInput` was created.
|
||||
*/
|
||||
interface AutocompleteInputSnapshot {
|
||||
/**
|
||||
* Original value of the input element at the time when it was created unmodified.
|
||||
*/
|
||||
origValue: string;
|
||||
|
||||
/**
|
||||
* The value of the input element at the time when it was created, but
|
||||
* trimmed from whitespace.
|
||||
*/
|
||||
trimmedValue: string;
|
||||
|
||||
/**
|
||||
* Can be `null` if the input value is empty.
|
||||
*/
|
||||
activeTerm: ActiveTerm | null;
|
||||
|
||||
/**
|
||||
* Cursor selection at the time when the snapshot was taken.
|
||||
*/
|
||||
selection: {
|
||||
start: number | null;
|
||||
end: number | null;
|
||||
direction: TextInputElement['selectionDirection'];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `multi-tags` autocompletion type is used to power inputs with complex
|
||||
* search queries like `(tag1 OR tag2), tag3` and tag lists like `tag1, tag2, tag3`
|
||||
* in the plain tag search/edit inputs.
|
||||
*
|
||||
* The `single-tag` autocompletion type is used to power the fancy tag editor
|
||||
* that manages separate input elements for every tag. In this mode the user
|
||||
* can input `-tag` prefix to remove the tag from the list. See more details
|
||||
* about how it works here: https://github.com/philomena-dev/philomena/pull/383
|
||||
*/
|
||||
type AutocompleteInputType = 'multi-tags' | 'single-tag';
|
||||
|
||||
/**
|
||||
* Parsed version of `TextInputElement`. Its behavior is controlled with various
|
||||
* `data-autocomplete*` attributes.
|
||||
*/
|
||||
export class AutocompletableInput {
|
||||
/**
|
||||
* HTML element that autocomplete is attached to.
|
||||
*/
|
||||
readonly element: TextInputElement;
|
||||
|
||||
readonly type: AutocompleteInputType;
|
||||
|
||||
/**
|
||||
* Captures the value of the input at the time when the `AutocompletableInput` was created.
|
||||
*/
|
||||
readonly snapshot: AutocompleteInputSnapshot;
|
||||
|
||||
/**
|
||||
* Defines the name of the parameter in `localStorage` that should be read
|
||||
* to conditionally enable the autocomplete feature.
|
||||
*/
|
||||
readonly condition?: string;
|
||||
|
||||
/**
|
||||
* An integer that overrides the default limit of maximum suggestions to show.
|
||||
*/
|
||||
readonly maxSuggestions: number;
|
||||
|
||||
/**
|
||||
* If present enables the history feature for the input element. The value
|
||||
* of this property defines the key in the `localStorage` where the history
|
||||
* records are stored.
|
||||
*/
|
||||
readonly historyId?: string;
|
||||
|
||||
/**
|
||||
* Returns `null` only if the element is not autocomplete-capable.
|
||||
*/
|
||||
static fromElement(element: unknown): AutocompletableInput | null {
|
||||
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This attribute marks the element as autocomplete-capable. It doesn't necessarily
|
||||
// mean that the autocomplete **will** show up for the element. It may be disabled
|
||||
// based on the setting value from the key specified under the attribute
|
||||
// `data-autocomplete-condition`.
|
||||
if (!element.dataset.autocomplete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AutocompletableInput(element);
|
||||
}
|
||||
|
||||
private constructor(element: TextInputElement) {
|
||||
this.element = element;
|
||||
this.condition = element.dataset.autocompleteCondition;
|
||||
this.historyId = element.dataset.autocompleteHistoryId;
|
||||
|
||||
const type = element.dataset.autocomplete;
|
||||
|
||||
if (type !== 'multi-tags' && type !== 'single-tag') {
|
||||
throw new Error(`BUG: invalid autocomplete type: ${type}`);
|
||||
}
|
||||
|
||||
this.type = type;
|
||||
this.snapshot = {
|
||||
origValue: element.value,
|
||||
trimmedValue: element.value.trim(),
|
||||
activeTerm: findActiveTerm(type, element),
|
||||
selection: {
|
||||
start: element.selectionStart,
|
||||
end: element.selectionEnd,
|
||||
direction: element.selectionDirection,
|
||||
},
|
||||
};
|
||||
|
||||
const maxSuggestions = element.dataset.autocompleteMaxSuggestions;
|
||||
|
||||
this.maxSuggestions = maxSuggestions ? parseInt(maxSuggestions, 10) : 10;
|
||||
}
|
||||
|
||||
hasHistory(): this is this & { historyId: string } {
|
||||
return Boolean(this.historyId);
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return !this.condition || store.get<boolean>(this.condition) || false;
|
||||
}
|
||||
}
|
||||
|
||||
function findActiveTerm(
|
||||
autocompleteType: AutocompleteInputType,
|
||||
{ value, selectionStart, selectionEnd }: TextInputElement,
|
||||
): ActiveTerm | null {
|
||||
if (selectionStart === null || selectionEnd === null) return null;
|
||||
|
||||
// Technically the user may select several characters and several terms at once,
|
||||
// but we just take the first one from the selection as the "cursor" index.
|
||||
const cursorIndex = Math.min(selectionStart, selectionEnd);
|
||||
|
||||
// Multi-line textarea elements should treat each line as different search queries.
|
||||
// Here we're looking for the actively edited line and use it instead of the whole value.
|
||||
const lineStart = value.lastIndexOf('\n', cursorIndex) + 1;
|
||||
const lineEnd = Math.max(value.indexOf('\n', cursorIndex), value.length);
|
||||
const line = value.slice(lineStart, lineEnd);
|
||||
|
||||
const terms = getTermContexts(line);
|
||||
const searchIndex = cursorIndex - lineStart;
|
||||
|
||||
const term = terms.find(({ range }) => range.start <= searchIndex && range.end >= searchIndex) ?? null;
|
||||
|
||||
if (!term) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { range } = term;
|
||||
const content = term.content.toLowerCase();
|
||||
const stripDash = content.startsWith('-') && autocompleteType === 'single-tag';
|
||||
|
||||
return {
|
||||
term: stripDash ? content.slice(1) : content,
|
||||
prefix: stripDash ? '-' : '',
|
||||
range: {
|
||||
// Convert line-specific indexes back to absolute ones.
|
||||
start: range.start + lineStart,
|
||||
end: range.end + lineStart,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -22,8 +22,15 @@ const tokenList: Token[] = [
|
|||
|
||||
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
|
||||
|
||||
export type Range = [number, number];
|
||||
export type TermContext = [Range, string];
|
||||
export interface Range {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface TermContext {
|
||||
range: Range;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LexResult {
|
||||
tokenList: TokenList;
|
||||
|
@ -61,7 +68,11 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
|
|||
if (searchTerm !== null) {
|
||||
// Push to stack.
|
||||
ret.tokenList.push(parseTerm(searchTerm, fuzz, boost));
|
||||
ret.termContexts.push([[termIndex, termIndex + searchTerm.length], searchTerm]);
|
||||
|
||||
ret.termContexts.push({
|
||||
range: { start: termIndex, end: termIndex + searchTerm.length },
|
||||
content: searchTerm,
|
||||
});
|
||||
// Reset term and options data.
|
||||
boost = 1;
|
||||
fuzz = 0;
|
||||
|
|
|
@ -28,17 +28,41 @@ function setupThemeSettings() {
|
|||
themeColorSelect.addEventListener('change', themePreviewCallback);
|
||||
}
|
||||
|
||||
function hideIf(element: HTMLElement, condition: boolean) {
|
||||
if (condition) {
|
||||
element.classList.add('hidden');
|
||||
} else {
|
||||
element.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setupAutocompleteSettings() {
|
||||
const autocompleteSettings = assertNotNull($<HTMLElement>('.autocomplete-settings'));
|
||||
|
||||
// Don't show search history settings if autocomplete is entirely disabled.
|
||||
assertNotNull($('#user_enable_search_ac')).addEventListener('change', event => {
|
||||
hideIf(autocompleteSettings, !(event.target as HTMLInputElement).checked);
|
||||
});
|
||||
|
||||
const autocompleteSearchHistorySettings = assertNotNull($<HTMLElement>('.autocomplete-search-history-settings'));
|
||||
|
||||
assertNotNull($('#user_autocomplete_search_history_hidden')).addEventListener('change', event => {
|
||||
hideIf(autocompleteSearchHistorySettings, (event.target as HTMLInputElement).checked);
|
||||
});
|
||||
}
|
||||
|
||||
export function setupSettings() {
|
||||
if (!$('#js-setting-table')) return;
|
||||
|
||||
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');
|
||||
|
||||
// Local settings
|
||||
localCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
store.set(checkbox.id.replace('user_', ''), checkbox.checked);
|
||||
for (const input of $$<HTMLInputElement>('[data-tab="local"] input')) {
|
||||
input.addEventListener('change', () => {
|
||||
const newValue = input.type === 'checkbox' ? input.checked : input.value;
|
||||
|
||||
store.set(input.id.replace('user_', ''), newValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupThemeSettings();
|
||||
setupAutocompleteSettings();
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import { assertNotNull, assertType } from './utils/assert';
|
||||
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
|
||||
import { TermSuggestion } from './utils/suggestions';
|
||||
|
||||
export function setupTagsInput(tagBlock: HTMLDivElement) {
|
||||
const form = assertNotNull(tagBlock.closest('form'));
|
||||
|
@ -48,8 +47,8 @@ export function setupTagsInput(tagBlock: HTMLDivElement) {
|
|||
importTags();
|
||||
}
|
||||
|
||||
function handleAutocomplete(event: CustomEvent<TermSuggestion>) {
|
||||
insertTag(event.detail.value);
|
||||
function handleAutocomplete(event: CustomEvent<string>) {
|
||||
insertTag(event.detail);
|
||||
inputField.focus();
|
||||
}
|
||||
|
||||
|
|
150
assets/js/utils/__tests__/debounced-cache.spec.ts
Normal file
150
assets/js/utils/__tests__/debounced-cache.spec.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import { DebouncedCache } from '../debounced-cache';
|
||||
|
||||
describe('DebouncedCache', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
const consoleSpy = {
|
||||
debug: vi.spyOn(console, 'debug'),
|
||||
error: vi.spyOn(console, 'error'),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.debug.mockClear();
|
||||
consoleSpy.error.mockClear();
|
||||
});
|
||||
|
||||
it('should call the function after a debounce threshold and cache the result', async () => {
|
||||
const { producer, cache } = createTestCache();
|
||||
|
||||
const consumer = vi.fn();
|
||||
|
||||
cache.schedule({ a: 1, b: 2 }, consumer);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(producer).toHaveBeenCalledWith({ a: 1, b: 2 });
|
||||
expect(consumer).toHaveBeenCalledWith(3);
|
||||
|
||||
cache.schedule({ a: 1, b: 2 }, consumer);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(producer).toHaveBeenCalledTimes(1);
|
||||
expect(consumer).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(consoleSpy.debug).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('should abort the last scheduled call when a new one is scheduled', () => {
|
||||
test('scheduling before the debounce threshold is reached', async () => {
|
||||
const { producer, cache } = createTestCache();
|
||||
|
||||
const consumer1 = vi.fn();
|
||||
const consumer2 = vi.fn();
|
||||
|
||||
cache.schedule({ a: 1, b: 2 }, consumer1);
|
||||
cache.schedule({ a: 1, b: 2 }, consumer2);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consumer1).not.toHaveBeenCalled();
|
||||
expect(consumer2).toHaveBeenCalledWith(3);
|
||||
expect(producer).toHaveBeenCalledOnce();
|
||||
|
||||
// No logs should be emitted because the `setTimeout` call itself should have been aborted.
|
||||
expect(consoleSpy.debug.mock.calls).toMatchInlineSnapshot(`[]`);
|
||||
expect(consoleSpy.error.mock.calls).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
|
||||
test('scheduling after the debounce threshold is reached', async () => {
|
||||
const threshold = 300;
|
||||
const { producer, cache } = createTestCache(threshold);
|
||||
|
||||
const consumer1 = vi.fn();
|
||||
const consumer2 = vi.fn();
|
||||
|
||||
cache.schedule({ a: 1, b: 2 }, consumer1);
|
||||
vi.advanceTimersByTime(threshold);
|
||||
|
||||
cache.schedule({ a: 1, b: 2 }, consumer2);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consumer1).not.toHaveBeenCalled();
|
||||
expect(consumer2).toHaveBeenCalledWith(3);
|
||||
expect(producer).toHaveBeenCalledOnce();
|
||||
|
||||
expect(consoleSpy.debug.mock.calls).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"A call was aborted after the debounce threshold was reached",
|
||||
DOMException {},
|
||||
],
|
||||
]
|
||||
`);
|
||||
expect(consoleSpy.error.mock.calls).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle errors by logging them', () => {
|
||||
test('error in producer', async () => {
|
||||
const producer = vi.fn(() => Promise.reject(new Error('producer error')));
|
||||
const cache = new DebouncedCache(producer);
|
||||
|
||||
const consumer = vi.fn();
|
||||
|
||||
cache.schedule(undefined, consumer);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consumer).not.toHaveBeenCalled();
|
||||
|
||||
expect(consoleSpy.debug).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.error.mock.calls).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"An error occurred while calling 'spy'.",
|
||||
[Error: producer error],
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('error in consumer', async () => {
|
||||
const { producer, cache } = createTestCache();
|
||||
|
||||
const consumer = vi.fn(() => {
|
||||
throw new Error('consumer error');
|
||||
});
|
||||
|
||||
cache.schedule({ a: 1, b: 2 }, consumer);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(producer).toHaveBeenCalledOnce();
|
||||
|
||||
expect(consoleSpy.debug).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.error.mock.calls).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"An error occurred while processing the result of 'producerImpl'.",
|
||||
[Error: consumer error],
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createTestCache(thresholdMs?: number) {
|
||||
const producer = vi.fn(producerImpl);
|
||||
const cache = new DebouncedCache(producer, { thresholdMs });
|
||||
|
||||
return { producer, cache };
|
||||
}
|
||||
|
||||
interface ProducerParams {
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
async function producerImpl(params: ProducerParams): Promise<number> {
|
||||
return params.a + params.b;
|
||||
}
|
|
@ -1,12 +1,4 @@
|
|||
import {
|
||||
delegate,
|
||||
fire,
|
||||
mouseMoveThenOver,
|
||||
leftClick,
|
||||
on,
|
||||
PhilomenaAvailableEventsMap,
|
||||
oncePersistedPageShown,
|
||||
} from '../events';
|
||||
import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap, oncePersistedPageShown } from '../events';
|
||||
import { getRandomArrayItem } from '../../../test/randomness';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
|
||||
|
@ -88,55 +80,6 @@ describe('Event utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('mouseMoveThenOver', () => {
|
||||
it('should NOT fire on first mouseover', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseOver(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should fire on the first mousemove', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseMove(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fire on subsequent mouseover', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseMove(mockButton);
|
||||
fireEvent.mouseOver(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should NOT fire on subsequent mousemove', () => {
|
||||
const mockButton = document.createElement('button');
|
||||
const mockHandler = vi.fn();
|
||||
|
||||
mouseMoveThenOver(mockButton, mockHandler);
|
||||
|
||||
fireEvent.mouseMove(mockButton);
|
||||
fireEvent.mouseOver(mockButton);
|
||||
fireEvent.mouseMove(mockButton);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oncePersistedPageShown', () => {
|
||||
it('should NOT fire on usual page show', () => {
|
||||
const mockHandler = vi.fn();
|
||||
|
|
58
assets/js/utils/__tests__/http-client.spec.ts
Normal file
58
assets/js/utils/__tests__/http-client.spec.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { HttpClient } from '../http-client';
|
||||
import { fetchMock } from '../../../test/fetch-mock';
|
||||
|
||||
describe('HttpClient', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock.enableMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
});
|
||||
|
||||
it('should throw an HttpError on non-OK responses', async () => {
|
||||
const client = new HttpClient();
|
||||
|
||||
fetchMock.mockResponse('Not Found', { status: 404, statusText: 'Not Found' });
|
||||
|
||||
await expect(client.fetch('/', {})).rejects.toThrowError(/404: Not Found/);
|
||||
|
||||
// 404 is non-retryable
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should retry 500 errors', async () => {
|
||||
const client = new HttpClient();
|
||||
|
||||
fetchMock.mockResponses(
|
||||
['Internal Server Error', { status: 500, statusText: 'Internal Server Error' }],
|
||||
['OK', { status: 200, statusText: 'OK' }],
|
||||
);
|
||||
|
||||
const promise = expect(client.fetch('/', {})).resolves.toMatchObject({ status: 200, statusText: 'OK' });
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not retry AbortError', async () => {
|
||||
const client = new HttpClient();
|
||||
|
||||
fetchMock.mockResponse('OK', { status: 200, statusText: 'OK' });
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
const promise = expect(client.fetch('/', { signal: abortController.signal })).rejects.toThrowError(
|
||||
'The operation was aborted.',
|
||||
);
|
||||
|
||||
abortController.abort();
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
|
@ -3,9 +3,8 @@ import { promises } from 'fs';
|
|||
import { join } from 'path';
|
||||
import { TextDecoder } from 'util';
|
||||
|
||||
describe('Local Autocompleter', () => {
|
||||
describe('LocalAutocompleter', () => {
|
||||
let mockData: ArrayBuffer;
|
||||
const defaultK = 5;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin');
|
||||
|
@ -44,59 +43,81 @@ describe('Local Autocompleter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('topK', () => {
|
||||
describe('matchPrefix', () => {
|
||||
const termStem = ['f', 'o'].join('');
|
||||
|
||||
let localAutocomplete: LocalAutocompleter;
|
||||
function expectLocalAutocomplete(term: string, topK = 5) {
|
||||
const localAutocomplete = new LocalAutocompleter(mockData);
|
||||
const results = localAutocomplete.matchPrefix(term, topK);
|
||||
const actual = results.map(result => {
|
||||
const canonical = `${result.canonical} (${result.images})`;
|
||||
return result.alias ? `${result.alias} -> ${canonical}` : canonical;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
localAutocomplete = new LocalAutocompleter(mockData);
|
||||
});
|
||||
return expect(actual);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.booru.hiddenTagList = [];
|
||||
});
|
||||
|
||||
it('should return suggestions for exact tag name match', () => {
|
||||
const result = localAutocomplete.matchPrefix('safe', defaultK);
|
||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'safe', name: 'safe', imageCount: 6 })]);
|
||||
expectLocalAutocomplete('safe').toMatchInlineSnapshot(`
|
||||
[
|
||||
"safe (6)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return suggestion for original tag when passed an alias', () => {
|
||||
const result = localAutocomplete.matchPrefix('flowers', defaultK);
|
||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'flowers', name: 'flower', imageCount: 1 })]);
|
||||
it('should return suggestion for an alias', () => {
|
||||
expectLocalAutocomplete('flowers').toMatchInlineSnapshot(`
|
||||
[
|
||||
"flowers -> flower (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should prefer canonical tag over an alias when both match', () => {
|
||||
expectLocalAutocomplete('flo').toMatchInlineSnapshot(`
|
||||
[
|
||||
"flower (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return suggestions sorted by image count', () => {
|
||||
const result = localAutocomplete.matchPrefix(termStem, defaultK);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 }),
|
||||
expect.objectContaining({ aliasName: 'fog', name: 'fog', imageCount: 1 }),
|
||||
expect.objectContaining({ aliasName: 'force field', name: 'force field', imageCount: 1 }),
|
||||
]);
|
||||
expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`
|
||||
[
|
||||
"forest (3)",
|
||||
"fog (1)",
|
||||
"force field (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return namespaced suggestions without including namespace', () => {
|
||||
const result = localAutocomplete.matchPrefix('test', defaultK);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ aliasName: 'artist:test', name: 'artist:test', imageCount: 1 }),
|
||||
]);
|
||||
expectLocalAutocomplete('test').toMatchInlineSnapshot(`
|
||||
[
|
||||
"artist:test (1)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should return only the required number of suggestions', () => {
|
||||
const result = localAutocomplete.matchPrefix(termStem, 1);
|
||||
expect(result).toEqual([expect.objectContaining({ aliasName: 'forest', name: 'forest', imageCount: 3 })]);
|
||||
expectLocalAutocomplete(termStem, 1).toMatchInlineSnapshot(`
|
||||
[
|
||||
"forest (3)",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should NOT return suggestions associated with hidden tags', () => {
|
||||
window.booru.hiddenTagList = [1];
|
||||
const result = localAutocomplete.matchPrefix(termStem, defaultK);
|
||||
expect(result).toEqual([]);
|
||||
expectLocalAutocomplete(termStem).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
|
||||
it('should return empty array for empty prefix', () => {
|
||||
const result = localAutocomplete.matchPrefix('', defaultK);
|
||||
expect(result).toEqual([]);
|
||||
expectLocalAutocomplete('').toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
216
assets/js/utils/__tests__/retry.spec.ts
Normal file
216
assets/js/utils/__tests__/retry.spec.ts
Normal file
|
@ -0,0 +1,216 @@
|
|||
import { mockDateNow, mockRandom } from '../../../test/mock';
|
||||
import { retry, RetryFunc, RetryParams } from '../retry';
|
||||
|
||||
describe('retry', () => {
|
||||
async function expectRetry<R>(params: RetryParams, maybeFunc?: RetryFunc<R>) {
|
||||
const func = maybeFunc ?? (() => Promise.reject(new Error('always failing')));
|
||||
const spy = vi.fn(func);
|
||||
|
||||
// Preserve the empty name of the anonymous functions. Spy wrapper overrides it.
|
||||
const funcParam = func.name === '' ? (...args: Parameters<RetryFunc<R>>) => spy(...args) : spy;
|
||||
|
||||
const promise = retry(funcParam, params).catch(err => `throw ${err}`);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
const retries = spy.mock.calls.map(([attempt, nextDelayMs]) => {
|
||||
const suffix = nextDelayMs === undefined ? '' : 'ms';
|
||||
return `${attempt}: ${nextDelayMs}${suffix}`;
|
||||
});
|
||||
|
||||
return expect([...retries, result]);
|
||||
}
|
||||
|
||||
// Remove randomness and real delays from the tests.
|
||||
mockRandom();
|
||||
mockDateNow(0);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error');
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('stops on a successful attempt', () => {
|
||||
it('first attempt', async () => {
|
||||
(await expectRetry({}, async () => 'ok')).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"ok",
|
||||
]
|
||||
`);
|
||||
});
|
||||
it('middle attempt', async () => {
|
||||
const func: RetryFunc<'ok'> = async attempt => {
|
||||
if (attempt !== 2) {
|
||||
throw new Error('middle failure');
|
||||
}
|
||||
return 'ok';
|
||||
};
|
||||
|
||||
(await expectRetry({}, func)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"ok",
|
||||
]
|
||||
`);
|
||||
});
|
||||
it('last attempt', async () => {
|
||||
const func: RetryFunc<'ok'> = async attempt => {
|
||||
if (attempt !== 3) {
|
||||
throw new Error('last failure');
|
||||
}
|
||||
return 'ok';
|
||||
};
|
||||
|
||||
(await expectRetry({}, func)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"3: undefined",
|
||||
"ok",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('produces a reasonable retry sequence within maxAttempts', async () => {
|
||||
(await expectRetry({})).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"3: undefined",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
|
||||
(await expectRetry({ maxAttempts: 5 })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"3: 600ms",
|
||||
"4: 1125ms",
|
||||
"5: undefined",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('turns into a fixed delay retry algorithm if min/max bounds are equal', async () => {
|
||||
(await expectRetry({ maxAttempts: 3, minDelayMs: 200, maxDelayMs: 200 })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 200ms",
|
||||
"3: undefined",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('allows for zero delay', async () => {
|
||||
(await expectRetry({ maxAttempts: 3, minDelayMs: 0, maxDelayMs: 0 })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 0ms",
|
||||
"2: 0ms",
|
||||
"3: undefined",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
describe('fails on first non-retryable error', () => {
|
||||
it('all errors are retryable', async () => {
|
||||
(await expectRetry({ isRetryable: () => false })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
});
|
||||
it('middle error is non-retriable', async () => {
|
||||
const func: RetryFunc<never> = async attempt => {
|
||||
if (attempt === 3) {
|
||||
throw new Error('non-retryable');
|
||||
}
|
||||
throw new Error('retryable');
|
||||
};
|
||||
|
||||
const params: RetryParams = {
|
||||
isRetryable: error => error.message === 'retryable',
|
||||
};
|
||||
|
||||
(await expectRetry(params, func)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"3: undefined",
|
||||
"throw Error: non-retryable",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid inputs', async () => {
|
||||
(await expectRetry({ maxAttempts: 0 })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"throw Error: Invalid 'maxAttempts' for retry: 0",
|
||||
]
|
||||
`);
|
||||
(await expectRetry({ minDelayMs: -1 })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"throw Error: Invalid 'minDelayMs' for retry: -1",
|
||||
]
|
||||
`);
|
||||
(await expectRetry({ maxDelayMs: 100 })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"throw Error: Invalid 'maxDelayMs' for retry: 100, 'minDelayMs' is 200",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should use the provided label in logs', async () => {
|
||||
(await expectRetry({ label: 'test-routine' })).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"3: undefined",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"All 3 attempts of running test-routine failed",
|
||||
[Error: always failing],
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should use the function name in logs', async () => {
|
||||
async function testFunc() {
|
||||
throw new Error('always failing');
|
||||
}
|
||||
|
||||
(await expectRetry({}, testFunc)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"1: 200ms",
|
||||
"2: 300ms",
|
||||
"3: undefined",
|
||||
"throw Error: always failing",
|
||||
]
|
||||
`);
|
||||
|
||||
expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
"All 3 attempts of running testFunc failed",
|
||||
[Error: always failing],
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -2,7 +2,7 @@ import store, { lastUpdatedSuffix } from '../store';
|
|||
import { mockStorageImpl } from '../../../test/mock-storage';
|
||||
import { getRandomIntBetween } from '../../../test/randomness';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { mockDateNow } from '../../../test/mock-date-now';
|
||||
import { mockDateNow } from '../../../test/mock';
|
||||
|
||||
describe('Store utilities', () => {
|
||||
const { setItemSpy, getItemSpy, removeItemSpy, forceStorageError, setStorageValue } = mockStorageImpl();
|
||||
|
|
|
@ -1,37 +1,35 @@
|
|||
import { fetchMock } from '../../../test/fetch-mock.ts';
|
||||
import {
|
||||
fetchLocalAutocomplete,
|
||||
fetchSuggestions,
|
||||
formatLocalAutocompleteResult,
|
||||
purgeSuggestionsCache,
|
||||
SuggestionsPopup,
|
||||
TermSuggestion,
|
||||
TagSuggestion,
|
||||
TagSuggestionParams,
|
||||
Suggestions,
|
||||
HistorySuggestion,
|
||||
ItemSelectedEvent,
|
||||
} from '../suggestions.ts';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { LocalAutocompleter } from '../local-autocompleter.ts';
|
||||
import { afterEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { getRandomIntBetween } from '../../../test/randomness.ts';
|
||||
import { assertNotNull } from '../assert.ts';
|
||||
|
||||
const mockedSuggestionsEndpoint = '/endpoint?term=';
|
||||
const mockedSuggestionsResponse = [
|
||||
{ label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' },
|
||||
{ label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' },
|
||||
{ label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' },
|
||||
{ label: 'artist:devinian (1)', value: 'artist:devinian' },
|
||||
{ label: 'artist:moe (1)', value: 'artist:moe' },
|
||||
];
|
||||
const mockedSuggestions: Suggestions = {
|
||||
history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestion(content, 0)),
|
||||
tags: [
|
||||
{ images: 10, canonical: 'artist:assasinmonkey' },
|
||||
{ images: 10, canonical: 'artist:hydrusbeta' },
|
||||
{ images: 10, canonical: 'artist:the sexy assistant' },
|
||||
{ images: 10, canonical: 'artist:devinian' },
|
||||
{ images: 10, canonical: 'artist:moe' },
|
||||
].map(tags => new TagSuggestion({ ...tags, matchLength: 0 })),
|
||||
};
|
||||
|
||||
function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] {
|
||||
const input = document.createElement('input');
|
||||
const popup = new SuggestionsPopup();
|
||||
|
||||
document.body.append(input);
|
||||
popup.showForField(input);
|
||||
popup.showForElement(input);
|
||||
|
||||
if (includeMockedSuggestions) {
|
||||
popup.renderSuggestions(mockedSuggestionsResponse);
|
||||
popup.setSuggestions(mockedSuggestions);
|
||||
}
|
||||
|
||||
return [popup, input];
|
||||
|
@ -40,27 +38,9 @@ function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [S
|
|||
const selectedItemClassName = 'autocomplete__item--selected';
|
||||
|
||||
describe('Suggestions', () => {
|
||||
let mockedAutocompleteBuffer: ArrayBuffer;
|
||||
let popup: SuggestionsPopup | undefined;
|
||||
let input: HTMLInputElement | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
fetchMock.enableMocks();
|
||||
|
||||
mockedAutocompleteBuffer = await fs.promises
|
||||
.readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin'))
|
||||
.then(fileBuffer => fileBuffer.buffer);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.disableMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
purgeSuggestionsCache();
|
||||
fetchMock.resetMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (input) {
|
||||
input.remove();
|
||||
|
@ -69,6 +49,7 @@ describe('Suggestions', () => {
|
|||
|
||||
if (popup) {
|
||||
popup.hide();
|
||||
popup.setSuggestions({ history: [], tags: [] });
|
||||
popup = undefined;
|
||||
}
|
||||
});
|
||||
|
@ -78,113 +59,113 @@ describe('Suggestions', () => {
|
|||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement);
|
||||
expect(popup.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should be removed when hidden', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
popup.hide();
|
||||
|
||||
expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement);
|
||||
expect(popup.isActive).toBe(false);
|
||||
expect(popup.isHidden).toBe(false);
|
||||
});
|
||||
|
||||
it('should render suggestions', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length);
|
||||
expect(document.querySelectorAll('.autocomplete__item').length).toBe(
|
||||
mockedSuggestions.history.length + mockedSuggestions.tags.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('should initially select first element when selectNext called', () => {
|
||||
it('should initially select first element when selectDown is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should initially select last element when selectPrevious called', () => {
|
||||
it('should initially select last element when selectUp is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
popup.selectPrevious();
|
||||
popup.selectUp();
|
||||
|
||||
expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should select and de-select items when hovering items over', () => {
|
||||
it('should jump to the next lower block when selectCtrlDown is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item:first-child');
|
||||
const lastItem = document.querySelector('.autocomplete__item:last-child');
|
||||
popup.selectCtrlDown();
|
||||
|
||||
if (firstItem) {
|
||||
fireEvent.mouseOver(firstItem);
|
||||
fireEvent.mouseMove(firstItem);
|
||||
}
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags[0]);
|
||||
expect(document.querySelector('.autocomplete__item__tag')).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(firstItem).toHaveClass(selectedItemClassName);
|
||||
popup.selectCtrlDown();
|
||||
|
||||
if (lastItem) {
|
||||
fireEvent.mouseOver(lastItem);
|
||||
fireEvent.mouseMove(lastItem);
|
||||
}
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
|
||||
expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(firstItem).not.toHaveClass(selectedItemClassName);
|
||||
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||
|
||||
if (lastItem) {
|
||||
fireEvent.mouseOut(lastItem);
|
||||
}
|
||||
|
||||
expect(lastItem).not.toHaveClass(selectedItemClassName);
|
||||
// Should loop around
|
||||
popup.selectCtrlDown();
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
|
||||
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should allow switching between mouse and selection', () => {
|
||||
it('should jump to the next upper block when selectCtrlUp is called', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
const secondItem = document.querySelector('.autocomplete__item:nth-child(2)');
|
||||
const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)');
|
||||
popup.selectCtrlUp();
|
||||
|
||||
if (secondItem) {
|
||||
fireEvent.mouseOver(secondItem);
|
||||
fireEvent.mouseMove(secondItem);
|
||||
}
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
|
||||
expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(secondItem).toHaveClass(selectedItemClassName);
|
||||
popup.selectCtrlUp();
|
||||
|
||||
popup.selectNext();
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history.at(-1));
|
||||
expect(
|
||||
document.querySelector(`.autocomplete__item__history:nth-child(${mockedSuggestions.history.length})`),
|
||||
).toHaveClass(selectedItemClassName);
|
||||
|
||||
expect(secondItem).not.toHaveClass(selectedItemClassName);
|
||||
expect(thirdItem).toHaveClass(selectedItemClassName);
|
||||
popup.selectCtrlUp();
|
||||
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
|
||||
expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName);
|
||||
|
||||
// Should loop around
|
||||
popup.selectCtrlUp();
|
||||
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1));
|
||||
expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
||||
it('should do nothing on selection changes when empty', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
popup.selectDown();
|
||||
popup.selectUp();
|
||||
popup.selectCtrlDown();
|
||||
popup.selectCtrlUp();
|
||||
|
||||
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should loop around when selecting next on last and previous on first', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item:first-child');
|
||||
const lastItem = document.querySelector('.autocomplete__item:last-child');
|
||||
const firstItem = assertNotNull(document.querySelector('.autocomplete__item:first-child'));
|
||||
const lastItem = assertNotNull(document.querySelector('.autocomplete__item:last-child'));
|
||||
|
||||
if (lastItem) {
|
||||
fireEvent.mouseOver(lastItem);
|
||||
fireEvent.mouseMove(lastItem);
|
||||
}
|
||||
popup.selectUp();
|
||||
|
||||
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(firstItem).toHaveClass(selectedItemClassName);
|
||||
|
||||
popup.selectPrevious();
|
||||
popup.selectUp();
|
||||
|
||||
expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull();
|
||||
|
||||
popup.selectPrevious();
|
||||
popup.selectUp();
|
||||
|
||||
expect(lastItem).toHaveClass(selectedItemClassName);
|
||||
});
|
||||
|
@ -192,176 +173,109 @@ describe('Suggestions', () => {
|
|||
it('should return selected item value', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
expect(popup.selectedTerm).toBe(null);
|
||||
expect(popup.selectedSuggestion).toBe(null);
|
||||
|
||||
popup.selectNext();
|
||||
popup.selectDown();
|
||||
|
||||
expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value);
|
||||
expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]);
|
||||
});
|
||||
|
||||
it('should emit an event when item was clicked with mouse', () => {
|
||||
it('should emit an event when an item was clicked with a mouse', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup(true);
|
||||
|
||||
let clickEvent: CustomEvent<TermSuggestion> | undefined;
|
||||
|
||||
const itemSelectedHandler = vi.fn((event: CustomEvent<TermSuggestion>) => {
|
||||
clickEvent = event;
|
||||
});
|
||||
const itemSelectedHandler = vi.fn<(event: ItemSelectedEvent) => void>();
|
||||
|
||||
popup.onItemSelected(itemSelectedHandler);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item');
|
||||
const firstItem = assertNotNull(document.querySelector('.autocomplete__item'));
|
||||
|
||||
if (firstItem) {
|
||||
fireEvent.click(firstItem);
|
||||
}
|
||||
fireEvent.click(firstItem);
|
||||
|
||||
expect(itemSelectedHandler).toBeCalledTimes(1);
|
||||
expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]);
|
||||
});
|
||||
|
||||
it('should not emit selection on items without value', () => {
|
||||
[popup, input] = mockBaseSuggestionsPopup();
|
||||
|
||||
popup.renderSuggestions([{ label: 'Option without value', value: '' }]);
|
||||
|
||||
const itemSelectionHandler = vi.fn();
|
||||
|
||||
popup.onItemSelected(itemSelectionHandler);
|
||||
|
||||
const firstItem = document.querySelector('.autocomplete__item:first-child')!;
|
||||
|
||||
if (firstItem) {
|
||||
fireEvent.click(firstItem);
|
||||
}
|
||||
|
||||
expect(itemSelectionHandler).not.toBeCalled();
|
||||
expect(itemSelectedHandler).toBeCalledWith({
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
suggestion: mockedSuggestions.history[0],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSuggestions', () => {
|
||||
it('should only call fetch once per single term', () => {
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should be case-insensitive to terms and trim spaces', () => {
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, 'Art');
|
||||
fetchSuggestions(mockedSuggestionsEndpoint, ' ART ');
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return the same suggestions from cache', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||
|
||||
const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(firstSuggestions).toBe(secondSuggestions);
|
||||
});
|
||||
|
||||
it('should parse and return array of suggestions', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||
|
||||
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||
expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length);
|
||||
expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse);
|
||||
});
|
||||
|
||||
it('should return empty array on server error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||
|
||||
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag');
|
||||
|
||||
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||
expect(resolvedSuggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array on invalid response format', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 }));
|
||||
|
||||
const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response');
|
||||
|
||||
expect(resolvedSuggestions).toBeInstanceOf(Array);
|
||||
expect(resolvedSuggestions.length).toBe(0);
|
||||
describe('HistorySuggestion', () => {
|
||||
it('should render the suggestion', () => {
|
||||
expectHistoryRender('foo bar').toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " foo bar",
|
||||
"value": "foo bar",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purgeSuggestionsCache', () => {
|
||||
it('should clear cached responses', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 }));
|
||||
|
||||
const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
purgeSuggestionsCache();
|
||||
const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art');
|
||||
|
||||
expect(fetch).toBeCalledTimes(2);
|
||||
expect(firstResult).not.toBe(resultAfterPurge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLocalAutocomplete', () => {
|
||||
it('should request binary with date-related cache key', () => {
|
||||
fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
|
||||
|
||||
const now = new Date();
|
||||
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||
const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`;
|
||||
|
||||
fetchLocalAutocomplete();
|
||||
|
||||
expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' });
|
||||
});
|
||||
|
||||
it('should return auto-completer instance', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 }));
|
||||
|
||||
const autocomplete = await fetchLocalAutocomplete();
|
||||
|
||||
expect(autocomplete).toBeInstanceOf(LocalAutocompleter);
|
||||
});
|
||||
|
||||
it('should throw generic server error on failing response', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('error', { status: 500 }));
|
||||
|
||||
expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLocalAutocompleteResult', () => {
|
||||
describe('TagSuggestion', () => {
|
||||
it('should format suggested tags as tag name and the count', () => {
|
||||
const tagName = 'safe';
|
||||
const tagCount = getRandomIntBetween(5, 10);
|
||||
|
||||
const resultObject = formatLocalAutocompleteResult({
|
||||
name: tagName,
|
||||
aliasName: tagName,
|
||||
imageCount: tagCount,
|
||||
});
|
||||
|
||||
expect(resultObject.label).toBe(`${tagName} (${tagCount})`);
|
||||
expect(resultObject.value).toBe(tagName);
|
||||
expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 10",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 10 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 100 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 1 000 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(`
|
||||
{
|
||||
"label": " safe 10 000 000",
|
||||
"value": "safe",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should display original alias name for aliased tags', () => {
|
||||
const tagName = 'safe';
|
||||
const tagAlias = 'rating:safe';
|
||||
const tagCount = getRandomIntBetween(5, 10);
|
||||
|
||||
const resultObject = formatLocalAutocompleteResult({
|
||||
name: tagName,
|
||||
aliasName: tagAlias,
|
||||
imageCount: tagCount,
|
||||
});
|
||||
|
||||
expect(resultObject.label).toBe(`${tagAlias} ⇒ ${tagName} (${tagCount})`);
|
||||
expect(resultObject.value).toBe(tagName);
|
||||
it('should display alias -> canonical for aliased tags', () => {
|
||||
expectTagRender({ images: 10, canonical: 'safe', alias: 'rating:safe' }).toMatchInlineSnapshot(
|
||||
`
|
||||
{
|
||||
"label": " rating:safe → safe 10",
|
||||
"value": "safe",
|
||||
}
|
||||
`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectHistoryRender(content: string) {
|
||||
const suggestion = new HistorySuggestion(content, 0);
|
||||
const label = suggestion
|
||||
.render()
|
||||
.map(el => el.textContent)
|
||||
.join('');
|
||||
const value = suggestion.value();
|
||||
|
||||
return expect({ label, value });
|
||||
}
|
||||
|
||||
function expectTagRender(params: Omit<TagSuggestionParams, 'matchLength'>) {
|
||||
const suggestion = new TagSuggestion({ ...params, matchLength: 0 });
|
||||
const label = suggestion
|
||||
.render()
|
||||
.map(el => el.textContent)
|
||||
.join('');
|
||||
const value = suggestion.value();
|
||||
|
||||
return expect({ label, value });
|
||||
}
|
||||
|
|
129
assets/js/utils/debounced-cache.ts
Normal file
129
assets/js/utils/debounced-cache.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
export interface DebouncedCacheParams {
|
||||
/**
|
||||
* Time in milliseconds to wait before calling the function.
|
||||
*/
|
||||
thresholdMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function, caches its results and debounces calls to it.
|
||||
*
|
||||
* *Debouncing* means that if the function is called multiple times within
|
||||
* the `thresholdMs` interval, then every new call resets the timer
|
||||
* and only the last call to the function will be executed after the timer
|
||||
* reaches the `thresholdMs` value. Also, in-progress operation
|
||||
* will be aborted, however, the result will still be cached, only the
|
||||
* result processing callback will not be called.
|
||||
*
|
||||
* See more details about the concept of debouncing here:
|
||||
* https://lodash.com/docs/4.17.15#debounce.
|
||||
*
|
||||
*
|
||||
* If the function is called with the arguments that were already cached,
|
||||
* then the cached result will be returned immediately and the previous
|
||||
* scheduled call will be cancelled.
|
||||
*/
|
||||
export class DebouncedCache<Params, R> {
|
||||
private thresholdMs: number;
|
||||
private cache = new Map<string, Promise<R>>();
|
||||
private func: (params: Params) => Promise<R>;
|
||||
|
||||
private lastSchedule?: {
|
||||
timeout?: ReturnType<typeof setTimeout>;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
constructor(func: (params: Params) => Promise<R>, params?: DebouncedCacheParams) {
|
||||
this.thresholdMs = params?.thresholdMs ?? 300;
|
||||
this.func = func;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a call to the wrapped function, that will take place only after
|
||||
* a `thresholdMs` delay given no new calls to `schedule` are made within that
|
||||
* time frame. If they are made, than the scheduled call will be canceled.
|
||||
*/
|
||||
schedule(params: Params, onResult: (result: R) => void): void {
|
||||
this.abortLastSchedule(`[DebouncedCache] A new call to '${this.func.name}' was scheduled`);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
const key = JSON.stringify(params);
|
||||
|
||||
if (this.cache.has(key)) {
|
||||
this.subscribe(this.cache.get(key)!, abortSignal, onResult);
|
||||
this.lastSchedule = { abortController };
|
||||
return;
|
||||
}
|
||||
|
||||
const afterTimeout = () => {
|
||||
// This can't be triggered via the public API of this class, because we cancel
|
||||
// the setTimeout call when abort is triggered, but it's here just in case
|
||||
/* v8 ignore start */
|
||||
if (this.shouldAbort(abortSignal)) {
|
||||
return;
|
||||
}
|
||||
/* v8 ignore end */
|
||||
|
||||
// In theory, we could pass the abort signal to the function, but we don't
|
||||
// do that and let the function run even if it was aborted, and then cache
|
||||
// its result. This works well under the assumption that the function isn't
|
||||
// too expensive to run (like a quick GET request), so aborting it in the
|
||||
// middle wouldn't save too much resources. If needed, we can make this
|
||||
// behavior configurable in the future.
|
||||
const promise = this.func.call(null, params);
|
||||
|
||||
// We don't remove an entry from the cache if the promise is rejected.
|
||||
// We expect that the underlying function will handle the errors and
|
||||
// do the retries internally if necessary.
|
||||
this.cache.set(key, promise);
|
||||
|
||||
this.subscribe(promise, abortSignal, onResult);
|
||||
};
|
||||
|
||||
this.lastSchedule = {
|
||||
timeout: setTimeout(afterTimeout, this.thresholdMs),
|
||||
abortController,
|
||||
};
|
||||
}
|
||||
|
||||
private shouldAbort(abortSignal: AbortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
console.debug(`A call was aborted after the debounce threshold was reached`, abortSignal.reason);
|
||||
}
|
||||
return abortSignal.aborted;
|
||||
}
|
||||
|
||||
private async subscribe(promise: Promise<R>, abortSignal: AbortSignal, onResult: (result: R) => void): Promise<void> {
|
||||
if (this.shouldAbort(abortSignal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await promise;
|
||||
} catch (error) {
|
||||
console.error(`An error occurred while calling '${this.func.name}'.`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldAbort(abortSignal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onResult(result);
|
||||
} catch (error) {
|
||||
console.error(`An error occurred while processing the result of '${this.func.name}'.`, error);
|
||||
}
|
||||
}
|
||||
|
||||
abortLastSchedule(reason: string): void {
|
||||
if (!this.lastSchedule) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.lastSchedule.timeout);
|
||||
this.lastSchedule.abortController.abort(new DOMException(reason, 'AbortError'));
|
||||
}
|
||||
}
|
|
@ -61,6 +61,7 @@ export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E
|
|||
export function makeEl<Tag extends keyof HTMLElementTagNameMap>(
|
||||
tag: Tag,
|
||||
attr?: Partial<HTMLElementTagNameMap[Tag]>,
|
||||
children: HTMLElement[] = [],
|
||||
): HTMLElementTagNameMap[Tag] {
|
||||
const el = document.createElement(tag);
|
||||
if (attr) {
|
||||
|
@ -71,6 +72,7 @@ export function makeEl<Tag extends keyof HTMLElementTagNameMap>(
|
|||
}
|
||||
}
|
||||
}
|
||||
el.append(...children);
|
||||
return el;
|
||||
}
|
||||
|
||||
|
|
|
@ -43,17 +43,6 @@ export function leftClick<E extends MouseEvent, Target extends EventTarget>(func
|
|||
};
|
||||
}
|
||||
|
||||
export function mouseMoveThenOver<El extends HTMLElement>(element: El, func: (e: MouseEvent) => void) {
|
||||
element.addEventListener(
|
||||
'mousemove',
|
||||
(event: MouseEvent) => {
|
||||
func(event);
|
||||
element.addEventListener('mouseover', func);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function oncePersistedPageShown(func: (e: PageTransitionEvent) => void) {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
|
96
assets/js/utils/http-client.ts
Normal file
96
assets/js/utils/http-client.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { retry } from './retry';
|
||||
|
||||
interface RequestParams extends RequestInit {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
query?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
response: Response;
|
||||
|
||||
constructor(request: Request, response: Response) {
|
||||
super(`${request.method} ${request.url} request failed (${response.status}: ${response.statusText})`);
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic HTTP Client with some batteries included:
|
||||
*
|
||||
* - Handles rendering of the URL with query parameters
|
||||
* - Throws an error on non-OK responses
|
||||
* - Automatically retries failed requests
|
||||
* - Add some useful meta headers
|
||||
* - ...Some other method-specific goodies
|
||||
*/
|
||||
export class HttpClient {
|
||||
// There isn't any state in this class at the time of this writing, but
|
||||
// we may add some in the future to allow for more advanced base configuration.
|
||||
|
||||
/**
|
||||
* Issues a request, expecting a JSON response.
|
||||
*/
|
||||
async fetchJson<T>(path: string, params?: RequestParams): Promise<T> {
|
||||
const response = await this.fetch(path, params);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetch(path: string, params: RequestParams = {}): Promise<Response> {
|
||||
const url = new URL(path, window.location.origin);
|
||||
|
||||
for (const [key, value] of Object.entries(params.query ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
params.headers ??= {};
|
||||
|
||||
// This header serves as an idempotency token that identifies the sequence
|
||||
// of retries of the same request. The backend may use this information to
|
||||
// ensure that the same retried request doesn't result in multiple accumulated
|
||||
// side-effects.
|
||||
params.headers['X-Retry-Sequence-Id'] = generateId('rs-');
|
||||
|
||||
return retry(
|
||||
async (attempt: number) => {
|
||||
params.headers!['X-Request-Id'] = generateId('req-');
|
||||
params.headers!['X-Retry-Attempt'] = String(attempt);
|
||||
|
||||
const request = new Request(url, params);
|
||||
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(request, response);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
{ isRetryable, label: `HTTP ${params.method ?? 'GET'} ${url}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryable(error: Error): boolean {
|
||||
return error instanceof HttpError && error.response.status >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a base32 ID with the given prefix as the ID discriminator.
|
||||
* The prefix is useful when reading or grepping thru logs to identify the type
|
||||
* of the ID (i.e. it's visually clear that strings that start with `req-` are
|
||||
* request IDs).
|
||||
*/
|
||||
function generateId(prefix: string) {
|
||||
// Base32 alphabet without any ambiguous characters.
|
||||
// (details: https://github.com/maksverver/key-encoding#eliminating-ambiguous-characters)
|
||||
const alphabet = '23456789abcdefghjklmnpqrstuvwxyz';
|
||||
|
||||
const chars = [prefix];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
chars.push(alphabet[Math.floor(Math.random() * alphabet.length)]);
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
}
|
|
@ -3,9 +3,21 @@ import { UniqueHeap } from './unique-heap';
|
|||
import store from './store';
|
||||
|
||||
export interface Result {
|
||||
aliasName: string;
|
||||
name: string;
|
||||
imageCount: number;
|
||||
/**
|
||||
* If present, then this suggestion is for a tag alias.
|
||||
* If absent, then this suggestion is for the `canonical` tag name.
|
||||
*/
|
||||
alias?: null | string;
|
||||
|
||||
/**
|
||||
* The canonical name of the tag (non-alias).
|
||||
*/
|
||||
canonical: string;
|
||||
|
||||
/**
|
||||
* Number of images tagged with this tag.
|
||||
*/
|
||||
images: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -253,10 +265,19 @@ export class LocalAutocompleter {
|
|||
this.scanResults(referenceToAliasIndex, namespaceMatch, hasFilteredAssociation, isAlias, results);
|
||||
|
||||
// Convert top K from heap into result array
|
||||
return results.topK(k).map((i: TagReferenceIndex) => ({
|
||||
aliasName: this.decoder.decode(this.referenceToName(i, false)),
|
||||
name: this.decoder.decode(this.referenceToName(i)),
|
||||
imageCount: this.getImageCount(i),
|
||||
}));
|
||||
return results.topK(k).map((i: TagReferenceIndex) => {
|
||||
const alias = this.decoder.decode(this.referenceToName(i, false));
|
||||
const canonical = this.decoder.decode(this.referenceToName(i));
|
||||
const result: Result = {
|
||||
canonical,
|
||||
images: this.getImageCount(i),
|
||||
};
|
||||
|
||||
if (alias !== canonical) {
|
||||
result.alias = alias;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
125
assets/js/utils/retry.ts
Normal file
125
assets/js/utils/retry.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
export interface RetryParams {
|
||||
/**
|
||||
* Maximum number of attempts to retry the operation. The first attempt counts
|
||||
* too, so setting this to 1 is equivalent to no retries.
|
||||
*/
|
||||
maxAttempts?: number;
|
||||
|
||||
/**
|
||||
* Initial delay for the first retry. Subsequent retries will be exponentially
|
||||
* delayed up to `maxDelayMs`.
|
||||
*/
|
||||
minDelayMs?: number;
|
||||
|
||||
/**
|
||||
* Max value a delay can reach. This is useful to avoid unreasonably long
|
||||
* delays that can be reached at a larger number of retries where the delay
|
||||
* grows exponentially very fast.
|
||||
*/
|
||||
maxDelayMs?: number;
|
||||
|
||||
/**
|
||||
* If present determines if the error should be retried or immediately re-thrown.
|
||||
* All errors that aren't instances of `Error` are considered non-retryable.
|
||||
*/
|
||||
isRetryable?(error: Error): boolean;
|
||||
|
||||
/**
|
||||
* Human-readable message to identify the operation being retried. By default
|
||||
* the function name is used.
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type RetryFunc<R = void> = (attempt: number, nextDelayMs?: number) => Promise<R>;
|
||||
|
||||
/**
|
||||
* Retry an async operation with exponential backoff and jitter.
|
||||
*
|
||||
* The callback receives the current attempt number and the delay before the
|
||||
* next attempt in case the current attempt fails. The next delay may be
|
||||
* `undefined` if this is the last attempt and no further retries will be scheduled.
|
||||
*
|
||||
* This is based on the following AWS paper:
|
||||
* https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
|
||||
*/
|
||||
export async function retry<R>(func: RetryFunc<R>, params?: RetryParams): Promise<R> {
|
||||
const maxAttempts = params?.maxAttempts ?? 3;
|
||||
|
||||
if (maxAttempts < 1) {
|
||||
throw new Error(`Invalid 'maxAttempts' for retry: ${maxAttempts}`);
|
||||
}
|
||||
|
||||
const minDelayMs = params?.minDelayMs ?? 200;
|
||||
|
||||
if (minDelayMs < 0) {
|
||||
throw new Error(`Invalid 'minDelayMs' for retry: ${minDelayMs}`);
|
||||
}
|
||||
|
||||
const maxDelayMs = params?.maxDelayMs ?? 1500;
|
||||
|
||||
if (maxDelayMs < minDelayMs) {
|
||||
throw new Error(`Invalid 'maxDelayMs' for retry: ${maxDelayMs}, 'minDelayMs' is ${minDelayMs}`);
|
||||
}
|
||||
|
||||
const label = params?.label || func.name || '{unnamed routine}';
|
||||
|
||||
const backoffExponent = 2;
|
||||
|
||||
let attempt = 1;
|
||||
let nextDelayMs = minDelayMs;
|
||||
|
||||
while (true) {
|
||||
const hasNextAttempts = attempt < maxAttempts;
|
||||
|
||||
try {
|
||||
// XXX: an `await` is important in this block to make sure the exception is caught
|
||||
// in this scope. Doing a `return func()` would be a big mistake, so don't try
|
||||
// to "refactor" that!
|
||||
const result = await func(attempt, hasNextAttempts ? nextDelayMs : undefined);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || (params?.isRetryable && !params.isRetryable(error))) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!hasNextAttempts) {
|
||||
console.error(`All ${maxAttempts} attempts of running ${label} failed`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[Attempt ${attempt}/${maxAttempts}] Error when running ${label}. Retrying in ${nextDelayMs} milliseconds...`,
|
||||
error,
|
||||
);
|
||||
|
||||
await sleep(nextDelayMs);
|
||||
|
||||
// Equal jitter algorithm taken from AWS blog post's code reference:
|
||||
// https://github.com/aws-samples/aws-arch-backoff-simulator/blob/66cb169277051eea207dbef8c7f71767fe6af144/src/backoff_simulator.py#L35-L38
|
||||
let pure = minDelayMs * backoffExponent ** attempt;
|
||||
|
||||
// Make sure we don't overflow
|
||||
pure = Math.min(maxDelayMs, pure);
|
||||
|
||||
// Now that we have a purely exponential delay, we add random jitter
|
||||
// to avoid DDOSing the backend from multiple clients retrying at
|
||||
// the same time (see the "thundering herd problem" on Wikipedia).
|
||||
const halfPure = pure / 2;
|
||||
nextDelayMs = halfPure + randomBetween(0, halfPure);
|
||||
|
||||
// Make sure we don't underflow
|
||||
nextDelayMs = Math.max(minDelayMs, nextDelayMs);
|
||||
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function randomBetween(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
|
@ -4,10 +4,18 @@
|
|||
|
||||
export const lastUpdatedSuffix = '__lastUpdated';
|
||||
|
||||
// We use this detached <div> element purely as an event bus to dispatch storage update
|
||||
// events. It is needed because the default 'stroge' event dispatched on the window
|
||||
// isn't triggered when the same page updates the storage.
|
||||
const localUpdates = document.createElement('div');
|
||||
|
||||
type StorageUpdateEvent = CustomEvent<string>;
|
||||
|
||||
export default {
|
||||
set(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
this.dispatchStorageUpdateEvent(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
@ -27,12 +35,18 @@ export default {
|
|||
remove(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
this.dispatchStorageUpdateEvent(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
dispatchStorageUpdateEvent(key: string) {
|
||||
const event: StorageUpdateEvent = new CustomEvent('storage-update', { detail: key });
|
||||
localUpdates.dispatchEvent(event);
|
||||
},
|
||||
|
||||
// Watch changes to a specified key - returns value on change
|
||||
watch<Value = unknown>(key: string, callback: (value: Value | null) => void) {
|
||||
const handler = (event: StorageEvent) => {
|
||||
|
@ -42,6 +56,12 @@ export default {
|
|||
return () => window.removeEventListener('storage', handler);
|
||||
},
|
||||
|
||||
// `null` key means the store was purged with `localStorage.clear()`
|
||||
watchAll(callback: (key: null | string) => void) {
|
||||
window.addEventListener('storage', event => callback(event.key));
|
||||
localUpdates.addEventListener('storage-update', event => callback((event as StorageUpdateEvent).detail));
|
||||
},
|
||||
|
||||
// set() with an additional key containing the current time + expiration time
|
||||
setWithExpireTime(key: string, value: unknown, maxAge: number) {
|
||||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||
|
|
|
@ -1,125 +1,335 @@
|
|||
import { makeEl } from './dom.ts';
|
||||
import { mouseMoveThenOver } from './events.ts';
|
||||
import { handleError } from './requests.ts';
|
||||
import { LocalAutocompleter, Result } from './local-autocompleter.ts';
|
||||
|
||||
export interface TermSuggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
export interface TagSuggestionParams {
|
||||
/**
|
||||
* If present, then this suggestion is for a tag alias.
|
||||
* If absent, then this suggestion is for the `canonical` tag name.
|
||||
*/
|
||||
alias?: null | string;
|
||||
|
||||
/**
|
||||
* The canonical name of the tag (non-alias).
|
||||
*/
|
||||
canonical: string;
|
||||
|
||||
/**
|
||||
* Number of images tagged with this tag.
|
||||
*/
|
||||
images: number;
|
||||
|
||||
/**
|
||||
* Length of the prefix in the suggestion that matches the prefix of the current input.
|
||||
*/
|
||||
matchLength: number;
|
||||
}
|
||||
|
||||
const selectedSuggestionClassName = 'autocomplete__item--selected';
|
||||
export class TagSuggestion {
|
||||
alias?: null | string;
|
||||
canonical: string;
|
||||
images: number;
|
||||
matchLength: number;
|
||||
|
||||
constructor(params: TagSuggestionParams) {
|
||||
this.alias = params.alias;
|
||||
this.canonical = params.canonical;
|
||||
this.images = params.images;
|
||||
this.matchLength = params.matchLength;
|
||||
}
|
||||
|
||||
value(): string {
|
||||
return this.canonical;
|
||||
}
|
||||
|
||||
render(): HTMLElement[] {
|
||||
const { alias: aliasName, canonical: canonicalName, images: imageCount } = this;
|
||||
|
||||
const label = aliasName ? `${aliasName} → ${canonicalName}` : canonicalName;
|
||||
|
||||
return [
|
||||
makeEl('div', { className: 'autocomplete__item__content' }, [
|
||||
makeEl('i', { className: 'fa-solid fa-tag' }),
|
||||
makeEl('b', {
|
||||
className: 'autocomplete__item__tag__match',
|
||||
textContent: ` ${label.slice(0, this.matchLength)}`,
|
||||
}),
|
||||
makeEl('span', {
|
||||
textContent: label.slice(this.matchLength),
|
||||
}),
|
||||
]),
|
||||
makeEl('span', {
|
||||
className: 'autocomplete__item__tag__count',
|
||||
textContent: ` ${TagSuggestion.formatImageCount(imageCount)}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
static formatImageCount(count: number): string {
|
||||
const chars = [...count.toString()];
|
||||
|
||||
for (let i = chars.length - 3; i > 0; i -= 3) {
|
||||
chars.splice(i, 0, ' ');
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
}
|
||||
}
|
||||
|
||||
export class HistorySuggestion {
|
||||
/**
|
||||
* Full query string that was previously searched and retrieved from the history.
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Length of the prefix in the suggestion that matches the prefix of the current input.
|
||||
*/
|
||||
matchLength: number;
|
||||
|
||||
constructor(content: string, matchIndex: number) {
|
||||
this.content = content;
|
||||
this.matchLength = matchIndex;
|
||||
}
|
||||
|
||||
value(): string {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
render(): HTMLElement[] {
|
||||
return [
|
||||
makeEl('div', { className: 'autocomplete__item__content' }, [
|
||||
makeEl('i', {
|
||||
className: 'autocomplete__item__history__icon fa-solid fa-history',
|
||||
}),
|
||||
makeEl('b', {
|
||||
textContent: ` ${this.content.slice(0, this.matchLength)}`,
|
||||
className: 'autocomplete__item__history__match',
|
||||
}),
|
||||
makeEl('span', {
|
||||
textContent: this.content.slice(this.matchLength),
|
||||
}),
|
||||
]),
|
||||
// Here will be a `delete` button to remove the item from the history.
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export type Suggestion = TagSuggestion | HistorySuggestion;
|
||||
|
||||
export interface Suggestions {
|
||||
history: HistorySuggestion[];
|
||||
tags: TagSuggestion[];
|
||||
}
|
||||
|
||||
export interface ItemSelectedEvent {
|
||||
suggestion: Suggestion;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
}
|
||||
|
||||
interface SuggestionItem {
|
||||
element: HTMLElement;
|
||||
suggestion: Suggestion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible for rendering the suggestions dropdown.
|
||||
*/
|
||||
export class SuggestionsPopup {
|
||||
/**
|
||||
* Index of the currently selected suggestion. -1 means an imaginary item
|
||||
* before the first item that represents the state where no item is selected.
|
||||
*/
|
||||
private cursor: number = -1;
|
||||
private items: SuggestionItem[];
|
||||
private readonly container: HTMLElement;
|
||||
private readonly listElement: HTMLUListElement;
|
||||
private selectedElement: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
this.container = makeEl('div', {
|
||||
className: 'autocomplete',
|
||||
className: 'autocomplete hidden',
|
||||
tabIndex: -1,
|
||||
});
|
||||
|
||||
this.listElement = makeEl('ul', {
|
||||
className: 'autocomplete__list',
|
||||
});
|
||||
|
||||
this.container.appendChild(this.listElement);
|
||||
// Make the container connected to DOM to make sure it's rendered when we unhide it
|
||||
document.body.appendChild(this.container);
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
get selectedTerm(): string | null {
|
||||
return this.selectedElement?.dataset.value || null;
|
||||
get selectedSuggestion(): Suggestion | null {
|
||||
return this.selectedItem?.suggestion ?? null;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.container.isConnected;
|
||||
private get selectedItem(): SuggestionItem | null {
|
||||
if (this.cursor < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.items[this.cursor];
|
||||
}
|
||||
|
||||
get isHidden(): boolean {
|
||||
return this.container.classList.contains('hidden');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.clearSelection();
|
||||
this.container.remove();
|
||||
this.container.classList.add('hidden');
|
||||
}
|
||||
|
||||
private clearSelection() {
|
||||
if (!this.selectedElement) return;
|
||||
|
||||
this.selectedElement.classList.remove(selectedSuggestionClassName);
|
||||
this.selectedElement = null;
|
||||
this.setSelection(-1);
|
||||
}
|
||||
|
||||
private updateSelection(targetItem: HTMLElement) {
|
||||
this.clearSelection();
|
||||
private setSelection(index: number) {
|
||||
if (this.cursor === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedElement = targetItem;
|
||||
this.selectedElement.classList.add(selectedSuggestionClassName);
|
||||
// This can't be triggered via the public API of this class
|
||||
/* v8 ignore start */
|
||||
if (index < -1 || index >= this.items.length) {
|
||||
throw new Error(`BUG: setSelection(): invalid selection index: ${index}`);
|
||||
}
|
||||
/* v8 ignore end */
|
||||
|
||||
const selectedClass = 'autocomplete__item--selected';
|
||||
|
||||
this.selectedItem?.element.classList.remove(selectedClass);
|
||||
this.cursor = index;
|
||||
|
||||
if (index >= 0) {
|
||||
this.selectedItem?.element.classList.add(selectedClass);
|
||||
}
|
||||
}
|
||||
|
||||
renderSuggestions(suggestions: TermSuggestion[]): SuggestionsPopup {
|
||||
this.clearSelection();
|
||||
setSuggestions(params: Suggestions): SuggestionsPopup {
|
||||
this.cursor = -1;
|
||||
this.items = [];
|
||||
this.container.innerHTML = '';
|
||||
|
||||
this.listElement.innerHTML = '';
|
||||
for (const suggestion of params.history) {
|
||||
this.appendSuggestion(suggestion);
|
||||
}
|
||||
|
||||
for (const suggestedTerm of suggestions) {
|
||||
const listItem = makeEl('li', {
|
||||
className: 'autocomplete__item',
|
||||
innerText: suggestedTerm.label,
|
||||
});
|
||||
if (params.tags.length > 0 && params.history.length > 0) {
|
||||
this.container.appendChild(makeEl('hr', { className: 'autocomplete__separator' }));
|
||||
}
|
||||
|
||||
listItem.dataset.value = suggestedTerm.value;
|
||||
|
||||
this.watchItem(listItem, suggestedTerm);
|
||||
this.listElement.appendChild(listItem);
|
||||
for (const suggestion of params.tags) {
|
||||
this.appendSuggestion(suggestion);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private watchItem(listItem: HTMLElement, suggestion: TermSuggestion) {
|
||||
// This makes sure the item isn't selected if the mouse pointer happens to
|
||||
// be right on top of the item when the list is rendered. So, the item may
|
||||
// only be selected on the first `mousemove` event occurring on the element.
|
||||
// See more details about this problem in the PR description:
|
||||
// https://github.com/philomena-dev/philomena/pull/350
|
||||
mouseMoveThenOver(listItem, () => this.updateSelection(listItem));
|
||||
appendSuggestion(suggestion: Suggestion) {
|
||||
const type = suggestion instanceof TagSuggestion ? 'tag' : 'history';
|
||||
|
||||
listItem.addEventListener('mouseout', () => this.clearSelection());
|
||||
const element = makeEl(
|
||||
'div',
|
||||
{
|
||||
className: `autocomplete__item autocomplete__item__${type}`,
|
||||
},
|
||||
suggestion.render(),
|
||||
);
|
||||
|
||||
listItem.addEventListener('click', () => {
|
||||
if (!listItem.dataset.value) {
|
||||
return;
|
||||
}
|
||||
const item: SuggestionItem = { element, suggestion };
|
||||
|
||||
this.container.dispatchEvent(new CustomEvent('item_selected', { detail: suggestion }));
|
||||
this.watchItem(item);
|
||||
|
||||
this.items.push(item);
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
|
||||
private watchItem(item: SuggestionItem) {
|
||||
item.element.addEventListener('click', event => {
|
||||
const detail: ItemSelectedEvent = {
|
||||
suggestion: item.suggestion,
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
};
|
||||
|
||||
this.container.dispatchEvent(new CustomEvent('item_selected', { detail }));
|
||||
});
|
||||
}
|
||||
|
||||
private changeSelection(direction: number) {
|
||||
let nextTargetElement: Element | null;
|
||||
|
||||
if (!this.selectedElement) {
|
||||
nextTargetElement = direction > 0 ? this.listElement.firstElementChild : this.listElement.lastElementChild;
|
||||
} else {
|
||||
nextTargetElement =
|
||||
direction > 0 ? this.selectedElement.nextElementSibling : this.selectedElement.previousElementSibling;
|
||||
}
|
||||
|
||||
if (!(nextTargetElement instanceof HTMLElement)) {
|
||||
this.clearSelection();
|
||||
if (this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSelection(nextTargetElement);
|
||||
const index = this.cursor + direction;
|
||||
|
||||
if (index === -1 || index >= this.items.length) {
|
||||
this.clearSelection();
|
||||
} else if (index < -1) {
|
||||
this.setSelection(this.items.length - 1);
|
||||
} else {
|
||||
this.setSelection(index);
|
||||
}
|
||||
}
|
||||
|
||||
selectNext() {
|
||||
selectDown() {
|
||||
this.changeSelection(1);
|
||||
}
|
||||
|
||||
selectPrevious() {
|
||||
selectUp() {
|
||||
this.changeSelection(-1);
|
||||
}
|
||||
|
||||
showForField(targetElement: HTMLElement) {
|
||||
/**
|
||||
* The user wants to jump to the next lower block of types of suggestions.
|
||||
*/
|
||||
selectCtrlDown() {
|
||||
if (this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cursor >= this.items.length - 1) {
|
||||
this.setSelection(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let index = this.cursor + 1;
|
||||
const type = this.itemType(index);
|
||||
|
||||
while (index < this.items.length - 1 && this.itemType(index) === type) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
this.setSelection(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user wants to jump to the next upper block of types of suggestions.
|
||||
*/
|
||||
selectCtrlUp() {
|
||||
if (this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cursor <= 0) {
|
||||
this.setSelection(this.items.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
let index = this.cursor - 1;
|
||||
const type = this.itemType(index);
|
||||
|
||||
while (index > 0 && this.itemType(index) === type) {
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
this.setSelection(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item's prototype that can be viewed as the item's type identifier.
|
||||
*/
|
||||
private itemType(index: number) {
|
||||
return this.items[index].suggestion instanceof TagSuggestion ? 'tag' : 'history';
|
||||
}
|
||||
|
||||
showForElement(targetElement: HTMLElement) {
|
||||
this.container.style.position = 'absolute';
|
||||
this.container.style.left = `${targetElement.offsetLeft}px`;
|
||||
|
||||
|
@ -130,66 +340,12 @@ export class SuggestionsPopup {
|
|||
}
|
||||
|
||||
this.container.style.top = `${topPosition}px`;
|
||||
|
||||
document.body.appendChild(this.container);
|
||||
this.container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
onItemSelected(callback: (event: CustomEvent<TermSuggestion>) => void) {
|
||||
this.container.addEventListener('item_selected', callback as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
const cachedSuggestions = new Map<string, Promise<TermSuggestion[]>>();
|
||||
|
||||
export async function fetchSuggestions(endpoint: string, targetTerm: string): Promise<TermSuggestion[]> {
|
||||
const normalizedTerm = targetTerm.trim().toLowerCase();
|
||||
|
||||
if (cachedSuggestions.has(normalizedTerm)) {
|
||||
return cachedSuggestions.get(normalizedTerm)!;
|
||||
}
|
||||
|
||||
const promisedSuggestions: Promise<TermSuggestion[]> = fetch(`${endpoint}${targetTerm}`)
|
||||
.then(handleError)
|
||||
.then(response => response.json())
|
||||
.catch(() => {
|
||||
// Deleting the promised result from cache to allow retrying
|
||||
cachedSuggestions.delete(normalizedTerm);
|
||||
|
||||
// And resolve failed promise with empty array
|
||||
return [];
|
||||
onItemSelected(callback: (event: ItemSelectedEvent) => void) {
|
||||
this.container.addEventListener('item_selected', event => {
|
||||
callback((event as CustomEvent<ItemSelectedEvent>).detail);
|
||||
});
|
||||
|
||||
cachedSuggestions.set(normalizedTerm, promisedSuggestions);
|
||||
|
||||
return promisedSuggestions;
|
||||
}
|
||||
|
||||
export function purgeSuggestionsCache() {
|
||||
cachedSuggestions.clear();
|
||||
}
|
||||
|
||||
export async function fetchLocalAutocomplete(): Promise<LocalAutocompleter> {
|
||||
const now = new Date();
|
||||
const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`;
|
||||
|
||||
return await fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, {
|
||||
credentials: 'omit',
|
||||
cache: 'force-cache',
|
||||
})
|
||||
.then(handleError)
|
||||
.then(resp => resp.arrayBuffer())
|
||||
.then(buf => new LocalAutocompleter(buf));
|
||||
}
|
||||
|
||||
export function formatLocalAutocompleteResult(result: Result): TermSuggestion {
|
||||
let tagName = result.name;
|
||||
|
||||
if (tagName !== result.aliasName) {
|
||||
tagName = `${result.aliasName} ⇒ ${tagName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
value: result.name,
|
||||
label: `${tagName} (${result.imageCount})`,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
export function mockDateNow(initialDateNow: number): void {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers().setSystemTime(initialDateNow);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
}
|
18
assets/test/mock.ts
Normal file
18
assets/test/mock.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
export function mockDateNow(initialDateNow: number): void {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers().setSystemTime(initialDateNow);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks `Math.random` to return a static value.
|
||||
*/
|
||||
export function mockRandom(staticValue = 0.5) {
|
||||
const realRandom = Math.random;
|
||||
beforeEach(() => (Math.random = () => staticValue));
|
||||
afterEach(() => (Math.random = realRandom));
|
||||
}
|
|
@ -5,7 +5,103 @@ defmodule PhilomenaWeb.Autocomplete.TagController do
|
|||
alias Philomena.Tags.Tag
|
||||
import Ecto.Query
|
||||
|
||||
def show(conn, params) do
|
||||
def show(conn, %{"vsn" => "2"} = params), do: show_v2(conn, params)
|
||||
def show(conn, params), do: show_v1(conn, params)
|
||||
|
||||
# Returns a list of tag suggestions for an incomplete term. Does a prefix search
|
||||
# on the canonical tag names and their aliases.
|
||||
#
|
||||
# See the docs on `show_v1` for the explanation on the breaking change we made
|
||||
# in the `v2` version.
|
||||
defp show_v2(conn, params) do
|
||||
with {:ok, term} <- extract_term_v2(params),
|
||||
{:ok, limit} <- extract_limit(params) do
|
||||
suggestions = search(term, limit)
|
||||
json(conn, %{suggestions: suggestions})
|
||||
else
|
||||
{:error, message} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: message})
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_term_v2(%{"term" => term}) when is_binary(term) and byte_size(term) > 2 do
|
||||
result =
|
||||
term
|
||||
|> String.downcase()
|
||||
|> String.trim()
|
||||
|
||||
{:ok, result}
|
||||
end
|
||||
|
||||
defp extract_term_v2(%{"term" => _}),
|
||||
do: {:error, "Term is too short, must be at least 3 characters"}
|
||||
|
||||
defp extract_term_v2(_params), do: {:error, "Term is missing"}
|
||||
|
||||
defp extract_limit(params) do
|
||||
limit =
|
||||
params
|
||||
|> Map.get("limit", "10")
|
||||
|> Integer.parse()
|
||||
|
||||
case limit do
|
||||
{limit, ""} when limit > 0 and limit <= 10 ->
|
||||
{:ok, limit}
|
||||
|
||||
_ ->
|
||||
{:error, "Limit must be an integer between 1 and 10"}
|
||||
end
|
||||
end
|
||||
|
||||
@spec search(String.t(), integer()) :: [map()]
|
||||
defp search(term, limit) do
|
||||
Tag
|
||||
|> Search.search_definition(
|
||||
%{
|
||||
query: %{
|
||||
bool: %{
|
||||
should: [
|
||||
%{prefix: %{name: term}},
|
||||
%{prefix: %{name_in_namespace: term}}
|
||||
]
|
||||
}
|
||||
},
|
||||
sort: %{images: :desc}
|
||||
},
|
||||
%{page_size: 10}
|
||||
)
|
||||
|> Search.search_records(preload(Tag, :aliased_tag))
|
||||
|> Enum.map(
|
||||
&%{
|
||||
:alias => if(is_nil(&1.aliased_tag), do: nil, else: &1.name),
|
||||
canonical: if(is_nil(&1.aliased_tag), do: &1.name, else: &1.aliased_tag.name),
|
||||
images:
|
||||
if(is_nil(&1.aliased_tag), do: &1.images_count, else: &1.aliased_tag.images_count),
|
||||
id: &1.id
|
||||
}
|
||||
)
|
||||
|> Enum.filter(&(&1.images > 0))
|
||||
|> Enum.take(limit)
|
||||
|> Enum.map(
|
||||
&%{
|
||||
:alias => &1.alias,
|
||||
canonical: &1.canonical,
|
||||
images: &1.images
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
# Version 1 is kept for backwards compatibility with the older versions of
|
||||
# the frontend application that may still be cached in user's browsers. Don't
|
||||
# change this code! All the new development should be done in the `v2` version.
|
||||
#
|
||||
# The problem of `v1` was that it was doing the work of formatting the completion
|
||||
# results on the backend, which was not ideal. So instead, the `v2` version
|
||||
# was created to return the raw data in fully structured JSON format, which
|
||||
# the frontend application can then format and style as needed.
|
||||
defp show_v1(conn, params) do
|
||||
tags =
|
||||
case extract_term(params) do
|
||||
nil ->
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
defmodule PhilomenaWeb.SettingController do
|
||||
require Logger
|
||||
use PhilomenaWeb, :controller
|
||||
|
||||
alias Philomena.Users
|
||||
|
@ -37,21 +38,36 @@ defmodule PhilomenaWeb.SettingController do
|
|||
|
||||
defp update_local_settings(conn, user_params) do
|
||||
conn
|
||||
|> set_cookie(user_params, "hidpi", "hidpi")
|
||||
|> set_cookie(user_params, "webm", "webm")
|
||||
|> set_cookie(user_params, "serve_webm", "serve_webm")
|
||||
|> set_cookie(user_params, "unmute_videos", "unmute_videos")
|
||||
|> set_cookie(user_params, "chan_nsfw", "chan_nsfw")
|
||||
|> set_cookie(user_params, "hide_staff_tools", "hide_staff_tools")
|
||||
|> set_cookie(user_params, "hide_uploader", "hide_uploader")
|
||||
|> set_cookie(user_params, "hide_score", "hide_score")
|
||||
|> set_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|
||||
|> set_cookie(user_params, "enable_search_ac", "enable_search_ac")
|
||||
|> set_bool_cookie(user_params, "hidpi", "hidpi")
|
||||
|> set_bool_cookie(user_params, "webm", "webm")
|
||||
|> set_bool_cookie(user_params, "serve_webm", "serve_webm")
|
||||
|> set_bool_cookie(user_params, "unmute_videos", "unmute_videos")
|
||||
|> set_bool_cookie(user_params, "chan_nsfw", "chan_nsfw")
|
||||
|> set_bool_cookie(user_params, "hide_staff_tools", "hide_staff_tools")
|
||||
|> set_bool_cookie(user_params, "hide_uploader", "hide_uploader")
|
||||
|> set_bool_cookie(user_params, "hide_score", "hide_score")
|
||||
|> set_bool_cookie(user_params, "unfilter_tag_suggestions", "unfilter_tag_suggestions")
|
||||
|> set_bool_cookie(user_params, "enable_search_ac", "enable_search_ac")
|
||||
|> set_bool_cookie(
|
||||
user_params,
|
||||
"autocomplete_search_history_hidden",
|
||||
"autocomplete_search_history_hidden"
|
||||
)
|
||||
|> set_cookie(
|
||||
"autocomplete_search_history_max_suggestions_when_typing",
|
||||
user_params["autocomplete_search_history_max_suggestions_when_typing"]
|
||||
)
|
||||
end
|
||||
|
||||
defp set_cookie(conn, params, param_name, cookie_name) do
|
||||
defp set_bool_cookie(conn, params, param_name, cookie_name) do
|
||||
set_cookie(conn, cookie_name, to_string(params[param_name] == "true"))
|
||||
end
|
||||
|
||||
defp set_cookie(conn, _, nil), do: conn
|
||||
|
||||
defp set_cookie(conn, cookie_name, value) do
|
||||
# JS wants access; max-age is set to 25 years from now
|
||||
Conn.put_resp_cookie(conn, cookie_name, to_string(params[param_name] == "true"),
|
||||
Conn.put_resp_cookie(conn, cookie_name, value,
|
||||
max_age: 788_923_800,
|
||||
http_only: false,
|
||||
extra: "SameSite=Lax"
|
||||
|
|
|
@ -25,8 +25,8 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do
|
|||
|
||||
csp_config = [
|
||||
{:default_src, ["'self'"]},
|
||||
{:script_src, [default_script_src() | script_src]},
|
||||
{:connect_src, [default_connect_src()]},
|
||||
{:script_src, [default_script_src(conn.host) | script_src]},
|
||||
{:connect_src, [default_connect_src(conn.host)]},
|
||||
{:style_src, [default_style_src() | style_src]},
|
||||
{:object_src, ["'none'"]},
|
||||
{:frame_ancestors, ["'none'"]},
|
||||
|
@ -66,10 +66,31 @@ defmodule PhilomenaWeb.ContentSecurityPolicyPlug do
|
|||
defp cdn_uri, do: Application.get_env(:philomena, :cdn_host) |> to_uri()
|
||||
defp camo_uri, do: Application.get_env(:philomena, :camo_host) |> to_uri()
|
||||
|
||||
defp default_script_src, do: vite_hmr?(do: "'self' localhost:5173", else: "'self'")
|
||||
# Use the "current host" in vite HMR mode for whatever the "current host" is.
|
||||
# Usually it's `localhost`, but it may be some other private IP address, that
|
||||
# you use to test the frontend on a mobile device connected via a local Wi-Fi.
|
||||
defp default_script_src(host) do
|
||||
# Workaround for a compile warning where `host` variable is unused if we
|
||||
# inline the if branches into the `vite_hmr?` macro.
|
||||
is_vite_hmr = vite_hmr?(do: true, else: false)
|
||||
|
||||
defp default_connect_src,
|
||||
do: vite_hmr?(do: "'self' localhost:5173 ws://localhost:5173", else: "'self'")
|
||||
if is_vite_hmr do
|
||||
"'self' #{host}:5173"
|
||||
else
|
||||
"'self'"
|
||||
end
|
||||
end
|
||||
|
||||
defp default_connect_src(host) do
|
||||
# Same workaround as in `default_script_src/1`
|
||||
is_vite_hmr = vite_hmr?(do: true, else: false)
|
||||
|
||||
if is_vite_hmr do
|
||||
"'self' #{host}:5173 ws://#{host}:5173"
|
||||
else
|
||||
"'self'"
|
||||
end
|
||||
end
|
||||
|
||||
defp default_style_src, do: vite_hmr?(do: "'self' 'unsafe-inline'", else: "'self'")
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
.field
|
||||
= label f, :spoilered_complex_str, "Complex Spoiler Filter"
|
||||
br
|
||||
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||
= textarea f, :spoilered_complex_str, class: "input input--wide", autocapitalize: "none", autocomplete: "off", data: [autocomplete: "multi-tags"]
|
||||
br
|
||||
= error_tag f, :spoilered_complex_str
|
||||
.fieldlabel
|
||||
|
@ -51,7 +51,7 @@
|
|||
.field
|
||||
= label f, :hidden_complex_str, "Complex Hide Filter"
|
||||
br
|
||||
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||
= textarea f, :hidden_complex_str, class: "input input--wide", autocapitalize: "none", autocomplete: "off", data: [autocomplete: "multi-tags"]
|
||||
br
|
||||
= error_tag f, :hidden_complex_str
|
||||
.fieldlabel
|
||||
|
|
|
@ -23,9 +23,11 @@ header.header
|
|||
value=@conn.params["q"]
|
||||
placeholder="Search"
|
||||
autocapitalize="none"
|
||||
data-autocomplete="true"
|
||||
data-autocomplete-min-length="3"
|
||||
data-autocomplete-mode="search"
|
||||
autocomplete=if(@conn.cookies["enable_search_ac"], do: "on", else: "off")
|
||||
inputmode="search"
|
||||
data-autocomplete="multi-tags"
|
||||
data-autocomplete-condition="enable_search_ac"
|
||||
data-autocomplete-history-id="search-history"
|
||||
]
|
||||
|
||||
= if present?(@conn.params["sf"]) do
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
' Artist Link validation is intended for artists. Validating your link will give you control over your content on the site, allowing you to create a
|
||||
a> href="/commissions" commissions
|
||||
' listing and request takedowns or DNPs. Do not request a link if the source contains no artwork which you have created.
|
||||
= text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [autocomplete: "true", autocomplete_min_length: "3", autocomplete_source: "/autocomplete/tags?term="]
|
||||
= text_input f, :tag_name, value: assigns[:tag_name], class: "input", autocomplete: "off", placeholder: "artist:your-name", data: [autocomplete: "single-tag"]
|
||||
= error_tag f, :tag
|
||||
|
||||
.field
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
h1 Search
|
||||
|
||||
= form_for :search, ~p"/search", [id: "searchform", method: "get", class: "js-search-form", enforce_utf8: false], fn f ->
|
||||
= text_input f, :q, class: "input input--wide js-search-field", placeholder: "Search terms are chained with commas", autocapitalize: "none", name: "q", value: @conn.params["q"], data: [autocomplete: "true", autocomplete_min_length: 3, autocomplete_mode: "search"]
|
||||
= text_input f, :q, class: "input input--wide js-search-field",
|
||||
placeholder: "Search terms are chained with commas",
|
||||
autocapitalize: "none",
|
||||
name: "q",
|
||||
value: @conn.params["q"],
|
||||
autocomplete: if(@conn.cookies["enable_search_ac"], do: "on", else: "off"),
|
||||
inputmode: "search",
|
||||
data: [ \
|
||||
autocomplete: "multi-tags",
|
||||
autocomplete_condition: "enable_search_ac",
|
||||
autocomplete_history_id: "search-history",
|
||||
]
|
||||
|
||||
.block
|
||||
.block__header.flex
|
||||
|
|
|
@ -183,8 +183,32 @@ h1 Content Settings
|
|||
.fieldlabel: i Show streams marked as NSFW on the channels page.
|
||||
.field
|
||||
=> label f, :enable_search_ac, "Enable search auto-completion"
|
||||
=> checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] === "true"
|
||||
.fieldlabel: i Enable the auto-completion of tags in search fields.
|
||||
=> checkbox f, :enable_search_ac, checked: @conn.cookies["enable_search_ac"] == "true"
|
||||
|
||||
.autocomplete-settings class=if(@conn.cookies["enable_search_ac"] != "true", do: "hidden", else: "")
|
||||
.field
|
||||
=> label f,
|
||||
:autocomplete_search_history_hidden,
|
||||
"Hide search history in auto-completion"
|
||||
=> checkbox f,
|
||||
:autocomplete_search_history_hidden,
|
||||
checked: @conn.cookies["autocomplete_search_history_hidden"] == "true"
|
||||
|
||||
.autocomplete-search-history-settings[
|
||||
class=if(@conn.cookies["autocomplete_search_history_hidden"] == "true", do: "hidden", else: "")
|
||||
]
|
||||
.field
|
||||
=> label f,
|
||||
:autocomplete_search_history_max_suggestions_when_typing,
|
||||
"Maximum number of search history suggestions in autocompletion when typing"
|
||||
=> number_input f,
|
||||
:autocomplete_search_history_max_suggestions_when_typing,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
value: @conn.cookies["autocomplete_search_history_max_suggestions_when_typing"] || 3,
|
||||
class: "input"
|
||||
|
||||
= if staff?(@conn.assigns.current_user) do
|
||||
.field
|
||||
=> label f, :hide_staff_tools
|
||||
|
|
|
@ -16,9 +16,8 @@ elixir:
|
|||
placeholder="add a tag"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
data-autocomplete="true"
|
||||
data-autocomplete-min-length="3"
|
||||
data-autocomplete-source="/autocomplete/tags?term="
|
||||
data-autocomplete="single-tag"
|
||||
data-autocomplete-max-suggestions=5
|
||||
]
|
||||
button.button.button--state-primary.button--bold[
|
||||
class="js-taginput-show"
|
||||
|
|
|
@ -2,7 +2,17 @@ h1 Tags
|
|||
|
||||
= form_for :tags, ~p"/tags", [method: "get", class: "hform", enforce_utf8: false], fn f ->
|
||||
.field
|
||||
= text_input f, :tq, name: :tq, value: @conn.params["tq"] || "*", class: "input hform__text", placeholder: "Search tags", autocapitalize: "none"
|
||||
= text_input f, :tq, name: :tq, value: @conn.params["tq"] || "*",
|
||||
class: "input hform__text",
|
||||
placeholder: "Search tags",
|
||||
autocapitalize: "none",
|
||||
autocomplete: if(@conn.cookies["enable_search_ac"], do: "on", else: "off"),
|
||||
inputmode: "search",
|
||||
data: [ \
|
||||
autocomplete: "multi-tags",
|
||||
autocomplete_condition: "enable_search_ac",
|
||||
]
|
||||
|
||||
= submit "Search", class: "hform__button button"
|
||||
|
||||
.fieldlabel
|
||||
|
@ -31,7 +41,7 @@ h2 Search Results
|
|||
.block.block--fixed.block--danger
|
||||
' Oops, there was an error parsing your query! Check for mistakes like mismatched parentheses. The error was:
|
||||
pre = assigns[:error]
|
||||
|
||||
|
||||
- true ->
|
||||
p
|
||||
' No tags found!
|
||||
|
|
Loading…
Add table
Reference in a new issue