mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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.
This commit is contained in:
parent
c955db03c3
commit
c090281cb2
30 changed files with 1793 additions and 422 deletions
181
app/composables/useFilterNode.ts
Normal file
181
app/composables/useFilterNode.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue