mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
import type { Node } from "@yaakapp-internal/query";
|
|
import { formatField, parseQuery } from "@yaakapp-internal/query";
|
|
|
|
/**
|
|
* A search over every flow in the database, as `list_flows` takes it: the search box's text,
|
|
* which the backend parses and runs, and whether System's flows are hidden.
|
|
*/
|
|
export interface FlowQuery {
|
|
query: string;
|
|
hideSystem: boolean;
|
|
}
|
|
|
|
/** The search the list shows, or null for all traffic */
|
|
export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null {
|
|
if (text.trim() === "" && !hideSystem) return null;
|
|
return { query: text.trim(), hideSystem };
|
|
}
|
|
|
|
/** `@field:value`, or `-@field:value`, the value quoted when it needs to be */
|
|
export function fieldTerm(field: string, value: string, negated = false): string {
|
|
return formatField(field, value, negated);
|
|
}
|
|
|
|
/** A site, its domain and every host below it: `@host:*.github.com` */
|
|
export function siteTerm(site: string): string {
|
|
const term = formatField("host", site);
|
|
return term.endsWith('"') ? term : `@host:*.${site}`;
|
|
}
|
|
|
|
/**
|
|
* A path and everything under it, `@path:/v1/charges/*`, as a sidebar row selects it. A path
|
|
* that has to be quoted cannot carry the wildcard, so it matches as written.
|
|
*/
|
|
export function pathTerm(path: string): string {
|
|
const term = formatField("path", path);
|
|
if (term.endsWith('"')) return term;
|
|
return `${term.replace(/\/+$/, "")}/*`;
|
|
}
|
|
|
|
/** `node` written back as query text, from its source, groups in parentheses where they need */
|
|
export function nodeText(node: Node, source: string, nested = false): string {
|
|
switch (node.type) {
|
|
case "And": {
|
|
const text = node.children.map((c) => nodeText(c, source, true)).join(" ");
|
|
return nested ? `(${text})` : text;
|
|
}
|
|
case "Or": {
|
|
const text = node.children.map((c) => nodeText(c, source, true)).join(" OR ");
|
|
return nested ? `(${text})` : text;
|
|
}
|
|
case "Not":
|
|
return `-${nodeText(node.node, source, true)}`;
|
|
default:
|
|
return source.slice(node.span.start, node.span.end);
|
|
}
|
|
}
|
|
|
|
/** The terms of a query, each as written and with a `-` when it is excluded, lowercased */
|
|
export function termKeys(text: string): string[] {
|
|
const keys: string[] = [];
|
|
const walk = (node: Node, negated: boolean) => {
|
|
switch (node.type) {
|
|
case "And":
|
|
case "Or":
|
|
for (const c of node.children) walk(c, negated);
|
|
break;
|
|
case "Not":
|
|
walk(node.node, !negated);
|
|
break;
|
|
default:
|
|
keys.push(
|
|
`${negated ? "-" : ""}${text.slice(node.span.start, node.span.end).toLowerCase()}`,
|
|
);
|
|
}
|
|
};
|
|
const root = parseQuery(text);
|
|
if (root != null) walk(root, false);
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Wider searches for one that matched nothing: the text with each of its terms left out in
|
|
* turn, without repeats. With groups `OR` separates, a term is left out of one group at a time
|
|
* and the groups kept, since each group alone is narrower still; a group left with nothing would
|
|
* take in every flow, so it is not offered.
|
|
*/
|
|
export function widerQueries(text: string): string[] {
|
|
const root = parseQuery(text);
|
|
if (root == null) return [];
|
|
const clauses = root.type === "Or" ? root.children : [root];
|
|
const termsOf = (clause: Node) => (clause.type === "And" ? clause.children : [clause]);
|
|
const write = (terms: Node[]) => terms.map((t) => nodeText(t, text, true)).join(" ");
|
|
const out = new Set<string>();
|
|
clauses.forEach((clause, i) => {
|
|
const terms = termsOf(clause);
|
|
if (terms.length <= 1) return;
|
|
terms.forEach((_, j) => {
|
|
const wider = clauses.map((c, k) =>
|
|
k === i ? write(terms.filter((_, l) => l !== j)) : write(termsOf(c)),
|
|
);
|
|
out.add(wider.join(" OR "));
|
|
});
|
|
});
|
|
return [...out];
|
|
}
|
|
|
|
/**
|
|
* The search has a `@time` term that counts back from now, `@time:<5m`, so which flows it covers
|
|
* changes as time passes, not only as flows are written
|
|
*/
|
|
export function countsBackFromNow(text: string): boolean {
|
|
let found = false;
|
|
const walk = (node: Node) => {
|
|
switch (node.type) {
|
|
case "And":
|
|
case "Or":
|
|
for (const c of node.children) walk(c);
|
|
break;
|
|
case "Not":
|
|
walk(node.node);
|
|
break;
|
|
case "Field":
|
|
if (
|
|
node.path.length === 1 &&
|
|
node.path[0]?.toLowerCase() === "time" &&
|
|
(node.value.type === "compare" || node.value.type === "range")
|
|
) {
|
|
found = true;
|
|
}
|
|
break;
|
|
default:
|
|
}
|
|
};
|
|
const root = parseQuery(text);
|
|
if (root != null) walk(root);
|
|
return found;
|
|
}
|
|
|
|
/** Any of the searches counts back from now, so its count changes as time passes */
|
|
export function anyCountsBackFromNow(queries: string[]): boolean {
|
|
return queries.some(countsBackFromNow);
|
|
}
|
|
|
|
/** `text` to sit beside other terms as one: in parentheses when it is an `OR` at its top */
|
|
export function asOneTerm(text: string): string {
|
|
return parseQuery(text)?.type === "Or" ? `(${text})` : text;
|
|
}
|