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.
- package/LICENSE +201 -0
- package/README.md +64 -2
- package/index.js +4 -0
- package/package.json +52 -3
- package/src/check/rules.js +379 -0
- package/src/check/summary.js +154 -0
- package/src/cli.js +99 -0
- package/src/commands/check.js +71 -0
- package/src/commands/index.js +7 -0
- package/src/commands/sql.js +65 -0
- package/src/commands/view.js +80 -0
- package/src/errors.js +39 -0
- package/src/graph/duplicates.js +78 -0
- package/src/graph/expr.js +54 -0
- package/src/graph/ids.js +24 -0
- package/src/graph/numbers.js +61 -0
- package/src/graph/parse.js +196 -0
- package/src/graph/schema.js +465 -0
- package/src/graph/trace.js +45 -0
- package/src/help.js +25 -0
- package/src/html/embed.js +142 -0
- package/src/html/page.js +101 -0
- package/src/meta.js +13 -0
- package/src/sql/compose.js +215 -0
- package/src/sql/style.js +193 -0
- package/src/viewer/client/bridge.js +141 -0
- package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
- package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
- package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
- package/src/viewer/client/index.html +95 -0
- package/src/viewer/client/lineage.js +650 -0
- package/src/viewer/client/themes.css +76 -0
- package/src/viewer/client/viewer.css +1418 -0
- package/src/viewer/client/viewer.js +761 -0
- package/src/viewer/payload.js +45 -0
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
+
}
|