import type { Column } from './types.js'; type FilterMode = 'table' | 'data'; export type RowPredicate = (row: T) => boolean; interface Term { field?: string; // if undefined, match across all fields value?: string; // lowercased string negate?: boolean; range?: { lower: string; upper: string; inclusive: boolean }; } interface Clause { terms: Term[]; // AND across terms } interface LuceneQuery { clauses: Clause[]; // OR across clauses } function stripQuotes(s: string): string { if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { return s.slice(1, -1); } return s; } function splitByOr(input: string): string[] { return input.split(/\s+OR\s+/i).map((s) => s.trim()).filter(Boolean); } function splitByAnd(input: string): string[] { return input.split(/\s+AND\s+/i).map((s) => s.trim()).filter(Boolean); } function parseTerm(raw: string): Term | null { if (!raw) return null; let negate = false; // handle NOT prefix or leading '-' const notMatch = raw.match(/^\s*(NOT\s+|-)\s*(.*)$/i); if (notMatch) { negate = true; raw = notMatch[2]; } // range: field:[lower TO upper] const rangeMatch = raw.match(/^([^:\s]+)\s*:\s*\[(.*?)\s+TO\s+(.*?)\]$/i); if (rangeMatch) { return { field: rangeMatch[1], negate, range: { lower: stripQuotes(rangeMatch[2]).toLowerCase(), upper: stripQuotes(rangeMatch[3]).toLowerCase(), inclusive: true }, }; } // field:value (value may be quoted) const m = raw.match(/^([^:\s]+)\s*:\s*("[^"]*"|'[^']*'|[^"'\s]+)$/); if (m) { return { field: m[1], value: stripQuotes(m[2]).toLowerCase(), negate }; } // plain term if (raw.length > 0) { return { value: stripQuotes(raw).toLowerCase(), negate }; } return null; } function parseLucene(input: string): LuceneQuery | null { if (!input) return null; const clauses = splitByOr(input).map((clauseStr) => { const terms = splitByAnd(clauseStr) .map(parseTerm) .filter((t): t is Term => !!t && !!t.value); return { terms } as Clause; }).filter((c) => c.terms.length > 0); if (clauses.length === 0) return null; return { clauses }; } export function compileLucenePredicate( input: string, mode: FilterMode, columns: Column[] ): RowPredicate | null { const ast = parseLucene(input); if (!ast) return null; const colMap = new Map>( columns.map((c) => [String(c.key), c]) ); const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); const coerce = (s: any) => { const str = String(s ?? '').toLowerCase(); const num = Number(str); const date = Date.parse(str); if (!Number.isNaN(num) && str.trim() !== '') return { t: 'n' as const, v: num }; if (!Number.isNaN(date)) return { t: 'd' as const, v: date }; return { t: 's' as const, v: str }; }; const inRange = (val: any, lower: string, upper: string) => { const a = coerce(val); const lo = coerce(lower); const up = coerce(upper); // All strings: lexical compare if (a.t === 's' && lo.t === 's' && up.t === 's') { const av = a.v as string; return cmp(av, lo.v as string) >= 0 && cmp(av, up.v as string) <= 0; } // All numbers if (a.t === 'n' && lo.t === 'n' && up.t === 'n') { const av = a.v as number; return av >= (lo.v as number) && av <= (up.v as number); } // All dates (as numbers) if (a.t === 'd' && lo.t === 'd' && up.t === 'd') { const av = a.v as number; return av >= (lo.v as number) && av <= (up.v as number); } // Fallback: compare string forms const as = String(val ?? '').toLowerCase(); return cmp(as, lower) >= 0 && cmp(as, upper) <= 0; }; return (row: T) => { for (const clause of ast.clauses) { let clauseOk = true; for (const term of clause.terms) { let ok = false; if (term.range && term.field) { // range compare on field if (mode === 'data') { ok = inRange((row as any)[term.field], term.range.lower, term.range.upper); } else { const col = colMap.get(term.field); if (!col || col.hidden) { ok = false; } else { const val = col.value ? col.value(row) : (row as any)[col.key as any]; ok = inRange(val, term.range.lower, term.range.upper); } } } else if (term.field && term.value != null) { if (mode === 'data') { const s = String((row as any)[term.field] ?? '').toLowerCase(); ok = s.includes(term.value); } else { const col = colMap.get(term.field); if (!col || col.hidden === true) { ok = false; } else { const val = col.value ? col.value(row) : (row as any)[col.key as any]; const s = String(val ?? '').toLowerCase(); ok = s.includes(term.value); } } } else if (term.value != null) { // search across all visible/raw fields if (mode === 'data') { ok = Object.values(row as any).some((v) => String(v ?? '').toLowerCase().includes(term.value!)); } else { ok = columns.some((col) => { if (col.hidden) return false; const val = col.value ? col.value(row) : (row as any)[col.key as any]; const s = String(val ?? '').toLowerCase(); return s.includes(term.value!); }); } } if (term.negate) ok = !ok; if (!ok) { clauseOk = false; break; } } if (clauseOk) return true; } return false; }; }