@protoboardai/cli 0.0.0-stage → 0.2.1
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 +202 -0
- package/NOTICE +13 -0
- package/README.md +61 -2
- package/bin/protoboard.mjs +5 -0
- package/build-info.json +8 -0
- package/package.json +54 -4
- package/src/agents.ts +235 -0
- package/src/bom.ts +145 -0
- package/src/dev/build-worker.ts +63 -0
- package/src/dev/layout.ts +199 -0
- package/src/dev/page.ts +284 -0
- package/src/dev/parts.ts +356 -0
- package/src/dev/relay.d.ts +60 -0
- package/src/dev/relay.js +147 -0
- package/src/dev/server.ts +727 -0
- package/src/dev/sources.ts +120 -0
- package/src/gate.ts +102 -0
- package/src/index.ts +2024 -0
- package/src/layout.ts +166 -0
- package/src/mcp/instructions.ts +60 -0
- package/src/mcp/server.ts +215 -0
- package/src/mcp/tools.ts +392 -0
- package/src/openUrl.ts +71 -0
- package/src/setup.ts +298 -0
- package/src/stack-messages.ts +53 -0
- package/src/views-check.ts +267 -0
package/src/bom.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `protoboard bom` (PB-904, PB-933): the project's bill of materials, the
|
|
3
|
+
* structured model the BOM views draw (`@protoboardai/core/bom`): what one
|
|
4
|
+
* unit needs, the offers the project holds, the offer chosen for each line,
|
|
5
|
+
* what must be bought for N units (whole packs, minimum orders), and both
|
|
6
|
+
* costs in dollars. Lines come from the design, or from a release's
|
|
7
|
+
* ordering list with `--release`. Offline: it reads what the project holds
|
|
8
|
+
* and fetches nothing.
|
|
9
|
+
*/
|
|
10
|
+
import { definitionTrust, formatPassive, placementPart, type Project } from "@protoboardai/core";
|
|
11
|
+
import { bomLines, formatUnitUsd, formatUsd, type Bom, type BomLine } from "@protoboardai/core/bom";
|
|
12
|
+
import { bomFileProblems, listReleases, projectBom as sharedBom, writeBomSettings, type CostIndex } from "@protoboardai/ext-sourcing/lib/cost.ts";
|
|
13
|
+
|
|
14
|
+
/** The bill's view of a loaded project. */
|
|
15
|
+
export function projectCostIndex(p: Project): CostIndex {
|
|
16
|
+
const caps = new Map<string, { capability: string; provider: string }>();
|
|
17
|
+
for (const b of p.capabilities) if (b.module && !b.problem && !caps.has(b.module)) caps.set(b.module, { capability: b.capability, provider: b.provider });
|
|
18
|
+
const byPath = new Map(p.instances.map((i) => [i.path, i]));
|
|
19
|
+
let trust: ReturnType<typeof definitionTrust> | undefined;
|
|
20
|
+
return {
|
|
21
|
+
instances: p.instances,
|
|
22
|
+
definition: (id) => p.definitions.get(id)?.def as ReturnType<CostIndex["definition"]>,
|
|
23
|
+
// a researched part's CAD config models a bought part; only the project's own designs are made
|
|
24
|
+
capability: (id) => (p.definitions.get(id)?.origin.tier === "project-specific" ? caps.get(id) : undefined),
|
|
25
|
+
placement: (path) => {
|
|
26
|
+
const inst = byPath.get(path);
|
|
27
|
+
const def = inst && p.lookup(inst.definition);
|
|
28
|
+
if (!def) return undefined;
|
|
29
|
+
const placed = placementPart(def, inst.overrides);
|
|
30
|
+
return { ...(placed.mpn ? { mpn: placed.mpn } : {}), ...(placed.value ? { value: formatPassive(placed.value) } : {}) };
|
|
31
|
+
},
|
|
32
|
+
partStatus: (id) => (trust ??= definitionTrust(p))[id]?.trust,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The bill, or why there is none. `release` takes the lines of that release's ordering list instead of the design. */
|
|
37
|
+
export function projectBom(p: Project, options: { release?: string; units?: number; now?: string } = {}): Bom {
|
|
38
|
+
if (options.units !== undefined && !(Number.isInteger(options.units) && options.units > 0)) throw new Error("--units is a whole number, 1 or more");
|
|
39
|
+
const problems = bomFileProblems(p.root);
|
|
40
|
+
if (problems.length) throw new Error(problems.map((x) => `${x.file}: ${x.message}`).join("\n"));
|
|
41
|
+
let release;
|
|
42
|
+
if (options.release) {
|
|
43
|
+
const all = listReleases(p.root);
|
|
44
|
+
release = all.find((r) => r.version === options.release);
|
|
45
|
+
if (!release) throw new Error(`no release ${options.release} in release/ (releases: ${all.map((r) => r.version).join(", ") || "none"})`);
|
|
46
|
+
if (!release.hasOrderingList) throw new Error(`release ${options.release} has no ordering list (release/${options.release}/system/ordering-list.csv)`);
|
|
47
|
+
}
|
|
48
|
+
const bom = sharedBom(p.root, p.name, projectCostIndex(p), { ...(release ? { release } : {}), ...(options.units ? { units: options.units } : {}), ...(options.now ? { now: options.now } : {}) });
|
|
49
|
+
if (!bom || !bom.counts.lines) throw new Error("nothing to list: the system has no parts yet");
|
|
50
|
+
return bom;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The line a person names: its key, its definition id, or its ordering code. */
|
|
54
|
+
export function findLine(bom: Bom, name: string): BomLine {
|
|
55
|
+
const lines = bomLines(bom);
|
|
56
|
+
const hit = lines.filter((l) => l.key === name);
|
|
57
|
+
const found = hit.length ? hit : lines.filter((l) => l.definition === name || (l.mpn && l.mpn.toLowerCase() === name.toLowerCase()));
|
|
58
|
+
if (found.length === 1) return found[0];
|
|
59
|
+
if (!found.length) throw new Error(`no line "${name}" in the bill of materials (lines: ${lines.map((l) => l.key).join(", ")})`);
|
|
60
|
+
throw new Error(`"${name}" names ${found.length} lines; use one of: ${found.map((l) => l.key).join(", ")}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Choose a line's offer by its id or its supplier's name; `auto` goes back to the best offer by the rules. Writes parts/bom.json. */
|
|
64
|
+
export function chooseOffer(p: Project, lineName: string, offerName: string): { line: string; offer: string | null; supplier?: string } {
|
|
65
|
+
const bom = projectBom(p);
|
|
66
|
+
const line = findLine(bom, lineName);
|
|
67
|
+
if (line.kind !== "buy") throw new Error(`${line.key} is ${line.kind === "make" ? "made, not bought" : "included with another part"}: it has no offers`);
|
|
68
|
+
if (offerName === "auto") {
|
|
69
|
+
writeBomSettings(p.root, { offer: { line: line.key, offer: null } });
|
|
70
|
+
return { line: line.key, offer: null };
|
|
71
|
+
}
|
|
72
|
+
const byId = line.offers.filter((o) => o.id === offerName);
|
|
73
|
+
const found = byId.length ? byId : line.offers.filter((o) => o.supplier.toLowerCase() === offerName.toLowerCase());
|
|
74
|
+
if (!found.length) throw new Error(`${line.key} holds no offer "${offerName}" (offers: ${line.offers.map((o) => o.id).join(", ") || "none; fetch them with `protoboard parts offers`"})`);
|
|
75
|
+
if (found.length > 1) throw new Error(`"${offerName}" names ${found.length} offers of ${line.key}; use an id: ${found.map((o) => o.id).join(", ")}`);
|
|
76
|
+
writeBomSettings(p.root, { offer: { line: line.key, offer: found[0].id } });
|
|
77
|
+
return { line: line.key, offer: found[0].id, supplier: found[0].supplier };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const day = (iso?: string) => (iso ? iso.slice(0, 10) : "");
|
|
81
|
+
|
|
82
|
+
/** The offers a line holds, best first, for the units the bill is priced at. */
|
|
83
|
+
export function formatOffers(bom: Bom, line: BomLine): string {
|
|
84
|
+
const out = [`${line.item} (${line.key}) · ${line.quantity} per unit × ${bom.units} = ${line.needed} needed`];
|
|
85
|
+
if (!line.offers.length) out.push(" no offers held (`protoboard parts offers` fetches the library's)");
|
|
86
|
+
for (const q of line.quotes) {
|
|
87
|
+
const o = line.offers.find((x) => x.id === q.offer)!;
|
|
88
|
+
const price = q.unitPrice !== undefined ? `${formatUnitUsd(q.unitPrice)} × ${q.buy} = ${formatUsd(q.orderCost!)}` : q.problem === "no-rate" ? `no rate for ${o.currency}` : "no price";
|
|
89
|
+
const marks = [
|
|
90
|
+
q.offer === line.chosen ? (line.choice === "pinned" ? "chosen" : "used") : "",
|
|
91
|
+
q.native ? `from ${q.native.unitPrice} ${q.native.currency}` : "",
|
|
92
|
+
q.packSize > 1 ? `pack of ${q.packSize}` : "",
|
|
93
|
+
q.minOrder > 1 ? `min ${q.minOrder}` : "",
|
|
94
|
+
typeof o.stock === "number" ? `stock ${o.stock}` : "",
|
|
95
|
+
q.stale ? "stale" : "",
|
|
96
|
+
q.fit === "reel-over" ? "full reel" : "",
|
|
97
|
+
day(o.observedAt),
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
out.push(` ${q.offer === line.chosen ? "▸" : " "} ${o.id.padEnd(44)} ${price.padEnd(30)} ${marks.join(" · ")}`);
|
|
100
|
+
}
|
|
101
|
+
return out.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The bill as text: one block per module, both totals last. */
|
|
105
|
+
export function formatBom(t: Bom): string {
|
|
106
|
+
const src = t.source.kind === "design" ? "the design" : t.source.kind === "release" ? `release ${t.source.version} (${t.source.file})` : t.source.file;
|
|
107
|
+
const cols = (l: BomLine) => {
|
|
108
|
+
const o = l.offers.find((x) => x.id === l.chosen);
|
|
109
|
+
const q = l.quote;
|
|
110
|
+
const right =
|
|
111
|
+
l.status === "priced"
|
|
112
|
+
? `${formatUnitUsd(q!.unitPrice!).padStart(9)} ${formatUsd(l.perUnitCost!).padStart(10)} ${String(q!.buy).padStart(6)} ${formatUsd(l.orderCost!).padStart(11)} ${o!.supplier}${l.choice === "pinned" ? " (chosen)" : ""}`
|
|
113
|
+
: l.status === "make"
|
|
114
|
+
? "needs a quote"
|
|
115
|
+
: l.status === "included"
|
|
116
|
+
? "included"
|
|
117
|
+
: "unpriced";
|
|
118
|
+
return ` ${l.item.slice(0, 38).padEnd(38)} ${(l.mpn || "—").slice(0, 18).padEnd(18)} ${String(l.quantity).padStart(4)} ${right}`;
|
|
119
|
+
};
|
|
120
|
+
const notable = (l: BomLine) => (l.status === "priced" || l.status === "unpriced" ? l.notes : []);
|
|
121
|
+
const lines = [
|
|
122
|
+
`Bill of materials · ${t.project} · ${src} · ${t.units} unit${t.units === 1 ? "" : "s"} · ${t.currency}`,
|
|
123
|
+
"",
|
|
124
|
+
` ${"Part".padEnd(38)} ${"MPN".padEnd(18)} ${"Qty".padStart(4)} ${"Unit".padStart(9)} ${"Per unit".padStart(10)} ${"Buy".padStart(6)} ${"Order".padStart(11)} Supplier`,
|
|
125
|
+
...t.groups.flatMap((g) => [
|
|
126
|
+
"",
|
|
127
|
+
`${g.name}${g.quantity > 1 ? ` ×${g.quantity}` : ""} (${g.lines.length} line${g.lines.length === 1 ? "" : "s"}${g.orderCost ? `, ${formatUsd(g.orderCost)}` : ""})`,
|
|
128
|
+
...g.lines.flatMap((l) => [cols(l), ...notable(l).map((n) => ` ${n}`)]),
|
|
129
|
+
]),
|
|
130
|
+
"",
|
|
131
|
+
t.counts.priced
|
|
132
|
+
? `Per unit: ${formatUsd(t.totals.perUnit)} (the parts one unit uses)`
|
|
133
|
+
: "Per unit: nothing priced",
|
|
134
|
+
...(t.counts.priced
|
|
135
|
+
? [`Order: ${formatUsd(t.totals.order)} for ${t.units} unit${t.units === 1 ? "" : "s"}${t.units > 1 ? ` (${formatUsd(t.totals.orderPerUnit)} each)` : ""}${t.totals.surplus > 0 ? `; ${formatUsd(t.totals.surplus)} of it buys pieces left over (packs and minimum orders)` : ""}`]
|
|
136
|
+
: []),
|
|
137
|
+
`Lines: ${t.counts.priced} of ${t.counts.buy} bought line(s) priced, ${t.counts.unpriced} unpriced, ${t.counts.make} made (need a quote), ${t.counts.included} included${t.counts.stale ? `; ${t.counts.stale} stale price(s)` : ""}`,
|
|
138
|
+
...(t.fx.used.length
|
|
139
|
+
? [`Converted: ${t.counts.converted} line(s), with the ${t.fx.source}${t.fx.project ? " (parts/fx-rates.json)" : ""} of ${t.fx.date}: ${t.fx.used.map((u) => `1 ${u.currency} = ${u.rate} USD`).join(", ")}`]
|
|
140
|
+
: []),
|
|
141
|
+
...(t.prices.newest ? [`Prices: observed ${day(t.prices.oldest)}${day(t.prices.oldest) !== day(t.prices.newest) ? ` to ${day(t.prices.newest)}` : ""}${t.offersRetrievedAt ? `; the library's offers were fetched ${day(t.offersRetrievedAt)}` : ""}. Tax and shipping are not included.`] : []),
|
|
142
|
+
...(t.unknownChoices.length ? [`Note: parts/bom.json chooses offers for lines the bill no longer has: ${t.unknownChoices.join(", ")}`] : []),
|
|
143
|
+
];
|
|
144
|
+
return lines.join("\n");
|
|
145
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `protoboard dev` build, in a process of its own: load the project (every
|
|
3
|
+
* validator that can run), write the UHD bundle atomically, and print one JSON
|
|
4
|
+
* line describing the result. Next to the bundle it writes where each
|
|
5
|
+
* definition and instance is declared (`sources.json`, ./sources.ts), for
|
|
6
|
+
* the viewer's openSource. A fresh process per build means every project
|
|
7
|
+
* source is evaluated again; the dev server's own module cache never holds a
|
|
8
|
+
* stale definition.
|
|
9
|
+
*
|
|
10
|
+
* node --import <tsx> build-worker.ts <project root> <bundle file>
|
|
11
|
+
*/
|
|
12
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, join } from "node:path";
|
|
14
|
+
import { definitionTrust, loadProject, resolveViews, stableStringify, uhdBundle, type PartTrust, type ResolvedView } from "@protoboardai/core";
|
|
15
|
+
import { SOURCES_FILE, sourceIndex } from "./sources.js";
|
|
16
|
+
|
|
17
|
+
export interface BuildReport {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
/** Milliseconds spent loading, checking and writing. */
|
|
20
|
+
ms: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
bytes?: number;
|
|
23
|
+
counts?: { error: number; warning: number; info: number };
|
|
24
|
+
/** What the dev server watches, from the project's configuration. */
|
|
25
|
+
watch?: { display: string; sources: string[]; catalog: string; scenarios?: string };
|
|
26
|
+
/** Views the project's extensions contribute (`contributes.views`), for the web viewer's tabs. */
|
|
27
|
+
views?: ResolvedView[];
|
|
28
|
+
/** Every definition's part status by id (PB-932): the viewer's shells mark the modules with it. */
|
|
29
|
+
partStatus?: Record<string, { trust: PartTrust; basis: string }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const [root, bundleFile] = process.argv.slice(2);
|
|
34
|
+
const t0 = Date.now();
|
|
35
|
+
const report = (r: Omit<BuildReport, "ms">) => process.stdout.write(JSON.stringify({ ...r, ms: Date.now() - t0 }) + "\n");
|
|
36
|
+
try {
|
|
37
|
+
const p = await loadProject(root);
|
|
38
|
+
const watch = {
|
|
39
|
+
display: p.config.display ?? ".protoboard/display.json",
|
|
40
|
+
sources: p.config.sources ?? ["src/**/*.uhd.ts"],
|
|
41
|
+
catalog: p.config.catalog ?? "parts/catalog",
|
|
42
|
+
...(p.config.scenarios ? { scenarios: p.config.scenarios.source } : {}),
|
|
43
|
+
};
|
|
44
|
+
const data = stableStringify(await uhdBundle(p));
|
|
45
|
+
// the server may be reading either file: replace each in one step
|
|
46
|
+
const write = (file: string, content: string) => {
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
48
|
+
const tmp = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
|
|
49
|
+
writeFileSync(tmp, content);
|
|
50
|
+
renameSync(tmp, file);
|
|
51
|
+
};
|
|
52
|
+
write(join(dirname(bundleFile), SOURCES_FILE), JSON.stringify(sourceIndex(p)));
|
|
53
|
+
write(bundleFile, data);
|
|
54
|
+
const n = (s: string) => p.diagnostics.filter((d) => d.severity === s).length;
|
|
55
|
+
const { views } = resolveViews(p.extensions);
|
|
56
|
+
report({ ok: true, bytes: data.length, counts: { error: n("error"), warning: n("warning"), info: n("info") }, watch, ...(views.length ? { views } : {}), partStatus: definitionTrust(p) });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
report({ ok: false, error: (e as Error).message.split("\n")[0] });
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await main();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout edits from the viewer → `.protoboard/display.json` (PB-815).
|
|
3
|
+
*
|
|
4
|
+
* The viewer reports what the user changed as a `ViewerLayout`: dragged node
|
|
5
|
+
* positions and link layouts (waypoints, label and harness-tab placement) per
|
|
6
|
+
* drill level. The project keeps its diagram layout as display hints:
|
|
7
|
+
* `diagram.layouts[<level>]`, a `LevelLayout` whose top-level nodes are
|
|
8
|
+
* absolute and whose nodes inside a container are relative to it. This module
|
|
9
|
+
* folds the first into the second.
|
|
10
|
+
*
|
|
11
|
+
* Two facts about the viewer (uhd-viewer 0.1.0) shape it:
|
|
12
|
+
*
|
|
13
|
+
* - Dragged positions arrive in absolute canvas coordinates for every node,
|
|
14
|
+
* children included (its Canvas adds the parent's offset before reporting a
|
|
15
|
+
* move). A child's relative position is its absolute position minus its
|
|
16
|
+
* container's, where the container is placed as the viewer places it: by
|
|
17
|
+
* its own edit, else by its hand position inside its own container.
|
|
18
|
+
* - A level with a hand layout is not laid out automatically: nodes the hand
|
|
19
|
+
* layout does not name sit at (0, 0) of their container. So the first save
|
|
20
|
+
* of a level that has no hand layout yet must name every node of the level.
|
|
21
|
+
* Those positions come from the page (`LayoutSnapshot`, read from the
|
|
22
|
+
* rendered diagram); without a snapshot that covers the edits, the level is
|
|
23
|
+
* not written and is reported as skipped.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** As in the viewer's host contract (`@deltarobotics/uhd-viewer`, `LinkLayout`). */
|
|
27
|
+
export interface LinkLayout {
|
|
28
|
+
waypoints?: [number, number][];
|
|
29
|
+
labelT?: number;
|
|
30
|
+
labelFlip?: boolean;
|
|
31
|
+
tabT?: number;
|
|
32
|
+
tabFlip?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** As in the viewer's host contract (`ViewerLayout`): both maps keyed by drill level ("" = top). */
|
|
36
|
+
export interface ViewerLayout {
|
|
37
|
+
positions: Record<string, Record<string, { x: number; y: number }>>;
|
|
38
|
+
links: Record<string, Record<string, LinkLayout>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One level of `diagram.layouts` in display.json, as the viewer reads it. */
|
|
42
|
+
export interface LevelLayout {
|
|
43
|
+
nodes: Record<string, { x?: number; y?: number; w?: number; h?: number }>;
|
|
44
|
+
ports?: Record<string, string>;
|
|
45
|
+
links?: Record<string, LinkLayout>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DisplayFile {
|
|
49
|
+
schema?: string;
|
|
50
|
+
diagram?: { layouts?: Record<string, LevelLayout>; [key: string]: unknown };
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the page sees on the canvas when it sends a layout: the level on screen. */
|
|
55
|
+
export interface LayoutSnapshot {
|
|
56
|
+
/** Absolute position of every node drawn, by node id. */
|
|
57
|
+
nodes: Record<string, { x: number; y: number }>;
|
|
58
|
+
/** Ids of the edges drawn (a link's edges start with its id). */
|
|
59
|
+
edges: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const DISPLAY_SCHEMA = "uhd.display/v0";
|
|
63
|
+
|
|
64
|
+
export interface LayoutMerge {
|
|
65
|
+
display: DisplayFile;
|
|
66
|
+
/** Levels whose layout changed. */
|
|
67
|
+
levels: string[];
|
|
68
|
+
/** Levels with edits that could not be written, and why. */
|
|
69
|
+
skipped: { level: string; reason: string }[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parentOf = (id: string) => (id.includes("/") ? id.slice(0, id.lastIndexOf("/")) : undefined);
|
|
73
|
+
const depth = (id: string) => id.split("/").length;
|
|
74
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
75
|
+
|
|
76
|
+
/** Whether the snapshot shows the level these edits were made on. */
|
|
77
|
+
function shows(snapshot: LayoutSnapshot | undefined, nodes: string[], links: string[]): boolean {
|
|
78
|
+
if (!snapshot) return false;
|
|
79
|
+
if (!nodes.every((id) => snapshot.nodes[id])) return false;
|
|
80
|
+
return links.every((id) => snapshot.edges.some((e) => e === id || e.startsWith(id)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Folds the viewer's edits into a display file. Returns a new display (the
|
|
85
|
+
* input is not modified); `levels` is empty when nothing changed.
|
|
86
|
+
*/
|
|
87
|
+
export function applyViewerLayout(input: DisplayFile, layout: ViewerLayout, snapshot?: LayoutSnapshot): LayoutMerge {
|
|
88
|
+
const display: DisplayFile = structuredClone(input ?? {});
|
|
89
|
+
display.schema ??= DISPLAY_SCHEMA;
|
|
90
|
+
display.diagram ??= {};
|
|
91
|
+
display.diagram.layouts ??= {};
|
|
92
|
+
const layouts = display.diagram.layouts;
|
|
93
|
+
const levels: string[] = [];
|
|
94
|
+
const skipped: LayoutMerge["skipped"] = [];
|
|
95
|
+
|
|
96
|
+
for (const level of [...new Set([...Object.keys(layout.positions ?? {}), ...Object.keys(layout.links ?? {})])].sort()) {
|
|
97
|
+
const moved = layout.positions?.[level] ?? {};
|
|
98
|
+
const linkEdits = layout.links?.[level] ?? {};
|
|
99
|
+
if (!Object.keys(moved).length && !Object.keys(linkEdits).length) continue;
|
|
100
|
+
const before = layouts[level];
|
|
101
|
+
let hand: LevelLayout;
|
|
102
|
+
if (before) hand = structuredClone(before);
|
|
103
|
+
else {
|
|
104
|
+
// first save of an automatically laid out level: name every node it shows
|
|
105
|
+
if (!shows(snapshot, Object.keys(moved), Object.keys(linkEdits))) {
|
|
106
|
+
skipped.push({ level, reason: `level "${level}" has no saved layout yet and is not the one on screen; its edits stay in the viewer until it is saved from that level` });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
hand = { nodes: {} };
|
|
110
|
+
const abs = snapshot!.nodes;
|
|
111
|
+
for (const id of Object.keys(abs).sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))) {
|
|
112
|
+
const parent = parentOf(id);
|
|
113
|
+
const origin = parent && abs[parent] ? abs[parent] : { x: 0, y: 0 };
|
|
114
|
+
hand.nodes[id] = { x: abs[id].x - origin.x, y: abs[id].y - origin.y };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// absolute placement as the viewer computes it: a node's own edit, else its container's placement plus its hand offset
|
|
119
|
+
const placed = new Map<string, { x: number; y: number }>();
|
|
120
|
+
const absolute = (id: string): { x: number; y: number } => {
|
|
121
|
+
const hit = placed.get(id);
|
|
122
|
+
if (hit) return hit;
|
|
123
|
+
const parent = parentOf(id);
|
|
124
|
+
const own = moved[id];
|
|
125
|
+
let at: { x: number; y: number };
|
|
126
|
+
if (own) at = { x: own.x, y: own.y };
|
|
127
|
+
else {
|
|
128
|
+
const base = parent ? absolute(parent) : { x: 0, y: 0 };
|
|
129
|
+
at = { x: base.x + (hand.nodes[id]?.x ?? 0), y: base.y + (hand.nodes[id]?.y ?? 0) };
|
|
130
|
+
}
|
|
131
|
+
placed.set(id, at);
|
|
132
|
+
return at;
|
|
133
|
+
};
|
|
134
|
+
// containers before their children, so a child is placed against its container's new position
|
|
135
|
+
for (const id of Object.keys(moved).sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))) {
|
|
136
|
+
const parent = parentOf(id);
|
|
137
|
+
const origin = parent ? absolute(parent) : { x: 0, y: 0 };
|
|
138
|
+
const at = absolute(id);
|
|
139
|
+
hand.nodes[id] = { ...hand.nodes[id], x: at.x - origin.x, y: at.y - origin.y };
|
|
140
|
+
}
|
|
141
|
+
if (Object.keys(linkEdits).length) {
|
|
142
|
+
hand.links ??= {};
|
|
143
|
+
for (const [id, edit] of Object.entries(linkEdits)) hand.links[id] = { ...hand.links[id], ...edit };
|
|
144
|
+
}
|
|
145
|
+
if (same(before, hand)) continue;
|
|
146
|
+
layouts[level] = hand;
|
|
147
|
+
levels.push(level);
|
|
148
|
+
}
|
|
149
|
+
return { display, levels, skipped };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
153
|
+
const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
154
|
+
|
|
155
|
+
/** The layout and snapshot from a request body, or a reason to refuse it. */
|
|
156
|
+
export function parseLayoutRequest(body: unknown): { layout: ViewerLayout; snapshot?: LayoutSnapshot; client?: string } | { error: string } {
|
|
157
|
+
if (!record(body) || !record(body.layout)) return { error: "expected { layout: { positions, links } }" };
|
|
158
|
+
const { positions = {}, links = {} } = body.layout as Record<string, unknown>;
|
|
159
|
+
if (!record(positions) || !record(links)) return { error: "layout.positions and layout.links must be objects" };
|
|
160
|
+
for (const [level, nodes] of Object.entries(positions)) {
|
|
161
|
+
if (!record(nodes)) return { error: `positions["${level}"] must be an object` };
|
|
162
|
+
for (const [id, p] of Object.entries(nodes)) if (!record(p) || !finite(p.x) || !finite(p.y)) return { error: `positions["${level}"]["${id}"] needs numeric x and y` };
|
|
163
|
+
}
|
|
164
|
+
for (const [level, byLink] of Object.entries(links)) {
|
|
165
|
+
if (!record(byLink)) return { error: `links["${level}"] must be an object` };
|
|
166
|
+
for (const [id, l] of Object.entries(byLink)) {
|
|
167
|
+
if (!record(l)) return { error: `links["${level}"]["${id}"] must be an object` };
|
|
168
|
+
const unknown = Object.keys(l).filter((k) => !["waypoints", "labelT", "labelFlip", "tabT", "tabFlip"].includes(k));
|
|
169
|
+
if (unknown.length) return { error: `links["${level}"]["${id}"] has unknown keys ${unknown.join(", ")}` };
|
|
170
|
+
if (l.waypoints !== undefined && !(Array.isArray(l.waypoints) && l.waypoints.every((w) => Array.isArray(w) && w.length === 2 && w.every(finite)))) return { error: `links["${level}"]["${id}"].waypoints must be [x, y] pairs` };
|
|
171
|
+
for (const k of ["labelT", "tabT"] as const) if (l[k] !== undefined && !finite(l[k])) return { error: `links["${level}"]["${id}"].${k} must be a number` };
|
|
172
|
+
for (const k of ["labelFlip", "tabFlip"] as const) if (l[k] !== undefined && typeof l[k] !== "boolean") return { error: `links["${level}"]["${id}"].${k} must be a boolean` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
let snapshot: LayoutSnapshot | undefined;
|
|
176
|
+
if (body.snapshot !== undefined) {
|
|
177
|
+
const s = body.snapshot;
|
|
178
|
+
if (!record(s) || !record(s.nodes) || !Array.isArray(s.edges) || !s.edges.every((e) => typeof e === "string")) return { error: "snapshot must be { nodes, edges }" };
|
|
179
|
+
for (const [id, p] of Object.entries(s.nodes)) if (!record(p) || !finite(p.x) || !finite(p.y)) return { error: `snapshot.nodes["${id}"] needs numeric x and y` };
|
|
180
|
+
snapshot = s as unknown as LayoutSnapshot;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
layout: { positions: positions as ViewerLayout["positions"], links: links as ViewerLayout["links"] },
|
|
184
|
+
...(snapshot ? { snapshot } : {}),
|
|
185
|
+
...(typeof body.client === "string" ? { client: body.client.slice(0, 64) } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The display file's text: two-space JSON. When the file on disk escapes
|
|
191
|
+
* non-ASCII characters (`…`, as Python's json module writes it) and
|
|
192
|
+
* holds none literally, the new text escapes them too, so a layout save
|
|
193
|
+
* does not rewrite lines nobody edited.
|
|
194
|
+
*/
|
|
195
|
+
export function formatDisplay(display: DisplayFile, previous?: string): string {
|
|
196
|
+
const text = JSON.stringify(display, null, 2) + "\n";
|
|
197
|
+
const escaped = previous !== undefined && /\\u(?:00[89a-fA-F][0-9a-fA-F]|0[1-9a-fA-F][0-9a-fA-F]{2}|[1-9a-fA-F][0-9a-fA-F]{3})/.test(previous) && !/[^\x00-\x7f]/.test(previous);
|
|
198
|
+
return escaped ? text.replace(/[\u0080-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`) : text;
|
|
199
|
+
}
|