@tuongaz/seeflow 0.1.31 → 0.1.39
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/README.md +6 -6
- package/dist/web/assets/index-B5Aku4dw.js +7838 -0
- package/dist/web/assets/index-BwdVgB2y.css +1 -0
- package/dist/web/assets/{index.es-B9awKpqd.js → index.es-PUp1NFtk.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-BPVV_TTL.js → jspdf.es.min-zaUNYWJ0.js} +3 -3
- package/dist/web/index.html +2 -2
- package/package.json +3 -4
- package/src/api.ts +212 -20
- package/src/cli.ts +154 -33
- package/src/diagram.ts +29 -69
- package/src/layout.ts +217 -0
- package/src/mcp.ts +10 -10
- package/src/merge.ts +50 -51
- package/src/operations.ts +184 -121
- package/src/registry.ts +10 -16
- package/src/schema.ts +46 -55
- package/src/status-runner.ts +6 -6
- package/src/watcher.ts +124 -31
- package/dist/web/assets/index-CYxryPhh.css +0 -1
- package/dist/web/assets/index-CeQZymwF.js +0 -7838
- /package/examples/ecommerce-platform/.seeflow/{architecture.json → flow.json} +0 -0
- /package/examples/order-pipeline/.seeflow/{architecture.json → flow.json} +0 -0
package/src/layout.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { runInThisContext } from 'node:vm';
|
|
4
|
+
import ELK from 'elkjs/lib/elk-api.js';
|
|
5
|
+
import type { FlowNode } from './schema.ts';
|
|
6
|
+
|
|
7
|
+
// elkjs Bun-compat shim. The `elk-worker.min.js` file inspects `self` at the
|
|
8
|
+
// bottom to decide between its browser-worker branch and its CJS-export
|
|
9
|
+
// branch. Bun exposes `self` globally, which makes it take the
|
|
10
|
+
// browser-worker branch — and skip the export — so we'd get an empty
|
|
11
|
+
// module otherwise. We load the vendored worker source via vm and run it
|
|
12
|
+
// inside a wrapper that shadows `self`, then pluck the Worker class out of
|
|
13
|
+
// the resulting module.exports. One-time cost at module load.
|
|
14
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
15
|
+
const workerSource = readFileSync(requireFromHere.resolve('elkjs/lib/elk-worker.min.js'), 'utf8');
|
|
16
|
+
type WorkerCtor = new () => { postMessage: (msg: unknown) => unknown };
|
|
17
|
+
const workerModule: { exports: { Worker?: WorkerCtor } } = { exports: {} };
|
|
18
|
+
const wrappedSource = `(function (module, exports) { var self; ${workerSource} })`;
|
|
19
|
+
const wrapped = runInThisContext(wrappedSource, {
|
|
20
|
+
filename: 'elkjs/lib/elk-worker.min.js',
|
|
21
|
+
}) as (m: typeof workerModule, e: typeof workerModule.exports) => void;
|
|
22
|
+
wrapped(workerModule, workerModule.exports);
|
|
23
|
+
const ElkWorker = workerModule.exports.Worker;
|
|
24
|
+
if (!ElkWorker) throw new Error('elkjs worker class not found after Bun shim');
|
|
25
|
+
|
|
26
|
+
// ELK's type expects a full DOM Worker; our shim provides the minimal duck
|
|
27
|
+
// (postMessage / addEventListener). ELK only checks `typeof postMessage ===
|
|
28
|
+
// 'function'` at runtime, so the cast is safe.
|
|
29
|
+
const elk = new ELK({
|
|
30
|
+
workerFactory: () => new ElkWorker() as unknown as Worker,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type LayoutDirection = 'RIGHT' | 'DOWN' | 'LEFT' | 'UP';
|
|
34
|
+
export type SourceHandle = 'r' | 'b';
|
|
35
|
+
export type TargetHandle = 't' | 'l';
|
|
36
|
+
|
|
37
|
+
export interface LayoutOptions {
|
|
38
|
+
direction?: LayoutDirection;
|
|
39
|
+
spacing?: { layer?: number; node?: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LayoutResult {
|
|
43
|
+
nodes: Record<string, { position: { x: number; y: number } }>;
|
|
44
|
+
connectors: Record<string, { sourceHandle: SourceHandle; targetHandle: TargetHandle }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Structural shape computeLayout cares about. Decoupled from the strict
|
|
48
|
+
// FlowSchema so callers like assembleDemo (which carries pre-validation
|
|
49
|
+
// passthrough data) can feed in their loose nodes without round-tripping
|
|
50
|
+
// through Zod.
|
|
51
|
+
export interface LayoutNode {
|
|
52
|
+
id: string;
|
|
53
|
+
type: FlowNode['type'];
|
|
54
|
+
data?: { width?: number; height?: number; shape?: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LayoutEdge {
|
|
58
|
+
id: string;
|
|
59
|
+
source: string;
|
|
60
|
+
target: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_DIMENSIONS: Record<FlowNode['type'], { width: number; height: number }> = {
|
|
64
|
+
playNode: { width: 220, height: 100 },
|
|
65
|
+
stateNode: { width: 220, height: 100 },
|
|
66
|
+
shapeNode: { width: 160, height: 80 },
|
|
67
|
+
iconNode: { width: 80, height: 80 },
|
|
68
|
+
htmlNode: { width: 320, height: 200 },
|
|
69
|
+
imageNode: { width: 200, height: 150 },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const SHAPE_OVERRIDES: Record<string, { width: number; height: number }> = {
|
|
73
|
+
text: { width: 160, height: 40 },
|
|
74
|
+
sticky: { width: 160, height: 180 },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const FLOATING_SHAPES = new Set(['sticky', 'text']);
|
|
78
|
+
|
|
79
|
+
const nodeDimensions = (node: LayoutNode): { width: number; height: number } => {
|
|
80
|
+
const data = node.data ?? {};
|
|
81
|
+
if (typeof data.width === 'number' && typeof data.height === 'number') {
|
|
82
|
+
return { width: data.width, height: data.height };
|
|
83
|
+
}
|
|
84
|
+
if (node.type === 'shapeNode' && data.shape) {
|
|
85
|
+
const override = SHAPE_OVERRIDES[data.shape];
|
|
86
|
+
if (override) return override;
|
|
87
|
+
}
|
|
88
|
+
return DEFAULT_DIMENSIONS[node.type];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Sticky / text shapes are floating annotations. They never participate in
|
|
92
|
+
// layered layout — they sit in a side column so the orthogonal flow stays
|
|
93
|
+
// clean.
|
|
94
|
+
const isFloatingAnnotation = (node: LayoutNode): boolean => {
|
|
95
|
+
if (node.type !== 'shapeNode') return false;
|
|
96
|
+
const shape = node.data?.shape;
|
|
97
|
+
return shape !== undefined && FLOATING_SHAPES.has(shape);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Schema vocabulary: SourceHandle ∈ {r, b}, TargetHandle ∈ {t, l}. After
|
|
101
|
+
// ELK lays out positions we pick handles geometrically — the layered LR
|
|
102
|
+
// algorithm puts most edges going east, so the default is source.r →
|
|
103
|
+
// target.l. Back-edges (target to the left or directly below) route via
|
|
104
|
+
// source.b → target.t so they don't try to enter from a side that has no
|
|
105
|
+
// target handle.
|
|
106
|
+
const pickHandles = (
|
|
107
|
+
src: { x: number; y: number; w: number; h: number },
|
|
108
|
+
tgt: { x: number; y: number; w: number; h: number },
|
|
109
|
+
): { sourceHandle: SourceHandle; targetHandle: TargetHandle } => {
|
|
110
|
+
const sCenter = { x: src.x + src.w / 2, y: src.y + src.h / 2 };
|
|
111
|
+
const tCenter = { x: tgt.x + tgt.w / 2, y: tgt.y + tgt.h / 2 };
|
|
112
|
+
const dx = tCenter.x - sCenter.x;
|
|
113
|
+
const dy = tCenter.y - sCenter.y;
|
|
114
|
+
|
|
115
|
+
if (dx > 0 && Math.abs(dx) >= Math.abs(dy)) {
|
|
116
|
+
return { sourceHandle: 'r', targetHandle: 'l' };
|
|
117
|
+
}
|
|
118
|
+
return { sourceHandle: 'b', targetHandle: 't' };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const computeLayout = async (
|
|
122
|
+
nodes: readonly LayoutNode[],
|
|
123
|
+
edges: readonly LayoutEdge[],
|
|
124
|
+
options?: LayoutOptions,
|
|
125
|
+
): Promise<LayoutResult> => {
|
|
126
|
+
// Stable input ordering keeps ELK output deterministic across runs.
|
|
127
|
+
const allNodes = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
128
|
+
const connectors = [...edges].sort((a, b) => a.id.localeCompare(b.id));
|
|
129
|
+
|
|
130
|
+
const result: LayoutResult = { nodes: {}, connectors: {} };
|
|
131
|
+
|
|
132
|
+
if (allNodes.length === 0) return result;
|
|
133
|
+
|
|
134
|
+
const dims = new Map<string, { width: number; height: number }>();
|
|
135
|
+
for (const n of allNodes) dims.set(n.id, nodeDimensions(n));
|
|
136
|
+
|
|
137
|
+
const referenced = new Set<string>();
|
|
138
|
+
for (const c of connectors) {
|
|
139
|
+
referenced.add(c.source);
|
|
140
|
+
referenced.add(c.target);
|
|
141
|
+
}
|
|
142
|
+
const laidOut = allNodes.filter((n) => referenced.has(n.id) && !isFloatingAnnotation(n));
|
|
143
|
+
const floatingNodes = allNodes.filter((n) => !laidOut.includes(n));
|
|
144
|
+
|
|
145
|
+
const layerSpacing = options?.spacing?.layer ?? 220;
|
|
146
|
+
const nodeSpacing = options?.spacing?.node ?? 140;
|
|
147
|
+
const direction = options?.direction ?? 'RIGHT';
|
|
148
|
+
|
|
149
|
+
if (laidOut.length > 0) {
|
|
150
|
+
const elkGraph = {
|
|
151
|
+
id: 'root',
|
|
152
|
+
layoutOptions: {
|
|
153
|
+
'elk.algorithm': 'layered',
|
|
154
|
+
'elk.direction': direction,
|
|
155
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing),
|
|
156
|
+
'elk.spacing.nodeNode': String(nodeSpacing),
|
|
157
|
+
'elk.spacing.edgeNode': '60',
|
|
158
|
+
'elk.spacing.edgeEdge': '30',
|
|
159
|
+
'elk.spacing.edgeLabel': '12',
|
|
160
|
+
'elk.layered.edgeLabels.sideSelection': 'SMART_DOWN',
|
|
161
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
162
|
+
'elk.separateConnectedComponents': 'true',
|
|
163
|
+
},
|
|
164
|
+
children: laidOut.map((n) => {
|
|
165
|
+
const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.playNode;
|
|
166
|
+
return { id: n.id, width: d.width, height: d.height };
|
|
167
|
+
}),
|
|
168
|
+
edges: connectors
|
|
169
|
+
.filter((c) => referenced.has(c.source) && referenced.has(c.target))
|
|
170
|
+
.map((c) => ({ id: c.id, sources: [c.source], targets: [c.target] })),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const out = await elk.layout(elkGraph);
|
|
174
|
+
for (const child of out.children ?? []) {
|
|
175
|
+
if (typeof child.x !== 'number' || typeof child.y !== 'number') continue;
|
|
176
|
+
result.nodes[child.id] = {
|
|
177
|
+
position: { x: Math.round(child.x), y: Math.round(child.y) },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Floating nodes get a right-side column at x = maxLaidOutX + gap. Avoids
|
|
183
|
+
// the (0,0) pile-up that the old hand-authored positions had whenever a
|
|
184
|
+
// sticky note slipped through without an explicit position.
|
|
185
|
+
if (floatingNodes.length > 0) {
|
|
186
|
+
let maxRight = 0;
|
|
187
|
+
for (const id of Object.keys(result.nodes)) {
|
|
188
|
+
const pos = result.nodes[id]?.position;
|
|
189
|
+
const d = dims.get(id);
|
|
190
|
+
if (!pos || !d) continue;
|
|
191
|
+
maxRight = Math.max(maxRight, pos.x + d.width);
|
|
192
|
+
}
|
|
193
|
+
const columnX = laidOut.length > 0 ? maxRight + 200 : 0;
|
|
194
|
+
let cursorY = 0;
|
|
195
|
+
for (const n of floatingNodes) {
|
|
196
|
+
const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.shapeNode;
|
|
197
|
+
result.nodes[n.id] = { position: { x: columnX, y: cursorY } };
|
|
198
|
+
cursorY += d.height + nodeSpacing;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Geometric handle assignment runs after positions are known so it can
|
|
203
|
+
// see whether each edge ended up going east, south, or backwards.
|
|
204
|
+
for (const c of connectors) {
|
|
205
|
+
const sPos = result.nodes[c.source]?.position;
|
|
206
|
+
const tPos = result.nodes[c.target]?.position;
|
|
207
|
+
const sDim = dims.get(c.source);
|
|
208
|
+
const tDim = dims.get(c.target);
|
|
209
|
+
if (!sPos || !tPos || !sDim || !tDim) continue;
|
|
210
|
+
result.connectors[c.id] = pickHandles(
|
|
211
|
+
{ x: sPos.x, y: sPos.y, w: sDim.width, h: sDim.height },
|
|
212
|
+
{ x: tPos.x, y: tPos.y, w: tDim.width, h: tDim.height },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
};
|
package/src/mcp.ts
CHANGED
|
@@ -98,7 +98,7 @@ const DemoNodeIdBaseSchema = z.object({
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
// add_node input: { flowId, node: <node payload> }. The inner `node` object is
|
|
101
|
-
// loose here (additionalProperties=true via passthrough) because
|
|
101
|
+
// loose here (additionalProperties=true via passthrough) because ResolvedFlowSchema
|
|
102
102
|
// runs the full validation server-side after the new node is merged in.
|
|
103
103
|
const AddNodeInputSchema = z.object({
|
|
104
104
|
flowId: z.string().min(1),
|
|
@@ -139,7 +139,7 @@ const PatchNodeInputSchema = NodePatchBodySchema.extend({
|
|
|
139
139
|
|
|
140
140
|
// add_connector input: { flowId, connector: <connector payload> }. The inner
|
|
141
141
|
// `connector` object is loose (additionalProperties=true via z.record) because
|
|
142
|
-
//
|
|
142
|
+
// ResolvedFlowSchema runs the full validation server-side after the new connector is
|
|
143
143
|
// merged in (post-mutation parse catches dangling source/target refs and
|
|
144
144
|
// kind-discriminator violations).
|
|
145
145
|
const AddConnectorInputSchema = z.object({
|
|
@@ -174,25 +174,25 @@ const buildTools = (deps: OperationsDeps): McpTool[] => [
|
|
|
174
174
|
{
|
|
175
175
|
name: 'validate_seeflow',
|
|
176
176
|
description:
|
|
177
|
-
'Validate
|
|
178
|
-
'
|
|
179
|
-
'
|
|
177
|
+
'Validate a flow.json (and optional style.json) against the SeeFlow ' +
|
|
178
|
+
'schemas. Stateless: no flow id, no file:// resolution, no registry ' +
|
|
179
|
+
'side-effects. Returns { ok: true } or { ok: false, issues }.',
|
|
180
180
|
inputSchema: {
|
|
181
181
|
type: 'object',
|
|
182
182
|
properties: {
|
|
183
|
-
|
|
183
|
+
flow: { type: 'object' },
|
|
184
184
|
style: { type: 'object' },
|
|
185
185
|
},
|
|
186
|
-
required: ['
|
|
186
|
+
required: ['flow'],
|
|
187
187
|
additionalProperties: false,
|
|
188
188
|
},
|
|
189
189
|
handler: async (args) => {
|
|
190
190
|
const body = args as Record<string, unknown> | undefined;
|
|
191
|
-
if (!body || !('
|
|
192
|
-
return errorResult('Body must include `
|
|
191
|
+
if (!body || !('flow' in body)) {
|
|
192
|
+
return errorResult('Body must include `flow`');
|
|
193
193
|
}
|
|
194
194
|
const result = validateImpl({
|
|
195
|
-
|
|
195
|
+
flow: body.flow,
|
|
196
196
|
style: body.style as unknown,
|
|
197
197
|
});
|
|
198
198
|
return okResult(result);
|
package/src/merge.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Flow, ResolvedFlow, Style } from './schema.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Merge
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Merge flow.json (semantic data) and the optional style.json (presentation
|
|
5
|
+
* overrides) into the merged ResolvedFlow shape consumed by the API, the
|
|
6
|
+
* canvas, and the rest of the studio.
|
|
7
7
|
*
|
|
8
|
-
* Style entries with no matching
|
|
9
|
-
*
|
|
8
|
+
* Style entries with no matching flow id are silently dropped — the write
|
|
9
|
+
* path strips dangling entries after delete, but a stale file on disk
|
|
10
10
|
* shouldn't break the read path.
|
|
11
11
|
*/
|
|
12
|
-
export function
|
|
12
|
+
export function mergeFlowAndStyle(flow: Flow, style: Style): ResolvedFlow {
|
|
13
13
|
const nodeStyles = style.nodes ?? {};
|
|
14
14
|
const connectorStyles = style.connectors ?? {};
|
|
15
15
|
|
|
16
|
-
const mergedNodes =
|
|
16
|
+
const mergedNodes = flow.nodes.map((node) => {
|
|
17
17
|
const s = nodeStyles[node.id] ?? {};
|
|
18
18
|
const { position, ...visual } = s;
|
|
19
19
|
return {
|
|
@@ -23,23 +23,23 @@ export function mergeArchitectureAndStyle(arch: Architecture, style: Style): Flo
|
|
|
23
23
|
};
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
const mergedConnectors =
|
|
26
|
+
const mergedConnectors = flow.connectors.map((conn) => {
|
|
27
27
|
const s = connectorStyles[conn.id] ?? {};
|
|
28
28
|
return { ...conn, ...s };
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
return {
|
|
32
|
-
version:
|
|
33
|
-
name:
|
|
34
|
-
...(
|
|
32
|
+
version: flow.version,
|
|
33
|
+
name: flow.name,
|
|
34
|
+
...(flow.resetAction ? { resetAction: flow.resetAction } : {}),
|
|
35
35
|
nodes: mergedNodes,
|
|
36
36
|
connectors: mergedConnectors,
|
|
37
|
-
} as
|
|
37
|
+
} as ResolvedFlow;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// Fields that live in a node's `data` block on
|
|
41
|
-
//
|
|
42
|
-
const
|
|
40
|
+
// Fields that live in a node's `data` block on flow.json. Every other data
|
|
41
|
+
// field is visual and routes to style.json.
|
|
42
|
+
const NODE_DATA_FLOW_KEYS = new Set([
|
|
43
43
|
'name',
|
|
44
44
|
'kind',
|
|
45
45
|
'stateSource',
|
|
@@ -65,14 +65,13 @@ const NODE_STYLE_KEYS = new Set([
|
|
|
65
65
|
'fontSize',
|
|
66
66
|
'textColor',
|
|
67
67
|
'cornerRadius',
|
|
68
|
-
'locked',
|
|
69
68
|
'borderWidth',
|
|
70
69
|
'color',
|
|
71
70
|
'strokeWidth',
|
|
72
71
|
'autoSize',
|
|
73
72
|
]);
|
|
74
73
|
|
|
75
|
-
const
|
|
74
|
+
const CONNECTOR_FLOW_KEYS = new Set([
|
|
76
75
|
'id',
|
|
77
76
|
'source',
|
|
78
77
|
'target',
|
|
@@ -100,28 +99,28 @@ const CONNECTOR_STYLE_KEYS = new Set([
|
|
|
100
99
|
]);
|
|
101
100
|
|
|
102
101
|
/**
|
|
103
|
-
* Split a merged
|
|
104
|
-
* inverse of
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
102
|
+
* Split a merged ResolvedFlow back into (flow, style) for atomic write. The
|
|
103
|
+
* inverse of mergeFlowAndStyle: position and every visual field on each node
|
|
104
|
+
* moves to `style.nodes[id]`; handles, pins, and visual fields on each
|
|
105
|
+
* connector move to `style.connectors[id]`. Flow keeps every semantic data
|
|
106
|
+
* field — the routing tables above are the source of truth.
|
|
108
107
|
*
|
|
109
108
|
* Style entries that end up empty are omitted from the output so the file
|
|
110
109
|
* stays compact (matches the design's "delete style.json when {}" rule).
|
|
111
110
|
*/
|
|
112
|
-
export function splitFlow(
|
|
111
|
+
export function splitFlow(resolved: {
|
|
113
112
|
version: number;
|
|
114
113
|
name: string;
|
|
115
114
|
resetAction?: unknown;
|
|
116
115
|
nodes: Array<Record<string, unknown>>;
|
|
117
116
|
connectors: Array<Record<string, unknown>>;
|
|
118
|
-
}): {
|
|
119
|
-
const
|
|
117
|
+
}): { flow: Record<string, unknown>; style: Record<string, unknown> } {
|
|
118
|
+
const flowNodes: Array<Record<string, unknown>> = [];
|
|
120
119
|
const styleNodes: Record<string, Record<string, unknown>> = {};
|
|
121
120
|
|
|
122
|
-
for (const node of
|
|
121
|
+
for (const node of resolved.nodes) {
|
|
123
122
|
const id = node.id as string;
|
|
124
|
-
const
|
|
123
|
+
const flowNode: Record<string, unknown> = { id, type: node.type };
|
|
125
124
|
const styleEntry: Record<string, unknown> = {};
|
|
126
125
|
|
|
127
126
|
if (node.position && typeof node.position === 'object') {
|
|
@@ -129,62 +128,62 @@ export function splitFlow(flow: {
|
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
const data = (node.data ?? {}) as Record<string, unknown>;
|
|
132
|
-
const
|
|
131
|
+
const flowData: Record<string, unknown> = {};
|
|
133
132
|
for (const [k, v] of Object.entries(data)) {
|
|
134
133
|
if (v === undefined) continue;
|
|
135
|
-
if (
|
|
136
|
-
|
|
134
|
+
if (NODE_DATA_FLOW_KEYS.has(k)) {
|
|
135
|
+
flowData[k] = v;
|
|
137
136
|
} else if (NODE_STYLE_KEYS.has(k)) {
|
|
138
137
|
styleEntry[k] = v;
|
|
139
138
|
} else {
|
|
140
|
-
// Unknown forward-compat key — keep on
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
139
|
+
// Unknown forward-compat key — keep on flow side so the schema's
|
|
140
|
+
// strict() will catch typos but extension is possible by updating
|
|
141
|
+
// the routing tables here.
|
|
142
|
+
flowData[k] = v;
|
|
144
143
|
}
|
|
145
144
|
}
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
flowNode.data = flowData;
|
|
146
|
+
flowNodes.push(flowNode);
|
|
148
147
|
|
|
149
148
|
if (Object.keys(styleEntry).length > 0) {
|
|
150
149
|
styleNodes[id] = styleEntry;
|
|
151
150
|
}
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
const
|
|
153
|
+
const flowConnectors: Array<Record<string, unknown>> = [];
|
|
155
154
|
const styleConnectors: Record<string, Record<string, unknown>> = {};
|
|
156
155
|
|
|
157
|
-
for (const conn of
|
|
156
|
+
for (const conn of resolved.connectors) {
|
|
158
157
|
const id = conn.id as string;
|
|
159
|
-
const
|
|
158
|
+
const flowConn: Record<string, unknown> = {};
|
|
160
159
|
const styleEntry: Record<string, unknown> = {};
|
|
161
160
|
for (const [k, v] of Object.entries(conn)) {
|
|
162
161
|
if (v === undefined) continue;
|
|
163
|
-
if (
|
|
164
|
-
|
|
162
|
+
if (CONNECTOR_FLOW_KEYS.has(k)) {
|
|
163
|
+
flowConn[k] = v;
|
|
165
164
|
} else if (CONNECTOR_STYLE_KEYS.has(k)) {
|
|
166
165
|
styleEntry[k] = v;
|
|
167
166
|
} else {
|
|
168
|
-
|
|
167
|
+
flowConn[k] = v;
|
|
169
168
|
}
|
|
170
169
|
}
|
|
171
|
-
|
|
170
|
+
flowConnectors.push(flowConn);
|
|
172
171
|
if (Object.keys(styleEntry).length > 0) {
|
|
173
172
|
styleConnectors[id] = styleEntry;
|
|
174
173
|
}
|
|
175
174
|
}
|
|
176
175
|
|
|
177
|
-
const
|
|
178
|
-
version:
|
|
179
|
-
name:
|
|
180
|
-
nodes:
|
|
181
|
-
connectors:
|
|
176
|
+
const flow: Record<string, unknown> = {
|
|
177
|
+
version: resolved.version,
|
|
178
|
+
name: resolved.name,
|
|
179
|
+
nodes: flowNodes,
|
|
180
|
+
connectors: flowConnectors,
|
|
182
181
|
};
|
|
183
|
-
if (
|
|
182
|
+
if (resolved.resetAction !== undefined) flow.resetAction = resolved.resetAction;
|
|
184
183
|
|
|
185
184
|
const style: Record<string, unknown> = {};
|
|
186
185
|
if (Object.keys(styleNodes).length > 0) style.nodes = styleNodes;
|
|
187
186
|
if (Object.keys(styleConnectors).length > 0) style.connectors = styleConnectors;
|
|
188
187
|
|
|
189
|
-
return {
|
|
188
|
+
return { flow, style };
|
|
190
189
|
}
|