@olenbetong/appframe-core 2.11.10 → 2.12.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.
- package/dist/esm/af.common.js +1982 -626
- package/dist/esm/af.common.legacy.js +1936 -626
- package/dist/esm/af.common.legacy.min.js +4 -3
- package/dist/esm/af.common.legacy.min.js.map +1 -7
- package/dist/esm/af.common.min.js +4 -3
- package/dist/esm/af.common.min.js.map +1 -7
- package/dist/iife/af.common.js +2140 -708
- package/dist/iife/af.common.legacy.js +2094 -708
- package/dist/iife/af.common.legacy.min.js +4 -3
- package/dist/iife/af.common.legacy.min.js.map +1 -7
- package/dist/iife/af.common.min.js +4 -3
- package/dist/iife/af.common.min.js.map +1 -7
- package/es/alert.js +16 -1
- package/es/filter/convertFilter.d.ts +23 -0
- package/es/filter/convertFilter.js +26 -0
- package/es/filter/describeFilter.d.ts +13 -0
- package/es/filter/describeFilter.js +60 -0
- package/es/filter/describeFilter.test.d.ts +1 -0
- package/es/filter/describeFilter.test.js +68 -0
- package/es/filter/fields.d.ts +28 -0
- package/es/filter/fields.js +37 -0
- package/es/filter/fields.test.d.ts +1 -0
- package/es/filter/fields.test.js +49 -0
- package/es/filter/index.d.ts +8 -0
- package/es/filter/index.js +7 -0
- package/es/filter/operators.d.ts +37 -0
- package/es/filter/operators.js +229 -0
- package/es/filter/operators.test.d.ts +1 -0
- package/es/filter/operators.test.js +106 -0
- package/es/filter/savedFilters.d.ts +111 -0
- package/es/filter/savedFilters.js +118 -0
- package/es/filter/savedFilters.test.d.ts +1 -0
- package/es/filter/savedFilters.test.js +108 -0
- package/es/filter/standardLayout.d.ts +18 -0
- package/es/filter/standardLayout.js +55 -0
- package/es/filter/standardLayout.test.d.ts +1 -0
- package/es/filter/standardLayout.test.js +66 -0
- package/es/filter/tree.d.ts +70 -0
- package/es/filter/tree.js +185 -0
- package/es/filter/tree.test.d.ts +1 -0
- package/es/filter/tree.test.js +184 -0
- package/es/filter/types.d.ts +73 -0
- package/es/filter/types.js +1 -0
- package/es/index.d.ts +2 -0
- package/es/index.js +3 -1
- package/es/parseFunc.test.js +5 -6
- package/es/report/index.d.ts +6 -0
- package/es/report/index.js +5 -0
- package/es/report/parameterDefaults.d.ts +26 -0
- package/es/report/parameterDefaults.js +77 -0
- package/es/report/parameters.d.ts +46 -0
- package/es/report/parameters.js +188 -0
- package/es/report/parameters.test.d.ts +1 -0
- package/es/report/parameters.test.js +161 -0
- package/es/report/recordSource.d.ts +30 -0
- package/es/report/recordSource.js +105 -0
- package/es/report/render.d.ts +81 -0
- package/es/report/render.js +219 -0
- package/es/report/render.test.d.ts +1 -0
- package/es/report/render.test.js +144 -0
- package/es/report/report.d.ts +16 -0
- package/es/report/report.js +50 -0
- package/es/report/report.test.d.ts +1 -0
- package/es/report/report.test.js +86 -0
- package/es/report/types.d.ts +124 -0
- package/es/report/types.js +8 -0
- package/package.json +7 -7
- package/src/alert.ts +16 -1
- package/src/filter/convertFilter.ts +47 -0
- package/src/filter/describeFilter.test.ts +103 -0
- package/src/filter/describeFilter.ts +79 -0
- package/src/filter/fields.test.ts +58 -0
- package/src/filter/fields.ts +56 -0
- package/src/filter/index.ts +62 -0
- package/src/filter/operators.test.ts +136 -0
- package/src/filter/operators.ts +252 -0
- package/src/filter/savedFilters.test.ts +145 -0
- package/src/filter/savedFilters.ts +192 -0
- package/src/filter/standardLayout.test.ts +88 -0
- package/src/filter/standardLayout.ts +65 -0
- package/src/filter/tree.test.ts +258 -0
- package/src/filter/tree.ts +238 -0
- package/src/filter/types.ts +146 -0
- package/src/index.ts +3 -1
- package/src/parseFunc.test.ts +5 -6
- package/src/report/index.ts +37 -0
- package/src/report/parameterDefaults.ts +101 -0
- package/src/report/parameters.test.ts +218 -0
- package/src/report/parameters.ts +230 -0
- package/src/report/recordSource.ts +123 -0
- package/src/report/render.test.ts +230 -0
- package/src/report/render.ts +269 -0
- package/src/report/report.test.ts +104 -0
- package/src/report/report.ts +51 -0
- package/src/report/types.ts +135 -0
package/dist/iife/af.common.js
CHANGED
|
@@ -1,581 +1,1919 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1
|
+
this.af = this.af || {};
|
|
2
|
+
this.af.common = (function(exports) {
|
|
3
|
+
|
|
4
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
5
|
+
//#region ../../node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs
|
|
6
|
+
function mitt_default(n) {
|
|
7
|
+
return {
|
|
8
|
+
all: n = n || /* @__PURE__ */ new Map(),
|
|
9
|
+
on: function(t, e) {
|
|
10
|
+
var i = n.get(t);
|
|
11
|
+
i ? i.push(e) : n.set(t, [e]);
|
|
12
|
+
},
|
|
13
|
+
off: function(t, e) {
|
|
14
|
+
var i = n.get(t);
|
|
15
|
+
i && (e ? i.splice(i.indexOf(e) >>> 0, 1) : n.set(t, []));
|
|
16
|
+
},
|
|
17
|
+
emit: function(t, e) {
|
|
18
|
+
var i = n.get(t);
|
|
19
|
+
i && i.slice().map(function(n) {
|
|
20
|
+
n(e);
|
|
21
|
+
}), (i = n.get("*")) && i.slice().map(function(n) {
|
|
22
|
+
n(t, e);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/typeof.js
|
|
30
|
+
function _typeof(o) {
|
|
31
|
+
"@babel/helpers - typeof";
|
|
32
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
33
|
+
return typeof o;
|
|
34
|
+
} : function(o) {
|
|
35
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
36
|
+
}, _typeof(o);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/toPrimitive.js
|
|
41
|
+
function toPrimitive(t, r) {
|
|
42
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
43
|
+
var e = t[Symbol.toPrimitive];
|
|
44
|
+
if (void 0 !== e) {
|
|
45
|
+
var i = e.call(t, r || "default");
|
|
46
|
+
if ("object" != _typeof(i)) return i;
|
|
47
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
48
|
+
}
|
|
49
|
+
return ("string" === r ? String : Number)(t);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/toPropertyKey.js
|
|
54
|
+
function toPropertyKey(t) {
|
|
55
|
+
var i = toPrimitive(t, "string");
|
|
56
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region \0@oxc-project+runtime@0.143.0/helpers/esm/defineProperty.js
|
|
61
|
+
function _defineProperty(e, r, t) {
|
|
62
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
63
|
+
value: t,
|
|
64
|
+
enumerable: !0,
|
|
65
|
+
configurable: !0,
|
|
66
|
+
writable: !0
|
|
67
|
+
}) : e[r] = t, e;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/SessionHandler.ts
|
|
72
|
+
var SessionHandler = class {
|
|
73
|
+
constructor() {
|
|
74
|
+
_defineProperty(this, "eventHandler", mitt_default());
|
|
75
|
+
}
|
|
76
|
+
authenticated() {
|
|
77
|
+
this.eventHandler.emit("onAuthenticated");
|
|
78
|
+
}
|
|
79
|
+
async expired(callback, type) {
|
|
80
|
+
try {
|
|
81
|
+
let authPromise = new Promise((resolve) => {
|
|
82
|
+
let eventHandler = () => {
|
|
83
|
+
this.eventHandler.off("onAuthenticated", eventHandler);
|
|
84
|
+
callback?.();
|
|
85
|
+
resolve();
|
|
86
|
+
};
|
|
87
|
+
this.eventHandler.on("onAuthenticated", eventHandler);
|
|
88
|
+
});
|
|
89
|
+
let dialog = document.querySelector("dialog.session-expired-dialog");
|
|
90
|
+
if (!dialog) {
|
|
91
|
+
dialog = document.createElement("dialog");
|
|
92
|
+
dialog.className = "session-expired-dialog";
|
|
93
|
+
dialog.style.display = "flex";
|
|
94
|
+
dialog.style.padding = "0";
|
|
95
|
+
dialog.style.left = "50%";
|
|
96
|
+
dialog.style.transform = "translateX(-50%)";
|
|
97
|
+
let link = "/login?ReturnUrl=%2fapi/sessionexpired/remove";
|
|
98
|
+
if (type !== "RequireTwoFactor") await fetch("/logout", { method: "POST" });
|
|
99
|
+
else link = "/login?RequireTwoFactor=1&ReturnUrl=%2fapi/sessionexpired/remove";
|
|
100
|
+
dialog.innerHTML = `<iframe sandbox="allow-same-origin allow-scripts allow-modals allow-top-navigation-by-user-activation allow-forms" src="${link}" style="border:0;width:min(500px,100vw);height:min(700px, 100vh);"></iframe>`;
|
|
101
|
+
document.body.append(dialog);
|
|
102
|
+
dialog.showModal();
|
|
103
|
+
await authPromise;
|
|
104
|
+
dialog.remove();
|
|
105
|
+
} else await authPromise;
|
|
106
|
+
} catch (_) {
|
|
107
|
+
window.location.href = `/login?ReturnUrl=${encodeURIComponent(window.location.pathname)}${encodeURIComponent(window.location.search)}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
attachEvent(eventName, handler) {
|
|
111
|
+
return this.eventHandler.on(eventName, handler);
|
|
112
|
+
}
|
|
113
|
+
detachEvent(eventName, handler) {
|
|
114
|
+
return this.eventHandler.off(eventName, handler);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/UserSession.ts
|
|
120
|
+
var UserSession = class extends SessionHandler {
|
|
121
|
+
constructor(userData) {
|
|
122
|
+
super();
|
|
123
|
+
_defineProperty(this, "amDesignator", "a.m.");
|
|
124
|
+
_defineProperty(this, "dayNames", [
|
|
125
|
+
"sunday",
|
|
126
|
+
"monday",
|
|
127
|
+
"tuesday",
|
|
128
|
+
"wednesday",
|
|
129
|
+
"thursday",
|
|
130
|
+
"friday",
|
|
131
|
+
"saturday"
|
|
132
|
+
]);
|
|
133
|
+
_defineProperty(this, "dayNamesShort", [
|
|
134
|
+
"sun",
|
|
135
|
+
"mon",
|
|
136
|
+
"tue",
|
|
137
|
+
"wed",
|
|
138
|
+
"thu",
|
|
139
|
+
"fri",
|
|
140
|
+
"sat"
|
|
141
|
+
]);
|
|
142
|
+
_defineProperty(this, "decimalSeparator", ".");
|
|
143
|
+
_defineProperty(this, "domain", "OB");
|
|
144
|
+
_defineProperty(this, "email", void 0);
|
|
145
|
+
_defineProperty(this, "formats", void 0);
|
|
146
|
+
_defineProperty(this, "groupSeparator", null);
|
|
147
|
+
_defineProperty(this, "integratedSecurity", false);
|
|
148
|
+
_defineProperty(this, "isDeveloper", false);
|
|
149
|
+
_defineProperty(this, "login", "");
|
|
150
|
+
_defineProperty(this, "loginType", void 0);
|
|
151
|
+
_defineProperty(this, "monthNames", [
|
|
152
|
+
"january",
|
|
153
|
+
"february",
|
|
154
|
+
"march",
|
|
155
|
+
"april",
|
|
156
|
+
"may",
|
|
157
|
+
"june",
|
|
158
|
+
"july",
|
|
159
|
+
"august",
|
|
160
|
+
"september",
|
|
161
|
+
"october",
|
|
162
|
+
"november",
|
|
163
|
+
"december",
|
|
164
|
+
""
|
|
165
|
+
]);
|
|
166
|
+
_defineProperty(this, "monthNamesShort", [
|
|
167
|
+
"jan",
|
|
168
|
+
"feb",
|
|
169
|
+
"mar",
|
|
170
|
+
"apr",
|
|
171
|
+
"may",
|
|
172
|
+
"jun",
|
|
173
|
+
"jul",
|
|
174
|
+
"augt",
|
|
175
|
+
"sep",
|
|
176
|
+
"oct",
|
|
177
|
+
"nov",
|
|
178
|
+
"dec",
|
|
179
|
+
""
|
|
180
|
+
]);
|
|
181
|
+
_defineProperty(this, "name", void 0);
|
|
182
|
+
_defineProperty(this, "pmDesignator", "p.m.");
|
|
183
|
+
_defineProperty(this, "shortDateFormat", "dd.MM.yyyy");
|
|
184
|
+
_defineProperty(this, "culture", "en-GB");
|
|
185
|
+
_defineProperty(this, "uiculture", "en-GB");
|
|
186
|
+
this.formats = {};
|
|
187
|
+
Object.assign(this, userData);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/log.ts
|
|
193
|
+
const article = window.af?.article?.id ?? window.window?.location.pathname.substring(1).split("/")[0];
|
|
194
|
+
/**
|
|
195
|
+
* Logs an activity to the website activity log
|
|
196
|
+
*
|
|
197
|
+
* @param activity name of the activity we are logging
|
|
198
|
+
* @param parameters A dictionary of parameters used when doing the activity
|
|
199
|
+
* @param timeElapsed Number of milliseconds elapsed while executing
|
|
200
|
+
*/
|
|
201
|
+
async function logActivity(activity, parameters = {}, timeElapsed = 0) {
|
|
202
|
+
try {
|
|
203
|
+
let response = await fetch("/api/logging/log-activity", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
article,
|
|
207
|
+
timeElapsed,
|
|
208
|
+
action: activity,
|
|
209
|
+
parameters: {
|
|
210
|
+
...parameters,
|
|
211
|
+
_session: getSessionData()
|
|
212
|
+
}
|
|
213
|
+
}),
|
|
214
|
+
headers: {
|
|
215
|
+
"Content-Type": "application/json",
|
|
216
|
+
Accept: "application/json"
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
if (response.headers.get("Content-Type")?.includes("application/json")) {
|
|
221
|
+
let data = await response.json();
|
|
222
|
+
console.error(data.error);
|
|
223
|
+
} else console.error(`${response.status} ${response.statusText}`);
|
|
224
|
+
}
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Records the current time, and returns a function that logs the activity with the
|
|
229
|
+
* time elapsed when it is called.
|
|
230
|
+
*
|
|
231
|
+
* @param activity Name of the activity we are logging
|
|
232
|
+
* @param parameters A dictionary of parameters used when doing the activity
|
|
233
|
+
* @returns A function that should be called when the activity is completed to log it to the website log
|
|
234
|
+
*/
|
|
235
|
+
function startActivity(activity, startParameters = {}) {
|
|
236
|
+
let start = performance.now();
|
|
237
|
+
return (endParameters = {}) => logActivity(activity, {
|
|
238
|
+
...startParameters,
|
|
239
|
+
...endParameters
|
|
240
|
+
}, performance.now() - start);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Records the time used to execute an operation and returns the value of the operation and the time
|
|
244
|
+
* in milliseconds
|
|
245
|
+
*/
|
|
246
|
+
async function time(callback) {
|
|
247
|
+
let start = performance.now();
|
|
248
|
+
return [await callback(), performance.now() - start];
|
|
249
|
+
}
|
|
250
|
+
async function logError(error) {
|
|
251
|
+
try {
|
|
252
|
+
if (!error) return;
|
|
253
|
+
let data = typeof error === "string" ? { error } : typeof error === "object" ? "message" in error ? {
|
|
254
|
+
error: error.message,
|
|
255
|
+
stack: error.stack ?? null
|
|
256
|
+
} : { error: error.toString() } : { error: "Unspecified error" };
|
|
257
|
+
console.error(error);
|
|
258
|
+
let result = await fetch("/data/Logger/LogError", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
body: JSON.stringify(data),
|
|
261
|
+
headers: { "Content-Type": "application/json; charset=utf-8" }
|
|
262
|
+
});
|
|
263
|
+
if (result.ok) return await result.json();
|
|
264
|
+
console.error(Error("Failed to log error"));
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
function getSessionId() {
|
|
268
|
+
return crypto.randomUUID?.();
|
|
269
|
+
}
|
|
270
|
+
let sessionStart = Date.now();
|
|
271
|
+
let sessionId = getSessionId();
|
|
272
|
+
let deviceId = "";
|
|
273
|
+
let browserSessionId = "";
|
|
274
|
+
if ("document" in window) {
|
|
275
|
+
let startSession = () => logActivity("AfSessionInit", {
|
|
276
|
+
"appframe-core": window.af?.common?.version,
|
|
277
|
+
"appframe-data": window.af?.data?.version,
|
|
278
|
+
"app-version": window.af?.article?.version
|
|
279
|
+
});
|
|
280
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
281
|
+
deviceId = localStorage.getItem("_afDeviceId");
|
|
282
|
+
browserSessionId = sessionStorage.getItem("_afSessionId");
|
|
283
|
+
if (!deviceId) {
|
|
284
|
+
deviceId = getSessionId();
|
|
285
|
+
localStorage.setItem("_afDeviceId", deviceId);
|
|
286
|
+
}
|
|
287
|
+
if (!browserSessionId) {
|
|
288
|
+
browserSessionId = sessionId;
|
|
289
|
+
sessionStorage.setItem("_afSessionId", browserSessionId);
|
|
290
|
+
}
|
|
291
|
+
startSession();
|
|
292
|
+
});
|
|
293
|
+
document.addEventListener("visibilitychange", function logData() {
|
|
294
|
+
if (document.visibilityState === "hidden") navigator.sendBeacon("/api/logging/log-activity", JSON.stringify({
|
|
295
|
+
article,
|
|
296
|
+
action: "AfSessionEnd",
|
|
297
|
+
parameters: { _session: getSessionData() }
|
|
298
|
+
}));
|
|
299
|
+
else startSession();
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function getSessionData() {
|
|
303
|
+
return {
|
|
304
|
+
browserSessionId,
|
|
305
|
+
sessionStart,
|
|
306
|
+
sessionLength: Date.now() - sessionStart,
|
|
307
|
+
sessionId,
|
|
308
|
+
deviceId
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/errorHandler.ts
|
|
314
|
+
window.window?.addEventListener("error", (event) => {
|
|
315
|
+
if (typeof event === "object" && "error" in event) logError(event.error);
|
|
316
|
+
else if (typeof event === "object" && "message" in event) logError(event);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/localStorage.ts
|
|
321
|
+
function getLocalKey(key) {
|
|
322
|
+
return [
|
|
323
|
+
af?.article?.id || "",
|
|
324
|
+
"common",
|
|
325
|
+
key
|
|
326
|
+
].join(".");
|
|
327
|
+
}
|
|
328
|
+
var LocalStorage = class {
|
|
329
|
+
constructor() {
|
|
330
|
+
_defineProperty(this, "isPersisted", () => true);
|
|
331
|
+
}
|
|
332
|
+
getLocalStorageKey(key, global) {
|
|
333
|
+
return global ? ["common", key].join(".") : getLocalKey(key);
|
|
334
|
+
}
|
|
335
|
+
get(key, defaultValue, global) {
|
|
336
|
+
const lsKey = this.getLocalStorageKey(key, global);
|
|
337
|
+
const value = localStorage.getItem(lsKey);
|
|
338
|
+
return value !== null ? JSON.parse(value) : defaultValue;
|
|
339
|
+
}
|
|
340
|
+
set(key, value, global) {
|
|
341
|
+
localStorage.setItem(this.getLocalStorageKey(key, global), JSON.stringify(value));
|
|
342
|
+
}
|
|
343
|
+
remove(key, global) {
|
|
344
|
+
const lsKey = this.getLocalStorageKey(key, global);
|
|
345
|
+
if (localStorage.getItem(lsKey) !== null) {
|
|
346
|
+
localStorage.removeItem(lsKey);
|
|
347
|
+
return true;
|
|
348
|
+
} else return false;
|
|
349
|
+
}
|
|
350
|
+
clear() {
|
|
351
|
+
const baseKey = getLocalKey("");
|
|
352
|
+
for (let key in localStorage) if (key.slice(0, baseKey.length) === baseKey) localStorage.removeItem(key);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
var localStorage_default = new LocalStorage();
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/date.ts
|
|
359
|
+
function toLocalISODate(date) {
|
|
360
|
+
return date ? toLocalISODateTime(date).substring(0, 10) : null;
|
|
361
|
+
}
|
|
362
|
+
function pad(num, digits = 2) {
|
|
363
|
+
return String(num).padStart(digits, "0");
|
|
364
|
+
}
|
|
365
|
+
function toLocalISODateTime(date) {
|
|
366
|
+
return date ? `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` : null;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Converts a date object to a string compatible with input type datetime-local.
|
|
370
|
+
* Will handle the UTC value of the date as if it was local, to be compatible with
|
|
371
|
+
* data objects.
|
|
372
|
+
*
|
|
373
|
+
* If null or undefined is passed, returns an empty string.
|
|
374
|
+
*
|
|
375
|
+
* @param date
|
|
376
|
+
*/
|
|
377
|
+
function toLocalDateTime(date) {
|
|
378
|
+
return date ? date.toISOString().substring(0, 16) : "";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/downloadBlob.ts
|
|
383
|
+
function downloadBlob(blob, fileName) {
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
try {
|
|
386
|
+
const url = URL.createObjectURL(blob);
|
|
387
|
+
const parent = document.body;
|
|
388
|
+
const anchor = document.createElement("a");
|
|
389
|
+
anchor.setAttribute("download", fileName);
|
|
390
|
+
anchor.href = url;
|
|
391
|
+
parent.append(anchor);
|
|
392
|
+
anchor.click();
|
|
393
|
+
parent.removeChild(anchor);
|
|
394
|
+
setTimeout(() => {
|
|
395
|
+
URL.revokeObjectURL(url);
|
|
396
|
+
resolve();
|
|
397
|
+
}, 250);
|
|
398
|
+
} catch (ex) {
|
|
399
|
+
reject(ex);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/invariant.ts
|
|
406
|
+
/**
|
|
407
|
+
* Throws an error if the condition is falsy. The provided message is used as the error message.
|
|
408
|
+
* A function can be provided to lazily generate the message.
|
|
409
|
+
*
|
|
410
|
+
* @param {any} condition
|
|
411
|
+
* @param {string | (() => string)} [message]
|
|
412
|
+
* @returns {void}
|
|
413
|
+
*/
|
|
414
|
+
function invariant(condition, message) {
|
|
415
|
+
if (condition) return;
|
|
416
|
+
const provided = typeof message === "function" ? message() : message;
|
|
417
|
+
throw new Error(provided ?? "Invariant failed");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/downloadRequest.ts
|
|
422
|
+
async function downloadRequest(request, { fileName, signal, onProgress }) {
|
|
423
|
+
let bytesReceived = 0;
|
|
424
|
+
onProgress?.({ status: "requested" });
|
|
425
|
+
try {
|
|
426
|
+
let response = await fetch(request);
|
|
427
|
+
if (!response.ok || !response.body) {
|
|
428
|
+
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
429
|
+
let data = await response.json();
|
|
430
|
+
invariant(!data.error, data.error);
|
|
431
|
+
}
|
|
432
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const reader = response.body.getReader();
|
|
436
|
+
const stream = new ReadableStream({ start: (controller) => {
|
|
437
|
+
function pump() {
|
|
438
|
+
return reader.read().then((read) => {
|
|
439
|
+
if (read.done) {
|
|
440
|
+
controller.close();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
bytesReceived += read.value.length;
|
|
444
|
+
onProgress?.({
|
|
445
|
+
bytesReceived,
|
|
446
|
+
status: "downloading"
|
|
447
|
+
});
|
|
448
|
+
controller.enqueue(read.value);
|
|
449
|
+
return pump();
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return pump();
|
|
453
|
+
} });
|
|
454
|
+
response = new Response(stream);
|
|
455
|
+
} catch {}
|
|
456
|
+
const blob = await response.blob();
|
|
457
|
+
onProgress?.({
|
|
458
|
+
bytesReceived,
|
|
459
|
+
status: "downloaded"
|
|
460
|
+
});
|
|
461
|
+
await downloadBlob(blob, fileName);
|
|
462
|
+
onProgress?.({
|
|
463
|
+
bytesReceived,
|
|
464
|
+
status: "completed"
|
|
465
|
+
});
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (signal?.aborted) onProgress?.({ status: "cancelled" });
|
|
468
|
+
else onProgress?.({
|
|
469
|
+
error: err.message,
|
|
470
|
+
status: "error"
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/parseFunc.ts
|
|
477
|
+
const funcRegex = /^function(?:\s+([a-zA-Z$_][a-zA-Z0-9$_]*))?\s*\(((?:(?:\s*[a-zA-Z$_][a-zA-Z0-9$_]*)(?:\s*,\s*[a-zA-Z$_][a-zA-Z0-9$_]*)*)?)\s*\)\s*\{((?:.|\n|\r)*)\}$/;
|
|
478
|
+
const arrowBlockRegex = /^(?:\(([^)]*)\)|([a-zA-Z$_][a-zA-Z0-9$_]*))\s*=>\s*\{([\s\S]*)\}$/;
|
|
479
|
+
const arrowExpressionRegex = /^(?:\(([^)]*)\)|([a-zA-Z$_][a-zA-Z0-9$_]*))\s*=>\s*([\s\S]+)$/;
|
|
480
|
+
const parseArguments = (args) => args.split(",").map((a) => a.trim()).filter(Boolean);
|
|
481
|
+
function parseFunc(func) {
|
|
482
|
+
const funcString = func.toString();
|
|
483
|
+
const arrowBlockMatch = funcString.match(arrowBlockRegex);
|
|
484
|
+
if (arrowBlockMatch) {
|
|
485
|
+
const args = parseArguments(arrowBlockMatch[1] ?? arrowBlockMatch[2] ?? "");
|
|
486
|
+
return {
|
|
487
|
+
name: func.name,
|
|
488
|
+
args,
|
|
489
|
+
body: arrowBlockMatch[3]
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const arrowExpressionMatch = funcString.match(arrowExpressionRegex);
|
|
493
|
+
if (arrowExpressionMatch) {
|
|
494
|
+
const args = parseArguments(arrowExpressionMatch[1] ?? arrowExpressionMatch[2] ?? "");
|
|
495
|
+
return {
|
|
496
|
+
name: func.name,
|
|
497
|
+
args,
|
|
498
|
+
body: arrowExpressionMatch[3]
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const match = funcString.match(funcRegex);
|
|
502
|
+
invariant(match, "Unable to parse function!");
|
|
503
|
+
return {
|
|
504
|
+
name: func.name ?? match[1],
|
|
505
|
+
args: parseArguments(match[2]),
|
|
506
|
+
body: match[3]
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/Namespace.ts
|
|
512
|
+
function Namespace() {}
|
|
513
|
+
Namespace.prototype = Object.create(null);
|
|
514
|
+
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/requireNamespace.ts
|
|
517
|
+
function requireNamespace(root, namespace) {
|
|
518
|
+
if (typeof root === "object") {
|
|
519
|
+
if (typeof namespace === "string") return requireNamespace(root, namespace.split("."));
|
|
520
|
+
else if (Array.isArray(namespace)) {
|
|
521
|
+
let current = root;
|
|
522
|
+
for (let i = 0; i < namespace.length; i++) current = current[namespace[i]] = current[namespace[i]] || new Namespace();
|
|
523
|
+
return current;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/expose.ts
|
|
530
|
+
/**
|
|
531
|
+
* Expose a value to a potentially deeply nested global variable
|
|
532
|
+
* @param name {string | object} Either a dot-separated path to the global variable, or an object to expose the value on
|
|
533
|
+
* @param value {object | Function | Function[]} The value(s) to expose
|
|
534
|
+
*/
|
|
535
|
+
function expose(name, value, options = {}) {
|
|
536
|
+
if (Array.isArray(value)) for (let func of value) {
|
|
537
|
+
const { name: funcName } = parseFunc(func);
|
|
538
|
+
invariant(funcName, "Unable to detect name of function! Name it, or only supply one function and include name in first parameter.");
|
|
539
|
+
expose(name, func);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
let keyName;
|
|
543
|
+
let namespace;
|
|
544
|
+
if (typeof value === "function") keyName = parseFunc(value).name;
|
|
545
|
+
if (typeof name === "string") {
|
|
546
|
+
let pathParts = name.split(".");
|
|
547
|
+
if (!keyName) {
|
|
548
|
+
keyName = pathParts[pathParts.length - 1];
|
|
549
|
+
pathParts = pathParts.slice(0, pathParts.length - 1);
|
|
550
|
+
}
|
|
551
|
+
namespace = requireNamespace(window, pathParts);
|
|
552
|
+
} else if (keyName && (typeof name === "object" || typeof name === "function")) namespace = name;
|
|
553
|
+
else invariant(false, "Invalid typeof parameter 'name', or unable to parse pValue!");
|
|
554
|
+
if (Object.hasOwn(namespace, keyName) && options?.overwrite !== true) console.warn(`${[name, keyName].join(".")} is already defined!`);
|
|
555
|
+
else namespace[keyName] = value;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/getLocalizedString.ts
|
|
561
|
+
async function fetchLocalizedString(text) {
|
|
562
|
+
try {
|
|
563
|
+
let response = await fetch(`/api/user/localize/new/${af.article.id}`, {
|
|
564
|
+
body: JSON.stringify({ text }),
|
|
565
|
+
headers: {
|
|
566
|
+
Accept: "application/json; */*",
|
|
567
|
+
"Content-Type": "application/json",
|
|
568
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
569
|
+
},
|
|
570
|
+
method: "POST"
|
|
571
|
+
});
|
|
572
|
+
if (response.ok) return await response.json();
|
|
573
|
+
} catch {}
|
|
574
|
+
return text;
|
|
575
|
+
}
|
|
576
|
+
function getLocalizedString(string, wait) {
|
|
577
|
+
if (typeof string === "string" && string.trim().length === 0) return string;
|
|
578
|
+
if (string === void 0 || string === "" || string === null) return;
|
|
579
|
+
if (window.af?.article?.i18n !== void 0) {
|
|
580
|
+
if (!Object.hasOwn(af.article.i18n, string)) {
|
|
581
|
+
af.article.i18n[string] = string;
|
|
582
|
+
let promise = fetchLocalizedString(string).then((text) => {
|
|
583
|
+
af.article.i18n[string] = text;
|
|
584
|
+
return text;
|
|
585
|
+
});
|
|
586
|
+
if (wait) return promise;
|
|
587
|
+
}
|
|
588
|
+
return af.article.i18n[string];
|
|
589
|
+
} else return string;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/filter/operators.ts
|
|
594
|
+
/**
|
|
595
|
+
* Operators that take no value at all.
|
|
596
|
+
*
|
|
597
|
+
* `eOperators.ParameterLessChecks` plus the blank checks.
|
|
598
|
+
*/
|
|
599
|
+
const PARAMETERLESS_OPERATORS = [
|
|
600
|
+
"isnull",
|
|
601
|
+
"isnotnull",
|
|
602
|
+
"istrue",
|
|
603
|
+
"isfalse",
|
|
604
|
+
"isblank",
|
|
605
|
+
"isnotblank"
|
|
606
|
+
];
|
|
607
|
+
/** Operators whose value is a two element `[from, to]` array. */
|
|
608
|
+
const PAIR_OPERATORS = [
|
|
609
|
+
"between",
|
|
610
|
+
"notbetween",
|
|
611
|
+
"datebetween",
|
|
612
|
+
"datenotbetween"
|
|
613
|
+
];
|
|
614
|
+
/** Operators whose value is an array of values. */
|
|
615
|
+
const LIST_OPERATORS = ["inlist", "notinlist"];
|
|
616
|
+
function getOperatorArity(operator) {
|
|
617
|
+
if (PARAMETERLESS_OPERATORS.includes(operator)) return "none";
|
|
618
|
+
if (PAIR_OPERATORS.includes(operator)) return "pair";
|
|
619
|
+
if (LIST_OPERATORS.includes(operator)) return "list";
|
|
620
|
+
return "single";
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Operator labels, keyed by operator. Comparison operators have a different
|
|
624
|
+
* label for dates than for numbers, matching the Windows filter editor.
|
|
625
|
+
*
|
|
626
|
+
* @see `appframe/Appframe3.Win/Controls/afFilterEditor/Utils/FilterLineControlUtils.vb`
|
|
627
|
+
*/
|
|
628
|
+
const OPERATOR_LABELS = {
|
|
629
|
+
equals: "Equal to",
|
|
630
|
+
notequals: "Not equal to",
|
|
631
|
+
greaterthan: "Greater than",
|
|
632
|
+
greaterthanorequal: "Greater than or equal to",
|
|
633
|
+
lessthan: "Less than",
|
|
634
|
+
lessthanorequal: "Less than or equal to",
|
|
635
|
+
beginswith: "Begins with",
|
|
636
|
+
notbeginswith: "Does not begin with",
|
|
637
|
+
endswith: "Ends with",
|
|
638
|
+
notendswith: "Does not end with",
|
|
639
|
+
contains: "Contains",
|
|
640
|
+
notcontains: "Does not contain",
|
|
641
|
+
like: "Like",
|
|
642
|
+
notlike: "Not like",
|
|
643
|
+
isnull: "Is null",
|
|
644
|
+
isnotnull: "Is not null",
|
|
645
|
+
isblank: "Is empty",
|
|
646
|
+
isnotblank: "Is not empty",
|
|
647
|
+
istrue: "Is true",
|
|
648
|
+
isfalse: "Is false",
|
|
649
|
+
inlist: "In list",
|
|
650
|
+
notinlist: "Not in list",
|
|
651
|
+
between: "Between",
|
|
652
|
+
notbetween: "Not between",
|
|
653
|
+
dateequals: "Equal to",
|
|
654
|
+
notdatequals: "Not equal to",
|
|
655
|
+
dategreaterthan: "After",
|
|
656
|
+
dategreaterthanorequal: "On or after",
|
|
657
|
+
datelessthan: "Before",
|
|
658
|
+
datelessthanorequal: "Up to",
|
|
659
|
+
datebetween: "Between",
|
|
660
|
+
datenotbetween: "Not between"
|
|
661
|
+
};
|
|
662
|
+
/** Comparison operator labels used for date and time fields. */
|
|
663
|
+
const DATE_OPERATOR_LABELS = {
|
|
664
|
+
greaterthan: "After",
|
|
665
|
+
greaterthanorequal: "On or after",
|
|
666
|
+
lessthan: "Before",
|
|
667
|
+
lessthanorequal: "Up to"
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Returns the localized label for an operator. Date fields get the
|
|
671
|
+
* "After"/"Before" wording instead of "Greater than"/"Less than".
|
|
672
|
+
*/
|
|
673
|
+
function getOperatorLabel(operator, fieldType) {
|
|
674
|
+
let label = (fieldType === "date" || fieldType === "datetime" ? DATE_OPERATOR_LABELS[operator] : void 0) ?? OPERATOR_LABELS[operator];
|
|
675
|
+
return getLocalizedString(label ?? operator);
|
|
676
|
+
}
|
|
677
|
+
const EQUALITY = ["equals", "notequals"];
|
|
678
|
+
const LIST_CHECKS = ["inlist", "notinlist"];
|
|
679
|
+
const RANGE = ["between", "notbetween"];
|
|
680
|
+
const COMPARISONS = [
|
|
681
|
+
"greaterthan",
|
|
682
|
+
"greaterthanorequal",
|
|
683
|
+
"lessthan",
|
|
684
|
+
"lessthanorequal"
|
|
685
|
+
];
|
|
686
|
+
const STRING_CHECKS = [
|
|
687
|
+
"contains",
|
|
688
|
+
"notcontains",
|
|
689
|
+
"beginswith",
|
|
690
|
+
"notbeginswith",
|
|
691
|
+
"endswith",
|
|
692
|
+
"notendswith",
|
|
693
|
+
"isblank",
|
|
694
|
+
"isnotblank"
|
|
695
|
+
];
|
|
696
|
+
/**
|
|
697
|
+
* Operators offered per field type, in the order the Windows filter editor
|
|
698
|
+
* presents them.
|
|
699
|
+
*/
|
|
700
|
+
const OPERATORS_BY_TYPE = {
|
|
701
|
+
date: [
|
|
702
|
+
...EQUALITY,
|
|
703
|
+
...LIST_CHECKS,
|
|
704
|
+
...RANGE,
|
|
705
|
+
...COMPARISONS
|
|
706
|
+
],
|
|
707
|
+
datetime: [
|
|
708
|
+
...EQUALITY,
|
|
709
|
+
...LIST_CHECKS,
|
|
710
|
+
...RANGE,
|
|
711
|
+
...COMPARISONS
|
|
712
|
+
],
|
|
713
|
+
number: [
|
|
714
|
+
...EQUALITY,
|
|
715
|
+
...LIST_CHECKS,
|
|
716
|
+
...RANGE,
|
|
717
|
+
...COMPARISONS
|
|
718
|
+
],
|
|
719
|
+
boolean: ["istrue", "isfalse"],
|
|
720
|
+
string: [
|
|
721
|
+
...EQUALITY,
|
|
722
|
+
...STRING_CHECKS,
|
|
723
|
+
...LIST_CHECKS,
|
|
724
|
+
...RANGE,
|
|
725
|
+
...COMPARISONS
|
|
726
|
+
],
|
|
727
|
+
uniqueidentifier: [
|
|
728
|
+
...EQUALITY,
|
|
729
|
+
...STRING_CHECKS,
|
|
730
|
+
...LIST_CHECKS,
|
|
731
|
+
...RANGE,
|
|
732
|
+
...COMPARISONS
|
|
733
|
+
]
|
|
734
|
+
};
|
|
735
|
+
/** Default operator per field type. */
|
|
736
|
+
const DEFAULT_OPERATOR_BY_TYPE = {
|
|
737
|
+
date: "equals",
|
|
738
|
+
datetime: "equals",
|
|
739
|
+
number: "equals",
|
|
740
|
+
boolean: "istrue",
|
|
741
|
+
string: "beginswith",
|
|
742
|
+
uniqueidentifier: "beginswith"
|
|
743
|
+
};
|
|
744
|
+
/**
|
|
745
|
+
* Returns the operators available for a field. Nullable fields additionally get
|
|
746
|
+
* the null checks. Boolean fields never get null checks, since `istrue` and
|
|
747
|
+
* `isfalse` already cover the parameterless cases.
|
|
748
|
+
*/
|
|
749
|
+
function getOperatorsForField(field) {
|
|
750
|
+
let operators = [...OPERATORS_BY_TYPE[field.type] ?? OPERATORS_BY_TYPE.string];
|
|
751
|
+
if (field.nullable && field.type !== "boolean") operators.push("isnull", "isnotnull");
|
|
752
|
+
return operators;
|
|
753
|
+
}
|
|
754
|
+
function getDefaultOperator(field) {
|
|
755
|
+
return DEFAULT_OPERATOR_BY_TYPE[field.type] ?? "equals";
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The `valueType` to send for a field/operator combination.
|
|
759
|
+
*
|
|
760
|
+
* Parameterless operators send `"null"`, list and range operators send
|
|
761
|
+
* `"array"`, everything else sends the field's own value type.
|
|
762
|
+
*/
|
|
763
|
+
function getValueType(field, operator) {
|
|
764
|
+
if (getOperatorArity(operator) === "none") return "null";
|
|
765
|
+
switch (field.type) {
|
|
766
|
+
case "number": return "number";
|
|
767
|
+
case "boolean": return "boolean";
|
|
768
|
+
case "date":
|
|
769
|
+
case "datetime": return "datetime";
|
|
770
|
+
case "uniqueidentifier": return "uniqueidentifier";
|
|
771
|
+
default: return "string";
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/** Relative date values offered in the date value editor, in menu order. */
|
|
775
|
+
const RELATIVE_DATE_VALUES = [
|
|
776
|
+
"Yesterday",
|
|
777
|
+
"Today",
|
|
778
|
+
"Tomorrow",
|
|
779
|
+
"ThisWeek",
|
|
780
|
+
"ThisMonth",
|
|
781
|
+
"ThisYear",
|
|
782
|
+
"LastWeek",
|
|
783
|
+
"LastMonth",
|
|
784
|
+
"LastYear",
|
|
785
|
+
"NextWeek",
|
|
786
|
+
"NextMonth",
|
|
787
|
+
"NextYear"
|
|
788
|
+
];
|
|
789
|
+
const DYNAMIC_VALUE_LABELS = {
|
|
790
|
+
CurrentUser: "Current user",
|
|
791
|
+
Yesterday: "Yesterday",
|
|
792
|
+
Today: "Today",
|
|
793
|
+
Tomorrow: "Tomorrow",
|
|
794
|
+
ThisWeek: "This week",
|
|
795
|
+
ThisMonth: "This month",
|
|
796
|
+
ThisYear: "This year",
|
|
797
|
+
LastWeek: "Last week",
|
|
798
|
+
LastMonth: "Last month",
|
|
799
|
+
LastYear: "Last year",
|
|
800
|
+
NextWeek: "Next week",
|
|
801
|
+
NextMonth: "Next month",
|
|
802
|
+
NextYear: "Next year",
|
|
803
|
+
UserYesterday: "Yesterday",
|
|
804
|
+
UserToday: "Today",
|
|
805
|
+
UserTomorrow: "Tomorrow",
|
|
806
|
+
UserThisWeek: "This week",
|
|
807
|
+
UserThisMonth: "This month",
|
|
808
|
+
UserThisYear: "This year",
|
|
809
|
+
UserLastWeek: "Last week",
|
|
810
|
+
UserLastMonth: "Last month",
|
|
811
|
+
UserLastYear: "Last year",
|
|
812
|
+
UserNextWeek: "Next week",
|
|
813
|
+
UserNextMonth: "Next month",
|
|
814
|
+
UserNextYear: "Next year"
|
|
815
|
+
};
|
|
816
|
+
function getDynamicValueLabel(value) {
|
|
817
|
+
return getLocalizedString(DYNAMIC_VALUE_LABELS[value] ?? value);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Returns the dynamic ("special") values that can be used with a field. Date
|
|
821
|
+
* fields get the relative dates; string fields can be compared to the current
|
|
822
|
+
* user name.
|
|
823
|
+
*/
|
|
824
|
+
function getDynamicValuesForField(field) {
|
|
825
|
+
if (field.type === "date" || field.type === "datetime") return [...RELATIVE_DATE_VALUES];
|
|
826
|
+
if (field.type === "string") return ["CurrentUser"];
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Aliases for names `eOperators.ToString()` can produce that are not operators
|
|
831
|
+
* in their own right. `Not` is the bare negation flag (i.e. "not equal to"),
|
|
832
|
+
* `Tweens` shares its numeric value with `NotBetween`, and combined flags are
|
|
833
|
+
* rendered as a comma separated list.
|
|
834
|
+
*
|
|
835
|
+
* @see `FilterElementConverter.FilterExpressionToDictionary`
|
|
836
|
+
*/
|
|
837
|
+
const OPERATOR_ALIASES = {
|
|
838
|
+
not: "notequals",
|
|
839
|
+
tweens: "notbetween",
|
|
840
|
+
notdate: "notdatequals",
|
|
841
|
+
notdateequals: "notdatequals"
|
|
842
|
+
};
|
|
843
|
+
/**
|
|
844
|
+
* Normalizes an operator string coming from the server or from another filter
|
|
845
|
+
* producer (`AfGrid` emits PascalCase names) to the canonical lower-case form.
|
|
846
|
+
*/
|
|
847
|
+
function normalizeOperator(operator) {
|
|
848
|
+
let normalized = operator.toLowerCase().replace(/[\s,]/g, "");
|
|
849
|
+
return OPERATOR_ALIASES[normalized] ?? normalized;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/filter/tree.ts
|
|
854
|
+
/**
|
|
855
|
+
* Creates the default empty filter: a single `and` group with no conditions.
|
|
856
|
+
*/
|
|
857
|
+
function createEmptyFilter() {
|
|
858
|
+
return createEmptyGroup("and");
|
|
859
|
+
}
|
|
860
|
+
function createEmptyGroup(mode = "and") {
|
|
861
|
+
return {
|
|
862
|
+
type: "group",
|
|
863
|
+
mode,
|
|
864
|
+
items: []
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
function isGroup(item) {
|
|
868
|
+
return item.type === "group";
|
|
869
|
+
}
|
|
870
|
+
function isExpression(item) {
|
|
871
|
+
return item.type === "expression";
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Normalizes any filter value into a tree the editor can render, without
|
|
875
|
+
* changing its structure. An expression is wrapped in a group; a missing filter
|
|
876
|
+
* becomes the default layout.
|
|
877
|
+
*/
|
|
878
|
+
function toFilterTree(filter) {
|
|
879
|
+
if (!filter) return createEmptyFilter();
|
|
880
|
+
if (isExpression(filter)) return {
|
|
881
|
+
type: "group",
|
|
882
|
+
mode: "and",
|
|
883
|
+
items: [filter]
|
|
884
|
+
};
|
|
885
|
+
return filter;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* True when the filter contains no expressions. An empty filter must not be
|
|
889
|
+
* saved or applied.
|
|
890
|
+
*/
|
|
891
|
+
function isEmptyFilter(filter) {
|
|
892
|
+
return countExpressions(filter) === 0;
|
|
893
|
+
}
|
|
894
|
+
function countExpressions(filter) {
|
|
895
|
+
if (!filter) return 0;
|
|
896
|
+
if (isExpression(filter)) return 1;
|
|
897
|
+
return filter.items.reduce((total, item) => total + countExpressions(item), 0);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Removes incomplete expressions and empty groups from the whole tree. Returns
|
|
901
|
+
* null when nothing is left. Use before applying a filter to a data object so
|
|
902
|
+
* incomplete rows are not sent.
|
|
903
|
+
*/
|
|
904
|
+
function pruneFilter(filter) {
|
|
905
|
+
if (!filter) return null;
|
|
906
|
+
if (isExpression(filter)) return isCompleteExpression(filter) ? {
|
|
907
|
+
type: "group",
|
|
908
|
+
mode: "and",
|
|
909
|
+
items: [filter]
|
|
910
|
+
} : null;
|
|
911
|
+
let items = filter.items.map((item) => isGroup(item) ? pruneFilter(item) : isCompleteExpression(item) ? item : null).filter((item) => item !== null);
|
|
912
|
+
if (items.length === 0) return null;
|
|
913
|
+
return {
|
|
914
|
+
...filter,
|
|
915
|
+
items
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* True when an expression has everything it needs to be sent to the server.
|
|
920
|
+
*/
|
|
921
|
+
function isCompleteExpression(item) {
|
|
922
|
+
if (isGroup(item)) return item.items.some(isCompleteExpression);
|
|
923
|
+
if (!item.column || !item.operator) return false;
|
|
924
|
+
let arity = getOperatorArity(normalizeOperator(item.operator));
|
|
925
|
+
if (arity === "none") return true;
|
|
926
|
+
if (item.value === null || item.value === void 0 || item.value === "") return false;
|
|
927
|
+
if (arity === "pair") return Array.isArray(item.value) && item.value.length === 2 && item.value.every((v) => v !== "" && v != null);
|
|
928
|
+
if (arity === "list") return Array.isArray(item.value) && item.value.length > 0;
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Creates an expression for a field, using the field's default operator and an
|
|
933
|
+
* empty value of the right shape.
|
|
934
|
+
*/
|
|
935
|
+
function createExpression(field, operator) {
|
|
936
|
+
let op = operator ?? getDefaultOperator(field);
|
|
937
|
+
return {
|
|
938
|
+
type: "expression",
|
|
939
|
+
column: field.name,
|
|
940
|
+
operator: op,
|
|
941
|
+
value: createEmptyValue(op),
|
|
942
|
+
valueType: getValueType(field, op)
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function createEmptyValue(operator) {
|
|
946
|
+
switch (getOperatorArity(operator)) {
|
|
947
|
+
case "none": return null;
|
|
948
|
+
case "pair": return ["", ""];
|
|
949
|
+
case "list": return [];
|
|
950
|
+
default: return "";
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Returns a copy of the expression with a new operator, adjusting the value to
|
|
955
|
+
* the new operator's arity and the value type accordingly.
|
|
956
|
+
*/
|
|
957
|
+
function changeOperator(expression, operator, field) {
|
|
958
|
+
let previousArity = getOperatorArity(normalizeOperator(expression.operator));
|
|
959
|
+
let arity = getOperatorArity(operator);
|
|
960
|
+
let value = expression.value;
|
|
961
|
+
if (arity !== previousArity) value = createEmptyValue(operator);
|
|
962
|
+
let valueType = expression.valueType === "special" && arity === previousArity ? "special" : getValueType(field, operator);
|
|
963
|
+
return {
|
|
964
|
+
...expression,
|
|
965
|
+
operator,
|
|
966
|
+
value,
|
|
967
|
+
valueType
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
/** Returns the node at `path`, or null when the path does not exist. */
|
|
971
|
+
function getNodeAtPath(filter, path) {
|
|
972
|
+
let node = filter;
|
|
973
|
+
for (let index of path) {
|
|
974
|
+
if (!node || !isGroup(node)) return null;
|
|
975
|
+
node = node.items[index];
|
|
976
|
+
}
|
|
977
|
+
return node ?? null;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Returns a copy of the tree with `updater` applied to the node at `path`.
|
|
981
|
+
* Returning null removes the node. An unknown path leaves the tree untouched.
|
|
982
|
+
*/
|
|
983
|
+
function updateNodeAtPath(filter, path, updater) {
|
|
984
|
+
if (path.length === 0) return updater(filter) ?? filter;
|
|
985
|
+
if (!isGroup(filter)) return filter;
|
|
986
|
+
let [index, ...rest] = path;
|
|
987
|
+
let child = filter.items[index];
|
|
988
|
+
if (!child) return filter;
|
|
989
|
+
let items = [...filter.items];
|
|
990
|
+
if (rest.length === 0) {
|
|
991
|
+
let updated = updater(child);
|
|
992
|
+
if (updated === null) items.splice(index, 1);
|
|
993
|
+
else items[index] = updated;
|
|
994
|
+
} else items[index] = updateNodeAtPath(child, rest, updater);
|
|
995
|
+
return {
|
|
996
|
+
...filter,
|
|
997
|
+
items
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/** Returns a copy of the tree with `node` appended to the group at `path`. */
|
|
1001
|
+
function insertNodeAtPath(filter, path, node) {
|
|
1002
|
+
return updateNodeAtPath(filter, path, (target) => isGroup(target) ? {
|
|
1003
|
+
...target,
|
|
1004
|
+
items: [...target.items, node]
|
|
1005
|
+
} : target);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Returns a copy of the tree with the node at `path` removed. The root is never
|
|
1009
|
+
* removed; it is reset to the default layout instead.
|
|
1010
|
+
*/
|
|
1011
|
+
function removeNodeAtPath(filter, path) {
|
|
1012
|
+
if (path.length === 0) return createEmptyFilter();
|
|
1013
|
+
return updateNodeAtPath(filter, path, () => null);
|
|
1014
|
+
}
|
|
1015
|
+
/** Returns a copy of the tree with the mode of the group at `path` changed. */
|
|
1016
|
+
function setGroupMode(filter, path, mode) {
|
|
1017
|
+
return updateNodeAtPath(filter, path, (node) => isGroup(node) ? {
|
|
1018
|
+
...node,
|
|
1019
|
+
mode
|
|
1020
|
+
} : node);
|
|
1021
|
+
}
|
|
1022
|
+
/** Returns a copy of the tree with an empty group appended to the group at `path`. */
|
|
1023
|
+
function addGroupAtPath(filter, path = []) {
|
|
1024
|
+
let parent = getNodeAtPath(filter, path);
|
|
1025
|
+
return insertNodeAtPath(filter, path, createEmptyGroup(parent && isGroup(parent) && parent.mode === "and" ? "or" : "and"));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region src/filter/convertFilter.ts
|
|
1030
|
+
const CONVERT_URL = "/api/filter/convert";
|
|
1031
|
+
/**
|
|
1032
|
+
* Converts between a structured filter and a DevExpress filter string.
|
|
1033
|
+
*
|
|
1034
|
+
* The conversion is done server-side by `api/filter/convert`, because the
|
|
1035
|
+
* criteria string grammar is implemented by the licensed DevExpress parser that
|
|
1036
|
+
* the Windows client and the report engine use. Doing it in the browser would
|
|
1037
|
+
* risk producing strings the server cannot parse back.
|
|
1038
|
+
*/
|
|
1039
|
+
async function convertFilter(request, signal) {
|
|
1040
|
+
let response = await fetch(CONVERT_URL, {
|
|
1041
|
+
method: "POST",
|
|
1042
|
+
headers: { "Content-Type": "application/json" },
|
|
1043
|
+
body: JSON.stringify(request),
|
|
1044
|
+
signal
|
|
1045
|
+
});
|
|
1046
|
+
if (!response.ok) throw new Error(`Failed to convert filter: ${response.status} ${response.statusText}`);
|
|
1047
|
+
let result = await response.json();
|
|
1048
|
+
return {
|
|
1049
|
+
...result,
|
|
1050
|
+
filterObject: result.filterObject ? toFilterTree(result.filterObject) : null
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
//#endregion
|
|
1055
|
+
//#region src/filter/describeFilter.ts
|
|
1056
|
+
/**
|
|
1057
|
+
* Builds a human-readable description of a filter, without contacting the
|
|
1058
|
+
* server. Use this for read-only summaries; the authoritative filter string is
|
|
1059
|
+
* produced by `api/filter/convert`, since only the server can generate the
|
|
1060
|
+
* DevExpress criteria syntax.
|
|
1061
|
+
*/
|
|
1062
|
+
function describeFilter(filter, options = {}) {
|
|
1063
|
+
if (!filter) return "";
|
|
1064
|
+
return describe(filter, new Map((options.fields ?? []).map((field) => [field.name.toLowerCase(), field])), 0);
|
|
1065
|
+
}
|
|
1066
|
+
function describe(item, fields, depth) {
|
|
1067
|
+
if (isExpression(item)) return describeExpression(item, fields);
|
|
1068
|
+
let parts = item.items.map((child) => describe(child, fields, depth + 1)).filter((part) => part !== "");
|
|
1069
|
+
if (parts.length === 0) return "";
|
|
1070
|
+
if (parts.length === 1) return parts[0];
|
|
1071
|
+
let separator = item.mode === "or" ? " OR " : " AND ";
|
|
1072
|
+
let joined = parts.join(separator);
|
|
1073
|
+
return depth === 0 ? joined : `(${joined})`;
|
|
1074
|
+
}
|
|
1075
|
+
function describeExpression(expression, fields) {
|
|
1076
|
+
if (!expression.column || !expression.operator) return "";
|
|
1077
|
+
let field = fields.get(expression.column.toLowerCase());
|
|
1078
|
+
let caption = field?.caption ?? expression.column;
|
|
1079
|
+
let operator = normalizeOperator(expression.operator);
|
|
1080
|
+
let label = getOperatorLabel(operator, field?.type);
|
|
1081
|
+
let arity = getOperatorArity(operator);
|
|
1082
|
+
if (arity === "none") return `[${caption}] ${label}`;
|
|
1083
|
+
return `[${caption}] ${label} ${formatValue(expression, arity)}`;
|
|
1084
|
+
}
|
|
1085
|
+
function formatValue(expression, arity) {
|
|
1086
|
+
let { value, valueType } = expression;
|
|
1087
|
+
if (valueType === "special" && typeof value === "string") return getDynamicValueLabel(value);
|
|
1088
|
+
if (Array.isArray(value)) {
|
|
1089
|
+
let formatted = value.map((item) => formatScalar(item, valueType));
|
|
1090
|
+
return arity === "pair" ? formatted.join(" - ") : `(${formatted.join(", ")})`;
|
|
1091
|
+
}
|
|
1092
|
+
return formatScalar(value, valueType);
|
|
1093
|
+
}
|
|
1094
|
+
function formatScalar(value, valueType) {
|
|
1095
|
+
if (value === null || value === void 0) return "";
|
|
1096
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
1097
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1098
|
+
if (valueType === "number" || valueType === "boolean") return String(value);
|
|
1099
|
+
return `'${String(value)}'`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region src/filter/fields.ts
|
|
1104
|
+
const FIELD_TYPES = [
|
|
1105
|
+
"string",
|
|
1106
|
+
"number",
|
|
1107
|
+
"boolean",
|
|
1108
|
+
"date",
|
|
1109
|
+
"datetime",
|
|
1110
|
+
"uniqueidentifier"
|
|
1111
|
+
];
|
|
1112
|
+
/** The builder type for a normalized `DataType`. Unknown types are filtered as text. */
|
|
1113
|
+
function toFilterFieldType(dataType) {
|
|
1114
|
+
let normalized = dataType?.trim().toLowerCase();
|
|
1115
|
+
return FIELD_TYPES.find((type) => type === normalized) ?? "string";
|
|
1116
|
+
}
|
|
1117
|
+
function toBoolean$1(value) {
|
|
1118
|
+
return value === true || value === 1;
|
|
1119
|
+
}
|
|
1120
|
+
/** Maps a view field record onto the metadata the builder renders. */
|
|
1121
|
+
function toFieldMetadata(record) {
|
|
1122
|
+
return {
|
|
1123
|
+
name: record.FieldName ?? "",
|
|
1124
|
+
type: toFilterFieldType(record.DataType),
|
|
1125
|
+
nullable: toBoolean$1(record.Nullable),
|
|
1126
|
+
excludeFromFilter: toBoolean$1(record.ExcludeFromFilter),
|
|
1127
|
+
valueListRecordSource: record.ValueListRecordSource || void 0
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Maps and sorts view field records. Fields the view marks as excluded are
|
|
1132
|
+
* dropped rather than hidden, since nothing else can use them.
|
|
1133
|
+
*/
|
|
1134
|
+
function toFieldMetadataList(records) {
|
|
1135
|
+
return records.map(toFieldMetadata).filter((field) => field.name !== "" && !field.excludeFromFilter).sort((a, b) => a.name.localeCompare(b.name));
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/filter/standardLayout.ts
|
|
1140
|
+
/**
|
|
1141
|
+
* The "standard layout" is the simplified shape the Windows filter editor can
|
|
1142
|
+
* render in its flow view: an outer `or` group whose items are `and` groups of
|
|
1143
|
+
* expressions. Anything else falls back to the advanced (DevExpress) editor
|
|
1144
|
+
* there, and to the nested tree editor on the web.
|
|
1145
|
+
*
|
|
1146
|
+
* @see `afFilterEditor.CanRenderFilter`
|
|
1147
|
+
*/
|
|
1148
|
+
function isStandardLayout(filter) {
|
|
1149
|
+
if (!filter || isExpression(filter) || filter.mode !== "or") return false;
|
|
1150
|
+
return filter.items.every((item) => isGroup(item) && item.mode === "and" && item.items.every(isExpression));
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Coerces an arbitrary filter tree into the standard layout.
|
|
1154
|
+
*
|
|
1155
|
+
* An `or` group's items each become their own `and` group. Anything else is
|
|
1156
|
+
* flattened into a single `and` group. This loses nesting, so only use it when
|
|
1157
|
+
* the consumer cannot handle a nested tree.
|
|
1158
|
+
*/
|
|
1159
|
+
function coerceToStandardLayout(filter) {
|
|
1160
|
+
if (!filter) return {
|
|
1161
|
+
type: "group",
|
|
1162
|
+
mode: "or",
|
|
1163
|
+
items: [{
|
|
1164
|
+
type: "group",
|
|
1165
|
+
mode: "and",
|
|
1166
|
+
items: []
|
|
1167
|
+
}]
|
|
1168
|
+
};
|
|
1169
|
+
if (isExpression(filter)) return {
|
|
1170
|
+
type: "group",
|
|
1171
|
+
mode: "or",
|
|
1172
|
+
items: [{
|
|
1173
|
+
type: "group",
|
|
1174
|
+
mode: "and",
|
|
1175
|
+
items: [filter]
|
|
1176
|
+
}]
|
|
1177
|
+
};
|
|
1178
|
+
if (filter.mode === "or") {
|
|
1179
|
+
let groups = filter.items.map((item) => {
|
|
1180
|
+
let expressions = [];
|
|
1181
|
+
collectExpressions(item, expressions);
|
|
1182
|
+
return {
|
|
1183
|
+
type: "group",
|
|
1184
|
+
mode: "and",
|
|
1185
|
+
items: expressions
|
|
1186
|
+
};
|
|
1187
|
+
});
|
|
1188
|
+
return {
|
|
1189
|
+
type: "group",
|
|
1190
|
+
mode: "or",
|
|
1191
|
+
items: groups.length > 0 ? groups : [{
|
|
1192
|
+
type: "group",
|
|
1193
|
+
mode: "and",
|
|
1194
|
+
items: []
|
|
1195
|
+
}]
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
let expressions = [];
|
|
1199
|
+
collectExpressions(filter, expressions);
|
|
1200
|
+
return {
|
|
1201
|
+
type: "group",
|
|
1202
|
+
mode: "or",
|
|
1203
|
+
items: [{
|
|
1204
|
+
type: "group",
|
|
1205
|
+
mode: "and",
|
|
1206
|
+
items: expressions
|
|
1207
|
+
}]
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Collects every expression in a (possibly nested) filter, in document order.
|
|
1212
|
+
*/
|
|
1213
|
+
function collectExpressions(item, into) {
|
|
1214
|
+
if (isExpression(item)) {
|
|
1215
|
+
into.push(item);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
for (let child of item.items) collectExpressions(child, into);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region src/filter/savedFilters.ts
|
|
1223
|
+
/**
|
|
1224
|
+
* `sviw_Database_MyFilters` unions in a synthetic "No Filter" row so the Windows
|
|
1225
|
+
* tree can render a "no filter" entry. It is not a real filter and must be
|
|
1226
|
+
* dropped before the list is shown.
|
|
1227
|
+
*/
|
|
1228
|
+
const NO_FILTER_ID = -1;
|
|
1229
|
+
/**
|
|
1230
|
+
* Maps a database record onto a `SavedFilter`.
|
|
1231
|
+
*
|
|
1232
|
+
* `CreatedByUser` needs care: the view computes it as the *string* `'true'` or
|
|
1233
|
+
* `'false'` (and `'False'` on the synthetic row), not as a bit.
|
|
1234
|
+
*/
|
|
1235
|
+
function toSavedFilter(record) {
|
|
1236
|
+
return {
|
|
1237
|
+
filterId: record.FilterID ?? -1,
|
|
1238
|
+
primKey: record.PrimKey ?? "",
|
|
1239
|
+
name: record.FilterName ?? "",
|
|
1240
|
+
criteria: record.FilterCriteria ?? "",
|
|
1241
|
+
dbObjectId: record.DBObjectID ?? "",
|
|
1242
|
+
accessLevel: toAccessLevel(record.AccessLevel),
|
|
1243
|
+
createdByUser: toBoolean(record.CreatedByUser),
|
|
1244
|
+
createdBy: record.CreatedBy ?? "",
|
|
1245
|
+
domain: record.Domain ?? null,
|
|
1246
|
+
crossDomain: toBoolean(record.CrossDomain),
|
|
1247
|
+
isPersonallyShared: toBoolean(record.IsPersonallyShared),
|
|
1248
|
+
hideCriteria: toBoolean(record.HideFilterCriteria),
|
|
1249
|
+
headerText: record.OptionalHeaderText || null,
|
|
1250
|
+
comment: record.OptionalComment || null,
|
|
1251
|
+
parameters: record.Parameters || null,
|
|
1252
|
+
reportTitle: record.ReportTitle || null,
|
|
1253
|
+
created: toDate(record.Created),
|
|
1254
|
+
updated: toDate(record.Updated)
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function toAccessLevel(value) {
|
|
1258
|
+
if (value === "Manager" || value === "Editor") return value;
|
|
1259
|
+
return "Reader";
|
|
1260
|
+
}
|
|
1261
|
+
function toBoolean(value) {
|
|
1262
|
+
if (typeof value === "string") return value.toLowerCase() === "true";
|
|
1263
|
+
return Boolean(value);
|
|
1264
|
+
}
|
|
1265
|
+
function toDate(value) {
|
|
1266
|
+
if (!value) return null;
|
|
1267
|
+
if (value instanceof Date) return value;
|
|
1268
|
+
let date = new Date(value);
|
|
1269
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
1270
|
+
}
|
|
1271
|
+
/** Drops the synthetic "No Filter" row and sorts by name. */
|
|
1272
|
+
function toSavedFilters(records) {
|
|
1273
|
+
return records.filter((record) => (record.FilterID ?? -1) !== -1).map(toSavedFilter).sort((a, b) => a.name.localeCompare(b.name));
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Splits filters into the two branches the Windows tree shows: the ones the user
|
|
1277
|
+
* created, and the ones that have been shared with them.
|
|
1278
|
+
*/
|
|
1279
|
+
function partitionSavedFilters(filters) {
|
|
1280
|
+
let mine = [];
|
|
1281
|
+
let shared = [];
|
|
1282
|
+
for (let filter of filters) if (filter.createdByUser) mine.push(filter);
|
|
1283
|
+
else shared.push(filter);
|
|
1284
|
+
return {
|
|
1285
|
+
mine,
|
|
1286
|
+
shared
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function findSavedFilter(filters, filterId) {
|
|
1290
|
+
if (filterId == null) return void 0;
|
|
1291
|
+
return filters.find((filter) => filter.filterId === filterId);
|
|
1292
|
+
}
|
|
1293
|
+
/** True when the user may overwrite the filter's criteria. */
|
|
1294
|
+
function canEditSavedFilter(filter) {
|
|
1295
|
+
return filter.accessLevel === "Editor" || filter.accessLevel === "Manager";
|
|
1296
|
+
}
|
|
1297
|
+
/** True when the user may change who the filter is shared with. */
|
|
1298
|
+
function canShareSavedFilter(filter) {
|
|
1299
|
+
return filter.accessLevel === "Manager";
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* True when the user may rename or delete the filter.
|
|
1303
|
+
*
|
|
1304
|
+
* `sstp_WebSiteCMS_FilterBuilder_RenameFilter` and `…_DeleteFilter` both require
|
|
1305
|
+
* `CreatedBy = SUSER_SNAME()` — the manager-access branch is commented out in
|
|
1306
|
+
* both procedures, deliberately, because the UI only ever offers these for a
|
|
1307
|
+
* user's own filters.
|
|
1308
|
+
*/
|
|
1309
|
+
function canRenameSavedFilter(filter) {
|
|
1310
|
+
return filter.createdByUser;
|
|
1311
|
+
}
|
|
1312
|
+
const canDeleteSavedFilter = canRenameSavedFilter;
|
|
1313
|
+
/**
|
|
1314
|
+
* True when the user may toggle the cross-domain flag.
|
|
1315
|
+
*
|
|
1316
|
+
* `sstp_Database_Filters_SetCrossDomain` also filters on
|
|
1317
|
+
* `CreatedBy = SUSER_SNAME()`.
|
|
1318
|
+
*/
|
|
1319
|
+
const canSetCrossDomain = canRenameSavedFilter;
|
|
1320
|
+
|
|
1321
|
+
//#endregion
|
|
1322
|
+
//#region src/getUserImage.ts
|
|
1323
|
+
const sizeMap = {
|
|
1324
|
+
xs: 64,
|
|
1325
|
+
sm: 128,
|
|
1326
|
+
md: 256,
|
|
1327
|
+
lg: 512,
|
|
1328
|
+
xl: void 0
|
|
1329
|
+
};
|
|
1330
|
+
function getUserImage(primKeyOrOptions, login, hash, size = "xs") {
|
|
1331
|
+
if (primKeyOrOptions && typeof primKeyOrOptions === "object") return getUserImage(primKeyOrOptions.primKey, primKeyOrOptions.login, primKeyOrOptions.hash, primKeyOrOptions.size);
|
|
1332
|
+
if (!primKeyOrOptions || !hash) return "";
|
|
1333
|
+
let sizePx = sizeMap[size];
|
|
1334
|
+
return `/api/data/blob/EmployeesLookup/${primKeyOrOptions}/Image/${encodeURIComponent(login ?? "")}${login ? "." : ""}${encodeURIComponent(hash)}.jpg${sizePx ? `?width=${sizePx}` : ""}`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/localize.ts
|
|
1339
|
+
/**
|
|
1340
|
+
* Localization function for tagged templates. Replaces the arguments
|
|
1341
|
+
* with $n, where n is the index, before asking Appframe to translate
|
|
1342
|
+
* the string.
|
|
1343
|
+
*
|
|
1344
|
+
* For example, if you call localize`Page ${2} or ${5}`, the string
|
|
1345
|
+
* "Page $0 of $1" will be sent to Appframe, and for norwegian,
|
|
1346
|
+
* "Side 2 av 5" will be returned.
|
|
1347
|
+
*
|
|
1348
|
+
* @param stringParts
|
|
1349
|
+
* @param args
|
|
1350
|
+
* @returns Translated string
|
|
1351
|
+
*/
|
|
1352
|
+
function localize(stringParts, ...args) {
|
|
1353
|
+
invariant(Array.isArray(stringParts) && stringParts.length === args.length + 1, "Method 'localize' must be used as a tag function");
|
|
1354
|
+
let stringToLocalize = stringParts.map((s, i) => i === stringParts.length - 1 ? s : `${s}$${i}`).join("");
|
|
1355
|
+
let localized = getLocalizedString(stringToLocalize);
|
|
1356
|
+
args.forEach((arg, index) => {
|
|
1357
|
+
localized = localized.replace(`$${index}`, arg);
|
|
1358
|
+
});
|
|
1359
|
+
return localized;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
//#endregion
|
|
1363
|
+
//#region src/report/recordSource.ts
|
|
1364
|
+
const XML_ENTITIES = {
|
|
1365
|
+
amp: "&",
|
|
1366
|
+
lt: "<",
|
|
1367
|
+
gt: ">",
|
|
1368
|
+
quot: "\"",
|
|
1369
|
+
apos: "'"
|
|
1370
|
+
};
|
|
1371
|
+
/** Resolves the XML entities the .NET serializer emits. */
|
|
1372
|
+
function decodeXml(value) {
|
|
1373
|
+
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => {
|
|
1374
|
+
if (entity.startsWith("#x") || entity.startsWith("#X")) return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
|
|
1375
|
+
if (entity.startsWith("#")) return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
|
|
1376
|
+
return XML_ENTITIES[entity.toLowerCase()] ?? match;
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
/** Escapes a value for use as XML text or as an attribute value. */
|
|
1380
|
+
function encodeXml(value) {
|
|
1381
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* The text content of the first `<name>` element, or `null` when the element is
|
|
1385
|
+
* missing or self-closing (`<FilterString />`, which the serializer writes for
|
|
1386
|
+
* an empty value).
|
|
1387
|
+
*/
|
|
1388
|
+
function readElement(xml, name) {
|
|
1389
|
+
let match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`).exec(xml);
|
|
1390
|
+
if (!match) return null;
|
|
1391
|
+
let value = decodeXml(match[1]).trim();
|
|
1392
|
+
return value === "" ? null : value;
|
|
1393
|
+
}
|
|
1394
|
+
/** The text content of every `<name>` element, in document order. */
|
|
1395
|
+
function readElements(xml, name) {
|
|
1396
|
+
return [...xml.matchAll(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "g"))].map((match) => decodeXml(match[1]).trim());
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* The CLR types the designer writes on a select column. Anything else — and a
|
|
1400
|
+
* column with no `DataType` at all, which is what it writes for strings — is
|
|
1401
|
+
* treated as text.
|
|
1402
|
+
*/
|
|
1403
|
+
const COLUMN_TYPES = {
|
|
1404
|
+
Byte: "number",
|
|
1405
|
+
Decimal: "number",
|
|
1406
|
+
Double: "number",
|
|
1407
|
+
Int16: "number",
|
|
1408
|
+
Int32: "number",
|
|
1409
|
+
Int64: "number",
|
|
1410
|
+
Single: "number",
|
|
1411
|
+
Boolean: "boolean",
|
|
1412
|
+
DateTime: "date"
|
|
1413
|
+
};
|
|
1414
|
+
/** The columns of a `<SelectColumns>` block, in document order. */
|
|
1415
|
+
function readSelectColumns(xml) {
|
|
1416
|
+
let block = /<SelectColumns>([\s\S]*?)<\/SelectColumns>/.exec(xml)?.[1] ?? "";
|
|
1417
|
+
let columns = [];
|
|
1418
|
+
for (let match of block.matchAll(/<afSelectColumn>([\s\S]*?)<\/afSelectColumn>/g)) {
|
|
1419
|
+
let name = readElement(match[1], "Name");
|
|
1420
|
+
if (!name) continue;
|
|
1421
|
+
columns.push({
|
|
1422
|
+
name,
|
|
1423
|
+
type: COLUMN_TYPES[readElement(match[1], "DataType") ?? ""] ?? "string"
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
return columns;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Reads the parts of an `afRecordSource` a web client can act on: the view, the
|
|
1430
|
+
* selected columns and the design-time filter.
|
|
1431
|
+
*
|
|
1432
|
+
* Returns `null` for anything that is not a record source, so a missing or
|
|
1433
|
+
* empty `RecordSource` column simply means "no lookup".
|
|
1434
|
+
*/
|
|
1435
|
+
function parseRecordSource(xml) {
|
|
1436
|
+
if (!xml || !xml.includes("<afRecordSource")) return null;
|
|
1437
|
+
let viewName = readElement(xml, "ViewName");
|
|
1438
|
+
if (!viewName) return null;
|
|
1439
|
+
let maxRecords = readElement(xml, "MaxRecords");
|
|
1440
|
+
return {
|
|
1441
|
+
viewName,
|
|
1442
|
+
columns: readSelectColumns(xml),
|
|
1443
|
+
whereClause: readElement(xml, "WhereClause"),
|
|
1444
|
+
filterString: readElement(xml, "FilterString"),
|
|
1445
|
+
maxRecords: maxRecords === null || maxRecords === "0" ? null : Number(maxRecords)
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
//#endregion
|
|
1450
|
+
//#region src/report/parameters.ts
|
|
1451
|
+
/**
|
|
1452
|
+
* Report parameters: mapping the metadata records onto something renderable,
|
|
1453
|
+
* and the XML the parameter values are stored as on a saved filter.
|
|
1454
|
+
*/
|
|
1455
|
+
const PARAMETER_TYPES = [
|
|
1456
|
+
"String",
|
|
1457
|
+
"Integer",
|
|
1458
|
+
"Double",
|
|
1459
|
+
"Date",
|
|
1460
|
+
"Boolean"
|
|
1461
|
+
];
|
|
1462
|
+
/**
|
|
1463
|
+
* A default value naming a stored procedure, e.g. `astp_Reports_CurrentPeriod`.
|
|
1464
|
+
*
|
|
1465
|
+
* The Windows launcher executes the procedure and uses the first column of the
|
|
1466
|
+
* first row. The web client cannot execute an arbitrary procedure, so these are
|
|
1467
|
+
* resolved through the report endpoint instead.
|
|
1468
|
+
*/
|
|
1469
|
+
const PROCEDURE_DEFAULT = /^[a-z]stp_/i;
|
|
1470
|
+
/** True when a `DefaultValue` names a stored procedure rather than a literal. */
|
|
1471
|
+
function isProcedureDefault(defaultValue) {
|
|
1472
|
+
return typeof defaultValue === "string" && PROCEDURE_DEFAULT.test(defaultValue.trim());
|
|
1473
|
+
}
|
|
1474
|
+
/** The choices of a parameter, one per line of its `Items` column. */
|
|
1475
|
+
function parseParameterItems(items) {
|
|
1476
|
+
if (!items) return [];
|
|
1477
|
+
return items.split(/\r\n|\r|\n/).map((item) => item.trim()).filter((item) => item !== "");
|
|
1478
|
+
}
|
|
1479
|
+
function toParameterType(dataType) {
|
|
1480
|
+
return PARAMETER_TYPES.find((type) => type.toLowerCase() === dataType?.trim().toLowerCase()) ?? "String";
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* The editor a parameter should use. A lookup or a fixed item list wins over the
|
|
1484
|
+
* data type, which is what the Windows launcher does as well.
|
|
1485
|
+
*/
|
|
1486
|
+
function toParameterEditor(type, hasLookup, hasItems) {
|
|
1487
|
+
if (hasLookup) return "lookup";
|
|
1488
|
+
if (hasItems) return "select";
|
|
1489
|
+
switch (type) {
|
|
1490
|
+
case "Integer":
|
|
1491
|
+
case "Double": return "number";
|
|
1492
|
+
case "Date": return "date";
|
|
1493
|
+
case "Boolean": return "boolean";
|
|
1494
|
+
default: return "text";
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
/** Maps a `sviw_WinClient_ReportsParameters` record onto a `ReportParameter`. */
|
|
1498
|
+
function toReportParameter(record) {
|
|
1499
|
+
let name = record.Name ?? "";
|
|
1500
|
+
let type = toParameterType(record.DataType);
|
|
1501
|
+
let items = parseParameterItems(record.Items);
|
|
1502
|
+
let recordSource = parseRecordSource(record.RecordSource);
|
|
1503
|
+
let valueMember = record.ValueMember ?? null;
|
|
1504
|
+
if (recordSource && !valueMember) valueMember = recordSource.columns[0]?.name ?? null;
|
|
1505
|
+
let displayMember = recordSource?.columns.find((column) => column.name !== valueMember)?.name ?? null;
|
|
1506
|
+
let hasLookup = Boolean(recordSource && valueMember);
|
|
1507
|
+
return {
|
|
1508
|
+
name,
|
|
1509
|
+
label: record.Label?.trim() || name,
|
|
1510
|
+
description: record.Description?.trim() || null,
|
|
1511
|
+
type,
|
|
1512
|
+
editor: toParameterEditor(type, hasLookup, items.length > 0),
|
|
1513
|
+
defaultValue: record.DefaultValue ?? null,
|
|
1514
|
+
items,
|
|
1515
|
+
recordSource: hasLookup ? recordSource : null,
|
|
1516
|
+
valueMember: hasLookup ? valueMember : null,
|
|
1517
|
+
displayMember: hasLookup ? displayMember : null,
|
|
1518
|
+
sortOrder: record.SortOrder ?? 0,
|
|
1519
|
+
lookupWidth: record.LookupWidth ?? null,
|
|
1520
|
+
lookupHeight: record.LookupHeight ?? null
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
/** Maps and sorts a report's parameter records. */
|
|
1524
|
+
function toReportParameters(records) {
|
|
1525
|
+
return records.filter((record) => record.Name).map(toReportParameter).sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label));
|
|
1526
|
+
}
|
|
1527
|
+
/** Converts a raw value to the type a parameter expects. */
|
|
1528
|
+
function coerceParameterValue(parameter, value) {
|
|
1529
|
+
if (value === null || value === void 0 || value === "") return parameter.type === "Boolean" ? false : null;
|
|
1530
|
+
switch (parameter.type) {
|
|
1531
|
+
case "Boolean": return typeof value === "boolean" ? value : /^(true|1|ja|yes)$/i.test(String(value));
|
|
1532
|
+
case "Integer": {
|
|
1533
|
+
let parsed = Number.parseInt(String(value), 10);
|
|
1534
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
1535
|
+
}
|
|
1536
|
+
case "Double": {
|
|
1537
|
+
let parsed = Number(String(value).replace(",", "."));
|
|
1538
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
1539
|
+
}
|
|
1540
|
+
default: return String(value);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* The initial values for a set of parameters.
|
|
1545
|
+
*
|
|
1546
|
+
* Defaults naming a stored procedure are left unset: they have to be resolved
|
|
1547
|
+
* server-side, and the caller fills them in once they are.
|
|
1548
|
+
*/
|
|
1549
|
+
function getDefaultParameterValues(parameters) {
|
|
1550
|
+
let values = {};
|
|
1551
|
+
for (let parameter of parameters) values[parameter.name] = isProcedureDefault(parameter.defaultValue) ? coerceParameterValue(parameter, null) : coerceParameterValue(parameter, parameter.defaultValue);
|
|
1552
|
+
return values;
|
|
1553
|
+
}
|
|
1554
|
+
function toXmlValue(parameter, value) {
|
|
1555
|
+
if (value === null || value === void 0) return "";
|
|
1556
|
+
if (typeof value === "boolean") return value ? "True" : "False";
|
|
1557
|
+
return String(value);
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Serializes parameter values the way the Windows filter builder does, so that
|
|
1561
|
+
* a filter saved on the web is readable in the Windows client and the other way
|
|
1562
|
+
* around.
|
|
1563
|
+
*
|
|
1564
|
+
* ```xml
|
|
1565
|
+
* <Parameters Version="1">
|
|
1566
|
+
* <Parameter Name="Period" Type="Integer">202601</Parameter>
|
|
1567
|
+
* </Parameters>
|
|
1568
|
+
* ```
|
|
1569
|
+
*/
|
|
1570
|
+
function serializeParameters(parameters, values) {
|
|
1571
|
+
return `<Parameters Version="1">\n${parameters.map((parameter) => {
|
|
1572
|
+
let value = toXmlValue(parameter, values[parameter.name] ?? null);
|
|
1573
|
+
return ` <Parameter Name="${encodeXml(parameter.name)}" Type="${parameter.type}">${encodeXml(value)}</Parameter>`;
|
|
1574
|
+
}).join("\n")}\n</Parameters>`;
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Reads the values back out of the XML stored on a saved filter. Values are
|
|
1578
|
+
* returned as strings and coerced against the report's own parameters by
|
|
1579
|
+
* `restoreParameterValues`, since the XML may name parameters the report no
|
|
1580
|
+
* longer has.
|
|
1581
|
+
*/
|
|
1582
|
+
function parseParameters(xml) {
|
|
1583
|
+
if (!xml) return {};
|
|
1584
|
+
let values = {};
|
|
1585
|
+
for (let match of xml.matchAll(/<Parameter\s+([^>]*?)\/?>(?:([\s\S]*?)<\/Parameter>)?/g)) {
|
|
1586
|
+
let name = /Name\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
|
|
1587
|
+
if (!name) continue;
|
|
1588
|
+
values[name] = readElement(match[0], "Parameter") ?? "";
|
|
1589
|
+
}
|
|
1590
|
+
return values;
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Applies the values stored on a saved filter to a report's parameters, keeping
|
|
1594
|
+
* the defaults for anything the filter does not mention.
|
|
1595
|
+
*/
|
|
1596
|
+
function restoreParameterValues(parameters, xml) {
|
|
1597
|
+
let stored = parseParameters(xml);
|
|
1598
|
+
let values = getDefaultParameterValues(parameters);
|
|
1599
|
+
for (let parameter of parameters) if (parameter.name in stored) values[parameter.name] = coerceParameterValue(parameter, stored[parameter.name]);
|
|
1600
|
+
return values;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
//#endregion
|
|
1604
|
+
//#region src/report/parameterDefaults.ts
|
|
1605
|
+
/**
|
|
1606
|
+
* Resolving report parameter defaults that name a stored procedure.
|
|
1607
|
+
*
|
|
1608
|
+
* A `DefaultValue` matching `^[a-z]stp_` names a procedure that produces the
|
|
1609
|
+
* default rather than being the default itself. Executing it is a server
|
|
1610
|
+
* concern, and the framework already exposes it through the `WebsiteCMS`
|
|
1611
|
+
* `api/reporting/filters` route.
|
|
1612
|
+
*/
|
|
1613
|
+
/**
|
|
1614
|
+
* Runs the stored procedure a parameter's default names and returns the value
|
|
1615
|
+
* it produces — the first column of the first row, the way the Windows launcher
|
|
1616
|
+
* reads it.
|
|
1617
|
+
*
|
|
1618
|
+
* Returns `null` when the parameter has no procedure default or the procedure
|
|
1619
|
+
* produced no rows.
|
|
1620
|
+
*/
|
|
1621
|
+
async function fetchParameterDefault(reportId, parameter, signal) {
|
|
1622
|
+
if (!parameter.defaultValue || !isProcedureDefault(parameter.defaultValue)) return null;
|
|
1623
|
+
let response = await fetch(`/api/reporting/filters/${encodeURIComponent(reportId)}/GetParameterDefaultValue`, {
|
|
1624
|
+
body: JSON.stringify({ parameterName: parameter.name }),
|
|
1625
|
+
credentials: "same-origin",
|
|
1626
|
+
headers: {
|
|
1627
|
+
"Content-Type": "application/json",
|
|
1628
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
1629
|
+
},
|
|
1630
|
+
method: "POST",
|
|
1631
|
+
signal
|
|
1632
|
+
});
|
|
1633
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
1634
|
+
return firstValue(await response.json());
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Resolves every procedure default of a report in parallel and merges the
|
|
1638
|
+
* results over the literal defaults.
|
|
1639
|
+
*
|
|
1640
|
+
* A parameter whose procedure fails is left at its literal default rather than
|
|
1641
|
+
* failing the whole form — the user can still type a value.
|
|
1642
|
+
*/
|
|
1643
|
+
async function resolveParameterDefaults(reportId, parameters, literalDefaults, signal) {
|
|
1644
|
+
let dynamic = parameters.filter((parameter) => isProcedureDefault(parameter.defaultValue ?? ""));
|
|
1645
|
+
if (dynamic.length === 0) return literalDefaults;
|
|
1646
|
+
let resolved = await Promise.all(dynamic.map(async (parameter) => {
|
|
1647
|
+
try {
|
|
1648
|
+
return [parameter.name, await fetchParameterDefault(reportId, parameter, signal)];
|
|
1649
|
+
} catch {
|
|
1650
|
+
return [parameter.name, null];
|
|
1651
|
+
}
|
|
1652
|
+
}));
|
|
1653
|
+
let values = { ...literalDefaults };
|
|
1654
|
+
for (let [name, value] of resolved) if (value !== null) values[name] = value;
|
|
1655
|
+
return values;
|
|
1656
|
+
}
|
|
1657
|
+
/** The first column of the first row of whichever result set came back. */
|
|
1658
|
+
function firstValue(result) {
|
|
1659
|
+
let row = (Array.isArray(result) ? result : Object.values(result).find((value) => Array.isArray(value)))?.[0];
|
|
1660
|
+
if (row === void 0 || row === null) return null;
|
|
1661
|
+
if (Array.isArray(row)) return row[0] ?? null;
|
|
1662
|
+
if (typeof row === "object") return Object.values(row)[0] ?? null;
|
|
1663
|
+
return row;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/report/render.ts
|
|
1668
|
+
/**
|
|
1669
|
+
* Rendering a report through the framework's report route.
|
|
1670
|
+
*
|
|
1671
|
+
* The route is a CMS code module owned by the Appframe framework
|
|
1672
|
+
* (`WebsiteCMS.Reports`), registered on
|
|
1673
|
+
* `api/report/{ReportID}/{FileName}.{Extension}`. Everything here builds
|
|
1674
|
+
* requests for it — there is no report endpoint of our own.
|
|
1675
|
+
*/
|
|
1676
|
+
/**
|
|
1677
|
+
* The file extension each format is requested with. The route only accepts
|
|
1678
|
+
* `(pdf|csv|xlsx?|html|xbm|mht|rtf|txt)`, which is why `TEXT` is `txt` and
|
|
1679
|
+
* `IMAGE` is `xbm` rather than the format name.
|
|
1680
|
+
*/
|
|
1681
|
+
const REPORT_EXTENSIONS = {
|
|
1682
|
+
CSV: "csv",
|
|
1683
|
+
HTML: "html",
|
|
1684
|
+
IMAGE: "xbm",
|
|
1685
|
+
MHT: "mht",
|
|
1686
|
+
PDF: "pdf",
|
|
1687
|
+
RTF: "rtf",
|
|
1688
|
+
TEXT: "txt",
|
|
1689
|
+
XLS: "xls",
|
|
1690
|
+
XLSX: "xlsx"
|
|
1691
|
+
};
|
|
1692
|
+
/** The media type each format is served as, for previewing a rendered report. */
|
|
1693
|
+
const REPORT_MEDIA_TYPES = {
|
|
1694
|
+
CSV: "text/csv",
|
|
1695
|
+
HTML: "text/html",
|
|
1696
|
+
IMAGE: "image/x-xbitmap",
|
|
1697
|
+
MHT: "message/rfc822",
|
|
1698
|
+
PDF: "application/pdf",
|
|
1699
|
+
RTF: "text/enriched",
|
|
1700
|
+
TEXT: "text/plain",
|
|
1701
|
+
XLS: "application/vnd.ms-excel",
|
|
1702
|
+
XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1703
|
+
};
|
|
1704
|
+
/** The extension a format is requested with. */
|
|
1705
|
+
function getReportExtension(format) {
|
|
1706
|
+
return REPORT_EXTENSIONS[format];
|
|
1707
|
+
}
|
|
1708
|
+
/** The media type a format is served as. */
|
|
1709
|
+
function getReportMediaType(format) {
|
|
1710
|
+
return REPORT_MEDIA_TYPES[format];
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Characters that must not reach the file name, because it is a path segment of
|
|
1714
|
+
* the route: separators, the extension delimiter, the query and fragment
|
|
1715
|
+
* delimiters, and control characters.
|
|
1716
|
+
*/
|
|
1717
|
+
const UNSAFE_FILE_NAME_CHARACTERS = /[\\/:*?"<>|.#&+%\u0000-\u001f]/g;
|
|
1718
|
+
/**
|
|
1719
|
+
* The name a rendered report is downloaded as, including its extension.
|
|
1720
|
+
*
|
|
1721
|
+
* The file name is part of the route rather than a query parameter, so anything
|
|
1722
|
+
* that would change the path is replaced.
|
|
1723
|
+
*/
|
|
1724
|
+
function getReportFileName(options) {
|
|
1725
|
+
let safe = (options.fileName ?? options.reportId).trim().replace(UNSAFE_FILE_NAME_CHARACTERS, "-").replace(/-{2,}/g, "-").replace(/^-|-$/g, "").trim();
|
|
1726
|
+
return `${safe === "" ? options.reportId : safe}.${getReportExtension(options.format)}`;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The route is always on the same origin, but a `Request` needs an absolute
|
|
1730
|
+
* URL, so the path is resolved against the document when there is one.
|
|
1731
|
+
*/
|
|
1732
|
+
function toRequestUrl(path) {
|
|
1733
|
+
let base = window.location?.href;
|
|
1734
|
+
return base ? new URL(path, base).toString() : path;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* The request that renders a report.
|
|
1738
|
+
*
|
|
1739
|
+
* A report without parameters is a plain `GET`, so it can be opened directly in
|
|
1740
|
+
* a new tab. As soon as there are parameters or a header the options go in a
|
|
1741
|
+
* JSON body, which the route only reads from a `POST`.
|
|
1742
|
+
*
|
|
1743
|
+
* `filter` is always sent alongside `parameters`: the route reads the filter out
|
|
1744
|
+
* of the body *guarded by the presence of `parameters`*, and reads it with an
|
|
1745
|
+
* indexer that throws when the key is missing.
|
|
1746
|
+
*/
|
|
1747
|
+
function getReportRequest(options) {
|
|
1748
|
+
let { domain, filter, headerText, hideFilterString, parameters, reportId, signal, timezone } = options;
|
|
1749
|
+
let fileName = getReportFileName(options);
|
|
1750
|
+
let url = `/api/report/${encodeURIComponent(reportId)}/${encodeURIComponent(fileName)}`;
|
|
1751
|
+
let query = new URLSearchParams();
|
|
1752
|
+
let entries = toParameterEntries(parameters);
|
|
1753
|
+
let needsBody = entries.length > 0 || Boolean(headerText) || hideFilterString === true;
|
|
1754
|
+
if (domain) query.set("Domain", domain);
|
|
1755
|
+
query.set("timezone", String(timezone ?? -(/* @__PURE__ */ new Date()).getTimezoneOffset() / 60));
|
|
1756
|
+
if (!needsBody) {
|
|
1757
|
+
if (filter) query.set("Filter", filter);
|
|
1758
|
+
return new Request(toRequestUrl(`${url}?${query}`), {
|
|
1759
|
+
credentials: "same-origin",
|
|
1760
|
+
signal
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
let body = {
|
|
1764
|
+
filter: filter ?? "",
|
|
1765
|
+
parameters: entries
|
|
1766
|
+
};
|
|
1767
|
+
if (headerText) body.optionalheader = headerText;
|
|
1768
|
+
if (hideFilterString === true) body.showfiltercriteria = "true";
|
|
1769
|
+
return new Request(toRequestUrl(`${url}?${query}`), {
|
|
1770
|
+
body: JSON.stringify(body),
|
|
1771
|
+
credentials: "same-origin",
|
|
1772
|
+
headers: {
|
|
1773
|
+
"Content-Type": "application/json",
|
|
1774
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
1775
|
+
},
|
|
1776
|
+
method: "POST",
|
|
1777
|
+
signal
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Renders a report and resolves with the rendered file.
|
|
1782
|
+
*
|
|
1783
|
+
* The route answers with a JSON body carrying an `error` when rendering fails,
|
|
1784
|
+
* rather than with a failing status, so a JSON response is turned into an error
|
|
1785
|
+
* here.
|
|
1786
|
+
*/
|
|
1787
|
+
async function renderReport(options) {
|
|
1788
|
+
let response = await fetch(getReportRequest(options));
|
|
1789
|
+
if ((response.headers.get("content-type") ?? "").includes("application/json")) {
|
|
1790
|
+
let data = await response.json();
|
|
1791
|
+
throw new Error(data.error ?? "Failed to render report");
|
|
1792
|
+
}
|
|
1793
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
1794
|
+
return response.blob();
|
|
1795
|
+
}
|
|
1796
|
+
/** Renders a report and saves it, reporting progress as it downloads. */
|
|
1797
|
+
function downloadReport(options) {
|
|
1798
|
+
return downloadRequest(getReportRequest(options), {
|
|
1799
|
+
fileName: getReportFileName(options),
|
|
1800
|
+
onProgress: options.onProgress,
|
|
1801
|
+
signal: options.signal
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Renders a report and opens it in a new tab.
|
|
1806
|
+
*
|
|
1807
|
+
* The blob is rendered first rather than pointing a tab at the route, so a
|
|
1808
|
+
* failure surfaces as an error here instead of as a tab showing JSON, and so
|
|
1809
|
+
* reports with parameters — which have to be posted — can be previewed too.
|
|
1810
|
+
*/
|
|
1811
|
+
async function openReport(options) {
|
|
1812
|
+
let blob = await renderReport(options);
|
|
1813
|
+
let url = URL.createObjectURL(blob.type ? blob : new Blob([blob], { type: getReportMediaType(options.format) }));
|
|
1814
|
+
if (!window.open(url, "_blank", "noopener")) {
|
|
1815
|
+
URL.revokeObjectURL(url);
|
|
1816
|
+
throw new Error("The report could not be opened. Allow pop-ups for this site and try again.");
|
|
1817
|
+
}
|
|
1818
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 6e4);
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Renders a report and opens the browser's print dialog for it.
|
|
1822
|
+
*
|
|
1823
|
+
* The report is loaded into an offscreen iframe rather than a new tab, so the
|
|
1824
|
+
* print dialog opens over the launcher and the user is not left with a stray
|
|
1825
|
+
* tab afterwards. Browsers that will not print an embedded document fall back
|
|
1826
|
+
* to opening it, which leaves the user one keystroke from printing.
|
|
1827
|
+
*/
|
|
1828
|
+
async function printReport(options) {
|
|
1829
|
+
let document_ = window.document;
|
|
1830
|
+
if (!document_?.body) return openReport(options);
|
|
1831
|
+
let blob = await renderReport(options);
|
|
1832
|
+
let url = URL.createObjectURL(blob.type ? blob : new Blob([blob], { type: getReportMediaType(options.format) }));
|
|
1833
|
+
let frame = document_.createElement("iframe");
|
|
1834
|
+
frame.setAttribute("aria-hidden", "true");
|
|
1835
|
+
frame.style.cssText = "position:fixed;inset-block-start:0;inset-inline-start:0;inline-size:1px;block-size:1px;border:0;visibility:hidden";
|
|
1836
|
+
let cleanUp = () => {
|
|
1837
|
+
frame.remove();
|
|
1838
|
+
URL.revokeObjectURL(url);
|
|
1839
|
+
};
|
|
1840
|
+
try {
|
|
1841
|
+
await new Promise((resolve, reject) => {
|
|
1842
|
+
frame.addEventListener("load", () => resolve(), { once: true });
|
|
1843
|
+
frame.addEventListener("error", () => reject(/* @__PURE__ */ new Error("The report could not be loaded for printing")), { once: true });
|
|
1844
|
+
frame.src = url;
|
|
1845
|
+
document_.body.append(frame);
|
|
1846
|
+
});
|
|
1847
|
+
frame.contentWindow?.focus();
|
|
1848
|
+
frame.contentWindow?.print();
|
|
1849
|
+
} catch {
|
|
1850
|
+
cleanUp();
|
|
1851
|
+
return openReport(options);
|
|
1852
|
+
}
|
|
1853
|
+
window.setTimeout(cleanUp, 6e4);
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Parameters in the shape the route expects — `{ name, value }` objects.
|
|
1857
|
+
*
|
|
1858
|
+
* Empty values are dropped: the route skips them anyway, and a report falls back
|
|
1859
|
+
* to its own default when a parameter is not sent.
|
|
1860
|
+
*/
|
|
1861
|
+
function toParameterEntries(parameters) {
|
|
1862
|
+
if (!parameters) return [];
|
|
1863
|
+
return Object.entries(parameters).filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([name, value]) => ({
|
|
1864
|
+
name,
|
|
1865
|
+
value: typeof value === "boolean" ? String(value) : value
|
|
1866
|
+
}));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
//#endregion
|
|
1870
|
+
//#region src/report/report.ts
|
|
1871
|
+
/**
|
|
1872
|
+
* Mapping a `sviw_WinClient_MyReports` record onto the report model, including
|
|
1873
|
+
* which output formats the report offers.
|
|
1874
|
+
*/
|
|
1875
|
+
function isSet(value) {
|
|
1876
|
+
return value === true || value === 1;
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* The formats a report may be rendered to, in the order the launcher offers
|
|
1880
|
+
* them.
|
|
1881
|
+
*
|
|
1882
|
+
* `FormatCSV` covers the whole spreadsheet family: the Windows launcher enables
|
|
1883
|
+
* "save as XLS" and "save as XLSX" from the same flag, because they all come out
|
|
1884
|
+
* of the same DevExpress export.
|
|
1885
|
+
*/
|
|
1886
|
+
function getReportFormats(record) {
|
|
1887
|
+
let formats = [];
|
|
1888
|
+
if (isSet(record.FormatPDF)) formats.push("PDF");
|
|
1889
|
+
if (isSet(record.FormatCSV)) formats.push("XLSX", "XLS", "CSV");
|
|
1890
|
+
if (isSet(record.FormatHTML)) formats.push("HTML");
|
|
1891
|
+
if (isSet(record.FormatRTF)) formats.push("RTF");
|
|
1892
|
+
if (isSet(record.FormatMHT)) formats.push("MHT");
|
|
1893
|
+
if (isSet(record.FormatTEXT)) formats.push("TEXT");
|
|
1894
|
+
if (isSet(record.FormatIMAGE)) formats.push("IMAGE");
|
|
1895
|
+
return formats;
|
|
1896
|
+
}
|
|
1897
|
+
/** Maps a report record onto the model the launcher renders. */
|
|
1898
|
+
function toReport(record) {
|
|
1899
|
+
let recordSource = parseRecordSource(record.RecordSource);
|
|
1900
|
+
return {
|
|
1901
|
+
reportId: record.ReportID ?? "",
|
|
1902
|
+
shortId: record.ShortID ?? null,
|
|
1903
|
+
title: record.Title?.trim() || null,
|
|
1904
|
+
description: record.Description?.trim() || null,
|
|
1905
|
+
viewName: recordSource?.viewName ?? null,
|
|
1906
|
+
initialFilter: recordSource?.filterString ?? null,
|
|
1907
|
+
hasTitle: isSet(record.HasTitle),
|
|
1908
|
+
hasHeader: isSet(record.HasHeader),
|
|
1909
|
+
formats: getReportFormats(record),
|
|
1910
|
+
timeout: record.Timeout ?? null
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/alert.ts
|
|
1916
|
+
const template = `
|
|
579
1917
|
<dialog class="alert-dialog">
|
|
580
1918
|
<form method="dialog">
|
|
581
1919
|
<article></article>
|
|
@@ -585,133 +1923,227 @@ var af;
|
|
|
585
1923
|
</form>
|
|
586
1924
|
</dialog>
|
|
587
1925
|
`;
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1926
|
+
function addDialogTemplate() {
|
|
1927
|
+
if (document.querySelector("#AlertDialogTemplate")) return;
|
|
1928
|
+
let $template = document.createElement("template");
|
|
1929
|
+
$template.id = "AlertDialogTemplate";
|
|
1930
|
+
$template.innerHTML = template;
|
|
1931
|
+
document.body.append($template);
|
|
1932
|
+
}
|
|
1933
|
+
if (window.document) {
|
|
1934
|
+
if (document.body) addDialogTemplate();
|
|
1935
|
+
else document.addEventListener("DOMContentLoaded", addDialogTemplate, { once: true });
|
|
1936
|
+
}
|
|
1937
|
+
function waitForDialog(dialog, cancelValue = "No") {
|
|
1938
|
+
return new Promise((resolve) => {
|
|
1939
|
+
let controller = new AbortController();
|
|
1940
|
+
dialog.addEventListener("close", () => {
|
|
1941
|
+
resolve(dialog.returnValue);
|
|
1942
|
+
controller.abort();
|
|
1943
|
+
}, { signal: controller.signal });
|
|
1944
|
+
dialog.addEventListener("cancel", () => {
|
|
1945
|
+
resolve(cancelValue);
|
|
1946
|
+
controller.abort();
|
|
1947
|
+
}, { signal: controller.signal });
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
const alertDialogPromiseMap = /* @__PURE__ */ new Map();
|
|
1951
|
+
async function openAlertDialog({ buttons, content, className, id, title }) {
|
|
1952
|
+
if (id && alertDialogPromiseMap.has(id)) return await alertDialogPromiseMap.get(id);
|
|
1953
|
+
let $template = document.querySelector("#AlertDialogTemplate");
|
|
1954
|
+
if (!$template) throw Error("No alert dialog template found");
|
|
1955
|
+
let $dialog = $template.content.firstElementChild?.cloneNode(true);
|
|
1956
|
+
let $content = $dialog?.querySelector("article");
|
|
1957
|
+
let $menu = $dialog?.querySelector("footer menu");
|
|
1958
|
+
if (!$dialog || !$content || !$menu) throw Error("Invalid alert dialog template");
|
|
1959
|
+
if (className) for (const className_ of className.split(/\s+/)) $dialog?.classList.add(className_);
|
|
1960
|
+
if (title) {
|
|
1961
|
+
let $header = document.createElement("header");
|
|
1962
|
+
$header.append(title);
|
|
1963
|
+
$content.parentNode?.insertBefore($header, $content);
|
|
1964
|
+
}
|
|
1965
|
+
$content.appendChild(typeof content === "string" ? document.createTextNode(content) : content);
|
|
1966
|
+
for (let { label, value, primary } of buttons) {
|
|
1967
|
+
let listItem = document.createElement("li");
|
|
1968
|
+
let buttonElem = document.createElement("button");
|
|
1969
|
+
buttonElem.value = value;
|
|
1970
|
+
if (primary) buttonElem.classList.add("primary");
|
|
1971
|
+
buttonElem.textContent = label;
|
|
1972
|
+
listItem.appendChild(buttonElem);
|
|
1973
|
+
$menu.appendChild(buttonElem);
|
|
1974
|
+
}
|
|
1975
|
+
document.body.appendChild($dialog);
|
|
1976
|
+
$dialog.showModal();
|
|
1977
|
+
let dialogPromise = waitForDialog($dialog).then((returnValue) => {
|
|
1978
|
+
if (id && alertDialogPromiseMap.has(id)) alertDialogPromiseMap.delete(id);
|
|
1979
|
+
if ($dialog) document.body.removeChild($dialog);
|
|
1980
|
+
return returnValue;
|
|
1981
|
+
});
|
|
1982
|
+
if (id) alertDialogPromiseMap.set(id, dialogPromise);
|
|
1983
|
+
return await dialogPromise;
|
|
1984
|
+
}
|
|
1985
|
+
let ConfirmButtons = /* @__PURE__ */ function(ConfirmButtons) {
|
|
1986
|
+
ConfirmButtons[ConfirmButtons["YesNo"] = 0] = "YesNo";
|
|
1987
|
+
ConfirmButtons[ConfirmButtons["OkCancel"] = 1] = "OkCancel";
|
|
1988
|
+
return ConfirmButtons;
|
|
1989
|
+
}({});
|
|
1990
|
+
async function confirm(prompt, options = {}) {
|
|
1991
|
+
let { buttons: optionButtons = 0, title } = options;
|
|
1992
|
+
return await openAlertDialog({
|
|
1993
|
+
content: prompt,
|
|
1994
|
+
buttons: optionButtons === 1 ? [{
|
|
1995
|
+
label: getLocalizedString("OK"),
|
|
1996
|
+
value: "yes",
|
|
1997
|
+
primary: true
|
|
1998
|
+
}, {
|
|
1999
|
+
label: getLocalizedString("Cancel"),
|
|
2000
|
+
value: "no"
|
|
2001
|
+
}] : [{
|
|
2002
|
+
label: getLocalizedString("Yes"),
|
|
2003
|
+
value: "yes",
|
|
2004
|
+
primary: true
|
|
2005
|
+
}, {
|
|
2006
|
+
label: getLocalizedString("No"),
|
|
2007
|
+
value: "no"
|
|
2008
|
+
}],
|
|
2009
|
+
className: "alert-dialog-confirm",
|
|
2010
|
+
title
|
|
2011
|
+
}) === "yes";
|
|
2012
|
+
}
|
|
2013
|
+
async function alert(prompt, options = {}) {
|
|
2014
|
+
await openAlertDialog({
|
|
2015
|
+
content: prompt,
|
|
2016
|
+
className: "alert-dialog-alert",
|
|
2017
|
+
buttons: [{
|
|
2018
|
+
label: getLocalizedString("OK"),
|
|
2019
|
+
value: "ok",
|
|
2020
|
+
primary: true
|
|
2021
|
+
}],
|
|
2022
|
+
...options
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
//#endregion
|
|
2027
|
+
//#region src/settings.ts
|
|
2028
|
+
function getKey(article) {
|
|
2029
|
+
return `${article ?? window.af?.article?.id ?? window.window?.location.pathname.substring(1).split("/")[0]}.${window.af?.userSession.login ?? "anonymous"}`;
|
|
2030
|
+
}
|
|
2031
|
+
function getSettings(setting, article) {
|
|
2032
|
+
if (!window.af?.userSession) return null;
|
|
2033
|
+
const key = getKey(article);
|
|
2034
|
+
const settings = JSON.parse(localStorage.getItem(key) || "{}");
|
|
2035
|
+
if (setting) return settings[setting] || null;
|
|
2036
|
+
return settings;
|
|
2037
|
+
}
|
|
2038
|
+
function saveSettings(setting, value, article) {
|
|
2039
|
+
if (!window.af?.userSession) return;
|
|
2040
|
+
const key = getKey(article);
|
|
2041
|
+
let userSettings = JSON.parse(localStorage.getItem(key) || "{}");
|
|
2042
|
+
if (typeof value !== "undefined") userSettings[setting] = value;
|
|
2043
|
+
else userSettings = setting;
|
|
2044
|
+
localStorage.setItem(key, JSON.stringify(userSettings));
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
//#endregion
|
|
2048
|
+
//#region src/index.ts
|
|
2049
|
+
const version = "2.11.10";
|
|
2050
|
+
|
|
2051
|
+
//#endregion
|
|
2052
|
+
exports.ConfirmButtons = ConfirmButtons;
|
|
2053
|
+
exports.NO_FILTER_ID = NO_FILTER_ID;
|
|
2054
|
+
exports.RELATIVE_DATE_VALUES = RELATIVE_DATE_VALUES;
|
|
2055
|
+
exports.SessionHandler = SessionHandler;
|
|
2056
|
+
exports.UserSession = UserSession;
|
|
2057
|
+
exports.addGroupAtPath = addGroupAtPath;
|
|
2058
|
+
exports.alert = alert;
|
|
2059
|
+
exports.canDeleteSavedFilter = canDeleteSavedFilter;
|
|
2060
|
+
exports.canEditSavedFilter = canEditSavedFilter;
|
|
2061
|
+
exports.canRenameSavedFilter = canRenameSavedFilter;
|
|
2062
|
+
exports.canSetCrossDomain = canSetCrossDomain;
|
|
2063
|
+
exports.canShareSavedFilter = canShareSavedFilter;
|
|
2064
|
+
exports.changeOperator = changeOperator;
|
|
2065
|
+
exports.coerceParameterValue = coerceParameterValue;
|
|
2066
|
+
exports.coerceToStandardLayout = coerceToStandardLayout;
|
|
2067
|
+
exports.confirm = confirm;
|
|
2068
|
+
exports.convertFilter = convertFilter;
|
|
2069
|
+
exports.countExpressions = countExpressions;
|
|
2070
|
+
exports.createEmptyFilter = createEmptyFilter;
|
|
2071
|
+
exports.createEmptyGroup = createEmptyGroup;
|
|
2072
|
+
exports.createExpression = createExpression;
|
|
2073
|
+
exports.decodeXml = decodeXml;
|
|
2074
|
+
exports.describeFilter = describeFilter;
|
|
2075
|
+
exports.downloadBlob = downloadBlob;
|
|
2076
|
+
exports.downloadReport = downloadReport;
|
|
2077
|
+
exports.downloadRequest = downloadRequest;
|
|
2078
|
+
exports.encodeXml = encodeXml;
|
|
2079
|
+
exports.expose = expose;
|
|
2080
|
+
exports.fetchParameterDefault = fetchParameterDefault;
|
|
2081
|
+
exports.findSavedFilter = findSavedFilter;
|
|
2082
|
+
exports.getDefaultOperator = getDefaultOperator;
|
|
2083
|
+
exports.getDefaultParameterValues = getDefaultParameterValues;
|
|
2084
|
+
exports.getDynamicValueLabel = getDynamicValueLabel;
|
|
2085
|
+
exports.getDynamicValuesForField = getDynamicValuesForField;
|
|
2086
|
+
exports.getLocalizedString = getLocalizedString;
|
|
2087
|
+
exports.getNodeAtPath = getNodeAtPath;
|
|
2088
|
+
exports.getOperatorArity = getOperatorArity;
|
|
2089
|
+
exports.getOperatorLabel = getOperatorLabel;
|
|
2090
|
+
exports.getOperatorsForField = getOperatorsForField;
|
|
2091
|
+
exports.getReportExtension = getReportExtension;
|
|
2092
|
+
exports.getReportFileName = getReportFileName;
|
|
2093
|
+
exports.getReportFormats = getReportFormats;
|
|
2094
|
+
exports.getReportMediaType = getReportMediaType;
|
|
2095
|
+
exports.getReportRequest = getReportRequest;
|
|
2096
|
+
exports.getSessionData = getSessionData;
|
|
2097
|
+
exports.getSettings = getSettings;
|
|
2098
|
+
exports.getUserImage = getUserImage;
|
|
2099
|
+
exports.getValueType = getValueType;
|
|
2100
|
+
exports.insertNodeAtPath = insertNodeAtPath;
|
|
2101
|
+
exports.invariant = invariant;
|
|
2102
|
+
exports.isCompleteExpression = isCompleteExpression;
|
|
2103
|
+
exports.isEmptyFilter = isEmptyFilter;
|
|
2104
|
+
exports.isExpression = isExpression;
|
|
2105
|
+
exports.isGroup = isGroup;
|
|
2106
|
+
exports.isProcedureDefault = isProcedureDefault;
|
|
2107
|
+
exports.isStandardLayout = isStandardLayout;
|
|
2108
|
+
exports.localStorage = localStorage_default;
|
|
2109
|
+
exports.localize = localize;
|
|
2110
|
+
exports.logActivity = logActivity;
|
|
2111
|
+
exports.logError = logError;
|
|
2112
|
+
exports.mitt = mitt_default;
|
|
2113
|
+
exports.normalizeOperator = normalizeOperator;
|
|
2114
|
+
exports.openAlertDialog = openAlertDialog;
|
|
2115
|
+
exports.openReport = openReport;
|
|
2116
|
+
exports.parseParameterItems = parseParameterItems;
|
|
2117
|
+
exports.parseParameters = parseParameters;
|
|
2118
|
+
exports.parseRecordSource = parseRecordSource;
|
|
2119
|
+
exports.partitionSavedFilters = partitionSavedFilters;
|
|
2120
|
+
exports.printReport = printReport;
|
|
2121
|
+
exports.pruneFilter = pruneFilter;
|
|
2122
|
+
exports.readElement = readElement;
|
|
2123
|
+
exports.readElements = readElements;
|
|
2124
|
+
exports.removeNodeAtPath = removeNodeAtPath;
|
|
2125
|
+
exports.renderReport = renderReport;
|
|
2126
|
+
exports.resolveParameterDefaults = resolveParameterDefaults;
|
|
2127
|
+
exports.restoreParameterValues = restoreParameterValues;
|
|
2128
|
+
exports.saveSettings = saveSettings;
|
|
2129
|
+
exports.serializeParameters = serializeParameters;
|
|
2130
|
+
exports.setGroupMode = setGroupMode;
|
|
2131
|
+
exports.startActivity = startActivity;
|
|
2132
|
+
exports.time = time;
|
|
2133
|
+
exports.toFieldMetadata = toFieldMetadata;
|
|
2134
|
+
exports.toFieldMetadataList = toFieldMetadataList;
|
|
2135
|
+
exports.toFilterFieldType = toFilterFieldType;
|
|
2136
|
+
exports.toFilterTree = toFilterTree;
|
|
2137
|
+
exports.toLocalDateTime = toLocalDateTime;
|
|
2138
|
+
exports.toLocalISODate = toLocalISODate;
|
|
2139
|
+
exports.toLocalISODateTime = toLocalISODateTime;
|
|
2140
|
+
exports.toReport = toReport;
|
|
2141
|
+
exports.toReportParameter = toReportParameter;
|
|
2142
|
+
exports.toReportParameters = toReportParameters;
|
|
2143
|
+
exports.toSavedFilter = toSavedFilter;
|
|
2144
|
+
exports.toSavedFilters = toSavedFilters;
|
|
2145
|
+
exports.updateNodeAtPath = updateNodeAtPath;
|
|
2146
|
+
exports.version = version;
|
|
2147
|
+
exports.waitForDialog = waitForDialog;
|
|
2148
|
+
return exports;
|
|
2149
|
+
})({});
|