@svgrid/enterprise 2.2.0 → 2.3.0

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.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +13 -1
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Free-text expression parsing, stringifying, and validation.
3
+ *
4
+ * The structured builder in `SvExpressionEditor` writes the AST directly; this
5
+ * module powers the optional free-text mode and the "explain this rule" display.
6
+ * The parser is a small recursive-descent / precedence parser - deliberately
7
+ * dependency-free.
8
+ *
9
+ * Grammar (informal):
10
+ * predicate := or
11
+ * or := and ( "OR" and )*
12
+ * and := not ( "AND" not )*
13
+ * not := "NOT" not | primary
14
+ * primary := "(" predicate ")" | comparison | boolLiteral
15
+ * comparison := scalar ( symOp scalar
16
+ * | "CONTAINS"|"STARTSWITH"|"ENDSWITH"|"MATCHES" scalar
17
+ * | "IN" "(" scalarList ")"
18
+ * | "BETWEEN" scalar "AND" scalar
19
+ * | "ISBLANK" | "ISNOTBLANK" )
20
+ * scalar := add ; add := mul (("+"|"-") mul)* ; mul := unary (("*"|"/"|"%") unary)*
21
+ * unary := "-" unary | atom
22
+ * atom := number | string | TRUE|FALSE|NULL | colRef
23
+ * | ("SUM"|"AVG"|"COUNT") "(" colRef ")" | ident "(" args ")" | "(" scalar ")"
24
+ * colRef := ident | "[" ... "]"
25
+ */
26
+ import type { ExcelFilterOperator } from '@svgrid/grid'
27
+ import type {
28
+ ComparisonOp,
29
+ PredicateExpr,
30
+ ScalarExpr,
31
+ } from './expression-types'
32
+ import {
33
+ columnRefText,
34
+ resolveColumnRef,
35
+ type ExprColumn,
36
+ } from './expression-columns'
37
+ import { isBuiltinFunction } from './evaluate'
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tokenizer
41
+ // ---------------------------------------------------------------------------
42
+
43
+ type TokKind = 'num' | 'str' | 'ident' | 'bracket' | 'kw' | 'op' | 'eof'
44
+ type Token = { kind: TokKind; value: string; pos: number }
45
+
46
+ const KEYWORDS = new Set([
47
+ 'AND', 'OR', 'NOT', 'TRUE', 'FALSE', 'NULL',
48
+ 'CONTAINS', 'STARTSWITH', 'ENDSWITH', 'MATCHES',
49
+ 'IN', 'BETWEEN', 'ISBLANK', 'ISNOTBLANK',
50
+ ])
51
+
52
+ const AGG_KEYWORDS = new Set(['SUM', 'AVG', 'COUNT'])
53
+
54
+ function tokenize(input: string): Token[] {
55
+ const tokens: Token[] = []
56
+ let i = 0
57
+ const n = input.length
58
+ while (i < n) {
59
+ const c = input[i] as string // i < n, so always defined
60
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
61
+ i++
62
+ continue
63
+ }
64
+ const start = i
65
+ // Bracketed column reference: [ ... ]
66
+ if (c === '[') {
67
+ const end = input.indexOf(']', i + 1)
68
+ if (end < 0) throw new ExpressionParseError('Unclosed "[" reference', start)
69
+ tokens.push({ kind: 'bracket', value: input.slice(i + 1, end), pos: start })
70
+ i = end + 1
71
+ continue
72
+ }
73
+ // String literal (single or double quote)
74
+ if (c === '"' || c === "'") {
75
+ let j = i + 1
76
+ let out = ''
77
+ while (j < n && input[j] !== c) {
78
+ if (input[j] === '\\' && j + 1 < n) {
79
+ out += input[j + 1]
80
+ j += 2
81
+ } else {
82
+ out += input[j]
83
+ j++
84
+ }
85
+ }
86
+ if (j >= n) throw new ExpressionParseError('Unterminated string', start)
87
+ tokens.push({ kind: 'str', value: out, pos: start })
88
+ i = j + 1
89
+ continue
90
+ }
91
+ // Number literal
92
+ if (isDigit(c) || (c === '.' && isDigit(input[i + 1] ?? ''))) {
93
+ let j = i
94
+ while (j < n && (isDigit(input[j]) || input[j] === '.')) j++
95
+ tokens.push({ kind: 'num', value: input.slice(i, j), pos: start })
96
+ i = j
97
+ continue
98
+ }
99
+ // Identifier / keyword
100
+ if (isIdentStart(c)) {
101
+ let j = i
102
+ while (j < n && isIdentPart(input[j])) j++
103
+ const raw = input.slice(i, j)
104
+ const upper = raw.toUpperCase()
105
+ tokens.push({ kind: KEYWORDS.has(upper) ? 'kw' : 'ident', value: KEYWORDS.has(upper) ? upper : raw, pos: start })
106
+ i = j
107
+ continue
108
+ }
109
+ // Multi-char operators
110
+ const two = input.slice(i, i + 2)
111
+ if (two === '>=' || two === '<=' || two === '!=' || two === '<>' || two === '==') {
112
+ tokens.push({ kind: 'op', value: two === '<>' ? '!=' : two === '==' ? '=' : two, pos: start })
113
+ i += 2
114
+ continue
115
+ }
116
+ if ('=<>+-*/%(),'.includes(c)) {
117
+ tokens.push({ kind: 'op', value: c, pos: start })
118
+ i++
119
+ continue
120
+ }
121
+ throw new ExpressionParseError(`Unexpected character "${c}"`, start)
122
+ }
123
+ tokens.push({ kind: 'eof', value: '', pos: n })
124
+ return tokens
125
+ }
126
+
127
+ function isDigit(c: string | undefined): boolean {
128
+ return c != null && c >= '0' && c <= '9'
129
+ }
130
+ function isIdentStart(c: string | undefined): boolean {
131
+ return c != null && /[A-Za-z_$]/.test(c)
132
+ }
133
+ function isIdentPart(c: string | undefined): boolean {
134
+ return c != null && /[\w$.]/.test(c)
135
+ }
136
+
137
+ /** Thrown by `parsePredicate` with a character offset into the source. */
138
+ export class ExpressionParseError extends Error {
139
+ pos: number
140
+ constructor(message: string, pos: number) {
141
+ super(message)
142
+ this.name = 'ExpressionParseError'
143
+ this.pos = pos
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Parser
149
+ // ---------------------------------------------------------------------------
150
+
151
+ const SYM_TO_CMP: Partial<Record<string, ExcelFilterOperator>> = {
152
+ '=': 'equals',
153
+ '!=': 'notEquals',
154
+ '>': 'greaterThan',
155
+ '<': 'lessThan',
156
+ }
157
+
158
+ class Parser {
159
+ private pos = 0
160
+ constructor(
161
+ private readonly tokens: Token[],
162
+ private readonly columns: ReadonlyArray<ExprColumn>,
163
+ ) {}
164
+
165
+ private peek(): Token {
166
+ // The token stream always ends with an `eof` token, so `pos` is in range.
167
+ return this.tokens[this.pos] as Token
168
+ }
169
+ private next(): Token {
170
+ return this.tokens[this.pos++] as Token
171
+ }
172
+ private eat(kind: TokKind, value?: string): Token {
173
+ const t = this.peek()
174
+ if (t.kind !== kind || (value != null && t.value !== value)) {
175
+ throw new ExpressionParseError(
176
+ `Expected ${value ?? kind}${t.kind === 'eof' ? ' but reached the end' : `, got "${t.value || t.kind}"`}`,
177
+ t.pos,
178
+ )
179
+ }
180
+ return this.next()
181
+ }
182
+ private isKw(v: string): boolean {
183
+ const t = this.peek()
184
+ return t.kind === 'kw' && t.value === v
185
+ }
186
+ private isOp(v: string): boolean {
187
+ const t = this.peek()
188
+ return t.kind === 'op' && t.value === v
189
+ }
190
+
191
+ parse(): PredicateExpr {
192
+ const expr = this.parseOr()
193
+ if (this.peek().kind !== 'eof') {
194
+ throw new ExpressionParseError(`Unexpected "${this.peek().value}"`, this.peek().pos)
195
+ }
196
+ return expr
197
+ }
198
+
199
+ private parseOr(): PredicateExpr {
200
+ const parts = [this.parseAnd()]
201
+ while (this.isKw('OR')) {
202
+ this.next()
203
+ parts.push(this.parseAnd())
204
+ }
205
+ return parts.length === 1 ? (parts[0] as PredicateExpr) : { kind: 'or', parts }
206
+ }
207
+
208
+ private parseAnd(): PredicateExpr {
209
+ const parts = [this.parseNot()]
210
+ while (this.isKw('AND')) {
211
+ this.next()
212
+ parts.push(this.parseNot())
213
+ }
214
+ return parts.length === 1 ? (parts[0] as PredicateExpr) : { kind: 'and', parts }
215
+ }
216
+
217
+ private parseNot(): PredicateExpr {
218
+ if (this.isKw('NOT')) {
219
+ this.next()
220
+ return { kind: 'not', expr: this.parseNot() }
221
+ }
222
+ return this.parsePrimary()
223
+ }
224
+
225
+ private parsePrimary(): PredicateExpr {
226
+ if (this.isOp('(')) {
227
+ // Could be a grouped predicate or a parenthesised scalar comparison.
228
+ const save = this.pos
229
+ this.next()
230
+ // Try predicate group first.
231
+ try {
232
+ const inner = this.parseOr()
233
+ if (this.isOp(')')) {
234
+ this.next()
235
+ return inner
236
+ }
237
+ } catch {
238
+ // fall through to scalar comparison
239
+ }
240
+ this.pos = save
241
+ }
242
+ return this.parseComparison()
243
+ }
244
+
245
+ private parseComparison(): PredicateExpr {
246
+ const left = this.parseScalar()
247
+ const t = this.peek()
248
+
249
+ // Postfix valueless keyword ops.
250
+ if (this.isKw('ISBLANK') || this.isKw('ISNOTBLANK')) {
251
+ const op: ExcelFilterOperator = this.next().value === 'ISBLANK' ? 'isBlank' : 'isNotBlank'
252
+ return { kind: 'cmp', column: this.expectColumn(left), op }
253
+ }
254
+ // Keyword text/set/range ops.
255
+ if (t.kind === 'kw') {
256
+ switch (t.value) {
257
+ case 'CONTAINS':
258
+ case 'STARTSWITH':
259
+ case 'ENDSWITH':
260
+ case 'MATCHES': {
261
+ this.next()
262
+ const rhs = this.parseScalar()
263
+ const op: ExcelFilterOperator =
264
+ t.value === 'CONTAINS' ? 'contains'
265
+ : t.value === 'STARTSWITH' ? 'startsWith'
266
+ : t.value === 'ENDSWITH' ? 'endsWith'
267
+ : 'regex'
268
+ return { kind: 'cmp', column: this.expectColumn(left), op, value: literalValue(rhs) }
269
+ }
270
+ case 'IN': {
271
+ this.next()
272
+ this.eat('op', '(')
273
+ const items: unknown[] = []
274
+ if (!this.isOp(')')) {
275
+ items.push(literalValue(this.parseScalar()))
276
+ while (this.isOp(',')) {
277
+ this.next()
278
+ items.push(literalValue(this.parseScalar()))
279
+ }
280
+ }
281
+ this.eat('op', ')')
282
+ return { kind: 'cmp', column: this.expectColumn(left), op: 'in', value: items.map((v) => String(v ?? '')) }
283
+ }
284
+ case 'BETWEEN': {
285
+ this.next()
286
+ const lo = literalValue(this.parseScalar())
287
+ this.eat('kw', 'AND')
288
+ const hi = literalValue(this.parseScalar())
289
+ return { kind: 'cmp', column: this.expectColumn(left), op: 'between', value: lo, valueTo: hi }
290
+ }
291
+ }
292
+ }
293
+ // Symbolic comparators.
294
+ if (t.kind === 'op' && ['=', '!=', '>', '<', '>=', '<='].includes(t.value)) {
295
+ const opTok = this.next().value
296
+ const right = this.parseScalar()
297
+ // Prefer a `cmp` (grid-filter semantics) for `col OP literal`; otherwise
298
+ // fall back to a general scalar comparison.
299
+ const cmpOp = SYM_TO_CMP[opTok]
300
+ if (cmpOp && left.kind === 'col' && right.kind === 'lit') {
301
+ return { kind: 'cmp', column: left.id, op: cmpOp, value: right.value }
302
+ }
303
+ return { kind: 'scalarCmp', left, op: opTok as ComparisonOp, right }
304
+ }
305
+ // Bare boolean literal is a constant predicate.
306
+ if (left.kind === 'lit' && typeof left.value === 'boolean') {
307
+ return { kind: 'const', value: left.value }
308
+ }
309
+ throw new ExpressionParseError('Expected a comparison operator', t.pos)
310
+ }
311
+
312
+ private expectColumn(expr: ScalarExpr): string {
313
+ if (expr.kind !== 'col') {
314
+ throw new ExpressionParseError('This operator requires a column on its left', this.peek().pos)
315
+ }
316
+ return expr.id
317
+ }
318
+
319
+ // Scalar precedence climbing --------------------------------------------
320
+
321
+ private parseScalar(): ScalarExpr {
322
+ return this.parseAdd()
323
+ }
324
+ private parseAdd(): ScalarExpr {
325
+ let left = this.parseMul()
326
+ while (this.isOp('+') || this.isOp('-')) {
327
+ const op = this.next().value as '+' | '-'
328
+ left = { kind: 'bin', op, left, right: this.parseMul() }
329
+ }
330
+ return left
331
+ }
332
+ private parseMul(): ScalarExpr {
333
+ let left = this.parseUnary()
334
+ while (this.isOp('*') || this.isOp('/') || this.isOp('%')) {
335
+ const op = this.next().value as '*' | '/' | '%'
336
+ left = { kind: 'bin', op, left, right: this.parseUnary() }
337
+ }
338
+ return left
339
+ }
340
+ private parseUnary(): ScalarExpr {
341
+ if (this.isOp('-')) {
342
+ this.next()
343
+ return { kind: 'neg', expr: this.parseUnary() }
344
+ }
345
+ return this.parseAtom()
346
+ }
347
+ private parseAtom(): ScalarExpr {
348
+ const t = this.peek()
349
+ if (t.kind === 'num') {
350
+ this.next()
351
+ return { kind: 'lit', value: Number(t.value) }
352
+ }
353
+ if (t.kind === 'str') {
354
+ this.next()
355
+ return { kind: 'lit', value: t.value }
356
+ }
357
+ if (t.kind === 'bracket') {
358
+ this.next()
359
+ return { kind: 'col', id: resolveColumnRef(this.columns, t.value) }
360
+ }
361
+ if (t.kind === 'kw' && (t.value === 'TRUE' || t.value === 'FALSE')) {
362
+ this.next()
363
+ return { kind: 'lit', value: t.value === 'TRUE' }
364
+ }
365
+ if (t.kind === 'kw' && t.value === 'NULL') {
366
+ this.next()
367
+ return { kind: 'lit', value: null }
368
+ }
369
+ if (this.isOp('(')) {
370
+ this.next()
371
+ const inner = this.parseScalar()
372
+ this.eat('op', ')')
373
+ return inner
374
+ }
375
+ if (t.kind === 'ident') {
376
+ this.next()
377
+ // Function or aggregate call?
378
+ if (this.isOp('(')) {
379
+ this.next()
380
+ const args: ScalarExpr[] = []
381
+ if (!this.isOp(')')) {
382
+ args.push(this.parseScalar())
383
+ while (this.isOp(',')) {
384
+ this.next()
385
+ args.push(this.parseScalar())
386
+ }
387
+ }
388
+ this.eat('op', ')')
389
+ const upper = t.value.toUpperCase()
390
+ if (AGG_KEYWORDS.has(upper)) {
391
+ const arg = args[0]
392
+ if (args.length !== 1 || !arg || arg.kind !== 'col') {
393
+ throw new ExpressionParseError(`${upper}() takes a single column reference`, t.pos)
394
+ }
395
+ return { kind: 'agg', fn: upper.toLowerCase() as 'sum' | 'avg' | 'count', column: arg.id }
396
+ }
397
+ return { kind: 'func', name: t.value, args }
398
+ }
399
+ // Bare identifier is a column reference.
400
+ return { kind: 'col', id: resolveColumnRef(this.columns, t.value) }
401
+ }
402
+ throw new ExpressionParseError(`Unexpected "${t.value || t.kind}"`, t.pos)
403
+ }
404
+ }
405
+
406
+ function literalValue(expr: ScalarExpr): unknown {
407
+ if (expr.kind === 'lit') return expr.value
408
+ // Non-literal RHS for a keyword op: fall back to its text form.
409
+ return stringifyScalar(expr, [])
410
+ }
411
+
412
+ /**
413
+ * Parse a free-text predicate into an AST. Column references (`[Name]` or a
414
+ * bare id) are resolved against `columns`. Throws `ExpressionParseError`
415
+ * (carrying a source offset) on a syntax error.
416
+ */
417
+ export function parsePredicate(
418
+ text: string,
419
+ columns: ReadonlyArray<ExprColumn> = [],
420
+ ): PredicateExpr {
421
+ const trimmed = text.trim()
422
+ if (!trimmed) throw new ExpressionParseError('Expression is empty', 0)
423
+ return new Parser(tokenize(trimmed), columns).parse()
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Stringify
428
+ // ---------------------------------------------------------------------------
429
+
430
+ function litText(value: unknown): string {
431
+ if (value == null) return 'null'
432
+ if (typeof value === 'number') return String(value)
433
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
434
+ return `"${String(value).replace(/"/g, '\\"')}"`
435
+ }
436
+
437
+ /** Render a scalar expression back to source text. */
438
+ export function stringifyScalar(
439
+ expr: ScalarExpr,
440
+ columns: ReadonlyArray<ExprColumn>,
441
+ ): string {
442
+ switch (expr.kind) {
443
+ case 'lit':
444
+ return litText(expr.value)
445
+ case 'col':
446
+ return columnRefText(columns, expr.id)
447
+ case 'neg':
448
+ return `-${wrapScalar(expr.expr, columns)}`
449
+ case 'bin':
450
+ return `${wrapScalar(expr.left, columns)} ${expr.op} ${wrapScalar(expr.right, columns)}`
451
+ case 'agg':
452
+ return `${expr.fn.toUpperCase()}(${columnRefText(columns, expr.column)})`
453
+ case 'func':
454
+ return `${expr.name.toUpperCase()}(${expr.args.map((a) => stringifyScalar(a, columns)).join(', ')})`
455
+ }
456
+ }
457
+
458
+ function wrapScalar(expr: ScalarExpr, columns: ReadonlyArray<ExprColumn>): string {
459
+ const s = stringifyScalar(expr, columns)
460
+ return expr.kind === 'bin' ? `(${s})` : s
461
+ }
462
+
463
+ const CMP_SYMBOL: Partial<Record<ExcelFilterOperator, string>> = {
464
+ equals: '=',
465
+ notEquals: '!=',
466
+ greaterThan: '>',
467
+ lessThan: '<',
468
+ }
469
+ const CMP_KEYWORD: Partial<Record<ExcelFilterOperator, string>> = {
470
+ contains: 'CONTAINS',
471
+ notContains: 'NOT CONTAINS',
472
+ startsWith: 'STARTSWITH',
473
+ endsWith: 'ENDSWITH',
474
+ regex: 'MATCHES',
475
+ }
476
+
477
+ /** Render a predicate expression back to readable source text. */
478
+ export function stringifyPredicate(
479
+ expr: PredicateExpr,
480
+ columns: ReadonlyArray<ExprColumn> = [],
481
+ ): string {
482
+ switch (expr.kind) {
483
+ case 'const':
484
+ return expr.value ? 'true' : 'false'
485
+ case 'not':
486
+ return `NOT ${wrapPred(expr.expr, columns)}`
487
+ case 'and':
488
+ return expr.parts.map((p) => wrapPred(p, columns)).join(' AND ')
489
+ case 'or':
490
+ return expr.parts.map((p) => wrapPred(p, columns)).join(' OR ')
491
+ case 'scalarCmp':
492
+ return `${stringifyScalar(expr.left, columns)} ${expr.op} ${stringifyScalar(expr.right, columns)}`
493
+ case 'cmp': {
494
+ const col = columnRefText(columns, expr.column)
495
+ if (expr.op === 'isBlank') return `${col} ISBLANK`
496
+ if (expr.op === 'isNotBlank') return `${col} ISNOTBLANK`
497
+ if (expr.op === 'between') return `${col} BETWEEN ${litText(expr.value)} AND ${litText(expr.valueTo)}`
498
+ if (expr.op === 'in' || expr.op === 'notIn') {
499
+ const items = Array.isArray(expr.value) ? expr.value : String(expr.value ?? '').split(/[\n,]/)
500
+ const list = items.map((v) => litText(String(v).trim())).join(', ')
501
+ return `${col} ${expr.op === 'in' ? 'IN' : 'NOT IN'} (${list})`
502
+ }
503
+ const sym = CMP_SYMBOL[expr.op]
504
+ if (sym) return `${col} ${sym} ${litText(expr.value)}`
505
+ const kw = CMP_KEYWORD[expr.op]
506
+ return `${col} ${kw ?? expr.op} ${litText(expr.value)}`
507
+ }
508
+ }
509
+ }
510
+
511
+ function wrapPred(expr: PredicateExpr, columns: ReadonlyArray<ExprColumn>): string {
512
+ const s = stringifyPredicate(expr, columns)
513
+ return expr.kind === 'and' || expr.kind === 'or' ? `(${s})` : s
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Validation + reference collection
518
+ // ---------------------------------------------------------------------------
519
+
520
+ /** Every column id referenced anywhere in a predicate (deduped, in order). */
521
+ export function collectColumnRefs(expr: PredicateExpr): string[] {
522
+ const out = new Set<string>()
523
+ walkPredicate(expr, out)
524
+ return [...out]
525
+ }
526
+
527
+ function walkPredicate(expr: PredicateExpr, out: Set<string>): void {
528
+ switch (expr.kind) {
529
+ case 'and':
530
+ case 'or':
531
+ expr.parts.forEach((p) => walkPredicate(p, out))
532
+ return
533
+ case 'not':
534
+ walkPredicate(expr.expr, out)
535
+ return
536
+ case 'cmp':
537
+ out.add(expr.column)
538
+ return
539
+ case 'scalarCmp':
540
+ walkScalar(expr.left, out)
541
+ walkScalar(expr.right, out)
542
+ return
543
+ case 'const':
544
+ return
545
+ }
546
+ }
547
+
548
+ function walkScalar(expr: ScalarExpr, out: Set<string>): void {
549
+ switch (expr.kind) {
550
+ case 'col':
551
+ out.add(expr.id)
552
+ return
553
+ case 'agg':
554
+ out.add(expr.column)
555
+ return
556
+ case 'neg':
557
+ walkScalar(expr.expr, out)
558
+ return
559
+ case 'bin':
560
+ walkScalar(expr.left, out)
561
+ walkScalar(expr.right, out)
562
+ return
563
+ case 'func':
564
+ expr.args.forEach((a) => walkScalar(a, out))
565
+ return
566
+ case 'lit':
567
+ return
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Structurally validate an AST against a column list. Returns a list of
573
+ * human-readable problems (empty when the expression is sound): unknown column
574
+ * references and unknown function names.
575
+ */
576
+ export function validateExpression(
577
+ expr: PredicateExpr,
578
+ columns: ReadonlyArray<ExprColumn>,
579
+ ): string[] {
580
+ const known = new Set(columns.map((c) => c.id))
581
+ const errors: string[] = []
582
+ for (const id of collectColumnRefs(expr)) {
583
+ if (!known.has(id)) errors.push(`Unknown column "${id}"`)
584
+ }
585
+ checkFuncs(expr, errors)
586
+ return errors
587
+ }
588
+
589
+ function checkFuncs(expr: PredicateExpr, errors: string[]): void {
590
+ const visit = (s: ScalarExpr): void => {
591
+ if (s.kind === 'func' && !isBuiltinFunction(s.name)) {
592
+ errors.push(`Unknown function "${s.name}"`)
593
+ }
594
+ if (s.kind === 'func') s.args.forEach(visit)
595
+ if (s.kind === 'bin') {
596
+ visit(s.left)
597
+ visit(s.right)
598
+ }
599
+ if (s.kind === 'neg') visit(s.expr)
600
+ }
601
+ const walk = (p: PredicateExpr): void => {
602
+ if (p.kind === 'and' || p.kind === 'or') p.parts.forEach(walk)
603
+ else if (p.kind === 'not') walk(p.expr)
604
+ else if (p.kind === 'scalarCmp') {
605
+ visit(p.left)
606
+ visit(p.right)
607
+ }
608
+ }
609
+ walk(expr)
610
+ }
package/src/import.ts CHANGED
@@ -199,6 +199,8 @@ async function getJSZip(): Promise<any> {
199
199
  jszipPromise = (async () => {
200
200
  let mod: unknown
201
201
  try {
202
+ // Optional peer, loaded on demand (keeps consumer type-checks clean when absent).
203
+ // @ts-ignore - "jszip" is an optional peerDependency
202
204
  mod = await import('jszip')
203
205
  } catch {
204
206
  throw new Error(