@businessdash/sdk 0.9.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +899 -0
- package/dist/adapters-revalidate.d.ts +44 -0
- package/dist/adapters-revalidate.d.ts.map +1 -0
- package/dist/adapters-revalidate.js +68 -0
- package/dist/adapters-revalidate.js.map +1 -0
- package/dist/alpha.d.ts +21 -0
- package/dist/alpha.d.ts.map +1 -0
- package/dist/alpha.js +21 -0
- package/dist/alpha.js.map +1 -0
- package/dist/analytics-core.d.ts +34 -0
- package/dist/analytics-core.d.ts.map +1 -0
- package/dist/analytics-core.js +138 -0
- package/dist/analytics-core.js.map +1 -0
- package/dist/analytics-server.d.ts +54 -0
- package/dist/analytics-server.d.ts.map +1 -0
- package/dist/analytics-server.js +78 -0
- package/dist/analytics-server.js.map +1 -0
- package/dist/attribution-logo.d.ts +2 -0
- package/dist/attribution-logo.d.ts.map +1 -0
- package/dist/attribution-logo.js +5 -0
- package/dist/attribution-logo.js.map +1 -0
- package/dist/auth-handler.d.ts +92 -0
- package/dist/auth-handler.d.ts.map +1 -0
- package/dist/auth-handler.js +277 -0
- package/dist/auth-handler.js.map +1 -0
- package/dist/biab-forms.css +484 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1824 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1681 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2845 -0
- package/dist/client.js.map +1 -0
- package/dist/collections.d.ts +13 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +13 -0
- package/dist/collections.js.map +1 -0
- package/dist/contracts.d.ts +14609 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +2428 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-model-schema.d.ts +962 -0
- package/dist/data-model-schema.d.ts.map +1 -0
- package/dist/data-model-schema.js +2295 -0
- package/dist/data-model-schema.js.map +1 -0
- package/dist/distribution.d.ts +38 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +61 -0
- package/dist/distribution.js.map +1 -0
- package/dist/element.d.ts +121 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +276 -0
- package/dist/element.js.map +1 -0
- package/dist/enums.d.ts +25 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +38 -0
- package/dist/enums.js.map +1 -0
- package/dist/errors.d.ts +195 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +188 -0
- package/dist/errors.js.map +1 -0
- package/dist/forms-core/autocomplete.d.ts +37 -0
- package/dist/forms-core/autocomplete.d.ts.map +1 -0
- package/dist/forms-core/autocomplete.js +82 -0
- package/dist/forms-core/autocomplete.js.map +1 -0
- package/dist/forms-core/business-function.d.ts +61 -0
- package/dist/forms-core/business-function.d.ts.map +1 -0
- package/dist/forms-core/business-function.js +149 -0
- package/dist/forms-core/business-function.js.map +1 -0
- package/dist/forms-core/calculation.d.ts +72 -0
- package/dist/forms-core/calculation.d.ts.map +1 -0
- package/dist/forms-core/calculation.js +103 -0
- package/dist/forms-core/calculation.js.map +1 -0
- package/dist/forms-core/controller.d.ts +249 -0
- package/dist/forms-core/controller.d.ts.map +1 -0
- package/dist/forms-core/controller.js +590 -0
- package/dist/forms-core/controller.js.map +1 -0
- package/dist/forms-core/elements.d.ts +44 -0
- package/dist/forms-core/elements.d.ts.map +1 -0
- package/dist/forms-core/elements.js +68 -0
- package/dist/forms-core/elements.js.map +1 -0
- package/dist/forms-core/email-domains.d.ts +10 -0
- package/dist/forms-core/email-domains.d.ts.map +1 -0
- package/dist/forms-core/email-domains.js +69 -0
- package/dist/forms-core/email-domains.js.map +1 -0
- package/dist/forms-core/file-rules.d.ts +51 -0
- package/dist/forms-core/file-rules.d.ts.map +1 -0
- package/dist/forms-core/file-rules.js +105 -0
- package/dist/forms-core/file-rules.js.map +1 -0
- package/dist/forms-core/formula.d.ts +114 -0
- package/dist/forms-core/formula.d.ts.map +1 -0
- package/dist/forms-core/formula.js +1170 -0
- package/dist/forms-core/formula.js.map +1 -0
- package/dist/forms-core/index.d.ts +24 -0
- package/dist/forms-core/index.d.ts.map +1 -0
- package/dist/forms-core/index.js +28 -0
- package/dist/forms-core/index.js.map +1 -0
- package/dist/forms-core/units.d.ts +49 -0
- package/dist/forms-core/units.d.ts.map +1 -0
- package/dist/forms-core/units.js +255 -0
- package/dist/forms-core/units.js.map +1 -0
- package/dist/forms-core/us-states.d.ts +14 -0
- package/dist/forms-core/us-states.d.ts.map +1 -0
- package/dist/forms-core/us-states.js +66 -0
- package/dist/forms-core/us-states.js.map +1 -0
- package/dist/forms-core/validation.d.ts +266 -0
- package/dist/forms-core/validation.d.ts.map +1 -0
- package/dist/forms-core/validation.js +850 -0
- package/dist/forms-core/validation.js.map +1 -0
- package/dist/forms.d.ts +19 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +29 -0
- package/dist/forms.js.map +1 -0
- package/dist/gallery-types.d.ts +32 -0
- package/dist/gallery-types.d.ts.map +1 -0
- package/dist/gallery-types.js +2 -0
- package/dist/gallery-types.js.map +1 -0
- package/dist/graphql.d.ts +97 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +212 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.cjs +19448 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/marketing-schema.d.ts +216 -0
- package/dist/marketing-schema.d.ts.map +1 -0
- package/dist/marketing-schema.js +200 -0
- package/dist/marketing-schema.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- package/dist/native.d.ts +45 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +43 -0
- package/dist/native.js.map +1 -0
- package/dist/next-revalidate.d.ts +43 -0
- package/dist/next-revalidate.d.ts.map +1 -0
- package/dist/next-revalidate.js +84 -0
- package/dist/next-revalidate.js.map +1 -0
- package/dist/parallel-pages.d.ts +102 -0
- package/dist/parallel-pages.d.ts.map +1 -0
- package/dist/parallel-pages.js +108 -0
- package/dist/parallel-pages.js.map +1 -0
- package/dist/payment-icons.d.ts +66 -0
- package/dist/payment-icons.d.ts.map +1 -0
- package/dist/payment-icons.js +160 -0
- package/dist/payment-icons.js.map +1 -0
- package/dist/proxy.d.ts +67 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +97 -0
- package/dist/proxy.js.map +1 -0
- package/dist/react-analytics.d.ts +39 -0
- package/dist/react-analytics.d.ts.map +1 -0
- package/dist/react-analytics.js +59 -0
- package/dist/react-analytics.js.map +1 -0
- package/dist/react-attribution.d.ts +99 -0
- package/dist/react-attribution.d.ts.map +1 -0
- package/dist/react-attribution.js +52 -0
- package/dist/react-attribution.js.map +1 -0
- package/dist/react-marketing.d.ts +181 -0
- package/dist/react-marketing.d.ts.map +1 -0
- package/dist/react-marketing.js +215 -0
- package/dist/react-marketing.js.map +1 -0
- package/dist/react.d.ts +727 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2418 -0
- package/dist/react.js.map +1 -0
- package/dist/reserved-paths.d.ts +24 -0
- package/dist/reserved-paths.d.ts.map +1 -0
- package/dist/reserved-paths.js +60 -0
- package/dist/reserved-paths.js.map +1 -0
- package/dist/revalidate-verify.d.ts +30 -0
- package/dist/revalidate-verify.d.ts.map +1 -0
- package/dist/revalidate-verify.js +74 -0
- package/dist/revalidate-verify.js.map +1 -0
- package/dist/sdk.d.ts +678 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +801 -0
- package/dist/sdk.js.map +1 -0
- package/dist/seo.d.ts +218 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +309 -0
- package/dist/seo.js.map +1 -0
- package/dist/socials.d.ts +47 -0
- package/dist/socials.d.ts.map +1 -0
- package/dist/socials.js +172 -0
- package/dist/socials.js.map +1 -0
- package/dist/static.d.ts +14 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +14 -0
- package/dist/static.js.map +1 -0
- package/dist/url-pattern.d.ts +47 -0
- package/dist/url-pattern.d.ts.map +1 -0
- package/dist/url-pattern.js +124 -0
- package/dist/url-pattern.js.map +1 -0
- package/dist/vanilla.d.ts +77 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +903 -0
- package/dist/vanilla.js.map +1 -0
- package/openapi.json +13779 -0
- package/package.json +204 -0
- package/src/adapters-revalidate.ts +88 -0
- package/src/alpha.ts +89 -0
- package/src/analytics-core.ts +175 -0
- package/src/analytics-server.ts +99 -0
- package/src/angular/biab-form.component.ts +1443 -0
- package/src/angular/index.ts +26 -0
- package/src/attribution-logo.ts +5 -0
- package/src/auth-handler.ts +352 -0
- package/src/cli.ts +2517 -0
- package/src/client.ts +3578 -0
- package/src/collections.ts +101 -0
- package/src/contracts.ts +3390 -0
- package/src/data-model-schema.ts +4042 -0
- package/src/distribution.ts +73 -0
- package/src/element.ts +321 -0
- package/src/enums.ts +65 -0
- package/src/errors.ts +338 -0
- package/src/forms-core/autocomplete.ts +97 -0
- package/src/forms-core/business-function.ts +209 -0
- package/src/forms-core/calculation.ts +167 -0
- package/src/forms-core/controller.ts +922 -0
- package/src/forms-core/elements.ts +86 -0
- package/src/forms-core/email-domains.ts +68 -0
- package/src/forms-core/file-rules.ts +123 -0
- package/src/forms-core/formula.ts +1339 -0
- package/src/forms-core/index.ts +122 -0
- package/src/forms-core/units.ts +308 -0
- package/src/forms-core/us-states.ts +68 -0
- package/src/forms-core/validation.ts +1037 -0
- package/src/forms.ts +63 -0
- package/src/gallery-types.ts +31 -0
- package/src/graphql.ts +295 -0
- package/src/index.ts +505 -0
- package/src/marketing-schema.ts +395 -0
- package/src/mcp.ts +108 -0
- package/src/native.ts +73 -0
- package/src/next-revalidate.ts +124 -0
- package/src/parallel-pages.ts +192 -0
- package/src/payment-icons.ts +205 -0
- package/src/proxy.ts +173 -0
- package/src/qwik.tsx +1234 -0
- package/src/react-analytics.tsx +76 -0
- package/src/react-attribution.tsx +192 -0
- package/src/react-marketing.tsx +338 -0
- package/src/react.tsx +5495 -0
- package/src/reserved-paths.ts +65 -0
- package/src/revalidate-verify.ts +98 -0
- package/src/sdk.ts +1671 -0
- package/src/seo.ts +488 -0
- package/src/socials.ts +230 -0
- package/src/solid.tsx +1466 -0
- package/src/static.ts +21 -0
- package/src/styles/biab-forms.css +484 -0
- package/src/svelte/BiabForm.svelte +1069 -0
- package/src/svelte/context.ts +57 -0
- package/src/svelte/index.ts +79 -0
- package/src/url-pattern.ts +153 -0
- package/src/vanilla.ts +1290 -0
- package/src/vue/BiabForm.vue +1538 -0
- package/src/vue/index.ts +119 -0
|
@@ -0,0 +1,1339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe, isomorphic formula engine for Business Functions + the Math form element.
|
|
3
|
+
*
|
|
4
|
+
* SECURITY: there is NO `eval`, `new Function`, or any dynamic code execution.
|
|
5
|
+
* A formula is tokenized → parsed into an AST → walked by an evaluator that only
|
|
6
|
+
* does arithmetic/logic and calls a fixed, whitelisted function table. Unknown
|
|
7
|
+
* identifiers (that aren't a provided variable) and unknown functions are hard
|
|
8
|
+
* errors. The same module runs on the client (non-sensitive functions, live) and
|
|
9
|
+
* the server (all functions) so results never disagree. The SDK keeps a mirror at
|
|
10
|
+
* `biab-dev/src/forms-core/formula.ts`.
|
|
11
|
+
*
|
|
12
|
+
* Values are scalars: number | string | boolean. (Tables / arrays are a
|
|
13
|
+
* fast-follow.) Function reference: see FORMULA_FUNCTIONS.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A record value — e.g. a measurement field `{ size, unit, base }`, a CRM record,
|
|
17
|
+
* or a multi-output function result. Fields are read with member access
|
|
18
|
+
* (`source.field`). */
|
|
19
|
+
export interface FormulaObject {
|
|
20
|
+
[key: string]: FormulaValue;
|
|
21
|
+
}
|
|
22
|
+
export type FormulaValue = number | string | boolean | FormulaObject;
|
|
23
|
+
export type FormulaVars = Record<string, FormulaValue | null | undefined>;
|
|
24
|
+
|
|
25
|
+
function isFormulaObject(v: FormulaValue): v is FormulaObject {
|
|
26
|
+
return typeof v === "object" && v !== null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Public guard: is this value a record (object) rather than a scalar? */
|
|
30
|
+
export function isFormulaObjectValue(v: unknown): v is FormulaObject {
|
|
31
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type FormulaResult =
|
|
35
|
+
| { ok: true; value: FormulaValue }
|
|
36
|
+
| { ok: false; error: string };
|
|
37
|
+
|
|
38
|
+
// ── AST ──────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
type Node =
|
|
41
|
+
| { t: "num"; v: number }
|
|
42
|
+
| { t: "str"; v: string }
|
|
43
|
+
| { t: "bool"; v: boolean }
|
|
44
|
+
| { t: "var"; name: string }
|
|
45
|
+
| { t: "member"; obj: Node; key: string }
|
|
46
|
+
| { t: "unary"; op: string; arg: Node }
|
|
47
|
+
| { t: "binary"; op: string; left: Node; right: Node }
|
|
48
|
+
| { t: "call"; name: string; args: Node[] };
|
|
49
|
+
|
|
50
|
+
/** A formula AST plus the names it references (for validation/UI). */
|
|
51
|
+
export interface ParsedFormula {
|
|
52
|
+
ast: Node;
|
|
53
|
+
/** Distinct variable identifiers referenced (excludes function names). */
|
|
54
|
+
variables: string[];
|
|
55
|
+
/** Distinct function names called (built-in or user/business functions). */
|
|
56
|
+
functions: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A user/business function callable from another formula by name. `params` are
|
|
61
|
+
* the ordered variable names the call's arguments bind to. Pass a map of these
|
|
62
|
+
* to {@link evaluateFormula} so formulas can reference other functions.
|
|
63
|
+
*/
|
|
64
|
+
export interface UserFunctionDef {
|
|
65
|
+
params: string[];
|
|
66
|
+
formula: ParsedFormula;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EvalOptions {
|
|
70
|
+
/** name → user function, enabling formula-references-formula composition. */
|
|
71
|
+
functions?: Record<string, UserFunctionDef>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface EvalCtx {
|
|
75
|
+
vars: FormulaVars;
|
|
76
|
+
fns: Record<string, UserFunctionDef>;
|
|
77
|
+
/** Names currently on the call stack — for circular-reference detection. */
|
|
78
|
+
stack: Set<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class FormulaError extends Error {}
|
|
82
|
+
|
|
83
|
+
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
type Tok =
|
|
86
|
+
| { k: "num"; v: number }
|
|
87
|
+
| { k: "str"; v: string }
|
|
88
|
+
| { k: "ident"; v: string }
|
|
89
|
+
| { k: "op"; v: string }
|
|
90
|
+
| { k: "lparen" }
|
|
91
|
+
| { k: "rparen" }
|
|
92
|
+
| { k: "comma" }
|
|
93
|
+
| { k: "dot" };
|
|
94
|
+
|
|
95
|
+
const MULTI_OPS = ["<=", ">=", "<>", "==", "!=", "&&", "||"];
|
|
96
|
+
const SINGLE_OPS = new Set([
|
|
97
|
+
"+",
|
|
98
|
+
"-",
|
|
99
|
+
"*",
|
|
100
|
+
"/",
|
|
101
|
+
"%",
|
|
102
|
+
"^",
|
|
103
|
+
"&",
|
|
104
|
+
"<",
|
|
105
|
+
">",
|
|
106
|
+
"=",
|
|
107
|
+
"!",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function tokenize(src: string): Tok[] {
|
|
111
|
+
const toks: Tok[] = [];
|
|
112
|
+
let i = 0;
|
|
113
|
+
const n = src.length;
|
|
114
|
+
while (i < n) {
|
|
115
|
+
const c = src[i] as string;
|
|
116
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// String literal: "..." with \" escape.
|
|
121
|
+
if (c === '"') {
|
|
122
|
+
let s = "";
|
|
123
|
+
i++;
|
|
124
|
+
while (i < n && src[i] !== '"') {
|
|
125
|
+
if (src[i] === "\\" && i + 1 < n) {
|
|
126
|
+
s += src[i + 1];
|
|
127
|
+
i += 2;
|
|
128
|
+
} else {
|
|
129
|
+
s += src[i];
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (i >= n) throw new FormulaError("Unterminated string literal.");
|
|
134
|
+
i++; // closing quote
|
|
135
|
+
toks.push({ k: "str", v: s });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Number: 12, 12.5, .5, 1e3
|
|
139
|
+
if (
|
|
140
|
+
(c >= "0" && c <= "9") ||
|
|
141
|
+
(c === "." && /[0-9]/.test(src[i + 1] ?? ""))
|
|
142
|
+
) {
|
|
143
|
+
let j = i;
|
|
144
|
+
while (j < n && /[0-9.]/.test(src[j] as string)) j++;
|
|
145
|
+
if ((src[j] === "e" || src[j] === "E") && j + 1 < n) {
|
|
146
|
+
j++;
|
|
147
|
+
if (src[j] === "+" || src[j] === "-") j++;
|
|
148
|
+
while (j < n && /[0-9]/.test(src[j] as string)) j++;
|
|
149
|
+
}
|
|
150
|
+
const raw = src.slice(i, j);
|
|
151
|
+
const num = Number(raw);
|
|
152
|
+
if (Number.isNaN(num)) throw new FormulaError(`Invalid number "${raw}".`);
|
|
153
|
+
toks.push({ k: "num", v: num });
|
|
154
|
+
i = j;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// Identifier (function / variable / TRUE / FALSE): letters, digits, _
|
|
158
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
159
|
+
let j = i;
|
|
160
|
+
while (j < n && /[A-Za-z0-9_]/.test(src[j] as string)) j++;
|
|
161
|
+
toks.push({ k: "ident", v: src.slice(i, j) });
|
|
162
|
+
i = j;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (c === "(") {
|
|
166
|
+
toks.push({ k: "lparen" });
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (c === ")") {
|
|
171
|
+
toks.push({ k: "rparen" });
|
|
172
|
+
i++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (c === ",") {
|
|
176
|
+
toks.push({ k: "comma" });
|
|
177
|
+
i++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Member access dot (a leading-digit "." was already consumed as a number).
|
|
181
|
+
if (c === ".") {
|
|
182
|
+
toks.push({ k: "dot" });
|
|
183
|
+
i++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Operators (multi-char first).
|
|
187
|
+
const two = src.slice(i, i + 2);
|
|
188
|
+
if (MULTI_OPS.includes(two)) {
|
|
189
|
+
toks.push({ k: "op", v: two });
|
|
190
|
+
i += 2;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (SINGLE_OPS.has(c)) {
|
|
194
|
+
toks.push({ k: "op", v: c });
|
|
195
|
+
i++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
throw new FormulaError(`Unexpected character "${c}".`);
|
|
199
|
+
}
|
|
200
|
+
return toks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Parser (recursive descent, lowest precedence first) ──────────────────────
|
|
204
|
+
|
|
205
|
+
class Parser {
|
|
206
|
+
private pos = 0;
|
|
207
|
+
constructor(private readonly toks: Tok[]) {}
|
|
208
|
+
|
|
209
|
+
private peek(): Tok | undefined {
|
|
210
|
+
return this.toks[this.pos];
|
|
211
|
+
}
|
|
212
|
+
private next(): Tok | undefined {
|
|
213
|
+
return this.toks[this.pos++];
|
|
214
|
+
}
|
|
215
|
+
private eat(k: Tok["k"]): Tok {
|
|
216
|
+
const t = this.next();
|
|
217
|
+
if (!t || t.k !== k) throw new FormulaError(`Expected ${k}.`);
|
|
218
|
+
return t;
|
|
219
|
+
}
|
|
220
|
+
private isOp(v: string): boolean {
|
|
221
|
+
const t = this.peek();
|
|
222
|
+
return !!t && t.k === "op" && t.v === v;
|
|
223
|
+
}
|
|
224
|
+
private isIdent(v: string): boolean {
|
|
225
|
+
const t = this.peek();
|
|
226
|
+
return !!t && t.k === "ident" && t.v.toUpperCase() === v;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
parse(): Node {
|
|
230
|
+
const node = this.parseOr();
|
|
231
|
+
if (this.pos < this.toks.length)
|
|
232
|
+
throw new FormulaError("Unexpected trailing input.");
|
|
233
|
+
return node;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private parseOr(): Node {
|
|
237
|
+
let left = this.parseAnd();
|
|
238
|
+
while (this.isOp("||") || this.isIdent("OR")) {
|
|
239
|
+
this.next();
|
|
240
|
+
const right = this.parseAnd();
|
|
241
|
+
left = { t: "binary", op: "OR", left, right };
|
|
242
|
+
}
|
|
243
|
+
return left;
|
|
244
|
+
}
|
|
245
|
+
private parseAnd(): Node {
|
|
246
|
+
let left = this.parseNot();
|
|
247
|
+
while (this.isOp("&&") || this.isIdent("AND")) {
|
|
248
|
+
this.next();
|
|
249
|
+
const right = this.parseNot();
|
|
250
|
+
left = { t: "binary", op: "AND", left, right };
|
|
251
|
+
}
|
|
252
|
+
return left;
|
|
253
|
+
}
|
|
254
|
+
private parseNot(): Node {
|
|
255
|
+
if (this.isOp("!") || this.isIdent("NOT")) {
|
|
256
|
+
this.next();
|
|
257
|
+
return { t: "unary", op: "NOT", arg: this.parseNot() };
|
|
258
|
+
}
|
|
259
|
+
return this.parseComparison();
|
|
260
|
+
}
|
|
261
|
+
private parseComparison(): Node {
|
|
262
|
+
let left = this.parseConcat();
|
|
263
|
+
const cmp = ["=", "==", "<>", "!=", "<", ">", "<=", ">="];
|
|
264
|
+
while (
|
|
265
|
+
this.peek()?.k === "op" &&
|
|
266
|
+
cmp.includes((this.peek() as { v: string }).v)
|
|
267
|
+
) {
|
|
268
|
+
const op = (this.next() as { v: string }).v;
|
|
269
|
+
const right = this.parseConcat();
|
|
270
|
+
left = { t: "binary", op, left, right };
|
|
271
|
+
}
|
|
272
|
+
return left;
|
|
273
|
+
}
|
|
274
|
+
private parseConcat(): Node {
|
|
275
|
+
let left = this.parseAdd();
|
|
276
|
+
while (this.isOp("&")) {
|
|
277
|
+
this.next();
|
|
278
|
+
left = { t: "binary", op: "&", left, right: this.parseAdd() };
|
|
279
|
+
}
|
|
280
|
+
return left;
|
|
281
|
+
}
|
|
282
|
+
private parseAdd(): Node {
|
|
283
|
+
let left = this.parseMul();
|
|
284
|
+
while (this.isOp("+") || this.isOp("-")) {
|
|
285
|
+
const op = (this.next() as { v: string }).v;
|
|
286
|
+
left = { t: "binary", op, left, right: this.parseMul() };
|
|
287
|
+
}
|
|
288
|
+
return left;
|
|
289
|
+
}
|
|
290
|
+
private parseMul(): Node {
|
|
291
|
+
let left = this.parseUnary();
|
|
292
|
+
while (this.isOp("*") || this.isOp("/") || this.isOp("%")) {
|
|
293
|
+
const op = (this.next() as { v: string }).v;
|
|
294
|
+
left = { t: "binary", op, left, right: this.parseUnary() };
|
|
295
|
+
}
|
|
296
|
+
return left;
|
|
297
|
+
}
|
|
298
|
+
private parseUnary(): Node {
|
|
299
|
+
if (this.isOp("-") || this.isOp("+")) {
|
|
300
|
+
const op = (this.next() as { v: string }).v;
|
|
301
|
+
return { t: "unary", op, arg: this.parseUnary() };
|
|
302
|
+
}
|
|
303
|
+
return this.parsePower();
|
|
304
|
+
}
|
|
305
|
+
private parsePower(): Node {
|
|
306
|
+
const base = this.parsePrimary();
|
|
307
|
+
if (this.isOp("^")) {
|
|
308
|
+
this.next();
|
|
309
|
+
// Right-associative.
|
|
310
|
+
return { t: "binary", op: "^", left: base, right: this.parseUnary() };
|
|
311
|
+
}
|
|
312
|
+
return base;
|
|
313
|
+
}
|
|
314
|
+
private parsePrimary(): Node {
|
|
315
|
+
// Atom followed by zero or more `.field` member accesses.
|
|
316
|
+
let node = this.parseAtom();
|
|
317
|
+
while (this.peek()?.k === "dot") {
|
|
318
|
+
this.next();
|
|
319
|
+
const id = this.eat("ident");
|
|
320
|
+
node = { t: "member", obj: node, key: (id as { v: string }).v };
|
|
321
|
+
}
|
|
322
|
+
return node;
|
|
323
|
+
}
|
|
324
|
+
private parseAtom(): Node {
|
|
325
|
+
const t = this.peek();
|
|
326
|
+
if (!t) throw new FormulaError("Unexpected end of formula.");
|
|
327
|
+
if (t.k === "num") {
|
|
328
|
+
this.next();
|
|
329
|
+
return { t: "num", v: t.v };
|
|
330
|
+
}
|
|
331
|
+
if (t.k === "str") {
|
|
332
|
+
this.next();
|
|
333
|
+
return { t: "str", v: t.v };
|
|
334
|
+
}
|
|
335
|
+
if (t.k === "lparen") {
|
|
336
|
+
this.next();
|
|
337
|
+
const inner = this.parseOr();
|
|
338
|
+
this.eat("rparen");
|
|
339
|
+
return inner;
|
|
340
|
+
}
|
|
341
|
+
if (t.k === "ident") {
|
|
342
|
+
this.next();
|
|
343
|
+
const upper = t.v.toUpperCase();
|
|
344
|
+
if (upper === "TRUE") return { t: "bool", v: true };
|
|
345
|
+
if (upper === "FALSE") return { t: "bool", v: false };
|
|
346
|
+
// Function call?
|
|
347
|
+
if (this.peek()?.k === "lparen") {
|
|
348
|
+
this.next();
|
|
349
|
+
const args: Node[] = [];
|
|
350
|
+
if (this.peek()?.k !== "rparen") {
|
|
351
|
+
args.push(this.parseOr());
|
|
352
|
+
while (this.peek()?.k === "comma") {
|
|
353
|
+
this.next();
|
|
354
|
+
args.push(this.parseOr());
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.eat("rparen");
|
|
358
|
+
return { t: "call", name: upper, args };
|
|
359
|
+
}
|
|
360
|
+
return { t: "var", name: t.v };
|
|
361
|
+
}
|
|
362
|
+
throw new FormulaError(`Unexpected token.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Coercion helpers ─────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function toNum(v: FormulaValue): number {
|
|
369
|
+
if (typeof v === "number") return v;
|
|
370
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
371
|
+
if (isFormulaObject(v))
|
|
372
|
+
throw new FormulaError("Cannot use an object as a number.");
|
|
373
|
+
const n = Number(v);
|
|
374
|
+
if (Number.isNaN(n)) throw new FormulaError(`"${v}" is not a number.`);
|
|
375
|
+
return n;
|
|
376
|
+
}
|
|
377
|
+
function toBool(v: FormulaValue): boolean {
|
|
378
|
+
if (typeof v === "boolean") return v;
|
|
379
|
+
if (typeof v === "number") return v !== 0;
|
|
380
|
+
if (isFormulaObject(v))
|
|
381
|
+
throw new FormulaError("Cannot use an object as a boolean.");
|
|
382
|
+
return v !== "" && v.toLowerCase() !== "false";
|
|
383
|
+
}
|
|
384
|
+
function toStr(v: FormulaValue): string {
|
|
385
|
+
if (typeof v === "string") return v;
|
|
386
|
+
if (isFormulaObject(v))
|
|
387
|
+
throw new FormulaError("Cannot use an object as text.");
|
|
388
|
+
return String(v);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── Function library ─────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
export type FormulaCategory =
|
|
394
|
+
| "math"
|
|
395
|
+
| "statistical"
|
|
396
|
+
| "logical"
|
|
397
|
+
| "text"
|
|
398
|
+
| "date"
|
|
399
|
+
| "reference"
|
|
400
|
+
| "geo";
|
|
401
|
+
|
|
402
|
+
interface FnDef {
|
|
403
|
+
category: FormulaCategory;
|
|
404
|
+
/** Min args (inclusive). */
|
|
405
|
+
min: number;
|
|
406
|
+
/** Max args, or null for variadic. */
|
|
407
|
+
max: number | null;
|
|
408
|
+
help: string;
|
|
409
|
+
fn: (args: FormulaValue[]) => FormulaValue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const R = (n: number, places = 0) => {
|
|
413
|
+
const f = 10 ** places;
|
|
414
|
+
return Math.round(n * f) / f;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
function parseDate(v: FormulaValue): Date {
|
|
418
|
+
const d = new Date(typeof v === "number" ? v : String(v));
|
|
419
|
+
if (Number.isNaN(d.getTime())) throw new FormulaError("Invalid date.");
|
|
420
|
+
return d;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Haversine distance between two lat/lng points. Default km; "mi" for miles. */
|
|
424
|
+
function haversine(
|
|
425
|
+
lat1: number,
|
|
426
|
+
lng1: number,
|
|
427
|
+
lat2: number,
|
|
428
|
+
lng2: number,
|
|
429
|
+
unit: "km" | "mi" = "km",
|
|
430
|
+
): number {
|
|
431
|
+
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
432
|
+
const Rk = 6371; // km
|
|
433
|
+
const dLat = toRad(lat2 - lat1);
|
|
434
|
+
const dLng = toRad(lng2 - lng1);
|
|
435
|
+
const a =
|
|
436
|
+
Math.sin(dLat / 2) ** 2 +
|
|
437
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
438
|
+
const km = 2 * Rk * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
439
|
+
return unit === "mi" ? km * 0.621371 : km;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Extension → file category for FILETYPE(url). */
|
|
443
|
+
const FILE_TYPE_BY_EXT: Record<string, string> = {};
|
|
444
|
+
for (const [type, exts] of [
|
|
445
|
+
["image", ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "heic"]],
|
|
446
|
+
["video", ["mp4", "mov", "webm", "mkv", "avi"]],
|
|
447
|
+
["audio", ["mp3", "wav", "m4a", "flac", "ogg", "aac"]],
|
|
448
|
+
["pdf", ["pdf"]],
|
|
449
|
+
["doc", ["doc", "docx", "txt", "rtf", "md", "pages"]],
|
|
450
|
+
["spreadsheet", ["xls", "xlsx", "csv", "numbers"]],
|
|
451
|
+
["archive", ["zip", "rar", "7z", "tar", "gz"]],
|
|
452
|
+
] as const) {
|
|
453
|
+
for (const ext of exts) FILE_TYPE_BY_EXT[ext] = type;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** File category of a URL/filename by its extension (lowercased, query/hash
|
|
457
|
+
* stripped). Empty / extension-less / unknown-extension input → "other". */
|
|
458
|
+
function fileTypeOf(url: string): string {
|
|
459
|
+
const path = (url.split(/[?#]/, 1)[0] ?? "").trim();
|
|
460
|
+
const dot = path.lastIndexOf(".");
|
|
461
|
+
if (dot === -1) return "other";
|
|
462
|
+
const ext = path.slice(dot + 1).toLowerCase();
|
|
463
|
+
return FILE_TYPE_BY_EXT[ext] ?? "other";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Replace every occurrence of any `find` with its paired `replace`, in ONE pass.
|
|
468
|
+
*
|
|
469
|
+
* Single-pass is the whole point. A naive implementation loops the pairs and
|
|
470
|
+
* calls `replaceAll` for each, which CHAINS: `SUBSTITUTE(t,"a","b","b","c")`
|
|
471
|
+
* would turn an "a" into "b" and then that "b" into "c". Scanning once and
|
|
472
|
+
* taking the first pair that matches at each position means a replacement's
|
|
473
|
+
* OUTPUT is never re-examined, so pairs stay independent of their order.
|
|
474
|
+
*
|
|
475
|
+
* Longest-match-first at each position, so overlapping patterns behave the way
|
|
476
|
+
* people expect ("ab" wins over "a" when both are listed).
|
|
477
|
+
*/
|
|
478
|
+
function substituteAll(
|
|
479
|
+
text: string,
|
|
480
|
+
pairs: ReadonlyArray<readonly [string, string]>,
|
|
481
|
+
): string {
|
|
482
|
+
// Empty search strings would match at every position and never advance.
|
|
483
|
+
const active = pairs.filter(([find]) => find.length > 0);
|
|
484
|
+
if (active.length === 0) return text;
|
|
485
|
+
const ordered = [...active].sort((a, b) => b[0].length - a[0].length);
|
|
486
|
+
|
|
487
|
+
let out = "";
|
|
488
|
+
let i = 0;
|
|
489
|
+
outer: while (i < text.length) {
|
|
490
|
+
for (const [find, replace] of ordered) {
|
|
491
|
+
if (text.startsWith(find, i)) {
|
|
492
|
+
out += replace;
|
|
493
|
+
i += find.length;
|
|
494
|
+
continue outer;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
out += text[i];
|
|
498
|
+
i += 1;
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The platform's canonical slug.
|
|
505
|
+
*
|
|
506
|
+
* There were five hand-rolled `slugify` functions in this codebase when this
|
|
507
|
+
* was written (docs TOC, site-builder, page-builder, company locations, blog
|
|
508
|
+
* editor) and they did NOT agree — some stripped accents, some didn't; some
|
|
509
|
+
* collapsed repeated separators, some left them. This is the one a form can
|
|
510
|
+
* reach, and it is deliberately the strict version:
|
|
511
|
+
*
|
|
512
|
+
* lowercase → strip accents → anything not [a-z0-9] becomes a hyphen →
|
|
513
|
+
* collapse runs → trim leading/trailing hyphens.
|
|
514
|
+
*
|
|
515
|
+
* "Solar Panel Install (2026)" → "solar-panel-install-2026"
|
|
516
|
+
*/
|
|
517
|
+
function slugifyText(text: string): string {
|
|
518
|
+
return (
|
|
519
|
+
text
|
|
520
|
+
.normalize("NFKD")
|
|
521
|
+
// Combining marks left behind by NFKD — é → e, not e+◌́.
|
|
522
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
523
|
+
.toLowerCase()
|
|
524
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
525
|
+
.replace(/^-+|-+$/g, "")
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export const FORMULA_FUNCTIONS: Record<string, FnDef> = {
|
|
530
|
+
// Math
|
|
531
|
+
SUM: {
|
|
532
|
+
category: "math",
|
|
533
|
+
min: 1,
|
|
534
|
+
max: null,
|
|
535
|
+
help: "Sum of the arguments.",
|
|
536
|
+
fn: (a) => a.reduce((s: number, x) => s + toNum(x), 0),
|
|
537
|
+
},
|
|
538
|
+
PRODUCT: {
|
|
539
|
+
category: "math",
|
|
540
|
+
min: 1,
|
|
541
|
+
max: null,
|
|
542
|
+
help: "Product of the arguments.",
|
|
543
|
+
fn: (a) => a.reduce((s: number, x) => s * toNum(x), 1),
|
|
544
|
+
},
|
|
545
|
+
ABS: {
|
|
546
|
+
category: "math",
|
|
547
|
+
min: 1,
|
|
548
|
+
max: 1,
|
|
549
|
+
help: "Absolute value.",
|
|
550
|
+
fn: (a) => Math.abs(toNum(a[0] as FormulaValue)),
|
|
551
|
+
},
|
|
552
|
+
ROUND: {
|
|
553
|
+
category: "math",
|
|
554
|
+
min: 1,
|
|
555
|
+
max: 2,
|
|
556
|
+
help: "Round to N decimal places.",
|
|
557
|
+
fn: (a) =>
|
|
558
|
+
R(toNum(a[0] as FormulaValue), a[1] !== undefined ? toNum(a[1]) : 0),
|
|
559
|
+
},
|
|
560
|
+
ROUNDUP: {
|
|
561
|
+
category: "math",
|
|
562
|
+
min: 1,
|
|
563
|
+
max: 2,
|
|
564
|
+
help: "Round up.",
|
|
565
|
+
fn: (a) => {
|
|
566
|
+
const p = a[1] !== undefined ? toNum(a[1]) : 0;
|
|
567
|
+
const f = 10 ** p;
|
|
568
|
+
return Math.ceil(toNum(a[0] as FormulaValue) * f) / f;
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
ROUNDDOWN: {
|
|
572
|
+
category: "math",
|
|
573
|
+
min: 1,
|
|
574
|
+
max: 2,
|
|
575
|
+
help: "Round down (toward zero).",
|
|
576
|
+
fn: (a) => {
|
|
577
|
+
const p = a[1] !== undefined ? toNum(a[1]) : 0;
|
|
578
|
+
const f = 10 ** p;
|
|
579
|
+
return Math.trunc(toNum(a[0] as FormulaValue) * f) / f;
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
CEILING: {
|
|
583
|
+
category: "math",
|
|
584
|
+
min: 1,
|
|
585
|
+
max: 1,
|
|
586
|
+
help: "Round up to the nearest integer.",
|
|
587
|
+
fn: (a) => Math.ceil(toNum(a[0] as FormulaValue)),
|
|
588
|
+
},
|
|
589
|
+
FLOOR: {
|
|
590
|
+
category: "math",
|
|
591
|
+
min: 1,
|
|
592
|
+
max: 1,
|
|
593
|
+
help: "Round down to the nearest integer.",
|
|
594
|
+
fn: (a) => Math.floor(toNum(a[0] as FormulaValue)),
|
|
595
|
+
},
|
|
596
|
+
INT: {
|
|
597
|
+
category: "math",
|
|
598
|
+
min: 1,
|
|
599
|
+
max: 1,
|
|
600
|
+
help: "Integer part (toward zero).",
|
|
601
|
+
fn: (a) => Math.trunc(toNum(a[0] as FormulaValue)),
|
|
602
|
+
},
|
|
603
|
+
MOD: {
|
|
604
|
+
category: "math",
|
|
605
|
+
min: 2,
|
|
606
|
+
max: 2,
|
|
607
|
+
help: "Remainder of division.",
|
|
608
|
+
fn: (a) => toNum(a[0] as FormulaValue) % toNum(a[1] as FormulaValue),
|
|
609
|
+
},
|
|
610
|
+
POWER: {
|
|
611
|
+
category: "math",
|
|
612
|
+
min: 2,
|
|
613
|
+
max: 2,
|
|
614
|
+
help: "Base raised to an exponent.",
|
|
615
|
+
fn: (a) => toNum(a[0] as FormulaValue) ** toNum(a[1] as FormulaValue),
|
|
616
|
+
},
|
|
617
|
+
SQRT: {
|
|
618
|
+
category: "math",
|
|
619
|
+
min: 1,
|
|
620
|
+
max: 1,
|
|
621
|
+
help: "Square root.",
|
|
622
|
+
fn: (a) => Math.sqrt(toNum(a[0] as FormulaValue)),
|
|
623
|
+
},
|
|
624
|
+
SIGN: {
|
|
625
|
+
category: "math",
|
|
626
|
+
min: 1,
|
|
627
|
+
max: 1,
|
|
628
|
+
help: "-1, 0, or 1.",
|
|
629
|
+
fn: (a) => Math.sign(toNum(a[0] as FormulaValue)),
|
|
630
|
+
},
|
|
631
|
+
EXP: {
|
|
632
|
+
category: "math",
|
|
633
|
+
min: 1,
|
|
634
|
+
max: 1,
|
|
635
|
+
help: "e raised to a power.",
|
|
636
|
+
fn: (a) => Math.exp(toNum(a[0] as FormulaValue)),
|
|
637
|
+
},
|
|
638
|
+
LN: {
|
|
639
|
+
category: "math",
|
|
640
|
+
min: 1,
|
|
641
|
+
max: 1,
|
|
642
|
+
help: "Natural log.",
|
|
643
|
+
fn: (a) => Math.log(toNum(a[0] as FormulaValue)),
|
|
644
|
+
},
|
|
645
|
+
LOG10: {
|
|
646
|
+
category: "math",
|
|
647
|
+
min: 1,
|
|
648
|
+
max: 1,
|
|
649
|
+
help: "Base-10 log.",
|
|
650
|
+
fn: (a) => Math.log10(toNum(a[0] as FormulaValue)),
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
// Statistical
|
|
654
|
+
AVERAGE: {
|
|
655
|
+
category: "statistical",
|
|
656
|
+
min: 1,
|
|
657
|
+
max: null,
|
|
658
|
+
help: "Mean of the arguments.",
|
|
659
|
+
fn: (a) => a.reduce((s: number, x) => s + toNum(x), 0) / a.length,
|
|
660
|
+
},
|
|
661
|
+
MIN: {
|
|
662
|
+
category: "statistical",
|
|
663
|
+
min: 1,
|
|
664
|
+
max: null,
|
|
665
|
+
help: "Smallest argument.",
|
|
666
|
+
fn: (a) => Math.min(...a.map(toNum)),
|
|
667
|
+
},
|
|
668
|
+
MAX: {
|
|
669
|
+
category: "statistical",
|
|
670
|
+
min: 1,
|
|
671
|
+
max: null,
|
|
672
|
+
help: "Largest argument.",
|
|
673
|
+
fn: (a) => Math.max(...a.map(toNum)),
|
|
674
|
+
},
|
|
675
|
+
MEDIAN: {
|
|
676
|
+
category: "statistical",
|
|
677
|
+
min: 1,
|
|
678
|
+
max: null,
|
|
679
|
+
help: "Middle value.",
|
|
680
|
+
fn: (a) => {
|
|
681
|
+
const s = a.map(toNum).sort((x, y) => x - y);
|
|
682
|
+
const m = Math.floor(s.length / 2);
|
|
683
|
+
return s.length % 2
|
|
684
|
+
? (s[m] as number)
|
|
685
|
+
: ((s[m - 1] as number) + (s[m] as number)) / 2;
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
COUNT: {
|
|
689
|
+
category: "statistical",
|
|
690
|
+
min: 1,
|
|
691
|
+
max: null,
|
|
692
|
+
help: "Count of numeric arguments.",
|
|
693
|
+
fn: (a) =>
|
|
694
|
+
a.filter(
|
|
695
|
+
(x) =>
|
|
696
|
+
typeof x === "number" ||
|
|
697
|
+
(typeof x === "string" &&
|
|
698
|
+
x.trim() !== "" &&
|
|
699
|
+
!Number.isNaN(Number(x))),
|
|
700
|
+
).length,
|
|
701
|
+
},
|
|
702
|
+
COUNTA: {
|
|
703
|
+
category: "statistical",
|
|
704
|
+
min: 1,
|
|
705
|
+
max: null,
|
|
706
|
+
help: "Count of non-empty arguments.",
|
|
707
|
+
fn: (a) =>
|
|
708
|
+
a.filter((x) => x !== "" && x !== null && x !== undefined).length,
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
// Logical
|
|
712
|
+
IF: {
|
|
713
|
+
category: "logical",
|
|
714
|
+
min: 2,
|
|
715
|
+
max: 3,
|
|
716
|
+
help: "IF(test, then, else).",
|
|
717
|
+
fn: (a) =>
|
|
718
|
+
toBool(a[0] as FormulaValue)
|
|
719
|
+
? (a[1] as FormulaValue)
|
|
720
|
+
: a[2] !== undefined
|
|
721
|
+
? a[2]
|
|
722
|
+
: false,
|
|
723
|
+
},
|
|
724
|
+
AND: {
|
|
725
|
+
category: "logical",
|
|
726
|
+
min: 1,
|
|
727
|
+
max: null,
|
|
728
|
+
help: "True when all are true.",
|
|
729
|
+
fn: (a) => a.every(toBool),
|
|
730
|
+
},
|
|
731
|
+
OR: {
|
|
732
|
+
category: "logical",
|
|
733
|
+
min: 1,
|
|
734
|
+
max: null,
|
|
735
|
+
help: "True when any is true.",
|
|
736
|
+
fn: (a) => a.some(toBool),
|
|
737
|
+
},
|
|
738
|
+
NOT: {
|
|
739
|
+
category: "logical",
|
|
740
|
+
min: 1,
|
|
741
|
+
max: 1,
|
|
742
|
+
help: "Logical negation.",
|
|
743
|
+
fn: (a) => !toBool(a[0] as FormulaValue),
|
|
744
|
+
},
|
|
745
|
+
XOR: {
|
|
746
|
+
category: "logical",
|
|
747
|
+
min: 1,
|
|
748
|
+
max: null,
|
|
749
|
+
help: "True when an odd number are true.",
|
|
750
|
+
fn: (a) => a.filter(toBool).length % 2 === 1,
|
|
751
|
+
},
|
|
752
|
+
TRUE: {
|
|
753
|
+
category: "logical",
|
|
754
|
+
min: 0,
|
|
755
|
+
max: 0,
|
|
756
|
+
help: "The boolean true.",
|
|
757
|
+
fn: () => true,
|
|
758
|
+
},
|
|
759
|
+
FALSE: {
|
|
760
|
+
category: "logical",
|
|
761
|
+
min: 0,
|
|
762
|
+
max: 0,
|
|
763
|
+
help: "The boolean false.",
|
|
764
|
+
fn: () => false,
|
|
765
|
+
},
|
|
766
|
+
IFS: {
|
|
767
|
+
category: "logical",
|
|
768
|
+
min: 2,
|
|
769
|
+
max: null,
|
|
770
|
+
help: "IFS(test1, val1, test2, val2, …).",
|
|
771
|
+
fn: (a) => {
|
|
772
|
+
for (let i = 0; i + 1 < a.length; i += 2) {
|
|
773
|
+
if (toBool(a[i] as FormulaValue)) return a[i + 1] as FormulaValue;
|
|
774
|
+
}
|
|
775
|
+
throw new FormulaError("No IFS condition matched.");
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
// Reference
|
|
780
|
+
CHOOSE: {
|
|
781
|
+
category: "reference",
|
|
782
|
+
min: 2,
|
|
783
|
+
max: null,
|
|
784
|
+
help: "CHOOSE(index, a, b, …) — 1-based.",
|
|
785
|
+
fn: (a) => {
|
|
786
|
+
const idx = Math.trunc(toNum(a[0] as FormulaValue));
|
|
787
|
+
const v = a[idx];
|
|
788
|
+
if (v === undefined) throw new FormulaError("CHOOSE index out of range.");
|
|
789
|
+
return v;
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
SWITCH: {
|
|
793
|
+
category: "reference",
|
|
794
|
+
min: 3,
|
|
795
|
+
max: null,
|
|
796
|
+
help: "SWITCH(expr, case1, val1, …, [default]).",
|
|
797
|
+
fn: (a) => {
|
|
798
|
+
const expr = a[0] as FormulaValue;
|
|
799
|
+
let i = 1;
|
|
800
|
+
for (; i + 1 < a.length; i += 2) {
|
|
801
|
+
if ((a[i] as FormulaValue) === expr) return a[i + 1] as FormulaValue;
|
|
802
|
+
}
|
|
803
|
+
return i < a.length ? (a[i] as FormulaValue) : false;
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
// Text
|
|
808
|
+
CONCATENATE: {
|
|
809
|
+
category: "text",
|
|
810
|
+
min: 1,
|
|
811
|
+
max: null,
|
|
812
|
+
help: "Join text together.",
|
|
813
|
+
fn: (a) => a.map(toStr).join(""),
|
|
814
|
+
},
|
|
815
|
+
LEN: {
|
|
816
|
+
category: "text",
|
|
817
|
+
min: 1,
|
|
818
|
+
max: 1,
|
|
819
|
+
help: "Length of text.",
|
|
820
|
+
fn: (a) => toStr(a[0] as FormulaValue).length,
|
|
821
|
+
},
|
|
822
|
+
LOWER: {
|
|
823
|
+
category: "text",
|
|
824
|
+
min: 1,
|
|
825
|
+
max: 1,
|
|
826
|
+
help: "Lowercase.",
|
|
827
|
+
fn: (a) => toStr(a[0] as FormulaValue).toLowerCase(),
|
|
828
|
+
},
|
|
829
|
+
UPPER: {
|
|
830
|
+
category: "text",
|
|
831
|
+
min: 1,
|
|
832
|
+
max: 1,
|
|
833
|
+
help: "Uppercase.",
|
|
834
|
+
fn: (a) => toStr(a[0] as FormulaValue).toUpperCase(),
|
|
835
|
+
},
|
|
836
|
+
SUBSTITUTE: {
|
|
837
|
+
category: "text",
|
|
838
|
+
min: 3,
|
|
839
|
+
max: null,
|
|
840
|
+
help: 'Replace text. SUBSTITUTE(text, find, replace, [find2, replace2, ...]). Pairs are applied in a single pass, so a replacement\'s output is never itself replaced. Repeat the same replacement to map several things onto one: SUBSTITUTE(name, " ", "-", "_", "-").',
|
|
841
|
+
fn: (a) => {
|
|
842
|
+
const text = toStr(a[0] as FormulaValue);
|
|
843
|
+
const rest = a.slice(1);
|
|
844
|
+
if (rest.length % 2 !== 0) {
|
|
845
|
+
throw new FormulaError(
|
|
846
|
+
"SUBSTITUTE needs find/replace in pairs — it got an odd number of arguments after the text.",
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
const pairs: Array<[string, string]> = [];
|
|
850
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
851
|
+
pairs.push([
|
|
852
|
+
toStr(rest[i] as FormulaValue),
|
|
853
|
+
toStr(rest[i + 1] as FormulaValue),
|
|
854
|
+
]);
|
|
855
|
+
}
|
|
856
|
+
return substituteAll(text, pairs);
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
SLUGIFY: {
|
|
860
|
+
category: "text",
|
|
861
|
+
min: 1,
|
|
862
|
+
max: 1,
|
|
863
|
+
help: 'URL-safe slug: lowercase, accents stripped, non-alphanumerics collapsed to single hyphens. SLUGIFY("Solar Panel Install (2026)") is "solar-panel-install-2026".',
|
|
864
|
+
fn: (a) => slugifyText(toStr(a[0] as FormulaValue)),
|
|
865
|
+
},
|
|
866
|
+
TRIM: {
|
|
867
|
+
category: "text",
|
|
868
|
+
min: 1,
|
|
869
|
+
max: 1,
|
|
870
|
+
help: "Trim surrounding whitespace.",
|
|
871
|
+
fn: (a) => toStr(a[0] as FormulaValue).trim(),
|
|
872
|
+
},
|
|
873
|
+
LEFT: {
|
|
874
|
+
category: "text",
|
|
875
|
+
min: 1,
|
|
876
|
+
max: 2,
|
|
877
|
+
help: "Leftmost N characters.",
|
|
878
|
+
fn: (a) =>
|
|
879
|
+
toStr(a[0] as FormulaValue).slice(
|
|
880
|
+
0,
|
|
881
|
+
a[1] !== undefined ? toNum(a[1]) : 1,
|
|
882
|
+
),
|
|
883
|
+
},
|
|
884
|
+
RIGHT: {
|
|
885
|
+
category: "text",
|
|
886
|
+
min: 1,
|
|
887
|
+
max: 2,
|
|
888
|
+
help: "Rightmost N characters.",
|
|
889
|
+
fn: (a) => {
|
|
890
|
+
const n = a[1] !== undefined ? toNum(a[1]) : 1;
|
|
891
|
+
return toStr(a[0] as FormulaValue).slice(-n);
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
MID: {
|
|
895
|
+
category: "text",
|
|
896
|
+
min: 3,
|
|
897
|
+
max: 3,
|
|
898
|
+
help: "MID(text, start, length) — 1-based start.",
|
|
899
|
+
fn: (a) => {
|
|
900
|
+
const start = toNum(a[1] as FormulaValue) - 1;
|
|
901
|
+
return toStr(a[0] as FormulaValue).slice(
|
|
902
|
+
start,
|
|
903
|
+
start + toNum(a[2] as FormulaValue),
|
|
904
|
+
);
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
FILETYPE: {
|
|
908
|
+
category: "text",
|
|
909
|
+
min: 1,
|
|
910
|
+
max: 1,
|
|
911
|
+
help: 'File category of a URL by extension: "image", "video", "audio", "pdf", "doc", "spreadsheet", "archive", or "other".',
|
|
912
|
+
fn: (a) => fileTypeOf(toStr(a[0] as FormulaValue)),
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
// Date (parse ISO strings; return numbers)
|
|
916
|
+
TODAY: {
|
|
917
|
+
category: "date",
|
|
918
|
+
min: 0,
|
|
919
|
+
max: 0,
|
|
920
|
+
help: "Today's date (ISO string).",
|
|
921
|
+
fn: () => new Date().toISOString().slice(0, 10),
|
|
922
|
+
},
|
|
923
|
+
YEAR: {
|
|
924
|
+
category: "date",
|
|
925
|
+
min: 1,
|
|
926
|
+
max: 1,
|
|
927
|
+
help: "Year of a date.",
|
|
928
|
+
fn: (a) => parseDate(a[0] as FormulaValue).getUTCFullYear(),
|
|
929
|
+
},
|
|
930
|
+
MONTH: {
|
|
931
|
+
category: "date",
|
|
932
|
+
min: 1,
|
|
933
|
+
max: 1,
|
|
934
|
+
help: "Month of a date (1-12).",
|
|
935
|
+
fn: (a) => parseDate(a[0] as FormulaValue).getUTCMonth() + 1,
|
|
936
|
+
},
|
|
937
|
+
DAY: {
|
|
938
|
+
category: "date",
|
|
939
|
+
min: 1,
|
|
940
|
+
max: 1,
|
|
941
|
+
help: "Day of the month.",
|
|
942
|
+
fn: (a) => parseDate(a[0] as FormulaValue).getUTCDate(),
|
|
943
|
+
},
|
|
944
|
+
DAYS: {
|
|
945
|
+
category: "date",
|
|
946
|
+
min: 2,
|
|
947
|
+
max: 2,
|
|
948
|
+
help: "Whole days between two dates (end - start).",
|
|
949
|
+
fn: (a) =>
|
|
950
|
+
Math.round(
|
|
951
|
+
(parseDate(a[0] as FormulaValue).getTime() -
|
|
952
|
+
parseDate(a[1] as FormulaValue).getTime()) /
|
|
953
|
+
86_400_000,
|
|
954
|
+
),
|
|
955
|
+
},
|
|
956
|
+
NOW: {
|
|
957
|
+
category: "date",
|
|
958
|
+
min: 0,
|
|
959
|
+
max: 0,
|
|
960
|
+
help: "Current date-time (ISO string).",
|
|
961
|
+
fn: () => new Date().toISOString(),
|
|
962
|
+
},
|
|
963
|
+
MINUTES: {
|
|
964
|
+
category: "date",
|
|
965
|
+
min: 2,
|
|
966
|
+
max: 2,
|
|
967
|
+
help: "Minutes between two date-times (end - start).",
|
|
968
|
+
fn: (a) =>
|
|
969
|
+
Math.round(
|
|
970
|
+
(parseDate(a[1] as FormulaValue).getTime() -
|
|
971
|
+
parseDate(a[0] as FormulaValue).getTime()) /
|
|
972
|
+
60_000,
|
|
973
|
+
),
|
|
974
|
+
},
|
|
975
|
+
HOURS: {
|
|
976
|
+
category: "date",
|
|
977
|
+
min: 2,
|
|
978
|
+
max: 3,
|
|
979
|
+
help: "Hours between two date-times (end - start); HOURS(start, end, decimals=2).",
|
|
980
|
+
fn: (a) => {
|
|
981
|
+
const p = a[2] !== undefined ? toNum(a[2]) : 2;
|
|
982
|
+
const f = 10 ** p;
|
|
983
|
+
return (
|
|
984
|
+
Math.round(
|
|
985
|
+
((parseDate(a[1] as FormulaValue).getTime() -
|
|
986
|
+
parseDate(a[0] as FormulaValue).getTime()) /
|
|
987
|
+
3_600_000) *
|
|
988
|
+
f,
|
|
989
|
+
) / f
|
|
990
|
+
);
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
WEEKS: {
|
|
994
|
+
category: "date",
|
|
995
|
+
min: 2,
|
|
996
|
+
max: 2,
|
|
997
|
+
help: "Whole weeks between two dates (end - start).",
|
|
998
|
+
fn: (a) =>
|
|
999
|
+
Math.trunc(
|
|
1000
|
+
(parseDate(a[1] as FormulaValue).getTime() -
|
|
1001
|
+
parseDate(a[0] as FormulaValue).getTime()) /
|
|
1002
|
+
604_800_000,
|
|
1003
|
+
),
|
|
1004
|
+
},
|
|
1005
|
+
MONTHS: {
|
|
1006
|
+
category: "date",
|
|
1007
|
+
min: 2,
|
|
1008
|
+
max: 2,
|
|
1009
|
+
help: "Whole calendar months completed between two dates (end - start).",
|
|
1010
|
+
fn: (a) => {
|
|
1011
|
+
const s = parseDate(a[0] as FormulaValue);
|
|
1012
|
+
const e = parseDate(a[1] as FormulaValue);
|
|
1013
|
+
let m =
|
|
1014
|
+
(e.getUTCFullYear() - s.getUTCFullYear()) * 12 +
|
|
1015
|
+
(e.getUTCMonth() - s.getUTCMonth());
|
|
1016
|
+
if (e.getUTCDate() < s.getUTCDate()) m -= 1;
|
|
1017
|
+
return m;
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
YEARS: {
|
|
1021
|
+
category: "date",
|
|
1022
|
+
min: 2,
|
|
1023
|
+
max: 2,
|
|
1024
|
+
help: "Whole years completed between two dates (end - start).",
|
|
1025
|
+
fn: (a) => {
|
|
1026
|
+
const s = parseDate(a[0] as FormulaValue);
|
|
1027
|
+
const e = parseDate(a[1] as FormulaValue);
|
|
1028
|
+
let y = e.getUTCFullYear() - s.getUTCFullYear();
|
|
1029
|
+
const anniv = Date.UTC(
|
|
1030
|
+
e.getUTCFullYear(),
|
|
1031
|
+
s.getUTCMonth(),
|
|
1032
|
+
s.getUTCDate(),
|
|
1033
|
+
);
|
|
1034
|
+
if (e.getTime() < anniv) y -= 1;
|
|
1035
|
+
return y;
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
DATEDIFF: {
|
|
1039
|
+
category: "date",
|
|
1040
|
+
min: 3,
|
|
1041
|
+
max: 3,
|
|
1042
|
+
help: 'DATEDIFF("minutes"|"hours"|"days"|"weeks"|"months"|"years", start, end).',
|
|
1043
|
+
fn: (a) => {
|
|
1044
|
+
const unit = toStr(a[0] as FormulaValue).toLowerCase();
|
|
1045
|
+
const s = parseDate(a[1] as FormulaValue);
|
|
1046
|
+
const e = parseDate(a[2] as FormulaValue);
|
|
1047
|
+
const ms = e.getTime() - s.getTime();
|
|
1048
|
+
switch (unit) {
|
|
1049
|
+
case "minutes":
|
|
1050
|
+
return Math.round(ms / 60_000);
|
|
1051
|
+
case "hours":
|
|
1052
|
+
return Math.round((ms / 3_600_000) * 100) / 100;
|
|
1053
|
+
case "days":
|
|
1054
|
+
return Math.round(ms / 86_400_000);
|
|
1055
|
+
case "weeks":
|
|
1056
|
+
return Math.trunc(ms / 604_800_000);
|
|
1057
|
+
case "months": {
|
|
1058
|
+
let m =
|
|
1059
|
+
(e.getUTCFullYear() - s.getUTCFullYear()) * 12 +
|
|
1060
|
+
(e.getUTCMonth() - s.getUTCMonth());
|
|
1061
|
+
if (e.getUTCDate() < s.getUTCDate()) m -= 1;
|
|
1062
|
+
return m;
|
|
1063
|
+
}
|
|
1064
|
+
case "years": {
|
|
1065
|
+
let y = e.getUTCFullYear() - s.getUTCFullYear();
|
|
1066
|
+
const anniv = Date.UTC(
|
|
1067
|
+
e.getUTCFullYear(),
|
|
1068
|
+
s.getUTCMonth(),
|
|
1069
|
+
s.getUTCDate(),
|
|
1070
|
+
);
|
|
1071
|
+
if (e.getTime() < anniv) y -= 1;
|
|
1072
|
+
return y;
|
|
1073
|
+
}
|
|
1074
|
+
default:
|
|
1075
|
+
throw new FormulaError(`Unknown DATEDIFF unit "${unit}".`);
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
|
|
1080
|
+
// Geo
|
|
1081
|
+
DISTANCE: {
|
|
1082
|
+
category: "geo",
|
|
1083
|
+
min: 4,
|
|
1084
|
+
max: 5,
|
|
1085
|
+
help: 'DISTANCE(lat1, lng1, lat2, lng2, ["km"|"mi"]) — straight-line (haversine).',
|
|
1086
|
+
fn: (a) => {
|
|
1087
|
+
const unit =
|
|
1088
|
+
a[4] !== undefined && toStr(a[4]).toLowerCase() === "mi" ? "mi" : "km";
|
|
1089
|
+
return haversine(
|
|
1090
|
+
toNum(a[0] as FormulaValue),
|
|
1091
|
+
toNum(a[1] as FormulaValue),
|
|
1092
|
+
toNum(a[2] as FormulaValue),
|
|
1093
|
+
toNum(a[3] as FormulaValue),
|
|
1094
|
+
unit,
|
|
1095
|
+
);
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// ── Evaluator ────────────────────────────────────────────────────────────────
|
|
1101
|
+
|
|
1102
|
+
function evalNode(node: Node, ctx: EvalCtx): FormulaValue {
|
|
1103
|
+
switch (node.t) {
|
|
1104
|
+
case "num":
|
|
1105
|
+
return node.v;
|
|
1106
|
+
case "str":
|
|
1107
|
+
return node.v;
|
|
1108
|
+
case "bool":
|
|
1109
|
+
return node.v;
|
|
1110
|
+
case "var": {
|
|
1111
|
+
const v = ctx.vars[node.name];
|
|
1112
|
+
if (v === undefined || v === null)
|
|
1113
|
+
throw new FormulaError(`Variable "${node.name}" has no value.`);
|
|
1114
|
+
return v;
|
|
1115
|
+
}
|
|
1116
|
+
case "member": {
|
|
1117
|
+
const obj = evalNode(node.obj, ctx);
|
|
1118
|
+
if (!isFormulaObject(obj))
|
|
1119
|
+
throw new FormulaError(
|
|
1120
|
+
`Cannot read field "${node.key}" of a non-object value.`,
|
|
1121
|
+
);
|
|
1122
|
+
const v = obj[node.key];
|
|
1123
|
+
if (v === undefined || v === null)
|
|
1124
|
+
throw new FormulaError(`Field "${node.key}" has no value.`);
|
|
1125
|
+
return v;
|
|
1126
|
+
}
|
|
1127
|
+
case "unary": {
|
|
1128
|
+
if (node.op === "NOT") return !toBool(evalNode(node.arg, ctx));
|
|
1129
|
+
const x = toNum(evalNode(node.arg, ctx));
|
|
1130
|
+
return node.op === "-" ? -x : x;
|
|
1131
|
+
}
|
|
1132
|
+
case "binary":
|
|
1133
|
+
return evalBinary(node, ctx);
|
|
1134
|
+
case "call": {
|
|
1135
|
+
const def = FORMULA_FUNCTIONS[node.name];
|
|
1136
|
+
if (def) {
|
|
1137
|
+
const args = node.args.map((a) => evalNode(a, ctx));
|
|
1138
|
+
if (
|
|
1139
|
+
args.length < def.min ||
|
|
1140
|
+
(def.max !== null && args.length > def.max)
|
|
1141
|
+
)
|
|
1142
|
+
throw new FormulaError(
|
|
1143
|
+
`${node.name} got the wrong number of arguments.`,
|
|
1144
|
+
);
|
|
1145
|
+
return def.fn(args);
|
|
1146
|
+
}
|
|
1147
|
+
// User / business function (formula references another formula).
|
|
1148
|
+
const user = ctx.fns[node.name];
|
|
1149
|
+
if (!user) throw new FormulaError(`Unknown function "${node.name}".`);
|
|
1150
|
+
if (ctx.stack.has(node.name))
|
|
1151
|
+
throw new FormulaError(`Circular function reference: "${node.name}".`);
|
|
1152
|
+
if (node.args.length !== user.params.length)
|
|
1153
|
+
throw new FormulaError(
|
|
1154
|
+
`${node.name} expects ${user.params.length} argument(s).`,
|
|
1155
|
+
);
|
|
1156
|
+
const args = node.args.map((a) => evalNode(a, ctx));
|
|
1157
|
+
const scope: FormulaVars = {};
|
|
1158
|
+
user.params.forEach((p, i) => {
|
|
1159
|
+
scope[p] = args[i];
|
|
1160
|
+
});
|
|
1161
|
+
return evalNode(user.formula.ast, {
|
|
1162
|
+
vars: scope,
|
|
1163
|
+
fns: ctx.fns,
|
|
1164
|
+
stack: new Set([...ctx.stack, node.name]),
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function evalBinary(
|
|
1171
|
+
node: Extract<Node, { t: "binary" }>,
|
|
1172
|
+
ctx: EvalCtx,
|
|
1173
|
+
): FormulaValue {
|
|
1174
|
+
const { op } = node;
|
|
1175
|
+
// Short-circuit logical operators.
|
|
1176
|
+
if (op === "AND")
|
|
1177
|
+
return (
|
|
1178
|
+
toBool(evalNode(node.left, ctx)) && toBool(evalNode(node.right, ctx))
|
|
1179
|
+
);
|
|
1180
|
+
if (op === "OR")
|
|
1181
|
+
return (
|
|
1182
|
+
toBool(evalNode(node.left, ctx)) || toBool(evalNode(node.right, ctx))
|
|
1183
|
+
);
|
|
1184
|
+
const l = evalNode(node.left, ctx);
|
|
1185
|
+
const r = evalNode(node.right, ctx);
|
|
1186
|
+
switch (op) {
|
|
1187
|
+
case "+":
|
|
1188
|
+
return toNum(l) + toNum(r);
|
|
1189
|
+
case "-":
|
|
1190
|
+
return toNum(l) - toNum(r);
|
|
1191
|
+
case "*":
|
|
1192
|
+
return toNum(l) * toNum(r);
|
|
1193
|
+
case "/": {
|
|
1194
|
+
const d = toNum(r);
|
|
1195
|
+
if (d === 0) throw new FormulaError("Division by zero.");
|
|
1196
|
+
return toNum(l) / d;
|
|
1197
|
+
}
|
|
1198
|
+
case "%": {
|
|
1199
|
+
const d = toNum(r);
|
|
1200
|
+
if (d === 0) throw new FormulaError("Division by zero.");
|
|
1201
|
+
return toNum(l) % d;
|
|
1202
|
+
}
|
|
1203
|
+
case "^":
|
|
1204
|
+
return toNum(l) ** toNum(r);
|
|
1205
|
+
case "&":
|
|
1206
|
+
return toStr(l) + toStr(r);
|
|
1207
|
+
case "=":
|
|
1208
|
+
case "==":
|
|
1209
|
+
return looseEq(l, r);
|
|
1210
|
+
case "<>":
|
|
1211
|
+
case "!=":
|
|
1212
|
+
return !looseEq(l, r);
|
|
1213
|
+
case "<":
|
|
1214
|
+
return cmp(l, r) < 0;
|
|
1215
|
+
case ">":
|
|
1216
|
+
return cmp(l, r) > 0;
|
|
1217
|
+
case "<=":
|
|
1218
|
+
return cmp(l, r) <= 0;
|
|
1219
|
+
case ">=":
|
|
1220
|
+
return cmp(l, r) >= 0;
|
|
1221
|
+
default:
|
|
1222
|
+
throw new FormulaError(`Unknown operator "${op}".`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function looseEq(l: FormulaValue, r: FormulaValue): boolean {
|
|
1227
|
+
if (typeof l === "number" || typeof r === "number") {
|
|
1228
|
+
const ln = Number(l);
|
|
1229
|
+
const rn = Number(r);
|
|
1230
|
+
if (!Number.isNaN(ln) && !Number.isNaN(rn)) return ln === rn;
|
|
1231
|
+
}
|
|
1232
|
+
return toStr(l) === toStr(r);
|
|
1233
|
+
}
|
|
1234
|
+
function cmp(l: FormulaValue, r: FormulaValue): number {
|
|
1235
|
+
if (typeof l === "number" || typeof r === "number") {
|
|
1236
|
+
return toNum(l) - toNum(r);
|
|
1237
|
+
}
|
|
1238
|
+
return toStr(l) < toStr(r) ? -1 : toStr(l) > toStr(r) ? 1 : 0;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
1242
|
+
|
|
1243
|
+
/** Parse a formula into an AST + its referenced variables. Throws on syntax error. */
|
|
1244
|
+
export function parseFormula(src: string): ParsedFormula {
|
|
1245
|
+
const ast = new Parser(tokenize(src)).parse();
|
|
1246
|
+
const variables = new Set<string>();
|
|
1247
|
+
const functions = new Set<string>();
|
|
1248
|
+
const collect = (n: Node) => {
|
|
1249
|
+
if (n.t === "var") variables.add(n.name);
|
|
1250
|
+
else if (n.t === "member") collect(n.obj);
|
|
1251
|
+
else if (n.t === "unary") collect(n.arg);
|
|
1252
|
+
else if (n.t === "binary") {
|
|
1253
|
+
collect(n.left);
|
|
1254
|
+
collect(n.right);
|
|
1255
|
+
} else if (n.t === "call") {
|
|
1256
|
+
functions.add(n.name);
|
|
1257
|
+
n.args.forEach(collect);
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
collect(ast);
|
|
1261
|
+
return { ast, variables: [...variables], functions: [...functions] };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Evaluate a parsed formula against a variables context. Never throws — returns
|
|
1265
|
+
* a controlled result. */
|
|
1266
|
+
export function evaluateFormula(
|
|
1267
|
+
parsed: ParsedFormula,
|
|
1268
|
+
vars: FormulaVars,
|
|
1269
|
+
options?: EvalOptions,
|
|
1270
|
+
): FormulaResult {
|
|
1271
|
+
try {
|
|
1272
|
+
const ctx: EvalCtx = {
|
|
1273
|
+
vars,
|
|
1274
|
+
fns: options?.functions ?? {},
|
|
1275
|
+
stack: new Set(),
|
|
1276
|
+
};
|
|
1277
|
+
return { ok: true, value: evalNode(parsed.ast, ctx) };
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
return {
|
|
1280
|
+
ok: false,
|
|
1281
|
+
error: err instanceof FormulaError ? err.message : "Formula error.",
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Parse + evaluate in one call. Never throws. */
|
|
1287
|
+
export function runFormula(
|
|
1288
|
+
src: string,
|
|
1289
|
+
vars: FormulaVars,
|
|
1290
|
+
options?: EvalOptions,
|
|
1291
|
+
): FormulaResult {
|
|
1292
|
+
let parsed: ParsedFormula;
|
|
1293
|
+
try {
|
|
1294
|
+
parsed = parseFormula(src);
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
return {
|
|
1297
|
+
ok: false,
|
|
1298
|
+
error: err instanceof FormulaError ? err.message : "Invalid formula.",
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
return evaluateFormula(parsed, vars, options);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Static check for the builder: valid syntax, and every referenced identifier is
|
|
1306
|
+
* either a known variable or a known function. Returns the unknown names so the
|
|
1307
|
+
* UI can flag them.
|
|
1308
|
+
*/
|
|
1309
|
+
export function validateFormula(
|
|
1310
|
+
src: string,
|
|
1311
|
+
knownVars: readonly string[],
|
|
1312
|
+
knownFunctions: readonly string[] = [],
|
|
1313
|
+
): { ok: true; variables: string[] } | { ok: false; error: string } {
|
|
1314
|
+
let parsed: ParsedFormula;
|
|
1315
|
+
try {
|
|
1316
|
+
parsed = parseFormula(src);
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
return {
|
|
1319
|
+
ok: false,
|
|
1320
|
+
error: err instanceof FormulaError ? err.message : "Invalid formula.",
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
const known = new Set(knownVars);
|
|
1324
|
+
const unknown = parsed.variables.filter((v) => !known.has(v));
|
|
1325
|
+
if (unknown.length > 0)
|
|
1326
|
+
return { ok: false, error: `Unknown variable(s): ${unknown.join(", ")}.` };
|
|
1327
|
+
// A called name must be a built-in or a known user/business function.
|
|
1328
|
+
const fnPool = new Set([
|
|
1329
|
+
...Object.keys(FORMULA_FUNCTIONS),
|
|
1330
|
+
...knownFunctions,
|
|
1331
|
+
]);
|
|
1332
|
+
const unknownFns = parsed.functions.filter((f) => !fnPool.has(f));
|
|
1333
|
+
if (unknownFns.length > 0)
|
|
1334
|
+
return {
|
|
1335
|
+
ok: false,
|
|
1336
|
+
error: `Unknown function(s): ${unknownFns.join(", ")}.`,
|
|
1337
|
+
};
|
|
1338
|
+
return { ok: true, variables: parsed.variables };
|
|
1339
|
+
}
|