/** * Advanced search syntax (spec §30). Browser-safe. * * openai pricing entity:org_openai type:pricing_change,terms_change after:2026-09-01 before:2026-09-10 * silent:true first_party:true confirmed:true importance:>70 confidence:>=50 signal:>80 * source:openai category:ai country:CA lang:fr class:pricing group:security cluster:clu_… * * Quoted values are supported: source:"bank of canada". Unknown keys stay in the free-text part. */ export interface ParsedSearch { text: string; filters: { entity?: string; source?: string; domain?: string; event_type?: string; group?: string; category?: string; country?: string; language?: string; after?: string; before?: string; silent_change?: boolean; first_party?: boolean; confirmed?: boolean; importance_min?: number; confidence_min?: number; signal_min?: number; change_class?: string; cluster?: string; sensor?: string; url?: string; }; tokens: { key: string; value: string }[]; } const KEYS = new Set(["entity", "source", "domain", "type", "event_type", "group", "category", "cat", "country", "lang", "language", "after", "since", "before", "until", "silent", "first_party", "firstparty", "confirmed", "importance", "imp", "confidence", "conf", "signal", "class", "cluster", "sensor", "url", "is"]); function bool(v: string): boolean | undefined { const s = v.toLowerCase(); if (["true", "1", "yes", "y"].includes(s)) return true; if (["false", "0", "no", "n"].includes(s)) return false; return undefined; } function num(v: string): number | undefined { const m = v.match(/^(?:>=|>|=)?\s*(\d+(?:\.\d+)?)$/); return m ? Number(m[1]) : undefined; } /** Relative dates: 1h, 6h, 24h, 7d, 30d → ISO timestamp. */ function date(v: string): string | undefined { const rel = v.match(/^(\d+)\s*([hdwm])$/i); if (rel) { const n = Number(rel[1]); const unit = rel[2]!.toLowerCase(); const ms = unit === "h" ? 3600e3 : unit === "d" ? 86400e3 : unit === "w" ? 7 * 86400e3 : 30 * 86400e3; return new Date(Date.now() - n * ms).toISOString(); } const d = new Date(v.length === 10 ? `${v}T00:00:00Z` : v); return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); } export function parseSearch(input: string): ParsedSearch { const tokens: { key: string; value: string }[] = []; const free: string[] = []; const re = /(\w+):("([^"]*)"|'([^']*)'|(\S+))|("([^"]*)")|(\S+)/g; let m: RegExpExecArray | null; while ((m = re.exec(input)) !== null) { if (m[1] && KEYS.has(m[1].toLowerCase())) tokens.push({ key: m[1].toLowerCase(), value: (m[3] ?? m[4] ?? m[5] ?? "").trim() }); else free.push(m[7] ?? m[8] ?? m[0]); } const f: ParsedSearch["filters"] = {}; for (const { key, value } of tokens) { switch (key) { case "entity": f.entity = /^(org|prd|ent)_/.test(value) ? value : `org_${value.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; break; case "source": f.source = value.toLowerCase().replace(/[^a-z0-9.-]+/g, "-"); break; case "domain": f.domain = value.toLowerCase(); break; case "type": case "event_type": f.event_type = value; break; case "group": f.group = value.toLowerCase(); break; case "category": case "cat": f.category = value.toLowerCase(); break; case "country": f.country = value.toUpperCase(); break; case "lang": case "language": f.language = value.toLowerCase(); break; case "after": case "since": f.after = date(value); break; case "before": case "until": f.before = date(value); break; case "silent": f.silent_change = bool(value); break; case "first_party": case "firstparty": f.first_party = bool(value); break; case "confirmed": f.confirmed = bool(value); break; case "importance": case "imp": f.importance_min = num(value); break; case "confidence": case "conf": f.confidence_min = num(value); break; case "signal": f.signal_min = num(value); break; case "class": f.change_class = value.toLowerCase(); break; case "cluster": f.cluster = value; break; case "sensor": f.sensor = value; break; case "url": f.url = value; break; case "is": if (value === "silent") f.silent_change = true; else if (value === "breaking") f.importance_min = Math.max(f.importance_min ?? 0, 80); else if (value === "confirmed") f.confirmed = true; else if (value === "first-party" || value === "firstparty") f.first_party = true; break; } } return { text: free.join(" ").trim(), filters: f, tokens }; } /** Inverse: build a query string from filters (for saved views / URL sync). */ export function stringifySearch(p: { text?: string; filters: ParsedSearch["filters"] }): string { const parts: string[] = []; if (p.text) parts.push(p.text); const f = p.filters; const q = (v: string): string => (/\s/.test(v) ? `"${v}"` : v); if (f.entity) parts.push(`entity:${q(f.entity)}`); if (f.source) parts.push(`source:${q(f.source)}`); if (f.domain) parts.push(`domain:${f.domain}`); if (f.event_type) parts.push(`type:${f.event_type}`); if (f.group) parts.push(`group:${f.group}`); if (f.category) parts.push(`category:${f.category}`); if (f.country) parts.push(`country:${f.country}`); if (f.language) parts.push(`lang:${f.language}`); if (f.after) parts.push(`after:${f.after.slice(0, 10)}`); if (f.before) parts.push(`before:${f.before.slice(0, 10)}`); if (f.silent_change !== undefined) parts.push(`silent:${f.silent_change}`); if (f.first_party !== undefined) parts.push(`first_party:${f.first_party}`); if (f.confirmed !== undefined) parts.push(`confirmed:${f.confirmed}`); if (f.importance_min !== undefined) parts.push(`importance:>${f.importance_min}`); if (f.confidence_min !== undefined) parts.push(`confidence:>${f.confidence_min}`); if (f.signal_min !== undefined) parts.push(`signal:>${f.signal_min}`); if (f.change_class) parts.push(`class:${f.change_class}`); if (f.cluster) parts.push(`cluster:${f.cluster}`); return parts.join(" "); }