| 
									
										
										
										
											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; | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | } |