2025-09-16 15:46:44 +00:00
|
|
|
import type { Column } from './types.js';
|
|
|
|
|
|
|
|
type FilterMode = 'table' | 'data';
|
|
|
|
|
|
|
|
export type RowPredicate<T> = (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<T>(
|
|
|
|
input: string,
|
|
|
|
mode: FilterMode,
|
|
|
|
columns: Column<T>[]
|
|
|
|
): RowPredicate<T> | null {
|
|
|
|
const ast = parseLucene(input);
|
|
|
|
if (!ast) return null;
|
|
|
|
const colMap = new Map<string, Column<T>>(
|
|
|
|
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);
|
2025-09-16 16:29:52 +00:00
|
|
|
// 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;
|
2025-09-16 15:46:44 +00:00
|
|
|
}
|
2025-09-16 16:29:52 +00:00
|
|
|
// 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;
|
2025-09-16 15:46:44 +00:00
|
|
|
};
|
|
|
|
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;
|
|
|
|
};
|
|
|
|
}
|