const NUMBER_RE = /\d+(?:\.\d+)?/; function lookup_unit_table(unitTable: Record, s: string, strict = false): number | null { const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/; const unitsRe = Object.keys(unitTable) .map(u => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join("|"); const re = new RegExp(`^(?${numRe.source})\\s*(?${unitsRe})\\b`, strict ? "" : "i"); const m = s.match(re); if (!m?.groups?.num || !m.groups.unit) { return null; } const numStr = m.groups.num.replace(",", "."); const unit = m.groups.unit; const mult = unitTable[unit]; if (undefined === mult) return null; return Math.round(parseFloat(numStr) * mult); } function parse_filesize(s: string | null): number | null { if (!s) return null; const UNIT_TABLE: Record = { B: 1, b: 1, bytes: 1, KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024, MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2, GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3, TiB: 1024 ** 4, TB: 1000 ** 4, terabytes: 1000 ** 4, tebibytes: 1024 ** 4, PiB: 1024 ** 5, PB: 1000 ** 5, petabytes: 1000 ** 5, pebibytes: 1024 ** 5, }; return lookup_unit_table(UNIT_TABLE, s); } function parse_duration(s: string): number | null { if (!s?.trim()) return null; // Support ISO-ish short forms: "2m", "10s", "1h", "1d" const simpleRe = /^(?\d+(?:\.\d+)?)(?[smhd])$/i; const sm = s.match(simpleRe); if (sm?.groups?.num && sm.groups.unit) { const num = parseFloat(sm.groups.num); switch (sm.groups.unit.toLowerCase()) { case "s": return num; case "m": return num * 60; case "h": return num * 3600; case "d": return num * 86400; } } // Support hh:mm:ss[.ms] const hmsRe = /^(?:(?:(?\d+):)?(?\d+):)?(?\d+):(?\d{1,2})(?[.:]\d+)?Z?$/; const m = s.match(hmsRe); if (m?.groups) { const { days, hours, mins, secs, ms } = m.groups; return ( (parseFloat(days ?? "0") * 86400) + (parseFloat(hours ?? "0") * 3600) + (parseFloat(mins ?? "0") * 60) + parseFloat(secs ?? "0") + parseFloat((ms ?? "0").replace(":", ".")) ); } return null; } // ---------- Match One ---------- function _match_one(filterPart: string, dct: Record): boolean { const STRING_OPERATORS: Record boolean> = { "*=": (a, b) => a.includes(b), "^=": (a, b) => a.startsWith(b), "$=": (a, b) => a.endsWith(b), "~=": (a, b) => new RegExp(b).test(a), }; const COMPARISON_OPERATORS: Record boolean> = { "*=": STRING_OPERATORS["*="]!, "^=": STRING_OPERATORS["^="]!, "$=": STRING_OPERATORS["$="]!, "~=": STRING_OPERATORS["~="]!, "<=": (a, b) => a <= b, "<": (a, b) => a < b, ">=": (a, b) => a >= b, ">": (a, b) => a > b, "=": (a, b) => a == b, }; const UNARY_OPERATORS: Record boolean> = { "": v => (typeof v === "boolean" ? v === true : v != null), "!": v => (typeof v === "boolean" ? v === false : v == null), }; const cmpOps = Object.keys(COMPARISON_OPERATORS) .map(op => op.replace(/([.*+?^${}()|[\]\\])/g, "\\$1")) .join("|"); const operatorRe = new RegExp( `^(?[a-z_]+)\\s*(?!\\s*)?(?${cmpOps})(?\\s*\\?)?\\s*(?:(?["'])(?.+?)(?:\\k)|(?.+?))?$` ); const m = filterPart.trim().match(operatorRe); if (m?.groups) { const { key, negation, op, quote, quoted, plain } = m.groups; const unnegatedOp = COMPARISON_OPERATORS[op!]; if (!unnegatedOp) throw new Error(`Invalid operator '${op}'`); // Handle incomplete expressions (missing value) const value = quoted ?? plain ?? ""; if (!value && op !== "=" && op !== "!") { return false; // Incomplete non-equality expression } const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp; let processedValue = value; if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, "g"), quote); const actual = dct[key!]; if (actual === undefined || actual === null) { return false; } let numeric: number | null = null; // Try to convert comparison value to numeric first numeric = Number(processedValue); if (Number.isNaN(numeric)) { numeric = parse_filesize(processedValue) ?? parse_filesize(`${processedValue}B`) ?? parse_duration(processedValue); } // Also try to convert actual value to numeric if we have a numeric comparison let finalActual = actual; if (numeric !== null && typeof actual === "string") { const numericActual = Number(actual); if (!Number.isNaN(numericActual)) { finalActual = numericActual; } else { const parsedActual = parse_filesize(actual) ?? parse_duration(actual); if (parsedActual !== null) { finalActual = parsedActual; } } } if (numeric !== null && op! in STRING_OPERATORS) { throw new Error(`Operator ${op} only supports string values!`); } return opFn(finalActual, numeric !== null ? numeric : processedValue); } // unary operator const unaryRe = /^(?!?)\s*(?[a-z_]+)$/; const mu = filterPart.trim().match(unaryRe); if (mu?.groups) { const { op, key } = mu.groups; const actual = dct[key!]; return UNARY_OPERATORS[op!]!(actual); } throw new Error(`Invalid filter part '${filterPart}'`); } // ---------- Parser ---------- type Node = ["ATOM", string] | ["AND", Node, Node] | ["OR", Node, Node]; class MatchFilterParser { expr: string; tokens: [string, string][]; pos: number; ast: Node; constructor(expr: string) { this.expr = expr; this.tokens = this._tokenize(expr); this.pos = 0; this.ast = this._parseOr(); } static run(expr: string, dct: Record = {}): boolean { return new MatchFilterParser(expr).evaluate(dct); } evaluate(dct: Record): boolean { return this._eval(this.ast, dct); } export(): string[] { return this._export(this.ast).map(parts => parts.join("&")); } private _tokenize(expr: string): [string, string][] { // First, let's normalize spaces around operators to make parsing easier // Replace spaced operators with non-spaced ones let normalizedExpr = expr; const operators = ["<=", ">=", "<", ">", "\\*=", "\\^=", "\\$=", "~=", "="]; for (const op of operators) { // Replace "key op value" with "keyopvalue" (removing extra spaces) const pattern = new RegExp(`([a-z_]+)\\s*(!?)\\s*(${op})\\s*`, "g"); normalizedExpr = normalizedExpr.replace(pattern, "$1$2$3"); } const tokenSpec: [RegExp, string | null][] = [ [/\|\|/, "OR"], [/&/, "AND"], [/\(/, "LPAREN"], [/\)/, "RPAREN"], [/[^\s&|()]+/, "ATOM"], [/\s+/, null], ]; const regex = new RegExp( tokenSpec.map(([pat, _name], i) => `(?${pat.source})`).join("|"), "g" ); const tokens: [string, string][] = []; let m: RegExpExecArray | null; while ((m = regex.exec(normalizedExpr)) !== null) { for (let i = 0; i < tokenSpec.length; i++) { if (m.groups?.[`T${i}`]) { const tokenSpecEntry = tokenSpec[i]; if (tokenSpecEntry) { const kind = tokenSpecEntry[1]; if (kind) tokens.push([kind, m[0]]); } break; } } } return tokens; } private _accept(kind: string): boolean { if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) { this.pos++; return true; } return false; } private _expect(kind: string): [string, string] { if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) { return this.tokens[this.pos++]!; } throw new SyntaxError(`Expected ${kind}`); } private _parseOr(): Node { let left = this._parseAnd(); while (this._accept("OR")) { const right = this._parseAnd(); left = ["OR", left, right]; } return left; } private _parseAnd(): Node { let left = this._parseAtom(); while (this._accept("AND")) { const right = this._parseAtom(); left = ["AND", left, right]; } return left; } private _parseAtom(): Node { if (this._accept("LPAREN")) { const node = this._parseOr(); this._expect("RPAREN"); return node; } const tok = this._expect("ATOM"); return ["ATOM", tok[1].trim()]; } private _eval(node: Node, dct: Record): boolean { if (node[0] === "ATOM") { return _match_one(node[1], dct); } if (node[0] === "AND") { return this._eval(node[1], dct) && this._eval(node[2], dct); } if (node[0] === "OR") { return this._eval(node[1], dct) || this._eval(node[2], dct); } throw new Error("Invalid AST node " + node[0]); } private _export(node: Node): string[][] { if (node[0] === "ATOM") return [[node[1]]]; if (node[0] === "AND") { const left = this._export(node[1]); const right = this._export(node[2]); return left.flatMap(l => right.map(r => [...l, ...r])); } if (node[0] === "OR") { return [...this._export(node[1]), ...this._export(node[2])]; } throw new Error("Invalid AST node " + node[0]); } } const match_str = (expr: string, dct: Record): boolean => MatchFilterParser.run(expr, dct) export { MatchFilterParser, match_str }