/** * Keyword matching for ./notes/search. Case- and accent-insensitive ("reunion" matches "Réunion"). * A query is split into terms: "quoted phrases" stay whole, other words split on whitespace. * `-word` excludes notes containing it. */ export type ParsedQuery = { terms: Array; excluded: Array } export function fold(s: string): string { return s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase() } /** Fold text char by char, keeping a map from folded index → original index (for snippets). */ export function foldWithMap(s: string): { folded: string; map: Array } { let folded = '' const map: Array = [] let i = 0 for (const ch of s) { const f = fold(ch) for (let k = 0; k < f.length; k++) map.push(i) folded += f i += ch.length } map.push(s.length) return { folded, map } } export function parseQuery(query: string): ParsedQuery { const terms: Array = [] const excluded: Array = [] const re = /(-?)"([^"]*)"|(\S+)/g let m: RegExpExecArray | null while ((m = re.exec(query))) { let raw = m[2] !== undefined ? m[2] : (m[3] as string) let neg = m[1] === '-' if (m[2] === undefined && raw.startsWith('-') && raw.length > 1) { neg = true raw = raw.slice(1) } const t = fold(raw.replace(/\s+/g, ' ').trim()) if (!t) continue ;(neg ? excluded : terms).push(t) } return { terms: [...new Set(terms)], excluded: [...new Set(excluded)] } } /** Non-overlapping occurrences of `term` in already-folded text. */ export function findAll(folded: string, term: string): Array { const out: Array = [] if (!term) return out let i = folded.indexOf(term) while (i !== -1) { out.push(i) i = folded.indexOf(term, i + term.length) } return out } export type FieldHit = { field: string; term: string; count: number } export type Snippet = { field: string; text: string } export type FieldText = { field: string; text: string; weight: number } export type MatchResult = { matched: boolean score: number termsMatched: Array hits: Record snippets: Array } function snippetAround(original: string, start: number, end: number, radius: number): string { let s = Math.max(0, start - radius) let e = Math.min(original.length, end + radius) // widen to word boundaries while (s > 0 && s > start - radius - 20 && /\S/.test(original[s - 1] as string)) s-- while (e < original.length && e < end + radius + 20 && /\S/.test(original[e] as string)) e++ const body = original.slice(s, e).replace(/\s+/g, ' ').trim() return `${s > 0 ? '…' : ''}${body}${e < original.length ? '…' : ''}` } /** * Score one note. `mode: 'all'` requires every term somewhere in the note (default); `'any'` * needs one. Score = Σ hits × field weight (title 3, attendees 2, others 1). Up to * `maxSnippets` snippets, preferring distinct terms and fields. */ export function matchNote(fields: Array, q: ParsedQuery, opts: { mode: 'all' | 'any'; radius: number; maxSnippets: number }): MatchResult { const hits: Record = {} const termsMatched = new Set() const candidates: Array<{ field: string; term: string; text: string; start: number; end: number; original: string }> = [] let score = 0 let excludedHit = false for (const f of fields) { if (!f.text) continue const { folded, map } = foldWithMap(f.text) for (const ex of q.excluded) if (folded.includes(ex)) excludedHit = true for (const term of q.terms) { const at = findAll(folded, term) if (!at.length) continue termsMatched.add(term) hits[f.field] = (hits[f.field] ?? 0) + at.length score += at.length * f.weight for (const pos of at.slice(0, 3)) { candidates.push({ field: f.field, term, text: '', start: map[pos] as number, end: map[pos + term.length] as number, original: f.text }) } } } const matched = !excludedHit && (q.terms.length === 0 ? false : opts.mode === 'all' ? termsMatched.size === q.terms.length : termsMatched.size > 0) const snippets: Array = [] if (matched) { const usedTerms = new Set() const usedKeys = new Set() const ordered = [...candidates.filter((c) => c.field !== 'title' && c.field !== 'attendees'), ...candidates.filter((c) => c.field === 'title' || c.field === 'attendees')] for (const pass of [0, 1]) { for (const c of ordered) { if (snippets.length >= opts.maxSnippets) break if (pass === 0 && usedTerms.has(c.term)) continue const text = c.field === 'title' ? c.original : snippetAround(c.original, c.start, c.end, opts.radius) const key = `${c.field}:${text}` if (usedKeys.has(key)) continue usedKeys.add(key) usedTerms.add(c.term) snippets.push({ field: c.field, text }) } } } return { matched, score: matched ? score : 0, termsMatched: [...termsMatched], hits, snippets } }