// Search helpers for JournalSearchBox and the search page: the term rules, // query-term marking that ignores case and diacritics, plural forms, listbox // keys and the suggestion request controller. Pure TypeScript, so // `node --test` imports it directly. import { JOURNAL_SEARCH_MIN_LENGTH } from './client.ts' /** Longer search terms are cut to this many characters. */ export const JOURNAL_SEARCH_MAX_LENGTH = 200 /** The ?q value as a trimmed term: the first value of a repeated parameter, "" when absent. */ export function searchTermFromQuery(raw: unknown): string { const value = Array.isArray(raw) ? raw[0] : raw if (typeof value !== 'string') return '' return [...value.trim()].slice(0, JOURNAL_SEARCH_MAX_LENGTH).join('').trim() } export type SearchTermState = 'empty' | 'short' | 'ready' /** Whether `term` is empty, too short for the API, or ready to search. */ export function searchTermState(term: string, minLength: number = JOURNAL_SEARCH_MIN_LENGTH): SearchTermState { const n = [...term.trim()].length if (n === 0) return 'empty' return n < minLength ? 'short' : 'ready' } // Letters NFD does not decompose into a base letter and a mark. const FOLD_EXTRA: Readonly> = { ł: 'l', Ł: 'l', ø: 'o', Ø: 'o', đ: 'd', Đ: 'd', ß: 'ss', æ: 'ae', Æ: 'ae', œ: 'oe', Œ: 'oe', } function foldChar(ch: string): string { return FOLD_EXTRA[ch] ?? ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase() } /** * Folds `text` to lower case without diacritics. `start[i]` and `end[i]` are * the bounds, in `text`, of the character that produced folded[i]. */ function fold(text: string): { folded: string; start: number[]; end: number[] } { let folded = '' const start: number[] = [] const end: number[] = [] let pos = 0 for (const ch of text) { const f = foldChar(ch) for (let k = 0; k < f.length; k++) { start.push(pos) end.push(pos + ch.length) } folded += f pos += ch.length } return { folded, start, end } } export interface TextSegment { text: string mark: boolean } /** * Splits `text` into plain and marked segments, marking every occurrence of * each word of `query` (2 or more characters) while ignoring case and * diacritics, so "grzybow" marks "grzybów". The segments are text: render * them as text nodes, never as HTML. */ export function markTerms(text: string | null | undefined, query: string | null | undefined): TextSegment[] { if (!text) return [] const words = [...new Set((query ?? '').split(/\s+/).map((w) => fold(w).folded))].filter((w) => w.length >= 2) if (words.length === 0) return [{ text, mark: false }] const { folded, start, end } = fold(text) const ranges: Array<[number, number]> = [] for (const word of words) { for (let i = folded.indexOf(word); i !== -1; i = folded.indexOf(word, i + 1)) { ranges.push([start[i]!, end[i + word.length - 1]!]) } } if (ranges.length === 0) return [{ text, mark: false }] ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]) const merged: Array<[number, number]> = [] for (const r of ranges) { const last = merged[merged.length - 1] if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]) else merged.push([r[0], r[1]]) } const out: TextSegment[] = [] let pos = 0 for (const [a, b] of merged) { if (a > pos) out.push({ text: text.slice(pos, a), mark: false }) out.push({ text: text.slice(a, b), mark: true }) pos = b } if (pos < text.length) out.push({ text: text.slice(pos), mark: false }) return out } export type PluralForm = 'one' | 'few' | 'many' | 'other' /** * The plural form of `n` in `language` (a BCP 47 tag), from Intl.PluralRules: * Polish gives one (1), few (2-4, 22-24) and many (0, 5-21). Forms a message * file does not carry (zero, two) fall back to other. */ export function pluralForm(language: string, n: number): PluralForm { let form: string try { form = new Intl.PluralRules(language || 'en').select(n) } catch { form = new Intl.PluralRules('en').select(n) } return form === 'one' || form === 'few' || form === 'many' ? form : 'other' } /** * The option a combobox key moves to among `count` options, from `current` * (-1 for none): ArrowDown and ArrowUp move one step and wrap. Any other key, * or no options, gives null. */ export function moveActive(current: number, count: number, key: string): number | null { if (count <= 0) return null if (key === 'ArrowDown') return current < 0 || current >= count - 1 ? 0 : current + 1 if (key === 'ArrowUp') return current <= 0 ? count - 1 : current - 1 return null } export type SuggestStatus = 'idle' | 'loading' | 'done' | 'error' export interface SuggestState { /** The term the rows (or the pending request) belong to. */ term: string status: SuggestStatus /** True once a request has run longer than the spinner delay. */ loading: boolean rows: T[] total: number } export interface SuggestControllerOptions { /** One suggestion request; it must stop when `signal` aborts. */ fetch: (term: string, signal: AbortSignal) => Promise<{ data: T[]; meta?: { total?: number } }> onChange: (state: SuggestState) => void /** Wait after the last keystroke before a request, in ms. Default 250. */ debounce?: number /** Shorter terms send nothing. Default JOURNAL_SEARCH_MIN_LENGTH. */ minLength?: number /** A request shows `loading` only after this many ms, to avoid flicker. Default 300. */ spinnerDelay?: number } export interface SuggestController { /** The input's current value. */ input(value: string): void /** Cancels the pending and running request. */ dispose(): void } /** * Debounced suggestion requests. Each new term cancels the pending timer, * aborts the running request and ignores any answer that still arrives, so * the rows always belong to the latest term. A failed request ends in status * "error" and never throws. */ export function createSuggestController(opts: SuggestControllerOptions): SuggestController { const debounce = opts.debounce ?? 250 const minLength = opts.minLength ?? JOURNAL_SEARCH_MIN_LENGTH const spinnerDelay = opts.spinnerDelay ?? 300 let state: SuggestState = { term: '', status: 'idle', loading: false, rows: [], total: 0 } let timer: ReturnType | undefined let spinner: ReturnType | undefined let abort: AbortController | null = null let seq = 0 const emit = (patch: Partial>) => { state = { ...state, ...patch } opts.onChange(state) } const cancel = () => { clearTimeout(timer) clearTimeout(spinner) abort?.abort() abort = null seq++ } const run = async (term: string) => { const id = ++seq const ctl = new AbortController() abort = ctl emit({ status: 'loading', loading: false }) spinner = setTimeout(() => { if (id === seq) emit({ loading: true }) }, spinnerDelay) try { const page = await opts.fetch(term, ctl.signal) if (id !== seq) return clearTimeout(spinner) emit({ status: 'done', loading: false, rows: page.data ?? [], total: page.meta?.total ?? 0 }) } catch { if (id !== seq) return clearTimeout(spinner) emit({ status: 'error', loading: false, rows: [], total: 0 }) } finally { if (abort === ctl) abort = null } } return { input(value) { const term = value.trim() if (term === state.term && (state.status === 'loading' || state.status === 'done')) return cancel() if (searchTermState(term, minLength) !== 'ready') { emit({ term, status: 'idle', loading: false, rows: [], total: 0 }) return } emit({ term }) timer = setTimeout(() => void run(term), debounce) }, dispose: cancel, } }