angular-my-report 0.0.0-stage → 0.1.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 (46) hide show
  1. package/README.md +99 -2
  2. package/ng-package.json +7 -0
  3. package/package.json +23 -3
  4. package/src/lib/engine/barcode.ts +118 -0
  5. package/src/lib/engine/chart.ts +85 -0
  6. package/src/lib/engine/chartLayout.ts +265 -0
  7. package/src/lib/engine/crosstab.ts +96 -0
  8. package/src/lib/engine/dataSources.ts +204 -0
  9. package/src/lib/engine/expressions.ts +734 -0
  10. package/src/lib/engine/measure.ts +62 -0
  11. package/src/lib/engine/parameters.ts +96 -0
  12. package/src/lib/engine/qr.ts +26 -0
  13. package/src/lib/engine/renderer.ts +324 -0
  14. package/src/lib/engine/table.ts +58 -0
  15. package/src/lib/model/defaults.ts +249 -0
  16. package/src/lib/model/factories.ts +324 -0
  17. package/src/lib/model/types.ts +281 -0
  18. package/src/lib/ui/css.ts +12 -0
  19. package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
  20. package/src/lib/ui/designer/canvas.component.ts +281 -0
  21. package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
  22. package/src/lib/ui/designer/element-box.component.ts +138 -0
  23. package/src/lib/ui/designer/graphics.components.ts +140 -0
  24. package/src/lib/ui/designer/left-panel.component.ts +189 -0
  25. package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
  26. package/src/lib/ui/designer/table-view.component.ts +120 -0
  27. package/src/lib/ui/designer.component.ts +296 -0
  28. package/src/lib/ui/designer.store.ts +230 -0
  29. package/src/lib/ui/inputs.ts +34 -0
  30. package/src/lib/ui/myreport.css +267 -0
  31. package/src/lib/ui/properties/band-props.component.ts +86 -0
  32. package/src/lib/ui/properties/element-props.component.ts +326 -0
  33. package/src/lib/ui/properties/expr-input.component.ts +191 -0
  34. package/src/lib/ui/properties/graphics-props.component.ts +260 -0
  35. package/src/lib/ui/properties/image-picker.component.ts +62 -0
  36. package/src/lib/ui/properties/properties-panel.component.ts +37 -0
  37. package/src/lib/ui/properties/report-extras.component.ts +198 -0
  38. package/src/lib/ui/properties/report-props.component.ts +211 -0
  39. package/src/lib/ui/properties/table-props.component.ts +197 -0
  40. package/src/lib/ui/viewer/page-view.component.ts +73 -0
  41. package/src/lib/ui/viewer/render.ts +161 -0
  42. package/src/lib/ui/viewer/viewer.component.ts +555 -0
  43. package/src/lib/ui/viewer.component.ts +107 -0
  44. package/src/public-api.ts +12 -0
  45. package/tsconfig.lib.json +12 -0
  46. package/tsconfig.lib.prod.json +5 -0
@@ -0,0 +1,734 @@
1
+ import type { FormatSpec } from '../model/types';
2
+
3
+ export type Row = Record<string, unknown>;
4
+
5
+ export interface EvalContext {
6
+ row: Row | undefined;
7
+ /** Rows in the current scope (current group, or all rows for report bands). */
8
+ rows: Row[];
9
+ allRows: Row[];
10
+ pageRows: Row[];
11
+ rowNumber: number;
12
+ pageNumber: number;
13
+ totalPages: number;
14
+ reportName: string;
15
+ /** Report parameter values, by name. */
16
+ params?: Record<string, unknown>;
17
+ /** The whole loaded document (e.g. a receipt object whose items are the rows); read as root.x. */
18
+ root?: unknown;
19
+ }
20
+
21
+ export function getPath(obj: unknown, path: string): unknown {
22
+ let cur: any = obj;
23
+ for (const part of path.split('.')) {
24
+ if (cur == null) return undefined;
25
+ cur = cur[part];
26
+ }
27
+ return cur;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------------------------
31
+ // Formula language
32
+ //
33
+ // literals 123 1.5 "text" 'text' true false null
34
+ // fields Customer address.city [Unit Price] (brackets for names with spaces)
35
+ // parameters @Country Parameters.Country
36
+ // operators + - * / % == = != <> < <= > >= && and || or ! not cond ? a : b
37
+ // functions sum(Quantity * UnitPrice) count() iif(Amount > 100, "High", "Low") upper(Customer) …
38
+ // aggregates take an optional scope as last argument: sum(Amount, report) | page | group
39
+ // specials PageNumber TotalPages Now Today RowNumber ReportName
40
+
41
+ type Node =
42
+ | { k: 'lit'; v: unknown }
43
+ | { k: 'id'; name: string }
44
+ | { k: 'param'; name: string }
45
+ | { k: 'un'; op: '-' | '!'; a: Node }
46
+ | { k: 'bin'; op: string; a: Node; b: Node }
47
+ | { k: 'cond'; c: Node; a: Node; b: Node }
48
+ | { k: 'call'; name: string; args: Node[] };
49
+
50
+ type Tok = { t: 'num'; v: number } | { t: 'str'; v: string } | { t: 'id'; v: string } | { t: 'param'; v: string } | { t: 'op'; v: string } | { t: 'eof' };
51
+
52
+ const IDENT = /^[A-Za-z_À-￿][\w.À-￿]*/;
53
+ const OPS = ['==', '!=', '<>', '<=', '>=', '&&', '||', '<', '>', '+', '-', '*', '/', '%', '!', '?', ':', '(', ')', ',', '='];
54
+
55
+ function tokenize(s: string): Tok[] {
56
+ const out: Tok[] = [];
57
+ let i = 0;
58
+ while (i < s.length) {
59
+ const c = s[i];
60
+ if (/\s/.test(c)) {
61
+ i++;
62
+ continue;
63
+ }
64
+ if (/\d/.test(c) || (c === '.' && /\d/.test(s[i + 1] ?? ''))) {
65
+ const m = /^\d*\.?\d+(?:[eE][+-]?\d+)?/.exec(s.slice(i))!;
66
+ out.push({ t: 'num', v: Number(m[0]) });
67
+ i += m[0].length;
68
+ continue;
69
+ }
70
+ if (c === '"' || c === "'") {
71
+ let j = i + 1;
72
+ let v = '';
73
+ while (j < s.length && s[j] !== c) {
74
+ if (s[j] === '\\' && j + 1 < s.length) {
75
+ j++;
76
+ v += s[j] === 'n' ? '\n' : s[j];
77
+ } else v += s[j];
78
+ j++;
79
+ }
80
+ if (j >= s.length) throw new Error('Unterminated string');
81
+ out.push({ t: 'str', v });
82
+ i = j + 1;
83
+ continue;
84
+ }
85
+ if (c === '[') {
86
+ const j = s.indexOf(']', i);
87
+ if (j < 0) throw new Error('Missing ]');
88
+ out.push({ t: 'id', v: s.slice(i + 1, j).trim() });
89
+ i = j + 1;
90
+ continue;
91
+ }
92
+ if (c === '@') {
93
+ const m = IDENT.exec(s.slice(i + 1));
94
+ if (!m) throw new Error('Parameter name expected after @');
95
+ out.push({ t: 'param', v: m[0] });
96
+ i += 1 + m[0].length;
97
+ continue;
98
+ }
99
+ const m = IDENT.exec(s.slice(i));
100
+ if (m) {
101
+ out.push({ t: 'id', v: m[0] });
102
+ i += m[0].length;
103
+ continue;
104
+ }
105
+ const op = OPS.find((o) => s.startsWith(o, i));
106
+ if (op) {
107
+ out.push({ t: 'op', v: op });
108
+ i += op.length;
109
+ continue;
110
+ }
111
+ throw new Error(`Unexpected "${c}"`);
112
+ }
113
+ out.push({ t: 'eof' });
114
+ return out;
115
+ }
116
+
117
+ const tokText = (t: Tok) => (t.t === 'eof' ? 'end' : String(t.v));
118
+
119
+ function parse(src: string): Node {
120
+ const toks = tokenize(src);
121
+ let p = 0;
122
+ const peek = () => toks[p];
123
+ const isOp = (v: string) => peek().t === 'op' && (peek() as { v: string }).v === v;
124
+ const isKw = (v: string) => peek().t === 'id' && (peek() as { v: string }).v.toLowerCase() === v;
125
+ const expect = (v: string) => {
126
+ if (!isOp(v)) throw new Error(`Expected "${v}" but found "${tokText(peek())}"`);
127
+ p++;
128
+ };
129
+ const opv = () => (toks[p++] as { v: string }).v;
130
+
131
+ function ternary(): Node {
132
+ const c = or();
133
+ if (isOp('?')) {
134
+ p++;
135
+ const a = ternary();
136
+ expect(':');
137
+ return { k: 'cond', c, a, b: ternary() };
138
+ }
139
+ return c;
140
+ }
141
+ function or(): Node {
142
+ let a = and();
143
+ while (isOp('||') || isKw('or')) {
144
+ p++;
145
+ a = { k: 'bin', op: '||', a, b: and() };
146
+ }
147
+ return a;
148
+ }
149
+ function and(): Node {
150
+ let a = eq();
151
+ while (isOp('&&') || isKw('and')) {
152
+ p++;
153
+ a = { k: 'bin', op: '&&', a, b: eq() };
154
+ }
155
+ return a;
156
+ }
157
+ function eq(): Node {
158
+ let a = rel();
159
+ for (;;) {
160
+ if (isOp('==') || isOp('=')) {
161
+ p++;
162
+ a = { k: 'bin', op: '==', a, b: rel() };
163
+ } else if (isOp('!=') || isOp('<>')) {
164
+ p++;
165
+ a = { k: 'bin', op: '!=', a, b: rel() };
166
+ } else return a;
167
+ }
168
+ }
169
+ function rel(): Node {
170
+ let a = add();
171
+ while (['<', '<=', '>', '>='].some(isOp)) a = { k: 'bin', op: opv(), a, b: add() };
172
+ return a;
173
+ }
174
+ function add(): Node {
175
+ let a = mul();
176
+ while (isOp('+') || isOp('-')) a = { k: 'bin', op: opv(), a, b: mul() };
177
+ return a;
178
+ }
179
+ function mul(): Node {
180
+ let a = unary();
181
+ while (isOp('*') || isOp('/') || isOp('%')) a = { k: 'bin', op: opv(), a, b: unary() };
182
+ return a;
183
+ }
184
+ function unary(): Node {
185
+ if (isOp('-')) {
186
+ p++;
187
+ return { k: 'un', op: '-', a: unary() };
188
+ }
189
+ if (isOp('!') || isKw('not')) {
190
+ p++;
191
+ return { k: 'un', op: '!', a: unary() };
192
+ }
193
+ return primary();
194
+ }
195
+ function primary(): Node {
196
+ const t = toks[p++];
197
+ switch (t.t) {
198
+ case 'num':
199
+ case 'str':
200
+ return { k: 'lit', v: t.v };
201
+ case 'param':
202
+ return { k: 'param', name: t.v };
203
+ case 'id': {
204
+ const low = t.v.toLowerCase();
205
+ if (low === 'true' || low === 'false') return { k: 'lit', v: low === 'true' };
206
+ if (low === 'null') return { k: 'lit', v: null };
207
+ if (isOp('(')) {
208
+ p++;
209
+ const args: Node[] = [];
210
+ if (!isOp(')')) {
211
+ args.push(ternary());
212
+ while (isOp(',')) {
213
+ p++;
214
+ args.push(ternary());
215
+ }
216
+ }
217
+ expect(')');
218
+ if (!FUNCTIONS_BY_NAME.has(low)) throw new Error(`Unknown function "${t.v}"`);
219
+ return { k: 'call', name: low, args };
220
+ }
221
+ return { k: 'id', name: t.v };
222
+ }
223
+ case 'op':
224
+ if (t.v === '(') {
225
+ const e = ternary();
226
+ expect(')');
227
+ return e;
228
+ }
229
+ throw new Error(`Unexpected "${t.v}"`);
230
+ default:
231
+ throw new Error('Incomplete expression');
232
+ }
233
+ }
234
+
235
+ const node = ternary();
236
+ if (peek().t !== 'eof') throw new Error(`Unexpected "${tokText(peek())}"`);
237
+ return node;
238
+ }
239
+
240
+ const cache = new Map<string, Node>();
241
+ function compile(src: string): Node {
242
+ let n = cache.get(src);
243
+ if (!n) {
244
+ n = parse(src);
245
+ if (cache.size > 5000) cache.clear();
246
+ cache.set(src, n);
247
+ }
248
+ return n;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------------------------
252
+ // Evaluation
253
+
254
+ const toNumber = (v: unknown): number =>
255
+ typeof v === 'number' ? v : v instanceof Date ? v.getTime() : v == null || v === '' ? NaN : typeof v === 'boolean' ? +v : Number(v);
256
+
257
+ function numLike(v: unknown): number | null {
258
+ if (typeof v === 'number') return v;
259
+ if (typeof v === 'boolean') return +v;
260
+ if (v instanceof Date) return v.getTime();
261
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
262
+ return null;
263
+ }
264
+
265
+ export const truthy = (v: unknown) => !(v == null || v === false || v === 0 || v === '' || (typeof v === 'number' && Number.isNaN(v)));
266
+
267
+ function compare(a: unknown, b: unknown): number {
268
+ const na = numLike(a);
269
+ const nb = numLike(b);
270
+ if (na !== null && nb !== null) return na - nb;
271
+ return String(a ?? '').localeCompare(String(b ?? ''), undefined, { numeric: true });
272
+ }
273
+
274
+ function equals(a: unknown, b: unknown): boolean {
275
+ if (a == null || b == null) return (a == null || a === '') && (b == null || b === '');
276
+ const na = numLike(a);
277
+ const nb = numLike(b);
278
+ if (na !== null && nb !== null) return na === nb;
279
+ return String(a) === String(b);
280
+ }
281
+
282
+ function param(ctx: EvalContext, name: string): unknown {
283
+ const p = ctx.params ?? {};
284
+ if (name in p) return p[name];
285
+ const key = Object.keys(p).find((k) => k.toLowerCase() === name.toLowerCase());
286
+ return key ? p[key] : undefined;
287
+ }
288
+
289
+ function resolveId(name: string, ctx: EvalContext): unknown {
290
+ if (ctx.row) {
291
+ const v = getPath(ctx.row, name);
292
+ if (v !== undefined) return v;
293
+ }
294
+ const low = name.toLowerCase();
295
+ switch (low) {
296
+ case 'pagenumber':
297
+ return ctx.pageNumber;
298
+ case 'totalpages':
299
+ return ctx.totalPages;
300
+ case 'now':
301
+ return new Date();
302
+ case 'today': {
303
+ const d = new Date();
304
+ d.setHours(0, 0, 0, 0);
305
+ return d;
306
+ }
307
+ case 'rownumber':
308
+ return ctx.rowNumber;
309
+ case 'reportname':
310
+ return ctx.reportName;
311
+ }
312
+ if (low.startsWith('parameters.')) return param(ctx, name.slice('parameters.'.length));
313
+ if (low === 'root') return ctx.root;
314
+ if (low.startsWith('root.')) return getPath(ctx.root, name.slice(5));
315
+ return undefined;
316
+ }
317
+
318
+ function evalNode(n: Node, ctx: EvalContext): unknown {
319
+ switch (n.k) {
320
+ case 'lit':
321
+ return n.v;
322
+ case 'param':
323
+ return param(ctx, n.name);
324
+ case 'id':
325
+ return resolveId(n.name, ctx);
326
+ case 'un': {
327
+ const v = evalNode(n.a, ctx);
328
+ return n.op === '-' ? -toNumber(v) : !truthy(v);
329
+ }
330
+ case 'cond':
331
+ return truthy(evalNode(n.c, ctx)) ? evalNode(n.a, ctx) : evalNode(n.b, ctx);
332
+ case 'bin': {
333
+ if (n.op === '&&') return truthy(evalNode(n.a, ctx)) && truthy(evalNode(n.b, ctx));
334
+ if (n.op === '||') return truthy(evalNode(n.a, ctx)) || truthy(evalNode(n.b, ctx));
335
+ const a = evalNode(n.a, ctx);
336
+ const b = evalNode(n.b, ctx);
337
+ switch (n.op) {
338
+ case '==':
339
+ return equals(a, b);
340
+ case '!=':
341
+ return !equals(a, b);
342
+ case '<':
343
+ return compare(a, b) < 0;
344
+ case '<=':
345
+ return compare(a, b) <= 0;
346
+ case '>':
347
+ return compare(a, b) > 0;
348
+ case '>=':
349
+ return compare(a, b) >= 0;
350
+ case '+':
351
+ // Text joins; numbers add.
352
+ if ((typeof a === 'string' && numLike(a) === null) || (typeof b === 'string' && numLike(b) === null) || a == null || b == null) {
353
+ if (a == null && b == null) return null;
354
+ if (numLike(a) !== null && b == null) return toNumber(a);
355
+ if (numLike(b) !== null && a == null) return toNumber(b);
356
+ return String(a ?? '') + String(b ?? '');
357
+ }
358
+ return toNumber(a) + toNumber(b);
359
+ case '-':
360
+ return toNumber(a) - toNumber(b);
361
+ case '*':
362
+ return toNumber(a) * toNumber(b);
363
+ case '/': {
364
+ const d = toNumber(b);
365
+ return d === 0 ? null : toNumber(a) / d;
366
+ }
367
+ case '%':
368
+ return toNumber(a) % toNumber(b);
369
+ }
370
+ throw new Error(`Unknown operator ${n.op}`);
371
+ }
372
+ case 'call':
373
+ return callFunction(n.name, n.args, ctx);
374
+ }
375
+ }
376
+
377
+ const SCOPES = new Set(['report', 'all', 'page', 'group']);
378
+ export const AGGREGATES = ['sum', 'avg', 'min', 'max', 'count', 'first', 'last', 'distinctcount'];
379
+ const AGG = new Set(AGGREGATES);
380
+
381
+ function callFunction(name: string, args: Node[], ctx: EvalContext): unknown {
382
+ if (AGG.has(name)) {
383
+ const last = args[args.length - 1];
384
+ const scope = last && last.k === 'id' && SCOPES.has(last.name.toLowerCase()) ? last.name.toLowerCase() : null;
385
+ const a = scope ? args.slice(0, -1) : args;
386
+ // min(a, b) / max(a, b) with 2+ values: plain numeric min/max.
387
+ if ((name === 'min' || name === 'max') && a.length >= 2) {
388
+ const nums = a.map((x) => toNumber(evalNode(x, ctx))).filter((x) => !Number.isNaN(x));
389
+ return nums.length ? (name === 'min' ? Math.min(...nums) : Math.max(...nums)) : null;
390
+ }
391
+ const set = scope === 'report' || scope === 'all' ? ctx.allRows : scope === 'page' ? ctx.pageRows ?? ctx.rows : ctx.rows;
392
+ if (name === 'count' && !a.length) return set.length;
393
+ if (!a.length) throw new Error(`${name}() needs a field or expression`);
394
+ const values = set.map((r) => evalNode(a[0], { ...ctx, row: r }));
395
+ return reduceAggregate(name, values);
396
+ }
397
+ const v = args.map((x) => evalNode(x, ctx));
398
+ const s = (i: number) => (v[i] == null ? '' : v[i] instanceof Date ? (v[i] as Date).toLocaleString() : String(v[i]));
399
+ const n = (i: number) => toNumber(v[i]);
400
+ const d = (i: number) => toDate(v[i]);
401
+ switch (name) {
402
+ // logic
403
+ case 'iif':
404
+ case 'if':
405
+ return truthy(v[0]) ? v[1] : v[2];
406
+ case 'isnull':
407
+ case 'coalesce':
408
+ return v.find((x) => x != null && x !== '') ?? (name === 'isnull' ? v[1] : null);
409
+ case 'isempty':
410
+ return v[0] == null || v[0] === '';
411
+ // text
412
+ case 'upper':
413
+ return s(0).toUpperCase();
414
+ case 'lower':
415
+ return s(0).toLowerCase();
416
+ case 'trim':
417
+ return s(0).trim();
418
+ case 'len':
419
+ case 'length':
420
+ return s(0).length;
421
+ case 'left':
422
+ return s(0).slice(0, Math.max(0, n(1)));
423
+ case 'right':
424
+ return n(1) > 0 ? s(0).slice(-n(1)) : '';
425
+ case 'substr':
426
+ return s(0).substr(n(1), args.length > 2 ? n(2) : undefined);
427
+ case 'replace':
428
+ return s(0).split(s(1)).join(s(2));
429
+ case 'concat':
430
+ return v.map((_, i) => s(i)).join('');
431
+ case 'contains':
432
+ return s(0).toLowerCase().includes(s(1).toLowerCase());
433
+ case 'startswith':
434
+ return s(0).toLowerCase().startsWith(s(1).toLowerCase());
435
+ case 'endswith':
436
+ return s(0).toLowerCase().endsWith(s(1).toLowerCase());
437
+ case 'padleft':
438
+ return s(0).padStart(n(1), args.length > 2 ? s(2) : ' ');
439
+ case 'tostring':
440
+ return s(0);
441
+ // math
442
+ case 'round': {
443
+ const f = Math.pow(10, args.length > 1 ? n(1) : 0);
444
+ return Math.round(n(0) * f) / f;
445
+ }
446
+ case 'floor':
447
+ return Math.floor(n(0));
448
+ case 'ceil':
449
+ return Math.ceil(n(0));
450
+ case 'abs':
451
+ return Math.abs(n(0));
452
+ case 'sqrt':
453
+ return Math.sqrt(n(0));
454
+ case 'pow':
455
+ return Math.pow(n(0), n(1));
456
+ case 'tonumber':
457
+ return n(0);
458
+ // dates
459
+ case 'now':
460
+ return new Date();
461
+ case 'today': {
462
+ const t = new Date();
463
+ t.setHours(0, 0, 0, 0);
464
+ return t;
465
+ }
466
+ case 'year':
467
+ return d(0)?.getFullYear() ?? null;
468
+ case 'month':
469
+ return d(0) ? d(0)!.getMonth() + 1 : null;
470
+ case 'day':
471
+ return d(0)?.getDate() ?? null;
472
+ case 'dateadd': {
473
+ const t = d(0);
474
+ if (!t) return null;
475
+ const r = new Date(t);
476
+ const unit = (s(2) || 'day').toLowerCase();
477
+ if (unit.startsWith('year')) r.setFullYear(r.getFullYear() + n(1));
478
+ else if (unit.startsWith('month')) r.setMonth(r.getMonth() + n(1));
479
+ else r.setDate(r.getDate() + n(1));
480
+ return r;
481
+ }
482
+ case 'datediff': {
483
+ const a = d(0);
484
+ const b = d(1);
485
+ if (!a || !b) return null;
486
+ const unit = (s(2) || 'day').toLowerCase();
487
+ if (unit.startsWith('year')) return b.getFullYear() - a.getFullYear();
488
+ if (unit.startsWith('month')) return (b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth();
489
+ return Math.round((b.getTime() - a.getTime()) / 86400000);
490
+ }
491
+ case 'formatdate': {
492
+ const t = d(0);
493
+ return t ? formatDatePattern(t, s(1) || 'yyyy-MM-dd') : '';
494
+ }
495
+ case 'format': {
496
+ const kind = (s(1) || 'number') as FormatSpec['kind'];
497
+ return formatValue(v[0], { kind, decimals: args.length > 2 ? n(2) : 2, currency: args.length > 3 ? s(3) : 'USD' });
498
+ }
499
+ }
500
+ throw new Error(`Unknown function ${name}`);
501
+ }
502
+
503
+ function reduceAggregate(fn: string, values: unknown[]): unknown {
504
+ switch (fn) {
505
+ case 'count':
506
+ return values.filter((x) => x != null && x !== '').length;
507
+ case 'first':
508
+ return values[0];
509
+ case 'last':
510
+ return values[values.length - 1];
511
+ case 'distinctcount':
512
+ return new Set(values.map((x) => String(x))).size;
513
+ }
514
+ const nums = values.map(toNumber).filter((x) => !Number.isNaN(x));
515
+ switch (fn) {
516
+ case 'sum':
517
+ return nums.reduce((a, b) => a + b, 0);
518
+ case 'avg':
519
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
520
+ case 'min':
521
+ return nums.length ? Math.min(...nums) : null;
522
+ case 'max':
523
+ return nums.length ? Math.max(...nums) : null;
524
+ }
525
+ throw new Error(`Unknown aggregate ${fn}`);
526
+ }
527
+
528
+ const SIMPLE_PATH = /^[A-Za-z_À-￿][\w.À-￿]*$/;
529
+
530
+ /** Aggregates a field (or expression) over rows — used by charts and cross tables. */
531
+ export function aggregate(fn: string, field: string, rows: Row[]): unknown {
532
+ const ctx: EvalContext = { row: undefined, rows, allRows: rows, pageRows: rows, rowNumber: 0, pageNumber: 1, totalPages: 1, reportName: '' };
533
+ if (!field) return fn === 'count' ? rows.length : null;
534
+ const arg = SIMPLE_PATH.test(field) ? field : `[${field}]`;
535
+ return evaluate(`${fn}(${arg})`, ctx);
536
+ }
537
+
538
+ /** Evaluates one expression (the part inside `{}`). */
539
+ export function evaluate(expr: string, ctx: EvalContext): unknown {
540
+ const e = expr.trim();
541
+ if (!e) return '';
542
+ return evalNode(compile(e), ctx);
543
+ }
544
+
545
+ /** Parse error message for an expression, or null when valid. */
546
+ export function checkExpression(expr: string): string | null {
547
+ if (!expr.trim()) return null;
548
+ try {
549
+ compile(expr.trim());
550
+ return null;
551
+ } catch (e) {
552
+ return (e as Error).message;
553
+ }
554
+ }
555
+
556
+ /** First parse error among the `{…}` parts of a template, or null. */
557
+ export function checkTemplate(template: string): string | null {
558
+ const open = (template.match(/\{/g) ?? []).length;
559
+ const close = (template.match(/\}/g) ?? []).length;
560
+ if (open !== close) return open > close ? 'Missing }' : 'Unexpected }';
561
+ for (const m of template.matchAll(/\{([^{}]*)\}/g)) {
562
+ const err = checkExpression(m[1]);
563
+ if (err) return `{${m[1]}}: ${err}`;
564
+ }
565
+ return null;
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------------------------
569
+ // Formatting
570
+
571
+ function toDate(v: unknown): Date | null {
572
+ if (v instanceof Date) return v;
573
+ if (typeof v === 'number') return new Date(v);
574
+ if (typeof v === 'string') {
575
+ const ms = /\/Date\((-?\d+)/.exec(v); // OData v2 "/Date(1234567890)/"
576
+ const d = ms ? new Date(Number(ms[1])) : new Date(v);
577
+ return Number.isNaN(d.getTime()) ? null : d;
578
+ }
579
+ return null;
580
+ }
581
+
582
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
583
+
584
+ /** yyyy yy MMM MM M dd d HH H mm ss */
585
+ export function formatDatePattern(d: Date, pattern: string): string {
586
+ const p2 = (x: number) => String(x).padStart(2, '0');
587
+ return pattern.replace(/yyyy|yy|MMM|MM|M|dd|d|HH|H|mm|ss/g, (t) => {
588
+ switch (t) {
589
+ case 'yyyy':
590
+ return String(d.getFullYear());
591
+ case 'yy':
592
+ return p2(d.getFullYear() % 100);
593
+ case 'MMM':
594
+ return MONTHS[d.getMonth()];
595
+ case 'MM':
596
+ return p2(d.getMonth() + 1);
597
+ case 'M':
598
+ return String(d.getMonth() + 1);
599
+ case 'dd':
600
+ return p2(d.getDate());
601
+ case 'd':
602
+ return String(d.getDate());
603
+ case 'HH':
604
+ return p2(d.getHours());
605
+ case 'H':
606
+ return String(d.getHours());
607
+ case 'mm':
608
+ return p2(d.getMinutes());
609
+ default:
610
+ return p2(d.getSeconds());
611
+ }
612
+ });
613
+ }
614
+
615
+ export function formatValue(v: unknown, f: FormatSpec): string {
616
+ if (v == null) return '';
617
+ const d = f.decimals ?? 2;
618
+ try {
619
+ switch (f.kind) {
620
+ case 'number':
621
+ case 'currency':
622
+ case 'percent': {
623
+ const n = toNumber(v);
624
+ if (Number.isNaN(n)) return String(v);
625
+ return n.toLocaleString(undefined, {
626
+ style: f.kind === 'number' ? 'decimal' : f.kind,
627
+ currency: f.kind === 'currency' ? f.currency || 'USD' : undefined,
628
+ minimumFractionDigits: d,
629
+ maximumFractionDigits: d,
630
+ });
631
+ }
632
+ case 'date':
633
+ case 'datetime':
634
+ case 'time': {
635
+ const dt = toDate(v);
636
+ if (!dt) return String(v);
637
+ if (f.kind === 'date') return dt.toLocaleDateString();
638
+ if (f.kind === 'time') return dt.toLocaleTimeString();
639
+ return dt.toLocaleString();
640
+ }
641
+ }
642
+ } catch {
643
+ return String(v);
644
+ }
645
+ if (v instanceof Date) return v.toLocaleString();
646
+ if (typeof v === 'boolean') return v ? 'Yes' : 'No';
647
+ if (typeof v === 'number' && !Number.isInteger(v)) return String(Math.round(v * 1e6) / 1e6);
648
+ if (typeof v === 'object') return JSON.stringify(v);
649
+ return String(v);
650
+ }
651
+
652
+ /** Replaces every `{expr}` in the template with its formatted value. */
653
+ export function renderTemplate(template: string, ctx: EvalContext, format: FormatSpec): string {
654
+ return template.replace(/\{([^{}]+)\}/g, (_, ex: string) => {
655
+ try {
656
+ return formatValue(evaluate(ex, ctx), format);
657
+ } catch {
658
+ return '#ERR';
659
+ }
660
+ });
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------------------------
664
+ // Function catalogue (drives autocomplete and validation)
665
+
666
+ export interface FunctionInfo {
667
+ name: string;
668
+ signature: string;
669
+ description: string;
670
+ group: 'Aggregate' | 'Logic' | 'Text' | 'Math' | 'Date' | 'Format';
671
+ }
672
+
673
+ export const FUNCTIONS: FunctionInfo[] = [
674
+ { name: 'sum', signature: 'sum(expr [, report|page])', description: 'Total over the rows in scope', group: 'Aggregate' },
675
+ { name: 'avg', signature: 'avg(expr [, scope])', description: 'Average', group: 'Aggregate' },
676
+ { name: 'min', signature: 'min(expr [, scope]) | min(a, b)', description: 'Smallest value', group: 'Aggregate' },
677
+ { name: 'max', signature: 'max(expr [, scope]) | max(a, b)', description: 'Largest value', group: 'Aggregate' },
678
+ { name: 'count', signature: 'count([expr] [, scope])', description: 'Number of rows / non-empty values', group: 'Aggregate' },
679
+ { name: 'distinctcount', signature: 'distinctcount(expr)', description: 'Number of different values', group: 'Aggregate' },
680
+ { name: 'first', signature: 'first(expr)', description: 'Value in the first row of the scope', group: 'Aggregate' },
681
+ { name: 'last', signature: 'last(expr)', description: 'Value in the last row of the scope', group: 'Aggregate' },
682
+ { name: 'iif', signature: 'iif(condition, then, else)', description: 'Pick a value by condition', group: 'Logic' },
683
+ { name: 'isnull', signature: 'isnull(value, fallback)', description: 'Fallback when empty', group: 'Logic' },
684
+ { name: 'coalesce', signature: 'coalesce(a, b, …)', description: 'First non-empty value', group: 'Logic' },
685
+ { name: 'isempty', signature: 'isempty(value)', description: 'True when empty', group: 'Logic' },
686
+ { name: 'upper', signature: 'upper(text)', description: 'UPPER CASE', group: 'Text' },
687
+ { name: 'lower', signature: 'lower(text)', description: 'lower case', group: 'Text' },
688
+ { name: 'trim', signature: 'trim(text)', description: 'Remove surrounding spaces', group: 'Text' },
689
+ { name: 'len', signature: 'len(text)', description: 'Length', group: 'Text' },
690
+ { name: 'left', signature: 'left(text, n)', description: 'First n characters', group: 'Text' },
691
+ { name: 'right', signature: 'right(text, n)', description: 'Last n characters', group: 'Text' },
692
+ { name: 'substr', signature: 'substr(text, start, [length])', description: 'Part of text (start at 0)', group: 'Text' },
693
+ { name: 'replace', signature: 'replace(text, find, with)', description: 'Replace all occurrences', group: 'Text' },
694
+ { name: 'concat', signature: 'concat(a, b, …)', description: 'Join text', group: 'Text' },
695
+ { name: 'contains', signature: 'contains(text, part)', description: 'True if text contains part', group: 'Text' },
696
+ { name: 'startswith', signature: 'startswith(text, part)', description: 'True if text starts with part', group: 'Text' },
697
+ { name: 'endswith', signature: 'endswith(text, part)', description: 'True if text ends with part', group: 'Text' },
698
+ { name: 'padleft', signature: 'padleft(text, width, [char])', description: 'Pad on the left, e.g. 00042', group: 'Text' },
699
+ { name: 'tostring', signature: 'tostring(value)', description: 'Convert to text', group: 'Text' },
700
+ { name: 'round', signature: 'round(number, [decimals])', description: 'Round', group: 'Math' },
701
+ { name: 'floor', signature: 'floor(number)', description: 'Round down', group: 'Math' },
702
+ { name: 'ceil', signature: 'ceil(number)', description: 'Round up', group: 'Math' },
703
+ { name: 'abs', signature: 'abs(number)', description: 'Absolute value', group: 'Math' },
704
+ { name: 'sqrt', signature: 'sqrt(number)', description: 'Square root', group: 'Math' },
705
+ { name: 'pow', signature: 'pow(number, power)', description: 'Power', group: 'Math' },
706
+ { name: 'tonumber', signature: 'tonumber(text)', description: 'Convert to number', group: 'Math' },
707
+ { name: 'now', signature: 'now()', description: 'Current date and time', group: 'Date' },
708
+ { name: 'today', signature: 'today()', description: 'Today at 00:00', group: 'Date' },
709
+ { name: 'year', signature: 'year(date)', description: 'Year', group: 'Date' },
710
+ { name: 'month', signature: 'month(date)', description: 'Month 1–12', group: 'Date' },
711
+ { name: 'day', signature: 'day(date)', description: 'Day of month', group: 'Date' },
712
+ { name: 'dateadd', signature: 'dateadd(date, n, "day"|"month"|"year")', description: 'Add to a date', group: 'Date' },
713
+ { name: 'datediff', signature: 'datediff(from, to, "day"|"month"|"year")', description: 'Difference between dates', group: 'Date' },
714
+ { name: 'formatdate', signature: 'formatdate(date, "dd/MM/yyyy")', description: 'Date as text: yyyy MM MMM dd HH mm ss', group: 'Date' },
715
+ { name: 'format', signature: 'format(value, "currency"|"number"|"percent"|"date", [decimals], [currency])', description: 'Format a value', group: 'Format' },
716
+ ];
717
+ const FUNCTIONS_BY_NAME = new Set([...FUNCTIONS.map((f) => f.name), 'if', 'length']);
718
+
719
+ export const SPECIAL_FIELDS = ['PageNumber', 'TotalPages', 'Now', 'Today', 'RowNumber', 'ReportName'];
720
+
721
+ /** Scalar values of a document as root.x paths (arrays skipped) — for the field list and autocomplete. */
722
+ export function documentFields(root: unknown): { name: string; type: string }[] {
723
+ const out: { name: string; type: string }[] = [];
724
+ const visit = (o: unknown, prefix: string, depth: number) => {
725
+ if (!o || typeof o !== 'object' || Array.isArray(o) || depth > 3) return;
726
+ for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
727
+ if (k.startsWith('@') || k.startsWith('__') || Array.isArray(v)) continue;
728
+ if (v && typeof v === 'object') visit(v, prefix + k + '.', depth + 1);
729
+ else out.push({ name: prefix + k, type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'boolean' : 'string' });
730
+ }
731
+ };
732
+ visit(root, 'root.', 0);
733
+ return out;
734
+ }