/** * Search query language — pure, dependency-free, unit-tested (tests/unit/search-query.test.ts). * * free text "quoted phrase" model:claude provider:openai project:research folder:clients * after:2026-08-01 before:2026-09-01 after:7d after:today role:user|assistant is:pinned|archived|shared * * Unknown `key:value` tokens are kept as free text so typing a URL or `10:30` still searches. * The same module also provides highlighting/snippet helpers shared by the API (snippets) and the UI (marks). */ export const FILTER_KEYS = ["model", "provider", "project", "folder", "after", "before", "role", "is"] as const; export type FilterKey = (typeof FILTER_KEYS)[number]; export type SearchRole = "user" | "assistant" | "system"; export type SearchFlag = "pinned" | "archived" | "shared"; export interface SearchFilters { /** Substring matched against `modelKey` and the registry display name. */ model: string[]; /** Provider id (normalized to lower case). */ provider: string[]; /** Project id or name (substring). */ project: string[]; /** Folder id or name (substring). */ folder: string[]; /** Inclusive lower bound. */ after: Date | null; /** Exclusive upper bound (`before:2026-09-01` = strictly before that day). */ before: Date | null; role: SearchRole | null; is: SearchFlag[]; } export interface ParsedQuery { /** Original input. */ raw: string; /** Free text (tokens + phrases) joined by spaces, used for full-text search. */ text: string; /** Individual free-text words (lower-cased, deduped, order preserved). */ words: string[]; /** Quoted phrases (original case). */ phrases: string[]; /** Words + phrases, lower-cased — what to highlight. */ terms: string[]; filters: SearchFilters; /** True when at least one filter is set. */ hasFilters: boolean; /** Tokens as typed (for chips): `{ key, value, raw }`. */ tokens: FilterToken[]; } export interface FilterToken { key: FilterKey; value: string; /** Exact substring of the raw query (used to remove the chip). */ raw: string; } const ROLE_ALIASES: Record = { user: "user", me: "user", you: "user", human: "user", assistant: "assistant", ai: "assistant", model: "assistant", bot: "assistant", system: "system" }; const FLAGS: SearchFlag[] = ["pinned", "archived", "shared"]; function emptyFilters(): SearchFilters { return { model: [], provider: [], project: [], folder: [], after: null, before: null, role: null, is: [] }; } /** Tokenizer: whitespace-separated, double quotes group words (also inside `key:"…"`). */ export function tokenize(input: string): string[] { const out: string[] = []; let cur = ""; let quoted = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (ch === '"') { quoted = !quoted; cur += ch; continue; } if (!quoted && /\s/.test(ch)) { if (cur) out.push(cur); cur = ""; continue; } cur += ch; } if (cur) out.push(cur); return out; } const DATE_RE = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/; const REL_RE = /^(\d{1,3})\s*(d|day|days|w|week|weeks|m|month|months|y|year|years)$/i; /** Parses absolute (`2026-08-01`, `2026-08`) and relative (`7d`, `2w`, `today`, `yesterday`) dates → UTC midnight. */ export function parseDateToken(value: string, now: Date, edge: "start" | "end"): Date | null { const v = value.trim().toLowerCase(); if (!v) return null; const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); const day = 86_400_000; const abs = DATE_RE.exec(v); if (abs) { const y = Number(abs[1]); const mo = Number(abs[2]) - 1; if (mo < 0 || mo > 11) return null; if (abs[3]) { const d = Number(abs[3]); if (d < 1 || d > 31) return null; const t = Date.UTC(y, mo, d); return new Date(edge === "start" ? t : t + day); } // whole month return new Date(edge === "start" ? Date.UTC(y, mo, 1) : Date.UTC(y, mo + 1, 1)); } if (v === "today") return new Date(edge === "start" ? todayUtc : todayUtc + day); if (v === "yesterday") return new Date(edge === "start" ? todayUtc - day : todayUtc); if (v === "week" || v === "thisweek") return new Date(edge === "start" ? todayUtc - 7 * day : todayUtc + day); if (v === "month" || v === "thismonth") return new Date(edge === "start" ? Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) : todayUtc + day); const rel = REL_RE.exec(v); if (rel) { const n = Number(rel[1]); const unit = rel[2][0]; const days = unit === "d" ? n : unit === "w" ? n * 7 : unit === "m" ? n * 30 : n * 365; const t = todayUtc - days * day; return new Date(edge === "start" ? t : t + day); } return null; } function unquote(s: string): string { return s.replace(/^"|"$/g, ""); } /** * Parse the query language. Pure: pass `now` for deterministic relative dates. */ export function parseQuery(raw: string, opts: { now?: Date } = {}): ParsedQuery { const now = opts.now ?? new Date(); const filters = emptyFilters(); const words: string[] = []; const phrases: string[] = []; const tokens: FilterToken[] = []; const seenWords = new Set(); for (const tok of tokenize(raw.trim().slice(0, 500))) { const m = /^([a-zA-Z]+):(.+)$/.exec(tok); if (m && (FILTER_KEYS as readonly string[]).includes(m[1].toLowerCase())) { const key = m[1].toLowerCase() as FilterKey; const value = unquote(m[2]).trim(); if (!value) continue; let accepted = true; switch (key) { case "model": filters.model.push(value.toLowerCase()); break; case "provider": filters.provider.push(value.toLowerCase()); break; case "project": filters.project.push(value); break; case "folder": filters.folder.push(value); break; case "after": { const d = parseDateToken(value, now, "start"); if (d) filters.after = d; else accepted = false; break; } case "before": { const d = parseDateToken(value, now, "end"); if (d) filters.before = d; else accepted = false; break; } case "role": { const r = ROLE_ALIASES[value.toLowerCase()]; if (r) filters.role = r; else accepted = false; break; } case "is": { const flag = value.toLowerCase() as SearchFlag; if (FLAGS.includes(flag)) { if (!filters.is.includes(flag)) filters.is.push(flag); } else accepted = false; break; } } if (accepted) { tokens.push({ key, value, raw: tok }); continue; } // invalid filter value → fall through and treat as free text } if (tok.startsWith('"') && tok.endsWith('"') && tok.length >= 2) { const p = unquote(tok).trim(); if (p) phrases.push(p); continue; } const w = unquote(tok).trim(); if (!w) continue; const lw = w.toLowerCase(); if (!seenWords.has(lw)) { seenWords.add(lw); words.push(lw); } } const terms = Array.from(new Set([...phrases.map((p) => p.toLowerCase()), ...words])); const text = [...words, ...phrases.map((p) => `"${p}"`)].join(" "); const hasFilters = filters.model.length > 0 || filters.provider.length > 0 || filters.project.length > 0 || filters.folder.length > 0 || filters.after !== null || filters.before !== null || filters.role !== null || filters.is.length > 0; return { raw, text, words, phrases, terms, filters, hasFilters, tokens }; } /** Single-valued keys are replaced when appended again. */ const SINGLE_KEYS: FilterKey[] = ["after", "before", "role"]; /** Quote a filter value when it contains whitespace. */ export function formatFilterToken(key: FilterKey, value: string): string { const v = value.trim(); return `${key}:${/\s/.test(v) ? `"${v.replace(/"/g, "")}"` : v}`; } /** Append (or replace, for single-valued keys) a filter to a raw query string. */ export function addFilter(raw: string, key: FilterKey, value: string): string { const token = formatFilterToken(key, value); const parsed = parseQuery(raw); let rest = tokenize(raw.trim()); if (SINGLE_KEYS.includes(key)) { const existing = new Set(parsed.tokens.filter((t) => t.key === key).map((t) => t.raw)); rest = rest.filter((t) => !existing.has(t)); } else if (parsed.tokens.some((t) => t.key === key && t.value.toLowerCase() === value.trim().toLowerCase())) { return raw.trim(); } return [...rest, token].join(" ").trim(); } /** Remove one filter token (exact raw match) from the query. */ export function removeFilter(raw: string, token: FilterToken): string { const toks = tokenize(raw.trim()); const idx = toks.indexOf(token.raw); if (idx >= 0) toks.splice(idx, 1); return toks.join(" ").trim(); } /** Remove every filter, keeping the free text. */ export function stripFilters(raw: string): string { const parsed = parseQuery(raw); const drop = new Set(parsed.tokens.map((t) => t.raw)); return tokenize(raw.trim()) .filter((t) => !drop.has(t)) .join(" ") .trim(); } /* ------------------------------------------------------------------------------------------------ * Highlighting & snippets * ---------------------------------------------------------------------------------------------- */ export interface Segment { text: string; hit: boolean; } function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Split `text` into hit / non-hit segments for the given terms (case-insensitive, longest term wins). */ export function highlightSegments(text: string, terms: string[]): Segment[] { const clean = terms.map((t) => t.trim()).filter((t) => t.length > 0); if (!text || clean.length === 0) return text ? [{ text, hit: false }] : []; const sorted = Array.from(new Set(clean)).sort((a, b) => b.length - a.length); const re = new RegExp(sorted.map(escapeRegExp).join("|"), "gi"); const out: Segment[] = []; let last = 0; for (const m of text.matchAll(re)) { const start = m.index ?? 0; if (start > last) out.push({ text: text.slice(last, start), hit: false }); out.push({ text: m[0], hit: true }); last = start + m[0].length; } if (last < text.length) out.push({ text: text.slice(last), hit: false }); return out; } /** Lightweight markdown → plain text for snippets (no HTML, no dependencies). */ export function stripMarkdown(md: string): string { return md .replace(/```[\w+-]*\n?/g, "") .replace(/^\s{0,3}#{1,6}\s+/gm, "") .replace(/^\s{0,3}>\s?/gm, "") .replace(/^\s*[-*+]\s+/gm, "") .replace(/^\s*\d+[.)]\s+/gm, "") .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") .replace(/(\*\*|__)(.*?)\1/g, "$2") .replace(/(\*|_)(?=\S)(.*?)(?<=\S)\1/g, "$2") .replace(/~~(.*?)~~/g, "$1") .replace(/`([^`]*)`/g, "$1") .replace(/\|/g, " ") .replace(/\s+/g, " ") .trim(); } /** * Build a short excerpt centred on the first matching term. Returns the full (trimmed) text when it is short. */ export function makeSnippet(text: string, terms: string[], span = 160): string { const plain = stripMarkdown(text); if (plain.length <= span) return plain; const lower = plain.toLowerCase(); let idx = -1; for (const t of terms) { const i = lower.indexOf(t.toLowerCase()); if (i >= 0 && (idx < 0 || i < idx)) idx = i; } if (idx < 0) return plain.slice(0, span).trimEnd() + "…"; let start = Math.max(0, idx - Math.floor(span / 3)); // snap to a word boundary if (start > 0) { const sp = plain.lastIndexOf(" ", start); if (sp > 0 && start - sp < 24) start = sp + 1; } let end = Math.min(plain.length, start + span); if (end < plain.length) { const sp = plain.indexOf(" ", end); if (sp > 0 && sp - end < 24) end = sp; } return (start > 0 ? "…" : "") + plain.slice(start, end).trim() + (end < plain.length ? "…" : ""); } /** Escape `%`/`_`/`\` for ILIKE patterns. */ export function escapeLike(s: string): string { return s.replace(/[\\%_]/g, "\\$&"); } /** `to_tsquery('simple', …)` prefix expression: `'foo':* & 'bar':*` (lexemes quoted). Returns null when nothing usable. */ export function prefixTsQuery(words: string[]): string | null { const lexemes = words .map((w) => w.replace(/['\\:&|!()<>*]/g, "").trim()) .filter((w) => w.length >= 2); if (!lexemes.length) return null; return lexemes.map((w) => `'${w}':*`).join(" & "); }