@lnsy/data-table 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/README.md +229 -0
- package/package.json +77 -0
- package/src/data-table.css +327 -0
- package/src/file-io.js +203 -0
- package/src/formula.js +632 -0
- package/src/index.js +20 -0
- package/src/table-component.js +1031 -0
- package/src/wikilinks.js +71 -0
- package/styles/fonts.css +111 -0
- package/styles/variables.css +151 -0
package/src/formula.js
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formula Engine
|
|
3
|
+
*
|
|
4
|
+
* A small, dependency-free formula parser and evaluator for grid data.
|
|
5
|
+
*
|
|
6
|
+
* Supported syntax:
|
|
7
|
+
* - Numbers, quoted strings, booleans (TRUE/FALSE)
|
|
8
|
+
* - Cell references: A1, $B$2
|
|
9
|
+
* - Ranges: A1:B10 (only valid inside functions)
|
|
10
|
+
* - Operators: + - * / ^ % & (concat) = <> < > <= >=
|
|
11
|
+
* - Parentheses, unary minus
|
|
12
|
+
* - Function calls: SUM(A1:A5), IF(A1>2, "yes", "no"), ...
|
|
13
|
+
*
|
|
14
|
+
* Endpoint access from calculations:
|
|
15
|
+
* =HTTP("https://…") → response body as text
|
|
16
|
+
* =HTTP("https://….json", "a.b.0") → JSON body, path-extracted value
|
|
17
|
+
* =ENDPOINT("name") → fetches a named endpoint registered on
|
|
18
|
+
* the component via `setEndpoints()`
|
|
19
|
+
*
|
|
20
|
+
* Evaluation is asynchronous because endpoint calls hit the network.
|
|
21
|
+
* Results are cached (with a TTL) so recalcs don't hammer endpoints.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tokenizer
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const TOKEN_RE = new RegExp(
|
|
29
|
+
[
|
|
30
|
+
'\\s+', // whitespace (skipped)
|
|
31
|
+
'\\d+\\.?\\d*(?:[eE][+-]?\\d+)?', // number
|
|
32
|
+
'"(?:[^"]|"")*"', // string ("" escapes ")
|
|
33
|
+
'\\$?[A-Za-z]+\\$?\\d+\\s*:\\s*\\$?[A-Za-z]+\\$?\\d+', // range
|
|
34
|
+
'\\$?[A-Za-z]+\\$?\\d+', // cell ref
|
|
35
|
+
'[A-Za-z_][A-Za-z0-9_.]*', // identifier / function name
|
|
36
|
+
'<>|<=|>=|[-+*/^%&=<>(),:]', // operators & punctuation
|
|
37
|
+
].join('|'),
|
|
38
|
+
'y'
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function tokenize(input) {
|
|
42
|
+
const tokens = [];
|
|
43
|
+
let pos = 0;
|
|
44
|
+
while (pos < input.length) {
|
|
45
|
+
TOKEN_RE.lastIndex = pos;
|
|
46
|
+
const match = TOKEN_RE.exec(input);
|
|
47
|
+
if (!match) {
|
|
48
|
+
throw new Error(`Unexpected character at ${pos}: "${input[pos]}"`);
|
|
49
|
+
}
|
|
50
|
+
const text = match[0];
|
|
51
|
+
if (!/^\s+$/.test(text)) {
|
|
52
|
+
tokens.push({ text, pos });
|
|
53
|
+
}
|
|
54
|
+
pos += text.length;
|
|
55
|
+
}
|
|
56
|
+
return tokens;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Parser — produces an AST of nested arrays/objects
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function parse(tokens) {
|
|
64
|
+
let i = 0;
|
|
65
|
+
|
|
66
|
+
const peek = () => tokens[i];
|
|
67
|
+
const next = () => tokens[i++];
|
|
68
|
+
const expect = (text) => {
|
|
69
|
+
const t = next();
|
|
70
|
+
if (!t || t.text !== text) {
|
|
71
|
+
throw new Error(`Expected "${text}"${t ? ` but found "${t.text}"` : ''}`);
|
|
72
|
+
}
|
|
73
|
+
return t;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function parseExpression() { return parseComparison(); }
|
|
77
|
+
|
|
78
|
+
function parseComparison() {
|
|
79
|
+
let left = parseConcat();
|
|
80
|
+
while (peek() && ['=', '<>', '<', '>', '<=', '>='].includes(peek().text)) {
|
|
81
|
+
const op = next().text;
|
|
82
|
+
const right = parseConcat();
|
|
83
|
+
left = { type: 'binary', op, left, right };
|
|
84
|
+
}
|
|
85
|
+
return left;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseConcat() {
|
|
89
|
+
let left = parseAdditive();
|
|
90
|
+
while (peek() && peek().text === '&') {
|
|
91
|
+
next();
|
|
92
|
+
const right = parseAdditive();
|
|
93
|
+
left = { type: 'binary', op: '&', left, right };
|
|
94
|
+
}
|
|
95
|
+
return left;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseAdditive() {
|
|
99
|
+
let left = parseMultiplicative();
|
|
100
|
+
while (peek() && (peek().text === '+' || peek().text === '-')) {
|
|
101
|
+
const op = next().text;
|
|
102
|
+
const right = parseMultiplicative();
|
|
103
|
+
left = { type: 'binary', op, left, right };
|
|
104
|
+
}
|
|
105
|
+
return left;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseMultiplicative() {
|
|
109
|
+
let left = parseUnary();
|
|
110
|
+
while (peek() && ['*', '/', '%'].includes(peek().text)) {
|
|
111
|
+
const op = next().text;
|
|
112
|
+
const right = parseUnary();
|
|
113
|
+
left = { type: 'binary', op, left, right };
|
|
114
|
+
}
|
|
115
|
+
return left;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseUnary() {
|
|
119
|
+
if (peek() && peek().text === '-') {
|
|
120
|
+
next();
|
|
121
|
+
return { type: 'negate', value: parseUnary() };
|
|
122
|
+
}
|
|
123
|
+
if (peek() && peek().text === '+') {
|
|
124
|
+
next();
|
|
125
|
+
return parseUnary();
|
|
126
|
+
}
|
|
127
|
+
return parsePower();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parsePower() {
|
|
131
|
+
const base = parsePrimary();
|
|
132
|
+
if (peek() && peek().text === '^') {
|
|
133
|
+
next();
|
|
134
|
+
const exponent = parseUnary(); // right associative
|
|
135
|
+
return { type: 'binary', op: '^', left: base, right: exponent };
|
|
136
|
+
}
|
|
137
|
+
return base;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parsePrimary() {
|
|
141
|
+
const token = next();
|
|
142
|
+
if (!token) throw new Error('Unexpected end of formula');
|
|
143
|
+
|
|
144
|
+
const text = token.text;
|
|
145
|
+
|
|
146
|
+
if (text === '(') {
|
|
147
|
+
const expr = parseExpression();
|
|
148
|
+
expect(')');
|
|
149
|
+
return expr;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (/^\d/.test(text)) {
|
|
153
|
+
return { type: 'number', value: parseFloat(text) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (text.startsWith('"')) {
|
|
157
|
+
return { type: 'string', value: text.slice(1, -1).replace(/""/g, '"') };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Range reference like "$A$1:$B$5" (single token)
|
|
161
|
+
if (text.includes(':')) {
|
|
162
|
+
const [a, b] = text.split(':').map((s) => s.trim());
|
|
163
|
+
return { type: 'range', start: parseRefToken(a), end: parseRefToken(b) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Cell reference like "A1" / "$B$2", possibly followed by ":C3"
|
|
167
|
+
if (/^\$?[A-Za-z]+\$?\d+$/.test(text)) {
|
|
168
|
+
const start = parseRefToken(text);
|
|
169
|
+
if (peek() && peek().text === ':') {
|
|
170
|
+
next();
|
|
171
|
+
const t2 = next();
|
|
172
|
+
if (!t2) throw new Error('Expected cell after ":"');
|
|
173
|
+
return { type: 'range', start, end: parseRefToken(t2.text) };
|
|
174
|
+
}
|
|
175
|
+
return { type: 'ref', ...start };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Function call or bare identifier
|
|
179
|
+
if (/^[A-Za-z_]/.test(text)) {
|
|
180
|
+
if (peek() && peek().text === '(') {
|
|
181
|
+
next(); // consume (
|
|
182
|
+
const args = [];
|
|
183
|
+
if (peek() && peek().text === ')') {
|
|
184
|
+
next();
|
|
185
|
+
} else {
|
|
186
|
+
for (;;) {
|
|
187
|
+
args.push(parseExpression());
|
|
188
|
+
if (peek() && peek().text === ',') {
|
|
189
|
+
next();
|
|
190
|
+
} else {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
expect(')');
|
|
195
|
+
}
|
|
196
|
+
return { type: 'call', name: normalizeName(text), args };
|
|
197
|
+
}
|
|
198
|
+
// Bare identifier: TRUE/FALSE only
|
|
199
|
+
const upper = text.toUpperCase();
|
|
200
|
+
if (upper === 'TRUE') return { type: 'boolean', value: true };
|
|
201
|
+
if (upper === 'FALSE') return { type: 'boolean', value: false };
|
|
202
|
+
throw new Error(`Unknown identifier "${text}"`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new Error(`Unexpected token "${text}"`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = parseExpression();
|
|
209
|
+
if (i !== tokens.length) {
|
|
210
|
+
throw new Error(`Unexpected token "${tokens[i].text}" after expression`);
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeName(name) {
|
|
216
|
+
return name.toUpperCase();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseRefToken(text) {
|
|
220
|
+
const cleaned = text.replace(/\$/g, '');
|
|
221
|
+
const m = cleaned.match(/^([A-Za-z]+)(\d+)$/);
|
|
222
|
+
if (!m) throw new Error(`Invalid cell reference "${text}"`);
|
|
223
|
+
return { col: colToIndex(m[1]), row: parseInt(m[2], 10) - 1 };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function colToIndex(letters) {
|
|
227
|
+
let n = 0;
|
|
228
|
+
for (const ch of letters.toUpperCase()) {
|
|
229
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
230
|
+
}
|
|
231
|
+
return n - 1; // zero-based column index
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function indexToCol(index) {
|
|
235
|
+
let s = '';
|
|
236
|
+
let n = index;
|
|
237
|
+
while (n >= 0) {
|
|
238
|
+
s = String.fromCharCode((n % 26) + 65) + s;
|
|
239
|
+
n = Math.floor(n / 26) - 1;
|
|
240
|
+
}
|
|
241
|
+
return s;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Runtime helpers
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
function flattenValues(args) {
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const arg of args) {
|
|
251
|
+
if (Array.isArray(arg)) {
|
|
252
|
+
out.push(...flattenValues(arg));
|
|
253
|
+
} else {
|
|
254
|
+
out.push(arg);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function toNumber(value) {
|
|
261
|
+
if (value === null || value === undefined || value === '') return 0;
|
|
262
|
+
if (typeof value === 'number') return value;
|
|
263
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
264
|
+
const n = parseFloat(String(value).replace(/[$,%]/g, ''));
|
|
265
|
+
if (Number.isNaN(n)) throw new Error(`"${value}" is not a number`);
|
|
266
|
+
return n;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** True when the value can be read as a number (COUNT skips everything else). */
|
|
270
|
+
function isNumeric(value) {
|
|
271
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
272
|
+
if (typeof value !== 'string' || value.trim() === '') return false;
|
|
273
|
+
return !Number.isNaN(parseFloat(value.replace(/[$,%]/g, '')));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function compare(a, b) {
|
|
277
|
+
const na = typeof a === 'number' ? a : parseFloat(a);
|
|
278
|
+
const nb = typeof b === 'number' ? b : parseFloat(b);
|
|
279
|
+
if (!Number.isNaN(na) && !Number.isNaN(nb) &&
|
|
280
|
+
a !== '' && b !== '' && String(a).trim() !== '' && String(b).trim() !== '') {
|
|
281
|
+
return na - nb;
|
|
282
|
+
}
|
|
283
|
+
const sa = String(a ?? '').toLowerCase();
|
|
284
|
+
const sb = String(b ?? '').toLowerCase();
|
|
285
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Extract a dotted path ("a.b.0.c") from parsed JSON. */
|
|
289
|
+
function extractJsonPath(data, path) {
|
|
290
|
+
if (!path) return data;
|
|
291
|
+
let current = data;
|
|
292
|
+
for (const part of path.split('.')) {
|
|
293
|
+
if (current === null || current === undefined) return '';
|
|
294
|
+
current = Array.isArray(current) ? current[parseInt(part, 10)] : current[part];
|
|
295
|
+
}
|
|
296
|
+
return current ?? '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// The engine
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
export class FormulaEngine {
|
|
304
|
+
/**
|
|
305
|
+
* @param {() => (string|number)[][]} getData raw grid contents
|
|
306
|
+
*/
|
|
307
|
+
constructor(getData) {
|
|
308
|
+
this.getData = getData;
|
|
309
|
+
/** computed values keyed "r:c" */
|
|
310
|
+
this.values = new Map();
|
|
311
|
+
/** formulas keyed "r:c" */
|
|
312
|
+
this.formulas = new Map();
|
|
313
|
+
/** named endpoints, e.g. { rates: "https://…" } */
|
|
314
|
+
this.endpoints = {};
|
|
315
|
+
/** ms to cache HTTP results */
|
|
316
|
+
this.httpCacheTTL = 30_000;
|
|
317
|
+
this._httpCache = new Map(); // url -> { time, promise/value }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
setEndpoints(endpoints) {
|
|
321
|
+
this.endpoints = endpoints || {};
|
|
322
|
+
this.clearHttpCache();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
clearHttpCache() {
|
|
326
|
+
this._httpCache.clear();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getRaw(row, col) {
|
|
330
|
+
const data = this.getData();
|
|
331
|
+
const value = data[row]?.[col];
|
|
332
|
+
return value === undefined || value === null ? '' : value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Recompute every formula cell. Returns a map "r:c" → error string. */
|
|
336
|
+
async recalcAll() {
|
|
337
|
+
const data = this.getData();
|
|
338
|
+
this.values.clear();
|
|
339
|
+
this.formulas.clear();
|
|
340
|
+
|
|
341
|
+
for (let r = 0; r < data.length; r++) {
|
|
342
|
+
const row = data[r] || [];
|
|
343
|
+
for (let c = 0; c < row.length; c++) {
|
|
344
|
+
const raw = row[c];
|
|
345
|
+
if (typeof raw === 'string' && raw.startsWith('=')) {
|
|
346
|
+
this.formulas.set(`${r}:${c}`, raw.slice(1));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const errors = new Map();
|
|
352
|
+
for (const key of this.formulas.keys()) {
|
|
353
|
+
try {
|
|
354
|
+
await this.evaluateKey(key, new Set());
|
|
355
|
+
} catch (err) {
|
|
356
|
+
errors.set(key, err.message);
|
|
357
|
+
this.values.set(key, `#ERROR!`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return errors;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async evaluateKey(key, visiting) {
|
|
364
|
+
if (this.values.has(key)) return this.values.get(key);
|
|
365
|
+
if (visiting.has(key)) throw new Error('#CIRCULAR!');
|
|
366
|
+
visiting.add(key);
|
|
367
|
+
|
|
368
|
+
const formula = this.formulas.get(key);
|
|
369
|
+
if (formula === undefined) {
|
|
370
|
+
const [r, c] = key.split(':').map(Number);
|
|
371
|
+
const raw = this.getRaw(r, c);
|
|
372
|
+
const value = raw === '' ? '' : coerceRaw(raw);
|
|
373
|
+
this.values.set(key, value);
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let ast;
|
|
378
|
+
try {
|
|
379
|
+
ast = parse(tokenize(formula));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
throw new Error(`#SYNTAX! (${err.message})`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const value = await this.evalNode(ast, visiting);
|
|
385
|
+
this.values.set(key, value);
|
|
386
|
+
visiting.delete(key);
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async evalNode(node, visiting) {
|
|
391
|
+
switch (node.type) {
|
|
392
|
+
case 'number':
|
|
393
|
+
case 'string':
|
|
394
|
+
case 'boolean':
|
|
395
|
+
return node.value;
|
|
396
|
+
|
|
397
|
+
case 'ref': {
|
|
398
|
+
const key = `${node.row}:${node.col}`;
|
|
399
|
+
if (this.formulas.has(key)) {
|
|
400
|
+
return this.evaluateKey(key, visiting);
|
|
401
|
+
}
|
|
402
|
+
const raw = this.getRaw(node.row, node.col);
|
|
403
|
+
return raw === '' ? '' : coerceRaw(raw);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case 'range': {
|
|
407
|
+
const out = [];
|
|
408
|
+
for (let r = node.start.row; r <= node.end.row; r++) {
|
|
409
|
+
const rowVals = [];
|
|
410
|
+
for (let c = node.start.col; c <= node.end.col; c++) {
|
|
411
|
+
const key = `${r}:${c}`;
|
|
412
|
+
if (this.formulas.has(key)) {
|
|
413
|
+
rowVals.push(await this.evaluateKey(key, visiting));
|
|
414
|
+
} else {
|
|
415
|
+
const raw = this.getRaw(r, c);
|
|
416
|
+
rowVals.push(raw === '' ? '' : coerceRaw(raw));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
out.push(rowVals);
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case 'negate':
|
|
425
|
+
return -toNumber(await this.evalNode(node.value, visiting));
|
|
426
|
+
|
|
427
|
+
case 'binary': {
|
|
428
|
+
const left = await this.evalNode(node.left, visiting);
|
|
429
|
+
const right = await this.evalNode(node.right, visiting);
|
|
430
|
+
return applyBinary(node.op, left, right);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
case 'call':
|
|
434
|
+
return this.callFunction(node.name, node.args, visiting);
|
|
435
|
+
|
|
436
|
+
default:
|
|
437
|
+
throw new Error(`Unknown node type "${node.type}"`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async callFunction(name, argNodes, visiting) {
|
|
442
|
+
// Short-circuiting functions evaluate lazily.
|
|
443
|
+
if (name === 'IF') {
|
|
444
|
+
if (argNodes.length < 2) throw new Error('IF requires 2–3 arguments');
|
|
445
|
+
const cond = await this.evalNode(argNodes[0], visiting);
|
|
446
|
+
if (truthy(cond)) return this.evalNode(argNodes[1], visiting);
|
|
447
|
+
return argNodes.length >= 3 ? this.evalNode(argNodes[2], visiting) : false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (name === 'IFERROR') {
|
|
451
|
+
try {
|
|
452
|
+
return await this.evalNode(argNodes[0], visiting);
|
|
453
|
+
} catch {
|
|
454
|
+
return argNodes.length >= 2 ? this.evalNode(argNodes[1], visiting) : '';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const args = [];
|
|
459
|
+
for (const arg of argNodes) {
|
|
460
|
+
args.push(await this.evalNode(arg, visiting));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const flat = () => flattenValues(args);
|
|
464
|
+
const nums = () => flat().filter((v) => v !== '' && v !== null && v !== undefined)
|
|
465
|
+
.map(toNumber);
|
|
466
|
+
|
|
467
|
+
switch (name) {
|
|
468
|
+
case 'SUM': return nums().reduce((a, b) => a + b, 0);
|
|
469
|
+
case 'AVERAGE':
|
|
470
|
+
case 'AVG': {
|
|
471
|
+
const list = nums();
|
|
472
|
+
return list.length ? list.reduce((a, b) => a + b, 0) / list.length : 0;
|
|
473
|
+
}
|
|
474
|
+
case 'MIN': {
|
|
475
|
+
const list = nums();
|
|
476
|
+
return list.length ? Math.min(...list) : 0;
|
|
477
|
+
}
|
|
478
|
+
case 'MAX': {
|
|
479
|
+
const list = nums();
|
|
480
|
+
return list.length ? Math.max(...list) : 0;
|
|
481
|
+
}
|
|
482
|
+
case 'COUNT': return flat().filter(isNumeric).length;
|
|
483
|
+
case 'COUNTA': return flat().filter((v) => v !== '' && v != null).length;
|
|
484
|
+
case 'ABS': return Math.abs(toNumber(args[0]));
|
|
485
|
+
case 'ROUND': {
|
|
486
|
+
const digits = args.length >= 2 ? toNumber(args[1]) : 0;
|
|
487
|
+
const factor = 10 ** digits;
|
|
488
|
+
return Math.round(toNumber(args[0]) * factor) / factor;
|
|
489
|
+
}
|
|
490
|
+
case 'FLOOR': return Math.floor(toNumber(args[0]));
|
|
491
|
+
case 'CEILING':
|
|
492
|
+
case 'CEIL': return Math.ceil(toNumber(args[0]));
|
|
493
|
+
case 'SQRT': return Math.sqrt(toNumber(args[0]));
|
|
494
|
+
case 'POWER':
|
|
495
|
+
case 'POW': return toNumber(args[0]) ** toNumber(args[1]);
|
|
496
|
+
case 'MOD': return toNumber(args[0]) % toNumber(args[1]);
|
|
497
|
+
case 'CONCAT':
|
|
498
|
+
case 'CONCATENATE':
|
|
499
|
+
return flat().map((v) => String(v ?? '')).join('');
|
|
500
|
+
case 'LEN': return String(args[0] ?? '').length;
|
|
501
|
+
case 'UPPER': return String(args[0] ?? '').toUpperCase();
|
|
502
|
+
case 'LOWER': return String(args[0] ?? '').toLowerCase();
|
|
503
|
+
case 'TRIM': return String(args[0] ?? '').trim();
|
|
504
|
+
case 'AND': return flat().every(truthy);
|
|
505
|
+
case 'OR': return flat().some(truthy);
|
|
506
|
+
case 'NOT': return !truthy(args[0]);
|
|
507
|
+
case 'TODAY': return new Date().toISOString().slice(0, 10);
|
|
508
|
+
case 'NOW': return new Date().toLocaleString();
|
|
509
|
+
|
|
510
|
+
/* ── Endpoint access ─────────────────────────────────── */
|
|
511
|
+
|
|
512
|
+
case 'HTTP': {
|
|
513
|
+
const url = String(args[0] ?? '').trim();
|
|
514
|
+
if (!url) return '';
|
|
515
|
+
const path = args.length >= 2 ? String(args[1]) : null;
|
|
516
|
+
const body = await this.fetchCached(url);
|
|
517
|
+
if (path === null) return typeof body === 'string'
|
|
518
|
+
? body
|
|
519
|
+
: JSON.stringify(body);
|
|
520
|
+
return extractJsonPath(body, path);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
case 'HTTPJSON': {
|
|
524
|
+
const url = String(args[0] ?? '').trim();
|
|
525
|
+
if (!url) return '';
|
|
526
|
+
const body = await this.fetchCached(url, true);
|
|
527
|
+
return extractJsonPath(body, args.length >= 2 ? String(args[1]) : null);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
case 'ENDPOINT': {
|
|
531
|
+
const endpointName = String(args[0] ?? '').trim();
|
|
532
|
+
const url = this.endpoints[endpointName];
|
|
533
|
+
if (!url) throw new Error(`#NOENDPOINT! Unknown endpoint "${endpointName}"`);
|
|
534
|
+
const path = args.length >= 2 ? String(args[1]) : null;
|
|
535
|
+
const body = await this.fetchCached(url);
|
|
536
|
+
if (path === null) return typeof body === 'string'
|
|
537
|
+
? body
|
|
538
|
+
: JSON.stringify(body);
|
|
539
|
+
return extractJsonPath(body, path);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
default:
|
|
543
|
+
throw new Error(`#NAME? Unknown function "${name}"`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Fetch with promise-deduplication and TTL caching. */
|
|
548
|
+
async fetchCached(url, forceJson = false) {
|
|
549
|
+
const cached = this._httpCache.get(url);
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
if (cached && now - cached.time < this.httpCacheTTL) {
|
|
552
|
+
return cached.promise;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const promise = (async () => {
|
|
556
|
+
const response = await fetch(url);
|
|
557
|
+
if (!response.ok) {
|
|
558
|
+
throw new Error(`#HTTP! ${response.status} ${response.statusText}`);
|
|
559
|
+
}
|
|
560
|
+
const contentType = response.headers.get('content-type') || '';
|
|
561
|
+
if (forceJson || contentType.includes('json')) {
|
|
562
|
+
return response.json();
|
|
563
|
+
}
|
|
564
|
+
const text = await response.text();
|
|
565
|
+
try {
|
|
566
|
+
return JSON.parse(text);
|
|
567
|
+
} catch {
|
|
568
|
+
return text;
|
|
569
|
+
}
|
|
570
|
+
})();
|
|
571
|
+
|
|
572
|
+
this._httpCache.set(url, { time: now, promise });
|
|
573
|
+
try {
|
|
574
|
+
return await promise;
|
|
575
|
+
} catch (err) {
|
|
576
|
+
// Don't cache failures for long.
|
|
577
|
+
this._httpCache.set(url, { time: now - this.httpCacheTTL + 3000, promise });
|
|
578
|
+
throw err;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// Value coercion
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
function coerceRaw(raw) {
|
|
588
|
+
if (typeof raw !== 'string') return raw;
|
|
589
|
+
const trimmed = raw.trim();
|
|
590
|
+
if (trimmed === '') return raw;
|
|
591
|
+
if (/^-?\d+\.?\d*(?:[eE][+-]?\d+)?$/.test(trimmed)) return parseFloat(trimmed);
|
|
592
|
+
return raw;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function applyBinary(op, left, right) {
|
|
596
|
+
switch (op) {
|
|
597
|
+
case '+': return toNumber(left) + toNumber(right);
|
|
598
|
+
case '-': return toNumber(left) - toNumber(right);
|
|
599
|
+
case '*': return toNumber(left) * toNumber(right);
|
|
600
|
+
case '/': return toNumber(left) / toNumber(right);
|
|
601
|
+
case '%': return toNumber(left) % toNumber(right);
|
|
602
|
+
case '^': return toNumber(left) ** toNumber(right);
|
|
603
|
+
case '&': return `${displayValue(left)}${displayValue(right)}`;
|
|
604
|
+
case '=': return compare(left, right) === 0;
|
|
605
|
+
case '<>': return compare(left, right) !== 0;
|
|
606
|
+
case '<': return compare(left, right) < 0;
|
|
607
|
+
case '>': return compare(left, right) > 0;
|
|
608
|
+
case '<=': return compare(left, right) <= 0;
|
|
609
|
+
case '>=': return compare(left, right) >= 0;
|
|
610
|
+
default: throw new Error(`Unknown operator "${op}"`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function truthy(value) {
|
|
615
|
+
if (typeof value === 'boolean') return value;
|
|
616
|
+
if (typeof value === 'number') return value !== 0;
|
|
617
|
+
if (typeof value === 'string') {
|
|
618
|
+
if (value.toUpperCase() === 'TRUE') return true;
|
|
619
|
+
if (value.toUpperCase() === 'FALSE') return false;
|
|
620
|
+
return value !== '';
|
|
621
|
+
}
|
|
622
|
+
return Boolean(value);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function displayValue(value) {
|
|
626
|
+
if (value === null || value === undefined) return '';
|
|
627
|
+
if (typeof value === 'number') {
|
|
628
|
+
return Number.isInteger(value) ? String(value) : String(Math.round(value * 1e10) / 1e10);
|
|
629
|
+
}
|
|
630
|
+
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
|
|
631
|
+
return String(value);
|
|
632
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data-table package entry point.
|
|
3
|
+
*
|
|
4
|
+
* This is the only public entry of the npm package. Importing it registers
|
|
5
|
+
* the <data-table> custom element (see table-component.js) and exports its
|
|
6
|
+
* class for programmatic use:
|
|
7
|
+
*
|
|
8
|
+
* import '@lnsy/data-table'; // registers <data-table>
|
|
9
|
+
* import DataTable from '@lnsy/data-table'; // the element class
|
|
10
|
+
*
|
|
11
|
+
* The component stylesheet ships separately:
|
|
12
|
+
*
|
|
13
|
+
* import '@lnsy/data-table/data-table.css';
|
|
14
|
+
*
|
|
15
|
+
* Application/demo wiring (command panel, file menus, endpoint defaults)
|
|
16
|
+
* is not part of the package — it lives in the repository's root index.js.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { default } from './table-component.js';
|
|
20
|
+
export { default as DataTable } from './table-component.js';
|