Files
ersatztv/web/src/builder/rules/parse.ts
T
timothy 3b5a78a08f
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17m57s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 44m33s
fix(176): compile date values to index yyyyMMdd format; guard Advanced toggle
2026-07-18 02:43:54 +02:00

138 lines
5.4 KiB
TypeScript

import type { FieldType, Group, Operator, Rule } from './types';
// Split a group body into top-level parts separated by a single connective, respecting quotes and
// one level of parens. Returns the parts and the connective, or null if the split is malformed or the
// connectives are mixed (mixed AND/OR at one level is out of subset).
function splitTopLevel(input: string): { parts: string[]; match: 'all' | 'any' } | null {
const parts: string[] = [];
const ops: string[] = [];
let depth = 0;
let quoted = false;
let token = '';
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch === '\\') { token += ch + (input[i + 1] ?? ''); i++; continue; } // escape pair, anywhere
if (ch === '"') { quoted = !quoted; token += ch; continue; }
if (quoted) { token += ch; continue; }
if (ch === '(') { depth++; token += ch; continue; }
if (ch === ')') { depth--; token += ch; continue; }
if (depth === 0 && input.startsWith(' AND ', i)) { parts.push(token); ops.push('AND'); token = ''; i += 4; continue; }
if (depth === 0 && input.startsWith(' OR ', i)) { parts.push(token); ops.push('OR'); token = ''; i += 3; continue; }
token += ch;
}
if (depth !== 0 || quoted) return null;
parts.push(token);
if (ops.length === 0) return { parts, match: 'all' };
const allAnd = ops.every((o) => o === 'AND');
const allOr = ops.every((o) => o === 'OR');
if (!allAnd && !allOr) return null; // mixed connectives
return { parts, match: allAnd ? 'all' : 'any' };
}
// Reverse of compile's date normalization: the index format yyyyMMdd back to the input's yyyy-MM-dd.
function fromIndexDate(value: string): string {
return /^\d{8}$/.test(value) ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` : value;
}
function unquote(value: string): string {
return value.replace(/\\(["\\])/g, '$1');
}
// A trailing '*' is an operator wildcard iff it is not escaped — i.e. preceded by an even run of backslashes.
function endsWithOperatorStar(s: string): boolean {
if (!s.endsWith('*')) return false;
let backslashes = 0;
let i = s.length - 2;
while (i >= 0 && s[i] === '\\') { backslashes++; i--; }
return backslashes % 2 === 0;
}
function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule | null {
const trimmed = atom.trim();
const negate = trimmed.startsWith('NOT ');
const body = negate ? trimmed.slice(4).trim() : trimmed;
const colon = body.indexOf(':');
if (colon < 0) return null;
const field = body.slice(0, colon);
const raw = body.slice(colon + 1);
const type = fieldTypes[field];
if (!type) return null;
// Ranges: {a TO b} (exclusive) and [a TO b] (inclusive between)
const range = raw.match(/^([[{])(\S+) TO (\S+)([\]}])$/);
if (range) {
const [, , lo, hi, close] = range;
if (type !== 'number' && type !== 'date') return null;
const fmt = (x: string) => (type === 'date' ? fromIndexDate(x) : x);
if (close === ']') return mk(field, 'between', fmt(lo), fmt(hi));
if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', fmt(hi));
if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', fmt(lo));
return null;
}
// Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
const v = unquote(raw.slice(1, -1));
if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v);
if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v);
return null;
}
if (negate) return null; // NOT only valid with quoted forms above
// Wildcards (text only): *v* → contains, v* → startsWith. The operator stars are the
// unescaped boundary stars; any literal * in the value was escaped to \* by the compiler.
if (type === 'text' && endsWithOperatorStar(raw)) {
if (raw.startsWith('*') && raw.length >= 2) {
return mk(field, 'contains', unescapeWild(raw.slice(1, -1)));
}
if (raw[0] !== '*') {
return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1)));
}
return null;
}
// Bare token → numeric eq only (reject fuzzy ~, boosts ^, etc.)
if (type === 'number' && /^[0-9.]+$/.test(raw)) return mk(field, 'eq', raw);
return null;
}
function unescapeWild(value: string): string {
return value.replace(/\\([\s\S])/g, '$1');
}
function mk(field: string, operator: Operator, value: string, value2?: string): Rule {
return value2 === undefined ? { field, operator, value } : { field, operator, value, value2 };
}
function parseGroup(input: string, fieldTypes: Record<string, FieldType>, allowNested: boolean): Group | null {
const split = splitTopLevel(input.trim());
if (!split) return null;
const children: Array<Rule | Group> = [];
for (const part of split.parts) {
const p = part.trim();
if (p.startsWith('(') && p.endsWith(')')) {
if (!allowNested) return null; // deeper than one level
const inner = parseGroup(p.slice(1, -1), fieldTypes, false);
if (!inner) return null;
children.push(inner);
} else {
const rule = parseAtom(p, fieldTypes);
if (!rule) return null;
children.push(rule);
}
}
if (children.length === 0) return null;
return { match: split.match, children };
}
export function parse(input: string, fieldTypes: Record<string, FieldType>): Group | null {
if (!input.trim()) return null;
return parseGroup(input, fieldTypes, true);
}