// Known search aliases — a curated dictionary that offers a *non-forcing* // suggestion when a typed term matches a known trigger. A concept ("loli") can // be spelled many ways (AI transcription in particular expands it to "lolly"/ // "loly"); one alias entry groups all those trigger spellings and offers one // robust replacement regex (e.g. "\\blol(i|ly)"). Nothing is auto-applied — the // viewer surfaces the suggestion and the user chooses to apply it. // // This module is PURE (no I/O) so it can be unit-tested and shared by the // server-side store (common/lib/aliasesStore.ts) and the client viewer // (common/components/QueryLeafView.tsx). Disk/merge/defaulting policy lives in // the store; fetch/coerce policy lives with the client fetch. import type { LayerScope } from "./searchQuery"; export type SearchAlias = { // Stable identity. Seeded defaults keep fixed ids so a per-site entry with the // same id can shadow (override or disable) a global one. See mergeAliases. id: string; // Human name of the concept, shown on the suggestion chip. label: string; // Spellings that fire the suggestion. Matched case-insensitively, whole-token. triggers: string[]; // The query to swap in on Apply, e.g. "\\blol(i|ly)". suggestion: string; // What to set the leaf's `useRegex` to on Apply (usually true). useRegex: boolean; // Optional author note ("AI euphemism variants"), shown muted on the chip. note?: string; // Defaults true; a per-site entry can set false to turn off a seeded default // without deleting it. enabled?: boolean; // Optional scope restriction; absent (or empty) = suggest in every scope. scopes?: LayerScope[]; }; export type AliasConfig = { aliases: SearchAlias[] }; const LAYER_SCOPES: readonly LayerScope[] = [ "transcripts", "chat", "metadata", "description", "tags", ]; function isLayerScope(v: unknown): v is LayerScope { return typeof v === "string" && (LAYER_SCOPES as readonly string[]).includes(v); } // Split a query into lowercased word tokens on any non-alphanumeric boundary. // Whole-token matching is deliberate: "loli" fires inside "loli clips" but NOT // inside "lolight" (substring matching would be too noisy and erode trust). // Triggers are tokenized the same way, so a multi-word trigger like // "graham platner" becomes ["graham", "platner"] and matching is case- and // whitespace-insensitive for free. export function tokenizeQuery(query: string): string[] { return query .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter(Boolean); } // True if `needle` (non-empty) appears as a contiguous run of whole tokens in // `haystack`. A single-token needle reduces to "token present anywhere", which // preserves the original whole-word behavior (fires on "loli clips", not on // "lolight"); a multi-token needle requires the phrase in order. function containsTokenSequence(haystack: string[], needle: string[]): boolean { if (needle.length === 0) return false; if (needle.length > haystack.length) return false; for (let i = 0; i + needle.length <= haystack.length; i++) { let all = true; for (let j = 0; j < needle.length; j++) { if (haystack[i + j] !== needle[j]) { all = false; break; } } if (all) return true; } return false; } function tokensEqual(a: string[], b: string[]): boolean { return a.length === b.length && a.every((t, i) => t === b[i]); } // Which aliases match the typed query for a given scope. Returns every match // (usually 0 or 1). // // By default a trigger fires when its token sequence appears as whole tokens in // the query (phrase-aware). In `regexMode` the user is crafting their own // pattern, so we fire ONLY when the whole query is exactly one of the triggers // (a bare literal, not a hand-written pattern) — this lets a plain typed word // still be upgraded to the curated regex, while an applied suggestion (whose // tokens don't equal any trigger) stays suppressed. export function matchAliases( query: string, scope: LayerScope, aliases: SearchAlias[], opts?: { regexMode?: boolean }, ): SearchAlias[] { const qTokens = tokenizeQuery(query); if (qTokens.length === 0) return []; const regexMode = opts?.regexMode === true; const out: SearchAlias[] = []; for (const a of aliases) { if (a.enabled === false) continue; if (a.scopes && a.scopes.length > 0 && !a.scopes.includes(scope)) continue; const fires = a.triggers.some((t) => { const tTokens = tokenizeQuery(t); if (tTokens.length === 0) return false; return regexMode ? tokensEqual(qTokens, tTokens) : containsTokenSequence(qTokens, tTokens); }); if (fires) out.push(a); } return out; } // Merge a global list with a per-site list. A per-site entry with the same id // REPLACES the matching global one (so a site can override or disable a global // alias); otherwise it appends. This is the effective list a site ships. export function mergeAliases( global: SearchAlias[], perSite: SearchAlias[], ): SearchAlias[] { const byId = new Map(); for (const a of global) byId.set(a.id, a); for (const a of perSite) byId.set(a.id, a); return Array.from(byId.values()); } // Turn an id-less label into a stable slug id (for hand-authored files that // omit `id`). Deterministic — no Date.now()/Math.random(). export function slugifyAliasId(label: string): string { const slug = label .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, "-") .replace(/^-+|-+$/g, ""); return slug || "alias"; } function coerceAlias(raw: unknown): SearchAlias | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; const label = typeof r.label === "string" ? r.label.trim() : ""; const suggestion = typeof r.suggestion === "string" ? r.suggestion : ""; const triggers = Array.isArray(r.triggers) ? r.triggers .filter((t): t is string => typeof t === "string" && t.trim() !== "") .map((t) => t.trim()) : []; // An entry without a label, a suggestion, or at least one trigger is unusable. if (!label || !suggestion || triggers.length === 0) return null; const id = typeof r.id === "string" && r.id.trim() !== "" ? r.id.trim() : slugifyAliasId(label); const scopes = Array.isArray(r.scopes) ? r.scopes.filter(isLayerScope) : undefined; const note = typeof r.note === "string" && r.note.trim() !== "" ? r.note.trim() : undefined; return { id, label, triggers, suggestion, useRegex: r.useRegex !== false, // default true enabled: r.enabled !== false, // default true ...(note ? { note } : {}), ...(scopes && scopes.length > 0 ? { scopes } : {}), }; } // Coerce a parsed JSON value into a valid AliasConfig, dropping malformed // entries. Missing/invalid input yields an EMPTY list — the "seed defaults when // the global file is absent" policy lives in the store, and the client fetch // treats a 404 as empty. This keeps coercion a pure shape-guard. export function coerceAliasConfig(raw: unknown): AliasConfig { if (!raw || typeof raw !== "object") return { aliases: [] }; const arr = Array.isArray((raw as { aliases?: unknown }).aliases) ? ((raw as { aliases: unknown[] }).aliases) : []; return { aliases: arr .map(coerceAlias) .filter((a): a is SearchAlias => a !== null), }; } // Seeded global defaults, returned by readGlobalAliases when no global file // exists yet (and shown pre-filled in the editor's Global section). Kept small // and clearly labeled; operators prune/edit from here. Stable ids so a site can // shadow them. The first entry is the canonical content-moderation case; the // second is a benign demonstrator of the whole-word / multi-spelling mechanic. export const DEFAULT_ALIASES: SearchAlias[] = [ { id: "loli", label: "loli", triggers: ["loli", "lolly", "loly", "lolli"], suggestion: "\\blol(i|ly)", useRegex: true, note: "AI transcription often expands this to “lolly”/“loly”.", }, { id: "youtube", label: "YouTube", triggers: ["youtube", "yt"], suggestion: "\\byou ?tube\\b|\\byt\\b", useRegex: true, note: "Catches “you tube” and the “yt” abbreviation.", }, ];