htmlineage 0.0.0 → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +64 -2
  3. package/index.js +4 -0
  4. package/package.json +52 -3
  5. package/src/check/rules.js +379 -0
  6. package/src/check/summary.js +154 -0
  7. package/src/cli.js +99 -0
  8. package/src/commands/check.js +71 -0
  9. package/src/commands/index.js +7 -0
  10. package/src/commands/sql.js +65 -0
  11. package/src/commands/view.js +80 -0
  12. package/src/errors.js +39 -0
  13. package/src/graph/duplicates.js +78 -0
  14. package/src/graph/expr.js +54 -0
  15. package/src/graph/ids.js +24 -0
  16. package/src/graph/numbers.js +61 -0
  17. package/src/graph/parse.js +196 -0
  18. package/src/graph/schema.js +465 -0
  19. package/src/graph/trace.js +45 -0
  20. package/src/help.js +25 -0
  21. package/src/html/embed.js +142 -0
  22. package/src/html/page.js +101 -0
  23. package/src/meta.js +13 -0
  24. package/src/sql/compose.js +215 -0
  25. package/src/sql/style.js +193 -0
  26. package/src/viewer/client/bridge.js +141 -0
  27. package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
  28. package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
  30. package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
  31. package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
  32. package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
  33. package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
  34. package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
  35. package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
  36. package/src/viewer/client/index.html +95 -0
  37. package/src/viewer/client/lineage.js +650 -0
  38. package/src/viewer/client/themes.css +76 -0
  39. package/src/viewer/client/viewer.css +1418 -0
  40. package/src/viewer/client/viewer.js +761 -0
  41. package/src/viewer/payload.js +45 -0
  42. package/src/viewer/server.js +188 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * The viewer's logic with no page in it: chain layout, trace state, titles,
3
+ * grounding, SQL highlighting, the status bar and what the copy buttons copy. An
4
+ * ES module with no imports, so the page and the tests load the same file.
5
+ */
6
+
7
+ /** True for a plain object: not null, not an array. */
8
+ const isObject = (x) =>
9
+ typeof x === "object" && x !== null && !Array.isArray(x);
10
+
11
+ /** A graph's nodes as an object, whatever the graph is. */
12
+ export function nodesOf(graph) {
13
+ if (!isObject(graph) || !isObject(graph.nodes)) return {};
14
+ return Object.fromEntries(
15
+ Object.entries(graph.nodes)
16
+ .filter(([, node]) => isObject(node))
17
+ .map(([id, node]) => [
18
+ id,
19
+ "from" in node && !Array.isArray(node.from)
20
+ ? { ...node, from: [] }
21
+ : node,
22
+ ]),
23
+ );
24
+ }
25
+
26
+ /** The trace state of a node, or "ungrounded" for an id not in the graph. */
27
+ export function nodeState(data, id) {
28
+ if (!data.graph.nodes[id]) return "ungrounded";
29
+ return data.trace[id] || "partial";
30
+ }
31
+
32
+ /**
33
+ * Every node under `id`, `id` included, and whether the walk met a cycle or a
34
+ * missing parent on the way.
35
+ */
36
+ function underneath(nodes, id) {
37
+ const seen = new Set();
38
+ const path = new Set();
39
+ let broken = false;
40
+ const walk = (x) => {
41
+ if (!nodes[x] || path.has(x)) return void (broken = true);
42
+ if (seen.has(x)) return;
43
+ seen.add(x);
44
+ path.add(x);
45
+ for (const parent of nodes[x].from ?? []) walk(parent);
46
+ path.delete(x);
47
+ };
48
+ walk(id);
49
+ return { ids: [...seen], broken };
50
+ }
51
+
52
+ /**
53
+ * What a node rests on: "sql" when its trace is full, "file" when an external
54
+ * source is under it, else "none". A cycle, a missing parent or an ungrounded
55
+ * node with no file under it rests on nothing.
56
+ * @returns {"sql" | "file" | "none"}
57
+ */
58
+ export function groundOf(data, id) {
59
+ const nodes = nodesOf(data.graph);
60
+ if (!nodes[id]) return "none";
61
+ if (data.trace[id] === "full") return "sql";
62
+ if (data.trace[id] === "ungrounded") return "none";
63
+ const { ids, broken } = underneath(nodes, id);
64
+ if (broken) return "none";
65
+ const file = ids.some(
66
+ (x) => nodes[x].step === "source" && nodes[x].kind === "external",
67
+ );
68
+ return file ? "file" : "none";
69
+ }
70
+
71
+ /**
72
+ * How much of the report rests on what. Numbers are the figures plus each
73
+ * bare number check found; claims are the insights.
74
+ * @returns {{ numbers: { sql: number, file: number, none: number }, claims: { supported: number, unsupported: number } }}
75
+ */
76
+ export function grounding(data) {
77
+ const numbers = { sql: 0, file: 0, none: 0 };
78
+ const claims = { supported: 0, unsupported: 0 };
79
+ for (const [id, node] of Object.entries(nodesOf(data.graph))) {
80
+ const ground = groundOf(data, id);
81
+ if (node.step === "figure") numbers[ground]++;
82
+ if (node.step === "insight") {
83
+ claims[ground === "none" ? "unsupported" : "supported"]++;
84
+ }
85
+ }
86
+ numbers.none += data.summary.issues.filter((i) => i.code === "C2").length;
87
+ return { numbers, claims };
88
+ }
89
+
90
+ /**
91
+ * The grounding counts as two plain-text lines, or null for a part with
92
+ * nothing in it.
93
+ * @returns {{ numbers: string | null, claims: string | null }}
94
+ */
95
+ export function headline({ numbers, claims }) {
96
+ const plural = (n, one, many) => (n === 1 ? one : many);
97
+ const nTotal = numbers.sql + numbers.file + numbers.none;
98
+ const cTotal = claims.supported + claims.unsupported;
99
+ const out = { numbers: null, claims: null };
100
+ if (nTotal) {
101
+ out.numbers =
102
+ numbers.sql === nTotal
103
+ ? nTotal === 1
104
+ ? "1 number traces to the warehouse."
105
+ : `All ${nTotal} numbers trace to the warehouse.`
106
+ : [
107
+ `${numbers.sql} of ${nTotal} numbers ${plural(numbers.sql, "traces", "trace")} to the warehouse.`,
108
+ numbers.file &&
109
+ `${numbers.file} ${plural(numbers.file, "rests", "rest")} on a file.`,
110
+ numbers.none &&
111
+ `${numbers.none} ${plural(numbers.none, "has nothing behind it", "have nothing behind them")}.`,
112
+ ]
113
+ .filter(Boolean)
114
+ .join(" ");
115
+ }
116
+ if (cTotal) {
117
+ out.claims =
118
+ claims.supported === cTotal
119
+ ? cTotal === 1
120
+ ? "1 claim is supported."
121
+ : `All ${cTotal} claims are supported.`
122
+ : `${claims.supported} of ${cTotal} claims ${plural(claims.supported, "is", "are")} supported. ${claims.unsupported} ${plural(claims.unsupported, "is", "are")} stated without support.`;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Whether check left a sql source's period pinned. Any C7 on it is a problem
129
+ * with the period except one that is only about `mart` or `columns`; C7's
130
+ * message parts are fixed strings, joined with "; ".
131
+ */
132
+ function isPinned(data, id) {
133
+ const aboutPeriod = (part) => {
134
+ const missing = /^sql source is missing (.*)$/.exec(part);
135
+ if (!missing) return true;
136
+ return /`(period|sql)`/.test(missing[1]);
137
+ };
138
+ return !data.summary.issues
139
+ .filter((i) => i.code === "C7" && i.node === id)
140
+ .some((i) => i.message.split("; ").some(aboutPeriod));
141
+ }
142
+
143
+ /**
144
+ * One row per source: what it is, its period, whether check found its period
145
+ * unpinned (a C7), and how many figures rest on it at any depth.
146
+ * @returns {({ node: string, kind: "sql", name: string | null, period: object | null, pinned: boolean, numbers: number } | { node: string, kind: "external", name: string | null, numbers: number })[]}
147
+ */
148
+ export function builtOn(data) {
149
+ const nodes = nodesOf(data.graph);
150
+ const figures = Object.keys(nodes).filter(
151
+ (id) => nodes[id].step === "figure",
152
+ );
153
+ return Object.entries(nodes)
154
+ .filter(([, node]) => node.step === "source")
155
+ .map(([id, node]) => {
156
+ const numbers = figures.filter((f) =>
157
+ underneath(nodes, f).ids.includes(id),
158
+ ).length;
159
+ if (node.kind !== "sql") {
160
+ return { node: id, kind: "external", name: node.ref ?? null, numbers };
161
+ }
162
+ return {
163
+ node: id,
164
+ kind: "sql",
165
+ name: node.mart ?? null,
166
+ period: node.period ?? null,
167
+ pinned: isPinned(data, id),
168
+ numbers,
169
+ };
170
+ });
171
+ }
172
+
173
+ /** The rows of `builtOn` for the sources under `id`, `id` included. */
174
+ export function builtFrom(data, id) {
175
+ const under = underneath(nodesOf(data.graph), id).ids;
176
+ return builtOn(data).filter((row) => under.includes(row.node));
177
+ }
178
+
179
+ /** Where the page and the graph differ: one row per C3, in check's order. */
180
+ export function mismatches(data) {
181
+ return data.summary.issues
182
+ .filter((i) => i.code === "C3")
183
+ .map(({ node, message }) => {
184
+ const read =
185
+ /^page shows ((?:"[^"]*"(?:, )?)+) but (?:display is "(.*)"|value is (.*))$/.exec(
186
+ message,
187
+ );
188
+ if (!read) return { node, page: [], graph: null };
189
+ return {
190
+ node,
191
+ page: [...read[1].matchAll(/"([^"]*)"/g)].map((m) => m[1]),
192
+ graph: read[2] ?? read[3],
193
+ };
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Numbers and claims with nothing behind them: figures, then bare numbers,
199
+ * then claims.
200
+ * @returns {{ node: string | null, kind: "figure" | "bare" | "claim", text: string | null }[]}
201
+ */
202
+ export function untraced(data) {
203
+ const nodes = Object.entries(nodesOf(data.graph));
204
+ const none = (step) =>
205
+ nodes.filter(
206
+ ([id, node]) => node.step === step && groundOf(data, id) === "none",
207
+ );
208
+ const bare = data.summary.issues
209
+ .filter((i) => i.code === "C2")
210
+ .map(({ node, message }) => ({
211
+ node,
212
+ kind: "bare",
213
+ text: /^bare number "(.*)" is not/.exec(message)?.[1] ?? message,
214
+ }));
215
+ return [
216
+ ...none("figure").map(([node, n]) => ({
217
+ node,
218
+ kind: "figure",
219
+ text: n.display ?? null,
220
+ })),
221
+ ...bare,
222
+ ...none("insight").map(([node, n]) => ({
223
+ node,
224
+ kind: "claim",
225
+ text: n.text ?? null,
226
+ })),
227
+ ];
228
+ }
229
+
230
+ /**
231
+ * The status bar's text, every issue for the drawer (errors first), and why
232
+ * copying is off.
233
+ * @returns {{ line: string, issues: object[], reason: string | null }}
234
+ */
235
+ export function checkStatus(data) {
236
+ const all = data.summary.issues;
237
+ const errors = all.filter((i) => i.severity === "error");
238
+ const notes = all.filter((i) => i.severity !== "error");
239
+ const count = (list, one, many) =>
240
+ list.length && `${list.length} ${list.length === 1 ? one : many}`;
241
+ const parts = [
242
+ count(errors, "error", "errors"),
243
+ count(notes, "note", "notes"),
244
+ ];
245
+ return {
246
+ line: ["check", ...(all.length ? parts.filter(Boolean) : ["clean"])].join(
247
+ " · ",
248
+ ),
249
+ issues: [...errors, ...notes],
250
+ reason: data.refused,
251
+ };
252
+ }
253
+
254
+ /** A short plain-text name for a node. */
255
+ export function title(graph, id) {
256
+ const node = graph.nodes[id];
257
+ if (!node) return "Not in the graph";
258
+ if (node.step === "figure") return node.label;
259
+ if (node.step === "view") {
260
+ return leadingComment(node.sql) || node.encoding?.visual || "view";
261
+ }
262
+ if (node.step === "insight") return node.text;
263
+ return node.kind === "sql" ? node.mart : node.ref;
264
+ }
265
+
266
+ /** The text of a SQL script's first line when it is a `--` comment. */
267
+ function leadingComment(sql) {
268
+ const match = /^\s*--\s*(.+)/.exec(sql || "");
269
+ return match ? match[1].trim() : "";
270
+ }
271
+
272
+ /**
273
+ * A node's parents, each once; a `from` that is not a list (check's S4) has
274
+ * none.
275
+ */
276
+ const parentsOf = (graph, x) => {
277
+ const from = graph.nodes[x]?.from;
278
+ return Array.isArray(from) ? [...new Set(from)] : [];
279
+ };
280
+
281
+ // Each graph's users of every node, built the first time it is asked for.
282
+ const USERS = new WeakMap();
283
+
284
+ /** The nodes whose `from` names `x`, in graph order. */
285
+ function usersOf(graph, x) {
286
+ if (!USERS.has(graph)) {
287
+ const users = new Map();
288
+ for (const id of Object.keys(graph.nodes))
289
+ for (const p of parentsOf(graph, id))
290
+ users.set(p, [...(users.get(p) ?? []), id]);
291
+ USERS.set(graph, users);
292
+ }
293
+ return USERS.get(graph).get(x) ?? [];
294
+ }
295
+
296
+ /**
297
+ * The rows under one step of the outline. `branch` is the clicked node, then
298
+ * each opened row down to the step whose rows are wanted.
299
+ * @returns {{ label: string | null, rows: { id: string, key: string, loop: boolean }[] }}
300
+ */
301
+ export function outline(graph, branch, { up = false } = {}) {
302
+ const last = branch.at(-1);
303
+ const next = up ? usersOf(graph, last) : parentsOf(graph, last);
304
+ const rows = next.map((id) => ({
305
+ id,
306
+ key: JSON.stringify([...branch.slice(1), id]),
307
+ loop: branch.includes(id),
308
+ }));
309
+ const word = up ? "Used by" : "Rests on";
310
+ return {
311
+ label: rows.length
312
+ ? `${word}${rows.length > 1 ? ` ${rows.length}` : ""}`
313
+ : null,
314
+ rows,
315
+ };
316
+ }
317
+
318
+ // Each graph's "Open all" keys by clicked step: one key per path below it, so
319
+ // they are walked once, not on every render.
320
+ const KEYS = new WeakMap();
321
+
322
+ /** The keys "Open all" opens: every row under `id`, going down. */
323
+ export function outlineKeys(graph, id) {
324
+ if (!KEYS.has(graph)) KEYS.set(graph, new Map());
325
+ const keys = KEYS.get(graph);
326
+ const walk = (branch) =>
327
+ outline(graph, branch)
328
+ .rows.filter((r) => !r.loop)
329
+ .flatMap((r) => [r.key, ...walk([...branch, r.id])]);
330
+ if (!keys.has(id)) keys.set(id, walk([id]));
331
+ return keys.get(id);
332
+ }
333
+
334
+ /**
335
+ * Whether the clicked step shows its own card: not a claim that rests on
336
+ * something, whose card would only list the rows below it.
337
+ */
338
+ export function ownCard(graph, id) {
339
+ return !(
340
+ graph.nodes[id]?.step === "insight" && parentsOf(graph, id).length > 0
341
+ );
342
+ }
343
+
344
+ /**
345
+ * What a row says about one step: its kind, title and value, and the names of
346
+ * the sources it ends at.
347
+ * @returns {{ id: string, kind: string, title: string | null, value: string | null, onPage: boolean, ends: string[], note: string | null }}
348
+ */
349
+ export function inputRow(data, id, { onPage = [] } = {}) {
350
+ const { kind, title, value } = stepHead(data.graph, id);
351
+ const nodes = nodesOf(data.graph);
352
+ const under = new Set(underneath(nodes, id).ids);
353
+ const ends = Object.keys(nodes)
354
+ .filter((x) => under.has(x) && nodes[x].step === "source")
355
+ .map((x) => (nodes[x].kind === "sql" ? nodes[x].mart : nodes[x].ref) ?? x);
356
+ const note = !nodes[id]
357
+ ? "not in the graph"
358
+ : !ends.length && !parentsOf(data.graph, id).length
359
+ ? "nothing behind it"
360
+ : null;
361
+ return { id, kind, title, value, onPage: onPage.includes(id), ends, note };
362
+ }
363
+
364
+ /** A string with something in it, else null. */
365
+ const words = (x) => (typeof x === "string" && x.trim() ? x : null);
366
+
367
+ /**
368
+ * What a step says about itself in words, and its SQL toggle: `show` is null
369
+ * when the step has no SQL to show. `label` is the word the panel prints.
370
+ * @returns {{ desc: string | null, note: string | null, show: { label: string, open: boolean } | null }}
371
+ */
372
+ export function stepText(graph, id) {
373
+ const node = nodesOf(graph)[id];
374
+ if (!node) return { desc: null, note: null, show: null };
375
+ // An external source says what it is in `given`, an insight in `text`.
376
+ const described = node.step !== "insight" && node.kind !== "external";
377
+ const desc = described ? words(node.desc) : null;
378
+ const note = words(node.transform);
379
+ // A formula is its own calculation, so it has no SQL to toggle.
380
+ if (words(node.sql) === null) return { desc, note, show: null };
381
+ const label = node.step === "source" ? "query" : "SQL";
382
+ return { desc, note, show: { label, open: desc === null } };
383
+ }
384
+
385
+ /** A figure's display, else its value as a string, else null. */
386
+ const valueOf = (node) =>
387
+ node.display ?? (node.value == null ? null : String(node.value));
388
+
389
+ /** The word for each kind of step; "Missing" for an id not in the graph. */
390
+ const kindOf = (node) =>
391
+ ({
392
+ source: "Source",
393
+ view: "View",
394
+ figure: "Figure",
395
+ insight: "Claim",
396
+ })[node?.step] ?? "Missing";
397
+
398
+ /**
399
+ * A step's title line: its kind word, its title and the line under it, and
400
+ * the value at its right. The clicked step (`top`) drops the kind and value,
401
+ * which the panel's head shows.
402
+ * @returns {{ kind: string | null, title: string | null, sub: string | null, value: string | null }}
403
+ */
404
+ export function stepHead(graph, id, { top = false } = {}) {
405
+ const node = nodesOf(graph)[id];
406
+ const { desc } = stepText(graph, id);
407
+ const external = node?.kind === "external";
408
+ if (top) {
409
+ // With no words of its own, point at the SQL, which is open.
410
+ const sql = node?.step === "source" ? "query" : "SQL";
411
+ const pointer = stepText(graph, id).show
412
+ ? `${node.step === "source" ? "Fetched" : "Worked out"} by the ${sql} below.`
413
+ : null;
414
+ const title = (external ? words(node.given) : desc) ?? pointer;
415
+ return { kind: null, title, sub: null, value: null };
416
+ }
417
+ const kind = kindOf(node);
418
+ const value = node?.step === "figure" ? valueOf(node) : null;
419
+ if (node?.step === "source") {
420
+ const sub = external ? words(node.given) : desc;
421
+ return { kind, title: words(title(graph, id)), sub, value };
422
+ }
423
+ return {
424
+ kind,
425
+ title: desc ?? (node ? words(title(graph, id)) : null),
426
+ sub: null,
427
+ value,
428
+ };
429
+ }
430
+
431
+ /**
432
+ * What is selected, for the status bar; null for an id not in the graph.
433
+ * @returns {{ id: string, kind: string, value: string | null, name: string | null } | null}
434
+ */
435
+ export function selection(graph, id) {
436
+ const node = nodesOf(graph)[id];
437
+ if (!node) return null;
438
+ const figure = node.step === "figure";
439
+ return {
440
+ id,
441
+ kind: kindOf(node),
442
+ value: figure ? valueOf(node) : null,
443
+ name: figure ? (node.label ?? null) : title(graph, id),
444
+ };
445
+ }
446
+
447
+ /**
448
+ * What the report holds, as the status bar counts it. Numbers are the figures
449
+ * plus each bare number check found.
450
+ * @returns {{ numbers: number, claims: number, views: number, sources: number }}
451
+ */
452
+ export function reportCounts(data) {
453
+ const steps = Object.values(nodesOf(data.graph)).map((n) => n.step);
454
+ const count = (step) => steps.filter((s) => s === step).length;
455
+ const bare = data.summary.issues.filter((i) => i.code === "C2").length;
456
+ return {
457
+ numbers: count("figure") + bare,
458
+ claims: count("insight"),
459
+ views: count("view"),
460
+ sources: count("source"),
461
+ };
462
+ }
463
+
464
+ /**
465
+ * The structured facts of a step, one shape per kind of step, read from its
466
+ * fields and never from its `desc`; null for an id not in the graph.
467
+ */
468
+ export function stepFacts(data, id) {
469
+ const node = nodesOf(data.graph)[id];
470
+ if (!node) return null;
471
+ const list = (x) => (Array.isArray(x) ? x : []);
472
+ if (node.step === "view") {
473
+ return {
474
+ kind: "view",
475
+ from: list(node.from),
476
+ columns: list(node.columns),
477
+ visual: node.encoding?.visual ?? null,
478
+ };
479
+ }
480
+ if (node.step === "insight") {
481
+ return { kind: "claim", text: node.text ?? null, from: list(node.from) };
482
+ }
483
+ if (node.step === "figure") {
484
+ const shown = {
485
+ value: valueOf(node),
486
+ label: node.label ?? null,
487
+ };
488
+ if (words(node.sql) === null && words(node.expr) !== null) {
489
+ return { kind: "formula", ...shown, expr: node.expr };
490
+ }
491
+ return { kind: "pick", ...shown, from: list(node.from) };
492
+ }
493
+ if (node.kind === "external") {
494
+ return {
495
+ kind: "external",
496
+ ref: node.ref ?? null,
497
+ given: node.given ?? null,
498
+ columns: list(node.columns),
499
+ };
500
+ }
501
+ return {
502
+ kind: "sql",
503
+ mart: node.mart ?? null,
504
+ period: node.period ?? null,
505
+ pinned: isPinned(data, id),
506
+ how: node.how ?? null,
507
+ columns: list(node.columns),
508
+ };
509
+ }
510
+
511
+ const escapeHtml = (text) =>
512
+ String(text).replace(
513
+ /[&<>"]/g,
514
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
515
+ );
516
+
517
+ const KEYWORDS =
518
+ /^(select|from|where|and|or|not|group|by|order|join|left|inner|on|as|sum|count|avg|min|max|rank|over|partition|desc|asc|limit|between|in|with|union|all|case|when|then|else|end|null|is|distinct|having|dateadd|current_date)$/i;
519
+
520
+ /** SQL as HTML, with keywords, strings, comments and numbers in spans. */
521
+ export function highlightSql(sql) {
522
+ return sql
523
+ .split(/('(?:[^']|'')*'|--[^\n]*|\b\w+\b)/)
524
+ .map((token, k) => {
525
+ if (k % 2 === 0) return escapeHtml(token);
526
+ if (token.startsWith("'")) {
527
+ return `<span class="str">${escapeHtml(token)}</span>`;
528
+ }
529
+ if (token.startsWith("--")) {
530
+ return `<span class="cm">${escapeHtml(token)}</span>`;
531
+ }
532
+ if (/^\d+$/.test(token)) return `<span class="num">${token}</span>`;
533
+ return KEYWORDS.test(token) ? `<span class="kw">${token}</span>` : token;
534
+ })
535
+ .join("");
536
+ }
537
+
538
+ /**
539
+ * What a copy button copies. Exactly one of `sql` and `reason` is null: a
540
+ * report that fails check copies nothing, and says why.
541
+ * @returns {{ sql: string | null, reason: string | null }}
542
+ */
543
+ function copyable(data, sql) {
544
+ if (data.refused !== null) return { sql: null, reason: data.refused };
545
+ if (!sql) return { sql: null, reason: "Nothing here to run" };
546
+ return { sql, reason: null };
547
+ }
548
+
549
+ /** The whole report composed into one script. */
550
+ export function reportSql(data) {
551
+ return copyable(data, data.composedReport);
552
+ }
553
+
554
+ /** The script behind one node and everything it rests on. */
555
+ export function chainSql(data, id) {
556
+ return copyable(data, data.composed[id]);
557
+ }
558
+
559
+ /** One step's own SQL. */
560
+ export function stepSql(data, id) {
561
+ return copyable(data, data.graph.nodes[id].sql);
562
+ }
563
+
564
+ /**
565
+ * Copy `text`: with the Clipboard API when there is one and it accepts, else
566
+ * with `execCopy`. Resolves whether the text was copied; never rejects.
567
+ * @param {string} text
568
+ * @param {{ clipboard?: { writeText?: (text: string) => Promise<void> }, execCopy: (text: string) => boolean }} how
569
+ * @returns {Promise<boolean>}
570
+ */
571
+ export async function copyText(text, { clipboard, execCopy }) {
572
+ try {
573
+ await clipboard.writeText(text);
574
+ return true;
575
+ } catch {
576
+ // Also reached when there is no clipboard to call.
577
+ try {
578
+ return execCopy(text);
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+ }
584
+
585
+ /**
586
+ * What a message from the report frame asks the panel to do. A report's own
587
+ * scripts can post anything, so only what a click or a key could do gets
588
+ * through.
589
+ * @returns {{ type: "select", id: string } | { type: "back" } | { type: "ready", counts: object } | null}
590
+ */
591
+ export function fromReport(data, message) {
592
+ if (!isObject(message)) return null;
593
+ if (message.lineage === "back") return { type: "back" };
594
+ if (message.lineage === "select") {
595
+ const { id } = message;
596
+ const known =
597
+ typeof id === "string" && Object.hasOwn(nodesOf(data.graph), id);
598
+ return known ? { type: "select", id } : null;
599
+ }
600
+ if (message.lineage === "ready") {
601
+ const nodes = nodesOf(data.graph);
602
+ const counts = isObject(message.counts) ? message.counts : {};
603
+ return {
604
+ type: "ready",
605
+ counts: Object.fromEntries(
606
+ Object.entries(counts).filter(
607
+ ([id, n]) => Object.hasOwn(nodes, id) && Number.isInteger(n) && n > 0,
608
+ ),
609
+ ),
610
+ };
611
+ }
612
+ return null;
613
+ }
614
+
615
+ /** Whether an expression is only numbers, ids, brackets and + - * /. */
616
+ export const isArithmetic = (expr) =>
617
+ /^[\w\s.+\-*/()]*$/.test(expr) && !/\w\s*\(/.test(expr);
618
+
619
+ /**
620
+ * An expression for the formula line: plain arithmetic is spaced and written
621
+ * with − and ×; any other SQL is shown as written. The sign in 1e-3 stays put.
622
+ */
623
+ export function tidyExpr(expr) {
624
+ if (!/^[\w\s.+\-*/(),]*$/.test(expr)) return expr;
625
+ return expr
626
+ .replace(
627
+ /\s*([*/]|(?<!\b\d+(?:\.\d*)?[eE])[-+])\s*/g,
628
+ (_, op) => ` ${{ "-": "−", "*": "×" }[op] ?? op} `,
629
+ )
630
+ .trim();
631
+ }
632
+
633
+ /**
634
+ * The tidied expression as { id } and { text } parts, for the formula line.
635
+ * Words inside quotes and function names stay text, even when `isId` says yes.
636
+ */
637
+ export function exprParts(expr, isId) {
638
+ const parts = [];
639
+ const pieces = tidyExpr(expr).split(
640
+ /('[^']*'|"[^"]*"|\b[A-Za-z_]\w*\b(?!\s*\())/,
641
+ );
642
+ for (const [k, t] of pieces.entries()) {
643
+ const last = parts.at(-1);
644
+ if (k % 2 && isId(t)) parts.push({ id: t });
645
+ else if (!t) continue;
646
+ else if (last?.text !== undefined) last.text += t;
647
+ else parts.push({ text: t });
648
+ }
649
+ return parts;
650
+ }