WebHooker/app/composables/useFilterNode.ts
RhenCloud c090281cb2
feat(filters): JSONPath field filters, operators, AST groups, fragments, and test-match
Add a field filter type reading any payload value by JSONPath with array expansion, 12 comparison operators (eq/ne/contains/startsWith/endsWith/regex/gt/gte/lt/lte/in/exists), a visual AST builder (all/any/not) in the route editor, chip-based multi-value input, a stateless POST /admin/api/test-match dry-run, and named filter fragments stored in D1 (d1_fragments, migration 0010) inlined into route ASTs on insert.
2026-08-24 22:51:20 +08:00

181 lines
5.4 KiB
TypeScript

import type { Filter, FilterNode, FilterOp, FilterType } from "~/types";
export interface LeafForm {
type: FilterType;
path: string;
op: FilterOp;
values: string[];
exclude: boolean;
}
export type NodeKind = "leaf" | "all" | "any" | "not";
export interface NodeForm {
kind: NodeKind;
leaf: LeafForm;
children: NodeForm[];
child: NodeForm | null;
}
export function blankLeafForm(): LeafForm {
return { type: "event", path: "", op: "eq", values: [], exclude: false };
}
export function blankNode(kind: NodeKind): NodeForm {
return {
kind,
leaf: blankLeafForm(),
children: kind === "all" || kind === "any" ? [blankNode("leaf")] : [],
child: kind === "not" ? blankNode("leaf") : null,
};
}
function matchToValues(match: string | string[] | undefined): string[] {
if (match === undefined || match === null) return [];
return Array.isArray(match) ? [...match] : [match];
}
function leafToForm(f: Filter): LeafForm {
return {
type: f.type,
path: f.path ?? "",
op: f.op ?? "eq",
values: matchToValues(f.match),
exclude: !!f.exclude,
};
}
export function nodeToForm(node: FilterNode): NodeForm {
if ("all" in node) {
return { kind: "all", leaf: blankLeafForm(), children: node.all.map(nodeToForm), child: null };
}
if ("any" in node) {
return { kind: "any", leaf: blankLeafForm(), children: node.any.map(nodeToForm), child: null };
}
if ("not" in node) {
return { kind: "not", leaf: blankLeafForm(), children: [], child: nodeToForm(node.not) };
}
return { kind: "leaf", leaf: leafToForm(node), children: [], child: null };
}
export function formToLeaf(lf: LeafForm): Filter | null {
const values = lf.values.map((v) => v.trim()).filter((v) => v.length > 0);
if (lf.op !== "exists" && values.length === 0) return null;
if (lf.type === "field" && lf.path.trim().length === 0) return null;
const filter: Filter = { type: lf.type };
if (lf.type === "field") filter.path = lf.path.trim();
if (lf.op !== "eq") filter.op = lf.op;
if (lf.op !== "exists") filter.match = values.length === 1 ? values[0] : values;
if (lf.exclude) filter.exclude = true;
return filter;
}
export function formToNode(nf: NodeForm): FilterNode | null {
if (nf.kind === "leaf") return formToLeaf(nf.leaf);
if (nf.kind === "all" || nf.kind === "any") {
const children = nf.children.map(formToNode).filter((c): c is FilterNode => c !== null);
if (children.length === 0) return null;
return nf.kind === "all" ? { all: children } : { any: children };
}
if (nf.kind === "not") {
const child = nf.child ? formToNode(nf.child) : null;
if (!child) return null;
return { not: child };
}
return null;
}
export function isTrivialNode(nf: NodeForm): boolean {
if (nf.kind === "leaf") {
return nf.leaf.type !== "field" && nf.leaf.op === "eq" && !nf.leaf.exclude;
}
if (nf.kind === "all") return nf.children.every(isTrivialNode);
return false;
}
export function flattenLeaves(nf: NodeForm): Filter[] {
const out: Filter[] = [];
const walk = (n: NodeForm): void => {
if (n.kind === "leaf") {
const f = formToLeaf(n.leaf);
if (f) out.push(f);
return;
}
if (n.kind === "all" || n.kind === "any") n.children.forEach(walk);
else if (n.kind === "not" && n.child) walk(n.child);
};
walk(nf);
return out;
}
export function nodeFormToRouteFilters(nf: NodeForm): { filters: Filter[]; ast?: FilterNode } {
if (isTrivialNode(nf)) {
return { filters: flattenLeaves(nf) };
}
const ast = formToNode(nf);
return { filters: [], ast: ast ?? undefined };
}
export function collectEvents(node: FilterNode): string[] {
const out: string[] = [];
const walk = (n: FilterNode): void => {
if ("all" in n) {
n.all.forEach(walk);
return;
}
if ("any" in n) {
n.any.forEach(walk);
return;
}
if ("not" in n) {
walk(n.not);
return;
}
if (n.type === "event") {
const m = n.match;
if (Array.isArray(m)) out.push(...m);
else if (typeof m === "string" && m) out.push(m);
}
};
walk(node);
return out;
}
export function describeLeaf(f: Filter, t: (key: string) => string): string {
const label = f.type === "field" ? (f.path ?? f.type) : t("filter." + f.type);
const op = f.op ?? "eq";
const match = f.match;
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
const value =
values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
let base: string;
if (f.type === "keyword") {
base = `${label} ${t("filterNode.matches")} ${value}`;
} else if (op === "exists") {
base = `${label} ${t("filterOp.exists")}`;
} else if (op === "eq") {
base = `${label} ${t("filterNode.is")} ${value}`;
} else {
base = `${label} ${t("filterOp." + op)} ${value}`;
}
return f.exclude ? `${t("filterNode.not")} (${base})` : base;
}
export function describeNode(node: FilterNode, t: (key: string) => string): string {
if ("all" in node) {
return node.all
.map((c) => describeNode(c, t))
.filter((s) => s.length)
.join(` ${t("filterNode.and")} `);
}
if ("any" in node) {
const parts = node.any
.map((c) => describeNode(c, t))
.filter((s) => s.length);
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
}
if ("not" in node) {
return `${t("filterNode.not")} (${describeNode(node.not, t)})`;
}
return describeLeaf(node, t);
}