@manny-est/node-red-flowpilot 0.4.0 → 0.5.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/CHANGELOG.md +33 -0
- package/PROJECT-OVERVIEW.md +7 -7
- package/README.md +14 -8
- package/USER-GUIDE.md +27 -10
- package/flowpilot-core.css +44 -0
- package/flowpilot.js +56 -141
- package/lib/build-core-script.js +44 -0
- package/lib/build-system-prompt.js +6 -0
- package/lib/core/apply-review.js +1677 -0
- package/lib/core/history.js +93 -0
- package/lib/core/init.js +1510 -0
- package/lib/core/main.js +1710 -0
- package/lib/core/markdown.js +165 -0
- package/lib/core/modes.js +1567 -0
- package/lib/core/redaction.js +165 -0
- package/lib/core/selection-context.js +211 -0
- package/lib/envelope.js +136 -0
- package/lib/modify-system-prompt.js +3 -1
- package/lib/storage.js +4 -0
- package/package.json +1 -2
- package/flowpilot-core.js +0 -6416
|
@@ -0,0 +1,1677 @@
|
|
|
1
|
+
var DIFF_SKIP = {
|
|
2
|
+
x: 1, y: 1, z: 1, _def: 1, _: 1, changed: 1, dirty: 1, selected: 1,
|
|
3
|
+
valid: 1, validationErrors: 1, _index: 1, resize: 1, moved: 1,
|
|
4
|
+
w: 1, h: 1, l: 1, __outputs: 1, inputs: 1, g: 1,
|
|
5
|
+
_config: 1, _orig: 1, credentials: 1,
|
|
6
|
+
// "group" is a SYNTHETIC field sanitizeNode adds to context (the
|
|
7
|
+
// real live property is "g", already skipped above) — informational
|
|
8
|
+
// only. Live-confirmed gap: the model tried to "ungroup" by setting
|
|
9
|
+
// group:null on member nodes via a "changes" patch; without this
|
|
10
|
+
// skip, computeNodeDiff happily diffed a property that doesn't
|
|
11
|
+
// exist on the real node object, and Tier 1 "applied" it by writing
|
|
12
|
+
// a meaningless liveNode.group = null — reporting false success
|
|
13
|
+
// while actually doing nothing to real membership (RED.nodes.node()
|
|
14
|
+
// doesn't even read a key named "group"). Real ungrouping goes
|
|
15
|
+
// through applyGroupChanges()/"newGroups" instead.
|
|
16
|
+
group: 1,
|
|
17
|
+
// "nodes" is a GROUP's live membership array — holds real node
|
|
18
|
+
// OBJECT references, never ids. Live-confirmed data-corrupting gap
|
|
19
|
+
// (2026-06-29): asked to "merge" several groups into one via plain
|
|
20
|
+
// Modify, the model sent a "changes" patch targeting the group
|
|
21
|
+
// entities directly (findLiveNode resolves a group by id) with a
|
|
22
|
+
// "nodes" value shaped like a list of ID STRINGS instead of going
|
|
23
|
+
// through applyGroupChanges()/"newGroups". Tier 1 wrote it
|
|
24
|
+
// verbatim (liveNode.nodes = [...ids]), replacing real node
|
|
25
|
+
// references with strings; NR's own redraw/export code calls
|
|
26
|
+
// .id/.g on each "nodes" entry expecting an object, so this
|
|
27
|
+
// corrupted the group immediately (canvas selection started
|
|
28
|
+
// throwing) AND on disk (each string serializes with id
|
|
29
|
+
// undefined -> null, confirmed via a live flows.json). Group
|
|
30
|
+
// membership must only ever change through applyGroupChanges().
|
|
31
|
+
nodes: 1
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Sentinel strings written by sanitizeNode (and redactDebugValue) for
|
|
35
|
+
// values it couldn't include or had to redact. If the model echoes one
|
|
36
|
+
// of these back, the field is opaque — skip it entirely; never write a
|
|
37
|
+
// sentinel string into a live node property. "[redacted]" is the plain
|
|
38
|
+
// top-level-secret-field sentinel; "[redacted: <kind>, <n> chars]" is the
|
|
39
|
+
// informative value-shape sentinel from redactDebugValue.
|
|
40
|
+
//
|
|
41
|
+
// MUST be recursive: sentinels can be nested inside arrays/objects (e.g.
|
|
42
|
+
// an inject node's "props" array, where individual v-fields are redacted).
|
|
43
|
+
// Live-confirmed data-corrupting gap (2026-06-29): asked to "ungroup"
|
|
44
|
+
// with a stale canvas state that showed group:null, the model echoed the
|
|
45
|
+
// REDACTED props array back verbatim. Tier 1 compared the live props
|
|
46
|
+
// (real values) vs the proposed props (sentinel strings nested inside)
|
|
47
|
+
// and saw them as different — a false "change". The old flat sentinel
|
|
48
|
+
// check returned false for the array itself, so the change was applied,
|
|
49
|
+
// overwriting real API keys with "[redacted: secret field, N chars]"
|
|
50
|
+
// strings in the live canvas. Disk was safe (user never deployed), but
|
|
51
|
+
// a deploy would have lost the keys permanently.
|
|
52
|
+
function isSanitizeSentinel(value) {
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
return value === "[unserializable]" || value === "[redacted]" || value.indexOf("[redacted:") === 0;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value.some(isSanitizeSentinel);
|
|
58
|
+
}
|
|
59
|
+
if (value !== null && typeof value === "object") {
|
|
60
|
+
return Object.keys(value).some(function (k) { return isSanitizeSentinel(value[k]); });
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function generateNodeId() {
|
|
66
|
+
var chars = "0123456789abcdef";
|
|
67
|
+
var id = "";
|
|
68
|
+
for (var i = 0; i < 16; i++) { id += chars[Math.floor(Math.random() * 16)]; }
|
|
69
|
+
return id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve a newWires from/to reference to a display label.
|
|
73
|
+
// ref is either a placeholder id present in newNodes or an existing node id.
|
|
74
|
+
function resolveWireRef(ref, newNodes) {
|
|
75
|
+
if (!ref) { return "(unknown)"; }
|
|
76
|
+
// Check against newNodes placeholder ids.
|
|
77
|
+
for (var i = 0; i < newNodes.length; i++) {
|
|
78
|
+
if (newNodes[i] && newNodes[i].id === ref) {
|
|
79
|
+
var n = newNodes[i];
|
|
80
|
+
return (n.name || n.type || ref) + " [new]";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Existing node.
|
|
84
|
+
var live = RED.nodes && RED.nodes.node ? RED.nodes.node(ref) : null;
|
|
85
|
+
return live ? (live.name || live.type || ref) : ref;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Port-count guards before ever calling RED.nodes.addLink() — confirmed
|
|
89
|
+
// via source that addLink does ZERO port validation: a wire to/from a
|
|
90
|
+
// nonexistent port silently registers in the data model and even
|
|
91
|
+
// RENDERS (NR's redraw anchors to a generic edge position regardless of
|
|
92
|
+
// whether a real port exists there). Live-confirmed: a wire from one
|
|
93
|
+
// comment node to another rendered fine but does nothing at runtime —
|
|
94
|
+
// comment/group are the common 0-port cases, but any node can have
|
|
95
|
+
// 0 outputs (e.g. debug) or a computed output count (e.g. switch's
|
|
96
|
+
// rules.length, already tracked on the live instance as .outputs).
|
|
97
|
+
function nodeOutputCount(node) {
|
|
98
|
+
if (!node || node.type === "group") { return 0; }
|
|
99
|
+
if (typeof node.outputs === "number") { return node.outputs; }
|
|
100
|
+
return (node._def && typeof node._def.outputs === "number") ? node._def.outputs : 1;
|
|
101
|
+
}
|
|
102
|
+
function nodeInputCount(node) {
|
|
103
|
+
if (!node || node.type === "group") { return 0; }
|
|
104
|
+
if (typeof node.inputs === "number") { return node.inputs; }
|
|
105
|
+
return (node._def && typeof node._def.inputs === "number") ? node._def.inputs : 1;
|
|
106
|
+
}
|
|
107
|
+
function canWire(fromNode, sourcePort, toNode) {
|
|
108
|
+
return nodeOutputCount(fromNode) > sourcePort && nodeInputCount(toNode) > 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Insert new nodes into the live graph and add wires connecting them to
|
|
112
|
+
// existing nodes. Uses RED.nodes.add (the same path Node-RED's undo uses
|
|
113
|
+
// internally for t:"add" events) and pushes one compound history entry so
|
|
114
|
+
// a single Ctrl+Z removes both the new nodes and their wires together.
|
|
115
|
+
function applyInsertions(newNodes, newWires, contextNodeIds) {
|
|
116
|
+
if (!newNodes || !newNodes.length) { return; }
|
|
117
|
+
|
|
118
|
+
// Determine z (flow-tab id) from the active workspace.
|
|
119
|
+
var z = "";
|
|
120
|
+
if (RED.workspaces && RED.workspaces.active) {
|
|
121
|
+
var ws = RED.workspaces.active();
|
|
122
|
+
z = ws ? (typeof ws === "string" ? ws : ws.id || "") : "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Collect model placeholder ids to distinguish them from existing-node
|
|
126
|
+
// ids when scanning newWires for spatial anchors.
|
|
127
|
+
var placeholderIds = {};
|
|
128
|
+
newNodes.forEach(function (n) { if (n.id) { placeholderIds[n.id] = true; } });
|
|
129
|
+
|
|
130
|
+
// Group new nodes into connected clusters (by each node's own
|
|
131
|
+
// "wires" plus any "newWires" edges between two placeholders), so
|
|
132
|
+
// multiple unrelated insertions in one response are each anchored to
|
|
133
|
+
// THEIR OWN existing-node connections instead of bunching together at
|
|
134
|
+
// one shared location.
|
|
135
|
+
var parent = {};
|
|
136
|
+
newNodes.forEach(function (n) { parent[n.id] = n.id; });
|
|
137
|
+
function findRoot(x) {
|
|
138
|
+
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
|
|
139
|
+
return x;
|
|
140
|
+
}
|
|
141
|
+
function union(a, b) {
|
|
142
|
+
if (parent[a] === undefined || parent[b] === undefined) { return; }
|
|
143
|
+
parent[findRoot(a)] = findRoot(b);
|
|
144
|
+
}
|
|
145
|
+
newNodes.forEach(function (n) {
|
|
146
|
+
(Array.isArray(n.wires) ? n.wires : []).forEach(function (port) {
|
|
147
|
+
(Array.isArray(port) ? port : []).forEach(function (tid) {
|
|
148
|
+
if (placeholderIds[tid]) { union(n.id, tid); }
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
(newWires || []).forEach(function (wire) {
|
|
153
|
+
if (placeholderIds[wire.from] && placeholderIds[wire.to]) {
|
|
154
|
+
union(wire.from, wire.to);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
var componentOf = {};
|
|
158
|
+
newNodes.forEach(function (n) { componentOf[n.id] = findRoot(n.id); });
|
|
159
|
+
|
|
160
|
+
// Per-component anchors: existing-node connections (from newWires)
|
|
161
|
+
// touching THIS component only. Split into "upstream" (existing node
|
|
162
|
+
// feeds INTO this component) and "downstream" (this component feeds
|
|
163
|
+
// INTO an existing node) — a wire's direction matters for where the
|
|
164
|
+
// cluster should land.
|
|
165
|
+
var componentAnchors = {};
|
|
166
|
+
Object.keys(componentOf).forEach(function (id) {
|
|
167
|
+
var c = componentOf[id];
|
|
168
|
+
if (!componentAnchors[c]) { componentAnchors[c] = { upstream: [], downstream: [] }; }
|
|
169
|
+
});
|
|
170
|
+
(newWires || []).forEach(function (wire) {
|
|
171
|
+
var fromIsNew = placeholderIds[wire.from];
|
|
172
|
+
var toIsNew = placeholderIds[wire.to];
|
|
173
|
+
if (!fromIsNew && toIsNew) {
|
|
174
|
+
var src = RED.nodes.node(wire.from);
|
|
175
|
+
if (src && typeof src.x === "number" && typeof src.y === "number") {
|
|
176
|
+
componentAnchors[componentOf[wire.to]].upstream.push(src);
|
|
177
|
+
}
|
|
178
|
+
} else if (fromIsNew && !toIsNew) {
|
|
179
|
+
var tgt = RED.nodes.node(wire.to);
|
|
180
|
+
if (tgt && typeof tgt.x === "number" && typeof tgt.y === "number") {
|
|
181
|
+
componentAnchors[componentOf[wire.from]].downstream.push(tgt);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Fallback (Finding #18): a cluster with no existing-node connection
|
|
187
|
+
// at all (e.g. "add a change node" with no wiring specified) anchors
|
|
188
|
+
// on the user's current Modify selection instead of
|
|
189
|
+
// layoutGeneratedFlow's hardcoded default (160,120), which can be far
|
|
190
|
+
// outside the user's current viewport on a larger flow.
|
|
191
|
+
var fallbackAnchors = [];
|
|
192
|
+
if (Array.isArray(contextNodeIds)) {
|
|
193
|
+
contextNodeIds.forEach(function (id) {
|
|
194
|
+
var live = RED.nodes.node(id);
|
|
195
|
+
if (live && typeof live.x === "number" && typeof live.y === "number") {
|
|
196
|
+
fallbackAnchors.push(live);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
Object.keys(componentAnchors).forEach(function (c) {
|
|
201
|
+
var a = componentAnchors[c];
|
|
202
|
+
if (a.upstream.length === 0 && a.downstream.length === 0) {
|
|
203
|
+
a.upstream = fallbackAnchors.slice();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Layout new nodes so they have x/y before adding to the graph.
|
|
208
|
+
var toLayout = newNodes.map(function (n) {
|
|
209
|
+
return Object.assign({}, n, { wires: Array.isArray(n.wires) ? n.wires : [[]] });
|
|
210
|
+
});
|
|
211
|
+
var laid = layoutGeneratedFlow(toLayout);
|
|
212
|
+
|
|
213
|
+
// Position each cluster relative to ITS OWN anchors, centred on their
|
|
214
|
+
// average Y, preserving that cluster's internal column/row layout
|
|
215
|
+
// from layoutGeneratedFlow.
|
|
216
|
+
// - If a cluster receives a connection FROM an existing node
|
|
217
|
+
// ("inserted after X" / "inserted between X and Y"), land 200px
|
|
218
|
+
// right of the rightmost such upstream anchor. This is correct
|
|
219
|
+
// even when downstream anchors exist further right (e.g. "insert
|
|
220
|
+
// a function between the inject and the existing debugs" should
|
|
221
|
+
// land right after the inject, not past the debugs).
|
|
222
|
+
// - Otherwise (the cluster only feeds existing nodes, e.g. "add an
|
|
223
|
+
// inject that triggers this debug"), land 200px left of the
|
|
224
|
+
// leftmost downstream anchor.
|
|
225
|
+
Object.keys(componentAnchors).forEach(function (c) {
|
|
226
|
+
var a = componentAnchors[c];
|
|
227
|
+
var anchorNodes = a.upstream.concat(a.downstream);
|
|
228
|
+
if (!anchorNodes.length) { return; }
|
|
229
|
+
|
|
230
|
+
var members = laid.filter(function (n) { return componentOf[n.id] === c; });
|
|
231
|
+
var minX = members.reduce(function (m, n) { return Math.min(m, n.x); }, members[0].x);
|
|
232
|
+
var minY = members.reduce(function (m, n) { return Math.min(m, n.y); }, members[0].y);
|
|
233
|
+
|
|
234
|
+
var avgY = anchorNodes.reduce(function (s, n) { return s + n.y; }, 0) / anchorNodes.length;
|
|
235
|
+
var targetX;
|
|
236
|
+
if (a.upstream.length > 0) {
|
|
237
|
+
var maxUpstreamX = a.upstream.reduce(function (m, n) { return Math.max(m, n.x); }, 0);
|
|
238
|
+
targetX = maxUpstreamX + 200;
|
|
239
|
+
} else {
|
|
240
|
+
var minDownstreamX = a.downstream.reduce(function (m, n) { return Math.min(m, n.x); }, a.downstream[0].x);
|
|
241
|
+
targetX = minDownstreamX - 200;
|
|
242
|
+
}
|
|
243
|
+
members.forEach(function (n) {
|
|
244
|
+
n.x = n.x - minX + targetX;
|
|
245
|
+
n.y = n.y - minY + avgY;
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Build placeholder-id → real-id map and assign real ids + z.
|
|
250
|
+
var idMap = {};
|
|
251
|
+
laid.forEach(function (n) {
|
|
252
|
+
var realId = generateNodeId();
|
|
253
|
+
idMap[n.id] = realId; // n.id is the model's placeholder
|
|
254
|
+
n.id = realId;
|
|
255
|
+
n.z = z;
|
|
256
|
+
if (!Array.isArray(n.wires)) { n.wires = [[]]; }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Rewrite any intra-new-node wires to use real ids.
|
|
260
|
+
laid.forEach(function (n) {
|
|
261
|
+
n.wires = n.wires.map(function (port) {
|
|
262
|
+
return Array.isArray(port) ? port.map(function (tid) {
|
|
263
|
+
return idMap[tid] || tid;
|
|
264
|
+
}) : [];
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Rewrite config-node references that point at another new node's
|
|
269
|
+
// placeholder id — e.g. an "mqtt out" node's "broker" pointing at a
|
|
270
|
+
// newly-inserted "mqtt-broker". The model reuses the same placeholder
|
|
271
|
+
// ids for these as it does for wires, but only "wires" was rewritten
|
|
272
|
+
// above; without this, the reference is left dangling on the
|
|
273
|
+
// placeholder string and the node fails validation at Deploy.
|
|
274
|
+
laid.forEach(function (n) {
|
|
275
|
+
if (n.type === "junction") { return; }
|
|
276
|
+
var typeDef = RED.nodes.getType(n.type);
|
|
277
|
+
if (!typeDef || !typeDef.defaults) { return; }
|
|
278
|
+
Object.keys(typeDef.defaults).forEach(function (k) {
|
|
279
|
+
var def = typeDef.defaults[k];
|
|
280
|
+
if (def && def.type && typeof n[k] === "string" && idMap[n[k]]) {
|
|
281
|
+
n[k] = idMap[n[k]];
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Add each node to the live graph.
|
|
287
|
+
// RED.nodes.add requires _def (the type's registration object) to be
|
|
288
|
+
// set on the node — it's what the undo system restores, not a raw cfg.
|
|
289
|
+
var addedNodes = [];
|
|
290
|
+
var addedJunctions = [];
|
|
291
|
+
var insertFailed = false;
|
|
292
|
+
laid.forEach(function (n) {
|
|
293
|
+
// Junctions (wire-splice points) are NOT registered node types —
|
|
294
|
+
// RED.nodes.getType("junction") returns undefined, so the regular
|
|
295
|
+
// RED.nodes.add path below would reject them as "not installed".
|
|
296
|
+
// They're added via RED.nodes.addJunction with a different shape
|
|
297
|
+
// (no defaults/_def beyond a stub), mirroring the object NR's own
|
|
298
|
+
// "split wire with junction" action builds (red.js addJunctionsToWires).
|
|
299
|
+
if (n.type === "junction") {
|
|
300
|
+
var junctionObj = {
|
|
301
|
+
_def: { defaults: {} },
|
|
302
|
+
type: "junction",
|
|
303
|
+
z: n.z,
|
|
304
|
+
id: n.id,
|
|
305
|
+
x: n.x,
|
|
306
|
+
y: n.y,
|
|
307
|
+
w: 0,
|
|
308
|
+
h: 0,
|
|
309
|
+
inputs: 1,
|
|
310
|
+
outputs: 1,
|
|
311
|
+
dirty: true,
|
|
312
|
+
moved: true
|
|
313
|
+
};
|
|
314
|
+
try {
|
|
315
|
+
junctionObj = RED.nodes.addJunction(junctionObj);
|
|
316
|
+
addedJunctions.push(junctionObj);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
addMessage("error", "Failed to add junction: " + (e.message || e));
|
|
319
|
+
insertFailed = true;
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
var typeDef = RED.nodes.getType(n.type);
|
|
325
|
+
if (!typeDef) {
|
|
326
|
+
addMessage("error", "Node type not installed: " + n.type);
|
|
327
|
+
insertFailed = true;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
n._def = typeDef;
|
|
331
|
+
// inputs/outputs are runtime state that add() reads from the node
|
|
332
|
+
// object directly — it doesn't copy them from _def automatically.
|
|
333
|
+
if (typeof n.inputs === "undefined") {
|
|
334
|
+
n.inputs = typeDef.inputs !== undefined ? typeDef.inputs : 1;
|
|
335
|
+
}
|
|
336
|
+
if (typeof n.outputs === "undefined") {
|
|
337
|
+
n.outputs = typeDef.outputs !== undefined
|
|
338
|
+
? typeDef.outputs
|
|
339
|
+
: (Array.isArray(n.wires) ? n.wires.length : 0);
|
|
340
|
+
}
|
|
341
|
+
// Apply type-definition defaults for any property the model omitted.
|
|
342
|
+
// This covers required fields (e.g. statusVal/statusType on debug)
|
|
343
|
+
// that oneditsave would normally set, preventing a spurious triangle.
|
|
344
|
+
if (typeDef.defaults) {
|
|
345
|
+
Object.keys(typeDef.defaults).forEach(function (k) {
|
|
346
|
+
if (n[k] === undefined) {
|
|
347
|
+
var d = typeDef.defaults[k];
|
|
348
|
+
if (d && d.value !== undefined) { n[k] = d.value; }
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
RED.nodes.add(n);
|
|
354
|
+
// Try the public validator; if it isn't exposed or still leaves
|
|
355
|
+
// the node invalid, clear the triangle explicitly — real
|
|
356
|
+
// validation runs at edit-dialog-close and at Deploy.
|
|
357
|
+
if (typeof RED.nodes.validateNode === "function") {
|
|
358
|
+
RED.nodes.validateNode(n);
|
|
359
|
+
}
|
|
360
|
+
if (!n.valid) { n.valid = true; n.validationErrors = []; }
|
|
361
|
+
addedNodes.push(n);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
addMessage("error", "Failed to add node '" + (n.type || "?") + "': " + (e.message || e));
|
|
364
|
+
insertFailed = true;
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// Bug found via code-review archaeology (Phase 6 item #11, left open
|
|
368
|
+
// and never revisited): bailing out entirely on ANY failure used to
|
|
369
|
+
// discard whatever DID succeed before the failing node — those nodes
|
|
370
|
+
// stayed on the live canvas (RED.nodes.add already ran) but with no
|
|
371
|
+
// RED.history entry, so Ctrl+Z couldn't remove them, and no message
|
|
372
|
+
// told the user anything had landed at all. Now only bail when
|
|
373
|
+
// NOTHING succeeded; the history push and wiring below already only
|
|
374
|
+
// reference addedNodes/addedJunctions/addedLinks (whatever's actually
|
|
375
|
+
// there), so a partial success gets undo coverage same as a full one.
|
|
376
|
+
if (!addedNodes.length && !addedJunctions.length) { return; }
|
|
377
|
+
|
|
378
|
+
// Re-link config-node "users" now that all new nodes (including any
|
|
379
|
+
// new config nodes, e.g. mqtt-broker) exist. addNode() ran this
|
|
380
|
+
// per-node as it was added, but a node added BEFORE its config-node
|
|
381
|
+
// dependency wouldn't have found it in configNodes yet — without this,
|
|
382
|
+
// the config node shows 0 users and may not be included on Deploy.
|
|
383
|
+
if (typeof RED.nodes.updateConfigNodeUsers === "function") {
|
|
384
|
+
addedNodes.forEach(function (n) {
|
|
385
|
+
RED.nodes.updateConfigNodeUsers(n, { action: "add" });
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Resolve newWires refs and add link objects. findLiveNode (not
|
|
390
|
+
// RED.nodes.node) because endpoints may be junctions we just added —
|
|
391
|
+
// junctions live in RED.nodes.junctions(z), not the normal registry.
|
|
392
|
+
var addedLinks = [];
|
|
393
|
+
(newWires || []).forEach(function (wire) {
|
|
394
|
+
var fromId = idMap[wire.from] || wire.from;
|
|
395
|
+
var toId = idMap[wire.to] || wire.to;
|
|
396
|
+
var fromNode = findLiveNode(fromId);
|
|
397
|
+
var toNode = findLiveNode(toId);
|
|
398
|
+
if (!fromNode || !toNode) {
|
|
399
|
+
addMessage("error", "Cannot wire — node not found: " + (!fromNode ? fromId : toId));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
var fromPort = wire.fromPort || 0;
|
|
403
|
+
if (!canWire(fromNode, fromPort, toNode)) {
|
|
404
|
+
addMessage("error", "Cannot wire — " +
|
|
405
|
+
(nodeOutputCount(fromNode) <= fromPort ? (fromNode.name || fromNode.type) + " has no output port " + fromPort
|
|
406
|
+
: (toNode.name || toNode.type) + " has no input"));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
var link = { source: fromNode, sourcePort: fromPort, target: toNode };
|
|
410
|
+
try {
|
|
411
|
+
RED.nodes.addLink(link);
|
|
412
|
+
addedLinks.push(link);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
addMessage("error", "Failed to add wire: " + (e.message || e));
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Also process wires inside newNodes that reference existing nodes.
|
|
419
|
+
// The model uses "wires" for intra-new-node connections and "newWires"
|
|
420
|
+
// for cross-boundary connections, but it may also use "wires" to point
|
|
421
|
+
// to existing nodes (e.g. a new junction wired to an existing debug).
|
|
422
|
+
laid.forEach(function (newNode) {
|
|
423
|
+
if (!newNode.wires || !Array.isArray(newNode.wires)) { return; }
|
|
424
|
+
newNode.wires.forEach(function (portWires, portIndex) {
|
|
425
|
+
if (!Array.isArray(portWires)) { return; }
|
|
426
|
+
portWires.forEach(function (targetId) {
|
|
427
|
+
// Skip if this is an intra-new-node wire (placeholder id)
|
|
428
|
+
if (idMap[targetId]) { return; }
|
|
429
|
+
// This is a wire to an existing node - create the link
|
|
430
|
+
var fromNode = findLiveNode(newNode.id);
|
|
431
|
+
var toNode = findLiveNode(targetId);
|
|
432
|
+
if (!fromNode || !toNode) {
|
|
433
|
+
addMessage("error", "Cannot wire new node to existing node — node not found: " + (!fromNode ? newNode.id : targetId));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (!canWire(fromNode, portIndex, toNode)) {
|
|
437
|
+
addMessage("error", "Cannot wire — " +
|
|
438
|
+
(nodeOutputCount(fromNode) <= portIndex ? (fromNode.name || fromNode.type) + " has no output port " + portIndex
|
|
439
|
+
: (toNode.name || toNode.type) + " has no input"));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
var link = { source: fromNode, sourcePort: portIndex, target: toNode };
|
|
443
|
+
try {
|
|
444
|
+
RED.nodes.addLink(link);
|
|
445
|
+
addedLinks.push(link);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
addMessage("error", "Failed to add wire: " + (e.message || e));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// One compound undo entry covers both the new nodes and their wires.
|
|
454
|
+
// NB: for t:"add", ev.nodes must be an array of ID STRINGS — NR's undo
|
|
455
|
+
// does RED.nodes.node(ev.nodes[i]) then reads .z, which throws (and
|
|
456
|
+
// silently breaks Ctrl+Z) if given node objects instead of ids.
|
|
457
|
+
RED.history.push({
|
|
458
|
+
t: "add",
|
|
459
|
+
nodes: addedNodes.map(function (n) { return n.id; }),
|
|
460
|
+
links: addedLinks,
|
|
461
|
+
groups: [],
|
|
462
|
+
junctions: addedJunctions,
|
|
463
|
+
subflow: { id: undefined, instances: [] },
|
|
464
|
+
subflowInputs: [],
|
|
465
|
+
subflowOutputs: [],
|
|
466
|
+
dirty: RED.nodes.dirty()
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
RED.nodes.dirty(true);
|
|
470
|
+
RED.view.redraw(true);
|
|
471
|
+
|
|
472
|
+
// Ground follow-up turns in what was just inserted. Report partial
|
|
473
|
+
// failure honestly instead of staying silent about it — the per-node
|
|
474
|
+
// "Failed to add..." errors above already explain what didn't make
|
|
475
|
+
// it, but without this the user has no summary tying it together.
|
|
476
|
+
var insertedCount = addedNodes.length + addedJunctions.length;
|
|
477
|
+
var insertedNote = insertFailed
|
|
478
|
+
? "Inserted " + insertedCount + " of " + laid.length + " node(s) — some failed " +
|
|
479
|
+
"(see errors above). Ctrl+Z undoes what was added."
|
|
480
|
+
: "Touchdown — inserted " + insertedCount + " node(s)" +
|
|
481
|
+
(addedLinks.length ? " and added " + addedLinks.length + " wire connection(s)" : "") +
|
|
482
|
+
". Ctrl+Z to undo.";
|
|
483
|
+
addMessage("assistant", insertedNote);
|
|
484
|
+
pushHistory("assistant", insertedNote);
|
|
485
|
+
updateSelectionStatus();
|
|
486
|
+
|
|
487
|
+
// Returned so applyModifications can resolve Tier 3 wire-diff targets
|
|
488
|
+
// that point at one of these placeholder ids (e.g. an existing node's
|
|
489
|
+
// "wires" rewired to a brand-new node added in the same response).
|
|
490
|
+
return idMap;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Look up a live node by id, falling back to the per-tab junction
|
|
494
|
+
// registry, then the group registry. Junction nodes (wire-splice
|
|
495
|
+
// points) and groups are NOT in RED.nodes.node()'s normal registry —
|
|
496
|
+
// confirmed via core source (red.js's getNode() only checks
|
|
497
|
+
// configNodes/allNodes) — junctions live in RED.nodes.junctions(z),
|
|
498
|
+
// groups in RED.nodes.group(id). Without these fallbacks, a
|
|
499
|
+
// modNode/removeNodes id referring to either reads as "not found",
|
|
500
|
+
// which (in addModifyReview) renders "⚠ Node not found in editor" and
|
|
501
|
+
// blocks Apply entirely.
|
|
502
|
+
function findLiveNode(id) {
|
|
503
|
+
var node = (RED.nodes && RED.nodes.node) ? RED.nodes.node(id) : null;
|
|
504
|
+
if (node) { return node; }
|
|
505
|
+
if (RED.nodes.junctions && RED.workspaces && RED.workspaces.active) {
|
|
506
|
+
var activeZ = RED.workspaces.active();
|
|
507
|
+
var junctionsOnTab = RED.nodes.junctions(activeZ) || [];
|
|
508
|
+
for (var i = 0; i < junctionsOnTab.length; i++) {
|
|
509
|
+
if (junctionsOnTab[i].id === id) { return junctionsOnTab[i]; }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (RED.nodes.group) {
|
|
513
|
+
var grp = RED.nodes.group(id);
|
|
514
|
+
if (grp) { return grp; }
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Compare a live editor node against a returned (modified) node, key by
|
|
520
|
+
// key. We only diff keys present in the returned node — internal editor
|
|
521
|
+
// fields the model omits are untouched on apply and excluded from the diff.
|
|
522
|
+
// `wires` is separated out so the review can flag it without applying it.
|
|
523
|
+
function computeNodeDiff(liveNode, modNode) {
|
|
524
|
+
var propertyChanges = [];
|
|
525
|
+
var wiresChanged = false;
|
|
526
|
+
var redactionSkips = 0;
|
|
527
|
+
Object.keys(modNode).forEach(function (k) {
|
|
528
|
+
if (DIFF_SKIP[k]) { return; }
|
|
529
|
+
var newRaw = modNode[k];
|
|
530
|
+
// If the model echoed a sanitizer sentinel, the field is opaque —
|
|
531
|
+
// we can't meaningfully compare or apply it, so skip entirely.
|
|
532
|
+
if (isSanitizeSentinel(newRaw)) { redactionSkips++; return; }
|
|
533
|
+
var oldRaw = liveNode ? liveNode[k] : undefined;
|
|
534
|
+
var oldStr, newStr;
|
|
535
|
+
try { oldStr = JSON.stringify(oldRaw); } catch (e) { oldStr = String(oldRaw); }
|
|
536
|
+
try { newStr = JSON.stringify(newRaw); } catch (e) { newStr = String(newRaw); }
|
|
537
|
+
if (oldStr === newStr) { return; }
|
|
538
|
+
if (k === "wires") { wiresChanged = true; return; }
|
|
539
|
+
propertyChanges.push({ key: k, oldVal: oldRaw, newVal: newRaw });
|
|
540
|
+
});
|
|
541
|
+
return { propertyChanges: propertyChanges, wiresChanged: wiresChanged, redactionSkips: redactionSkips };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Diff outgoing wires for an existing node: compare what's live in the graph
|
|
545
|
+
// against what the model returned. Returns { toRemove, toAdd } each an array
|
|
546
|
+
// of { sourcePort, targetId }. Used by Tier 3 (rewire) to apply wire changes.
|
|
547
|
+
//
|
|
548
|
+
// validTargetIds is the set of node ids the model actually had in context
|
|
549
|
+
// (i.e. ids present in the returned "flow"). The model can't see or preserve
|
|
550
|
+
// connections to nodes outside its selection, so a live connection whose
|
|
551
|
+
// target is NOT in validTargetIds is left alone even if it's missing from
|
|
552
|
+
// modelWires — otherwise every out-of-context connection would look like an
|
|
553
|
+
// unintended removal (e.g. a node wired to a debug node that wasn't selected).
|
|
554
|
+
function computeWireDiff(nodeId, modelWires, validTargetIds) {
|
|
555
|
+
var currentByPort = {};
|
|
556
|
+
RED.nodes.eachLink(function (l) {
|
|
557
|
+
if (l.source && l.source.id === nodeId) {
|
|
558
|
+
var port = l.sourcePort || 0;
|
|
559
|
+
if (!currentByPort[port]) { currentByPort[port] = []; }
|
|
560
|
+
currentByPort[port].push(l.target.id);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
var desiredByPort = {};
|
|
565
|
+
var wires = Array.isArray(modelWires) ? modelWires : [];
|
|
566
|
+
wires.forEach(function (targets, port) {
|
|
567
|
+
if (Array.isArray(targets) && targets.length > 0) {
|
|
568
|
+
desiredByPort[port] = targets.slice();
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
var toRemove = [];
|
|
573
|
+
Object.keys(currentByPort).forEach(function (port) {
|
|
574
|
+
var portNum = parseInt(port, 10);
|
|
575
|
+
var curTargets = currentByPort[port];
|
|
576
|
+
var desTargets = desiredByPort[portNum] || [];
|
|
577
|
+
curTargets.forEach(function (tid) {
|
|
578
|
+
if (desTargets.indexOf(tid) === -1 && validTargetIds && validTargetIds[tid]) {
|
|
579
|
+
toRemove.push({ sourcePort: portNum, targetId: tid });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
var toAdd = [];
|
|
585
|
+
Object.keys(desiredByPort).forEach(function (port) {
|
|
586
|
+
var portNum = parseInt(port, 10);
|
|
587
|
+
var desTargets = desiredByPort[port];
|
|
588
|
+
var curTargets = currentByPort[portNum] || [];
|
|
589
|
+
desTargets.forEach(function (tid) {
|
|
590
|
+
if (curTargets.indexOf(tid) === -1) {
|
|
591
|
+
toAdd.push({ sourcePort: portNum, targetId: tid });
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return { toRemove: toRemove, toAdd: toAdd };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function formatDiffVal(v) {
|
|
600
|
+
if (v === undefined || v === null) { return "(none)"; }
|
|
601
|
+
if (typeof v === "object") { return JSON.stringify(v); }
|
|
602
|
+
return String(v);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Tabbed diff review for a modify response. Shows property diffs for
|
|
606
|
+
// existing nodes and, when the model also returned new nodes to insert,
|
|
607
|
+
// a list of those plus the wire connections to be made. The Apply button
|
|
608
|
+
// label adapts: "Apply Changes" / "Insert Nodes" / "Apply & Insert".
|
|
609
|
+
// buildFixInfo: present only for a /build loop review's fix envelope
|
|
610
|
+
// (handleBuildReviewResult) — `{ capReached }`, carried in the pop-out's
|
|
611
|
+
// relay tag since applying there also needs the loop bookkeeping
|
|
612
|
+
// applyBuildLoopFix does, not just applyModifications.
|
|
613
|
+
// newGroups: Phase 8.5 C2 slice 3 — see applyGroupChanges.
|
|
614
|
+
function addModifyReview(modifiedFlow, newNodes, newWires, removeNodes, applyCallback, buildFixInfo, newGroups) {
|
|
615
|
+
var $box = el("#fp-messages");
|
|
616
|
+
if (!$box.length) { return; }
|
|
617
|
+
|
|
618
|
+
var nodes = Array.isArray(modifiedFlow) ? modifiedFlow : [];
|
|
619
|
+
newNodes = Array.isArray(newNodes) ? newNodes : [];
|
|
620
|
+
newWires = Array.isArray(newWires) ? newWires : [];
|
|
621
|
+
removeNodes = Array.isArray(removeNodes) ? removeNodes : [];
|
|
622
|
+
newGroups = Array.isArray(newGroups) ? newGroups : [];
|
|
623
|
+
|
|
624
|
+
// Ids the model actually had in context (the returned "flow"). Used by
|
|
625
|
+
// computeWireDiff to avoid flagging connections to out-of-context nodes
|
|
626
|
+
// as removals — the model can't see or preserve those.
|
|
627
|
+
var validTargetIds = {};
|
|
628
|
+
nodes.forEach(function (n) { if (n && n.id) { validTargetIds[n.id] = true; } });
|
|
629
|
+
|
|
630
|
+
// Match each returned node to its live counterpart and compute diffs.
|
|
631
|
+
// wiresDiff is computed from the live graph via eachLink, not from
|
|
632
|
+
// node.wires (which is empty in the live editor — export-time artifact).
|
|
633
|
+
var nodeDiffs = nodes.map(function (modNode) {
|
|
634
|
+
var liveNode = findLiveNode(modNode.id);
|
|
635
|
+
var diff = computeNodeDiff(liveNode || {}, modNode);
|
|
636
|
+
var wiresDiff = (diff.wiresChanged && liveNode)
|
|
637
|
+
? computeWireDiff(modNode.id, modNode.wires, validTargetIds)
|
|
638
|
+
: { toRemove: [], toAdd: [] };
|
|
639
|
+
return {
|
|
640
|
+
modNode: modNode,
|
|
641
|
+
liveNode: liveNode,
|
|
642
|
+
propertyChanges: diff.propertyChanges,
|
|
643
|
+
wiresChanged: diff.wiresChanged,
|
|
644
|
+
redactionSkips: diff.redactionSkips,
|
|
645
|
+
wiresDiff: wiresDiff,
|
|
646
|
+
name: modNode.name || (liveNode && liveNode.name) || "",
|
|
647
|
+
type: modNode.type || (liveNode && liveNode.type) || ""
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
var totalPropChanges = nodeDiffs.reduce(function (s, d) {
|
|
652
|
+
return s + d.propertyChanges.length;
|
|
653
|
+
}, 0);
|
|
654
|
+
var nodesWithChanges = nodeDiffs.filter(function (d) {
|
|
655
|
+
return d.propertyChanges.length > 0;
|
|
656
|
+
}).length;
|
|
657
|
+
var missingLive = nodeDiffs.some(function (d) { return !d.liveNode; });
|
|
658
|
+
var hasPropChanges = totalPropChanges > 0;
|
|
659
|
+
var hasWireChanges = nodeDiffs.some(function (d) {
|
|
660
|
+
return d.wiresDiff && (d.wiresDiff.toRemove.length > 0 || d.wiresDiff.toAdd.length > 0);
|
|
661
|
+
});
|
|
662
|
+
var hasNewNodes = newNodes.length > 0;
|
|
663
|
+
var hasRemoveNodes = removeNodes.length > 0;
|
|
664
|
+
var hasNewGroups = newGroups.length > 0;
|
|
665
|
+
var hasAnyChanges = hasPropChanges || hasWireChanges || hasNewNodes || hasRemoveNodes || hasNewGroups;
|
|
666
|
+
var totalRedactionSkips = nodeDiffs.reduce(function (s, d) { return s + (d.redactionSkips || 0); }, 0);
|
|
667
|
+
|
|
668
|
+
var $msg = $("<div>").addClass("fp-message fp-review");
|
|
669
|
+
$("<div>").addClass("fp-label").text("MODIFY FLOW — REVIEW CHANGES").appendTo($msg);
|
|
670
|
+
|
|
671
|
+
var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
|
|
672
|
+
var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
|
|
673
|
+
$("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
|
|
674
|
+
|
|
675
|
+
var $summaryPanel = $("<div>").addClass("fp-tab-panel");
|
|
676
|
+
var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
|
|
677
|
+
$msg.append($summaryPanel, $jsonPanel);
|
|
678
|
+
|
|
679
|
+
// ---- Summary tab: property diffs for existing nodes ----
|
|
680
|
+
// Only show this section when there are actual property changes —
|
|
681
|
+
// "0 of N will change" is confusing when we're purely inserting or rewiring.
|
|
682
|
+
if (hasPropChanges) {
|
|
683
|
+
$("<div>").addClass("fp-review-count")
|
|
684
|
+
.text(nodesWithChanges + " of " + nodes.length + " node(s) will change:")
|
|
685
|
+
.appendTo($summaryPanel);
|
|
686
|
+
nodeDiffs.forEach(function (d) {
|
|
687
|
+
if (d.propertyChanges.length === 0) { return; }
|
|
688
|
+
var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
|
|
689
|
+
var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
|
|
690
|
+
$("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
|
|
691
|
+
if (!d.liveNode) {
|
|
692
|
+
$("<div>").addClass("fp-diff-warn")
|
|
693
|
+
.text("⚠ Node not found in editor (id: " + d.modNode.id + ")")
|
|
694
|
+
.appendTo($section);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
d.propertyChanges.forEach(function (c) {
|
|
698
|
+
var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
|
|
699
|
+
$("<span>").addClass("fp-diff-key").text(c.key).appendTo($row);
|
|
700
|
+
$("<span>").addClass("fp-diff-old").text(formatDiffVal(c.oldVal)).appendTo($row);
|
|
701
|
+
$("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
|
|
702
|
+
$("<span>").addClass("fp-diff-new").text(formatDiffVal(c.newVal)).appendTo($row);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---- Summary tab: redaction-stripped changes ----
|
|
708
|
+
// Nodes where every proposed change was blocked by isSanitizeSentinel —
|
|
709
|
+
// the model proposed a change to a field that was shown as a placeholder,
|
|
710
|
+
// so the diff was silently dropped. Surfaces the node name + a hint
|
|
711
|
+
// rather than hiding the skip entirely (which was the old behavior).
|
|
712
|
+
var redactionOnlyDiffs = nodeDiffs.filter(function (d) {
|
|
713
|
+
return d.redactionSkips > 0 && d.propertyChanges.length === 0 &&
|
|
714
|
+
(!d.wiresDiff || (!d.wiresDiff.toRemove.length && !d.wiresDiff.toAdd.length));
|
|
715
|
+
});
|
|
716
|
+
if (redactionOnlyDiffs.length > 0) {
|
|
717
|
+
var redSectionTop = hasPropChanges ? "12px" : "0";
|
|
718
|
+
$("<div>").addClass("fp-review-count").css("margin-top", redSectionTop)
|
|
719
|
+
.text("Not applied — targets redacted value(s):")
|
|
720
|
+
.appendTo($summaryPanel);
|
|
721
|
+
redactionOnlyDiffs.forEach(function (d) {
|
|
722
|
+
var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
|
|
723
|
+
var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
|
|
724
|
+
$("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
|
|
725
|
+
$("<div>").addClass("fp-diff-warn")
|
|
726
|
+
.text("Proposed change targets a redacted field — edit the value directly in the node editor.")
|
|
727
|
+
.appendTo($section);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---- Summary tab: wiring changes (Tier 3) ----
|
|
732
|
+
// Shown when model changed wires on existing nodes. Removed wires appear
|
|
733
|
+
// in the old column (red strikethrough); added wires in the new column (green).
|
|
734
|
+
if (hasWireChanges) {
|
|
735
|
+
var sectionTop = (hasPropChanges || redactionOnlyDiffs.length > 0) ? "12px" : "0";
|
|
736
|
+
$("<div>").addClass("fp-review-count").css("margin-top", sectionTop)
|
|
737
|
+
.text("Wiring changes:")
|
|
738
|
+
.appendTo($summaryPanel);
|
|
739
|
+
nodeDiffs.forEach(function (d) {
|
|
740
|
+
var wd = d.wiresDiff;
|
|
741
|
+
if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
|
|
742
|
+
var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
|
|
743
|
+
var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
|
|
744
|
+
$("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
|
|
745
|
+
wd.toRemove.forEach(function (entry) {
|
|
746
|
+
var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
|
|
747
|
+
var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
|
|
748
|
+
var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
|
|
749
|
+
$("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
|
|
750
|
+
$("<span>").addClass("fp-diff-old").text("→ " + tgtLabel).appendTo($row);
|
|
751
|
+
$("<span>").addClass("fp-diff-arrow").text("✕").appendTo($row);
|
|
752
|
+
$("<span>").addClass("fp-diff-new").text("").appendTo($row);
|
|
753
|
+
});
|
|
754
|
+
wd.toAdd.forEach(function (entry) {
|
|
755
|
+
var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
|
|
756
|
+
var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
|
|
757
|
+
var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
|
|
758
|
+
$("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
|
|
759
|
+
$("<span>").addClass("fp-diff-old").text("").appendTo($row);
|
|
760
|
+
$("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
|
|
761
|
+
$("<span>").addClass("fp-diff-new").text(tgtLabel).appendTo($row);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---- Summary tab: nodes to remove (Tier 4) ----
|
|
767
|
+
if (hasRemoveNodes) {
|
|
768
|
+
var rmTop = (hasPropChanges || hasWireChanges) ? "12px" : "0";
|
|
769
|
+
$("<div>").addClass("fp-review-count fp-diff-warn").css("margin-top", rmTop)
|
|
770
|
+
.text(removeNodes.length + " node(s) to remove:")
|
|
771
|
+
.appendTo($summaryPanel);
|
|
772
|
+
var $rmList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
|
|
773
|
+
removeNodes.forEach(function (id) {
|
|
774
|
+
var lv = RED.nodes.node ? RED.nodes.node(id) : null;
|
|
775
|
+
var label = lv ? ((lv.name || lv.type || id) + " (" + id + ")") : id + " (not found)";
|
|
776
|
+
$("<li>").addClass("fp-diff-warn").text("✕ " + label).appendTo($rmList);
|
|
777
|
+
});
|
|
778
|
+
$("<div>").addClass("fp-diff-warn").css("margin-top", "4px")
|
|
779
|
+
.text("All wires to/from removed nodes will also be deleted.")
|
|
780
|
+
.appendTo($summaryPanel);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---- Summary tab: new nodes to insert ----
|
|
784
|
+
if (hasNewNodes) {
|
|
785
|
+
var insTop = (hasPropChanges || hasWireChanges || hasRemoveNodes) ? "12px" : "0";
|
|
786
|
+
$("<div>").addClass("fp-review-count").css("margin-top", insTop)
|
|
787
|
+
.text(newNodes.length + " node(s) to insert:")
|
|
788
|
+
.appendTo($summaryPanel);
|
|
789
|
+
var $newList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
|
|
790
|
+
newNodes.forEach(function (n) {
|
|
791
|
+
$("<li>").text((n.type || "unknown") + (n.name ? " — \"" + n.name + "\"" : "")).appendTo($newList);
|
|
792
|
+
});
|
|
793
|
+
if (newWires.length > 0) {
|
|
794
|
+
$("<div>").addClass("fp-review-count").css("margin-top", "8px")
|
|
795
|
+
.text(newWires.length + " wire connection(s):")
|
|
796
|
+
.appendTo($summaryPanel);
|
|
797
|
+
var $wireList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
|
|
798
|
+
newWires.forEach(function (wire) {
|
|
799
|
+
var fromLabel = resolveWireRef(wire.from, newNodes);
|
|
800
|
+
var toLabel = resolveWireRef(wire.to, newNodes);
|
|
801
|
+
var portNote = (wire.fromPort && wire.fromPort > 0) ? " [port " + wire.fromPort + "]" : "";
|
|
802
|
+
$("<li>").text(fromLabel + portNote + " → " + toLabel).appendTo($wireList);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ---- Summary tab: groups to create/update ----
|
|
808
|
+
if (hasNewGroups) {
|
|
809
|
+
var grpTop = (hasPropChanges || hasWireChanges || hasRemoveNodes || hasNewNodes) ? "12px" : "0";
|
|
810
|
+
$("<div>").addClass("fp-review-count").css("margin-top", grpTop)
|
|
811
|
+
.text(newGroups.length + " group(s) to create/update:")
|
|
812
|
+
.appendTo($summaryPanel);
|
|
813
|
+
var $grpList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
|
|
814
|
+
newGroups.forEach(function (g) {
|
|
815
|
+
var label = (g.name ? "\"" + g.name + "\"" : "(unnamed group)") +
|
|
816
|
+
" — " + (Array.isArray(g.nodes) ? g.nodes.length : 0) + " node(s)";
|
|
817
|
+
$("<li>").text(label).appendTo($grpList);
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ---- JSON tab ----
|
|
822
|
+
var jsonPayload = (hasNewNodes || hasRemoveNodes || hasNewGroups)
|
|
823
|
+
? { modifiedNodes: nodes, newNodes: newNodes, newWires: newWires, removeNodes: removeNodes, newGroups: newGroups }
|
|
824
|
+
: nodes;
|
|
825
|
+
var jsonText = JSON.stringify(jsonPayload, null, 2);
|
|
826
|
+
var $copyBtn = $("<button>")
|
|
827
|
+
.addClass("red-ui-button red-ui-button-small")
|
|
828
|
+
.attr("type", "button")
|
|
829
|
+
.text("Copy")
|
|
830
|
+
.on("click", function () { copyToClipboard($copyBtn, jsonText); });
|
|
831
|
+
$("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
|
|
832
|
+
$("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
|
|
833
|
+
|
|
834
|
+
function activateTab(showSummary) {
|
|
835
|
+
$tabSummary.toggleClass("fp-tab-active", showSummary);
|
|
836
|
+
$tabJson.toggleClass("fp-tab-active", !showSummary);
|
|
837
|
+
$summaryPanel.toggleClass("fp-hidden", !showSummary);
|
|
838
|
+
$jsonPanel.toggleClass("fp-hidden", showSummary);
|
|
839
|
+
}
|
|
840
|
+
$tabSummary.on("click", function () { activateTab(true); });
|
|
841
|
+
$tabJson.on("click", function () { activateTab(false); });
|
|
842
|
+
|
|
843
|
+
// ---- Action row ----
|
|
844
|
+
var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
|
|
845
|
+
if (!hasAnyChanges) {
|
|
846
|
+
if (totalRedactionSkips > 0) {
|
|
847
|
+
$("<div>").addClass("fp-warning")
|
|
848
|
+
.text("All proposed changes target redacted fields — FlowPilot can only see those values as placeholders. Edit the field(s) directly in the node editor, then retry for any remaining changes.")
|
|
849
|
+
.appendTo($actions);
|
|
850
|
+
} else {
|
|
851
|
+
$("<div>").addClass("fp-review-hint")
|
|
852
|
+
.text("No changes detected — nothing to apply.")
|
|
853
|
+
.appendTo($actions);
|
|
854
|
+
}
|
|
855
|
+
} else if (missingLive && (hasPropChanges || hasWireChanges)) {
|
|
856
|
+
$("<div>").addClass("fp-warning")
|
|
857
|
+
.text("One or more nodes could not be found in the editor. Cannot apply safely.")
|
|
858
|
+
.appendTo($actions);
|
|
859
|
+
} else {
|
|
860
|
+
var hasMutations = hasPropChanges || hasWireChanges || hasRemoveNodes;
|
|
861
|
+
var btnLabel = (hasMutations && hasNewNodes) ? "Apply & Insert"
|
|
862
|
+
: hasMutations ? "Apply Changes"
|
|
863
|
+
: hasNewNodes ? "Insert Nodes"
|
|
864
|
+
: "Apply Changes"; // covers a request that ONLY creates/updates a group
|
|
865
|
+
|
|
866
|
+
// Pop-out: tag this panel with everything applyInsertions/
|
|
867
|
+
// applyModifications/applyGroupChanges need to re-run from a
|
|
868
|
+
// relayed click — nodeDiffs re-serialized without liveNode (a
|
|
869
|
+
// live RED node object, not JSON-safe; applyModifications
|
|
870
|
+
// re-fetches it itself via findLiveNode anyway, so nothing is
|
|
871
|
+
// lost). A plain Modify call (applyCallback is the bare
|
|
872
|
+
// applyModifications reference) gets "data-fp-apply-modify"; a
|
|
873
|
+
// /build loop fix (buildFixInfo set — see applyBuildLoopFix)
|
|
874
|
+
// gets "data-fp-apply-build-fix" instead, carrying capReached
|
|
875
|
+
// too since the relayed click needs to run the SAME loop
|
|
876
|
+
// bookkeeping a local click would, not just applyModifications.
|
|
877
|
+
var sharedApplyData = {
|
|
878
|
+
nodeDiffs: nodeDiffs.map(function (d) {
|
|
879
|
+
return {
|
|
880
|
+
modNode: d.modNode,
|
|
881
|
+
propertyChanges: d.propertyChanges,
|
|
882
|
+
wiresChanged: d.wiresChanged,
|
|
883
|
+
wiresDiff: d.wiresDiff,
|
|
884
|
+
name: d.name,
|
|
885
|
+
type: d.type
|
|
886
|
+
};
|
|
887
|
+
}),
|
|
888
|
+
removeNodes: removeNodes,
|
|
889
|
+
newNodes: newNodes,
|
|
890
|
+
newWires: newWires,
|
|
891
|
+
newGroups: newGroups,
|
|
892
|
+
existingNodeIds: nodes.map(function (n) { return n.id; }),
|
|
893
|
+
hasMutations: hasMutations
|
|
894
|
+
};
|
|
895
|
+
if (applyCallback === applyModifications) {
|
|
896
|
+
$msg.attr("data-fp-apply-modify", JSON.stringify(sharedApplyData));
|
|
897
|
+
} else if (buildFixInfo) {
|
|
898
|
+
sharedApplyData.capReached = !!buildFixInfo.capReached;
|
|
899
|
+
$msg.attr("data-fp-apply-build-fix", JSON.stringify(sharedApplyData));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
var $applyBtn = $("<button>")
|
|
903
|
+
.addClass("red-ui-button red-ui-button-primary")
|
|
904
|
+
.attr("type", "button")
|
|
905
|
+
.text(btnLabel)
|
|
906
|
+
.on("click", function () {
|
|
907
|
+
$applyBtn.prop("disabled", true).text("Applying…");
|
|
908
|
+
// Insertions run FIRST so their placeholder→real-id map is
|
|
909
|
+
// available to applyModifications/applyGroupChanges — an
|
|
910
|
+
// existing node's rewired "wires" (Tier 3) or a new
|
|
911
|
+
// group's membership may point at a node being inserted
|
|
912
|
+
// in this same response.
|
|
913
|
+
var idMap = {};
|
|
914
|
+
if (hasNewNodes) {
|
|
915
|
+
idMap = applyInsertions(newNodes, newWires, nodes.map(function (n) { return n.id; })) || {};
|
|
916
|
+
}
|
|
917
|
+
if (hasMutations && applyCallback) { applyCallback(nodeDiffs, removeNodes, null, idMap); }
|
|
918
|
+
if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
|
|
919
|
+
$applyBtn.text("Done ✓");
|
|
920
|
+
});
|
|
921
|
+
$actions.append($applyBtn);
|
|
922
|
+
var hintParts = [];
|
|
923
|
+
if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
|
|
924
|
+
if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
|
|
925
|
+
if (hasNewGroups) { hintParts.push("groups are created/updated"); }
|
|
926
|
+
if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
|
|
927
|
+
var hintText = "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.";
|
|
928
|
+
$("<span>").addClass("fp-review-hint").text(hintText).appendTo($actions);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
$box.append($msg);
|
|
932
|
+
scrollMessagesToBottom();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Apply mechanism covering all modification tiers:
|
|
936
|
+
// Tier 1 — property changes: mutate live node + {t:"edit"} history entry
|
|
937
|
+
// Tier 3 — wire changes: removeLink/addLink + {t:"add", removedLinks} entry
|
|
938
|
+
// Tier 4 — node removals: collect links, removeLink, remove node + {t:"delete"} entry
|
|
939
|
+
// One history entry per node per type so Ctrl+Z steps back through them cleanly.
|
|
940
|
+
function applyModifications(nodeDiffs, removeNodes, $applyBtn, idMap) {
|
|
941
|
+
idMap = idMap || {};
|
|
942
|
+
var propApplied = 0;
|
|
943
|
+
var wireNodesApplied = 0;
|
|
944
|
+
var nodesRemoved = 0;
|
|
945
|
+
var failed = [];
|
|
946
|
+
removeNodes = Array.isArray(removeNodes) ? removeNodes : [];
|
|
947
|
+
|
|
948
|
+
// --- Tier 1: property changes ---
|
|
949
|
+
nodeDiffs.forEach(function (d) {
|
|
950
|
+
if (d.propertyChanges.length === 0) { return; }
|
|
951
|
+
var liveNode = findLiveNode(d.modNode.id);
|
|
952
|
+
if (!liveNode) { failed.push(d.modNode.id); return; }
|
|
953
|
+
|
|
954
|
+
var oldValues = {};
|
|
955
|
+
d.propertyChanges.forEach(function (c) { oldValues[c.key] = liveNode[c.key]; });
|
|
956
|
+
d.propertyChanges.forEach(function (c) { liveNode[c.key] = c.newVal; });
|
|
957
|
+
|
|
958
|
+
// Switch nodes derive their port count from rules.length (the edit
|
|
959
|
+
// dialog's "outputCount" map assigns each rule its own output and
|
|
960
|
+
// writes the resulting count to node.outputs on save). FlowPilot's
|
|
961
|
+
// direct mutation above changes "rules" but never touches
|
|
962
|
+
// "outputs", leaving the canvas showing the old port count even
|
|
963
|
+
// though the new wires/rules are correct. Recompute it here so
|
|
964
|
+
// RED.view.redraw(true) (below) rebuilds the ports - the redraw
|
|
965
|
+
// loop rebuilds a node's output ports whenever __outputs__.length
|
|
966
|
+
// !== d.outputs.
|
|
967
|
+
if (liveNode.type === "switch" && Array.isArray(liveNode.rules) &&
|
|
968
|
+
liveNode.rules.length !== liveNode.outputs) {
|
|
969
|
+
if (!oldValues.hasOwnProperty("outputs")) { oldValues.outputs = liveNode.outputs; }
|
|
970
|
+
liveNode.outputs = liveNode.rules.length;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
liveNode.changed = true;
|
|
974
|
+
liveNode.dirty = true;
|
|
975
|
+
RED.history.push({
|
|
976
|
+
t: "edit",
|
|
977
|
+
node: liveNode,
|
|
978
|
+
changes: oldValues,
|
|
979
|
+
dirty: RED.nodes.dirty()
|
|
980
|
+
});
|
|
981
|
+
propApplied++;
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// --- Tier 3: wire changes ---
|
|
985
|
+
// removeLink old + addLink new, then push ONE {t:"add", removedLinks} entry
|
|
986
|
+
// per node. NR's undo for this shape removes the new links AND restores
|
|
987
|
+
// the old ones (removedLinks field is present on every "add" wire event).
|
|
988
|
+
nodeDiffs.forEach(function (d) {
|
|
989
|
+
var wd = d.wiresDiff;
|
|
990
|
+
if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
|
|
991
|
+
var liveNode = findLiveNode(d.modNode.id);
|
|
992
|
+
if (!liveNode) { return; }
|
|
993
|
+
|
|
994
|
+
var removedLinks = [];
|
|
995
|
+
var addedLinks = [];
|
|
996
|
+
|
|
997
|
+
wd.toRemove.forEach(function (entry) {
|
|
998
|
+
var found = null;
|
|
999
|
+
RED.nodes.eachLink(function (l) {
|
|
1000
|
+
if (found) { return; }
|
|
1001
|
+
if (l.source && l.source.id === d.modNode.id &&
|
|
1002
|
+
(l.sourcePort || 0) === entry.sourcePort &&
|
|
1003
|
+
l.target && l.target.id === entry.targetId) {
|
|
1004
|
+
found = l;
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
if (found) {
|
|
1008
|
+
RED.nodes.removeLink(found);
|
|
1009
|
+
removedLinks.push(found);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
wd.toAdd.forEach(function (entry) {
|
|
1014
|
+
// entry.targetId may be a placeholder id for a node inserted
|
|
1015
|
+
// by the SAME response (e.g. "fp-new-0") — resolve it to the
|
|
1016
|
+
// real id applyInsertions just assigned, if any.
|
|
1017
|
+
var targetId = idMap[entry.targetId] || entry.targetId;
|
|
1018
|
+
var toNode = (RED.nodes && RED.nodes.node) ? RED.nodes.node(targetId) : null;
|
|
1019
|
+
if (!toNode) { return; }
|
|
1020
|
+
if (!canWire(liveNode, entry.sourcePort, toNode)) {
|
|
1021
|
+
addMessage("error", "Cannot wire — " +
|
|
1022
|
+
(nodeOutputCount(liveNode) <= entry.sourcePort ? (liveNode.name || liveNode.type) + " has no output port " + entry.sourcePort
|
|
1023
|
+
: (toNode.name || toNode.type) + " has no input"));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
var link = { source: liveNode, sourcePort: entry.sourcePort, target: toNode };
|
|
1027
|
+
try {
|
|
1028
|
+
RED.nodes.addLink(link);
|
|
1029
|
+
addedLinks.push(link);
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
addMessage("error", "Failed to add wire: " + (e.message || e));
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
if (removedLinks.length || addedLinks.length) {
|
|
1036
|
+
RED.history.push({
|
|
1037
|
+
t: "add",
|
|
1038
|
+
links: addedLinks,
|
|
1039
|
+
removedLinks: removedLinks,
|
|
1040
|
+
dirty: RED.nodes.dirty()
|
|
1041
|
+
});
|
|
1042
|
+
wireNodesApplied++;
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// --- Tier 4: node removals ---
|
|
1047
|
+
// Collect connected links BEFORE removing anything (for the history entry),
|
|
1048
|
+
// then remove the node — RED.nodes.remove(id) cleans up its own links
|
|
1049
|
+
// internally. RED.nodes.remove(id) takes an ID STRING (not a node object —
|
|
1050
|
+
// confirmed via red.js: removeNode(id) does `allNodes.hasNode(id)`, which
|
|
1051
|
+
// a node object fails silently, making it a no-op with no error). Push one
|
|
1052
|
+
// compound {t:"delete"} entry per node so a single Ctrl+Z restores both
|
|
1053
|
+
// the node and its links.
|
|
1054
|
+
//
|
|
1055
|
+
// Junction nodes (wire-splice points) are removed via
|
|
1056
|
+
// RED.nodes.removeJunction(junctionObj) (object, not id), with the
|
|
1057
|
+
// history entry's junction going in `junctions`, not `nodes` —
|
|
1058
|
+
// findLiveNode's junction-registry fallback returns the junction
|
|
1059
|
+
// object itself, which doubles as the isJunction check via .type.
|
|
1060
|
+
removeNodes.forEach(function (id) {
|
|
1061
|
+
var liveNode = findLiveNode(id);
|
|
1062
|
+
var isJunction = !!(liveNode && liveNode.type === "junction");
|
|
1063
|
+
|
|
1064
|
+
if (!liveNode) { failed.push(id); return; }
|
|
1065
|
+
|
|
1066
|
+
var connectedLinks = [];
|
|
1067
|
+
RED.nodes.eachLink(function (l) {
|
|
1068
|
+
if ((l.source && l.source.id === id) || (l.target && l.target.id === id)) {
|
|
1069
|
+
connectedLinks.push(l);
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
try {
|
|
1074
|
+
if (isJunction) {
|
|
1075
|
+
RED.nodes.removeJunction(liveNode);
|
|
1076
|
+
} else {
|
|
1077
|
+
RED.nodes.remove(liveNode.id);
|
|
1078
|
+
}
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
addMessage("error", "Failed to remove node " + id + ": " + (e.message || e));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// RED.nodes.remove()/removeJunction() do NOT clean up group
|
|
1085
|
+
// membership at all (confirmed via core source — getNode()'s
|
|
1086
|
+
// removal path never touches .g or group.nodes). Node-RED's
|
|
1087
|
+
// own UI delete action does this extra bookkeeping itself
|
|
1088
|
+
// (red.js's deleteSelection(), ~line 27151) rather than baking
|
|
1089
|
+
// it into the data-model removal call — replicate it here so a
|
|
1090
|
+
// removed grouped node doesn't leave a dangling reference in
|
|
1091
|
+
// group.nodes with a stale bounding box.
|
|
1092
|
+
if (liveNode.g && RED.nodes.group) {
|
|
1093
|
+
var ownerGroup = RED.nodes.group(liveNode.g);
|
|
1094
|
+
if (ownerGroup) {
|
|
1095
|
+
var memberIdx = ownerGroup.nodes.indexOf(liveNode);
|
|
1096
|
+
if (memberIdx !== -1) { ownerGroup.nodes.splice(memberIdx, 1); }
|
|
1097
|
+
RED.group.markDirty(ownerGroup);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
RED.history.push({
|
|
1102
|
+
t: "delete",
|
|
1103
|
+
nodes: isJunction ? [] : [liveNode],
|
|
1104
|
+
links: connectedLinks,
|
|
1105
|
+
groups: [],
|
|
1106
|
+
junctions: isJunction ? [liveNode] : [],
|
|
1107
|
+
subflow: { id: undefined, instances: [] },
|
|
1108
|
+
subflowInputs: [],
|
|
1109
|
+
subflowOutputs: [],
|
|
1110
|
+
dirty: RED.nodes.dirty()
|
|
1111
|
+
});
|
|
1112
|
+
nodesRemoved++;
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
RED.nodes.dirty(true);
|
|
1116
|
+
RED.view.redraw(true);
|
|
1117
|
+
|
|
1118
|
+
if ($applyBtn) { $applyBtn.prop("disabled", true).text("Applied ✓"); }
|
|
1119
|
+
|
|
1120
|
+
var parts = [];
|
|
1121
|
+
if (propApplied) { parts.push("changes to " + propApplied + " node(s)"); }
|
|
1122
|
+
if (wireNodesApplied) { parts.push("wiring updates on " + wireNodesApplied + " node(s)"); }
|
|
1123
|
+
if (nodesRemoved) { parts.push("removed " + nodesRemoved + " node(s)"); }
|
|
1124
|
+
|
|
1125
|
+
if (failed.length) {
|
|
1126
|
+
addMessage("error",
|
|
1127
|
+
(parts.length ? "Applied: " + parts.join(", ") + ". " : "") +
|
|
1128
|
+
failed.length + " node(s) not found and skipped: " + failed.join(", "));
|
|
1129
|
+
} else if (parts.length) {
|
|
1130
|
+
// Ground follow-up turns ("now undo the topic
|
|
1131
|
+
// change") in what was actually applied.
|
|
1132
|
+
var appliedNote = "Touchdown — applied " + parts.join(", ") + ". Ctrl+Z to undo.";
|
|
1133
|
+
addMessage("assistant", appliedNote);
|
|
1134
|
+
pushHistory("assistant", appliedNote);
|
|
1135
|
+
updateSelectionStatus();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Reconciles a Modify response's "newGroups" entries (Phase 8.5 C2
|
|
1140
|
+
// slice 3) against live state. Each entry's "nodes" is the FULL
|
|
1141
|
+
// desired membership for that group id — declarative, like "changes"
|
|
1142
|
+
// — not a one-shot "add these" instruction:
|
|
1143
|
+
// - If a LIVE group already exists with this id (the model learned
|
|
1144
|
+
// about it via sanitizeNode's context "group" field), membership
|
|
1145
|
+
// is diffed against what's actually there now and reconciled via
|
|
1146
|
+
// RED.group.addToGroup/removeFromGroup (both confirmed via core
|
|
1147
|
+
// source to handle bounding-box math + dirty-marking themselves —
|
|
1148
|
+
// nothing to reimplement), and a changed "name" is applied as a
|
|
1149
|
+
// direct property edit, same shape as Tier 1.
|
|
1150
|
+
// - If no live group matches, RED.group.createGroup(memberNodes)
|
|
1151
|
+
// makes a brand new one — it always assigns its OWN fresh id
|
|
1152
|
+
// (RED.nodes.id()), unlike applyInsertions' regular nodes, so
|
|
1153
|
+
// there's no idMap entry to register for it (nothing in v1 wires
|
|
1154
|
+
// to a group afterward anyway).
|
|
1155
|
+
// One RED.history.push per discrete operation (matching how Node-RED's
|
|
1156
|
+
// own group UI actions push them separately too), not one giant batch.
|
|
1157
|
+
// RED.group.createGroup()/addToGroup() both require every member to
|
|
1158
|
+
// share the exact same STARTING .g (all currently ungrouped, or all
|
|
1159
|
+
// already in the identical group) — a mix is rejected by NR core
|
|
1160
|
+
// itself: createGroup silently console.warns and returns undefined;
|
|
1161
|
+
// addToGroup throws outright (and even then only tolerates ONE
|
|
1162
|
+
// pre-existing source group, and only if that node is at index 0).
|
|
1163
|
+
// Live-confirmed (2026-06-30): asked to group a mix of previously-
|
|
1164
|
+
// ungrouped comment nodes plus one already-grouped node, createGroup
|
|
1165
|
+
// returned undefined, and "newGroup.name = g.name" then threw on
|
|
1166
|
+
// that undefined — caught and reported as "Failed to create group".
|
|
1167
|
+
// Detach every member from whatever group it's CURRENTLY in first
|
|
1168
|
+
// (batched per distinct old group, so each is one clean undo step),
|
|
1169
|
+
// so every member starts from .g === undefined before create/extend
|
|
1170
|
+
// ever runs — handles members arriving from any mix of prior states.
|
|
1171
|
+
function detachFromCurrentGroups(nodes, exceptGroupId) {
|
|
1172
|
+
var byOldGroup = {};
|
|
1173
|
+
nodes.forEach(function (n) {
|
|
1174
|
+
if (n.g && n.g !== exceptGroupId) {
|
|
1175
|
+
(byOldGroup[n.g] = byOldGroup[n.g] || []).push(n);
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
Object.keys(byOldGroup).forEach(function (oldGroupId) {
|
|
1179
|
+
var oldGroup = findLiveNode(oldGroupId);
|
|
1180
|
+
if (oldGroup && oldGroup.type === "group") {
|
|
1181
|
+
var detached = byOldGroup[oldGroupId];
|
|
1182
|
+
RED.group.removeFromGroup(oldGroup, detached, false);
|
|
1183
|
+
RED.history.push({ t: "removeFromGroup", group: oldGroup, nodes: detached, dirty: RED.nodes.dirty() });
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function applyGroupChanges(newGroups, idMap) {
|
|
1189
|
+
idMap = idMap || {};
|
|
1190
|
+
newGroups = Array.isArray(newGroups) ? newGroups : [];
|
|
1191
|
+
var groupsApplied = 0;
|
|
1192
|
+
|
|
1193
|
+
newGroups.forEach(function (g) {
|
|
1194
|
+
if (!g || !g.id) { return; }
|
|
1195
|
+
var memberIds = Array.isArray(g.nodes) ? g.nodes : [];
|
|
1196
|
+
var memberNodes = memberIds.map(function (ref) {
|
|
1197
|
+
return findLiveNode(idMap[ref] || ref);
|
|
1198
|
+
}).filter(function (n) { return !!n; });
|
|
1199
|
+
|
|
1200
|
+
// An EXISTING group reconciling down to ZERO members is a
|
|
1201
|
+
// legitimate, meaningful request — "ungroup everyone in this
|
|
1202
|
+
// group" — so the empty-members case is only a no-op for the
|
|
1203
|
+
// CREATE branch below (RED.group.createGroup on nothing makes
|
|
1204
|
+
// no sense; an existing group going empty does).
|
|
1205
|
+
var liveGroup = findLiveNode(g.id);
|
|
1206
|
+
if (!memberNodes.length && !(liveGroup && liveGroup.type === "group")) { return; }
|
|
1207
|
+
|
|
1208
|
+
if (liveGroup && liveGroup.type === "group") {
|
|
1209
|
+
if (!memberNodes.length) {
|
|
1210
|
+
// Full disband. RED.group.removeFromGroup only empties
|
|
1211
|
+
// .nodes - it never removes the group object itself, so
|
|
1212
|
+
// looping it down to zero members leaves a tiny, dangling
|
|
1213
|
+
// empty group on the canvas (confirmed live). The
|
|
1214
|
+
// editor's own "Ungroup Selection" action uses a
|
|
1215
|
+
// different API for this exact case - RED.group.ungroup
|
|
1216
|
+
// reparents members (or clears their .g) AND calls
|
|
1217
|
+
// RED.nodes.removeGroup to actually remove the group.
|
|
1218
|
+
RED.group.ungroup(liveGroup);
|
|
1219
|
+
RED.history.push({ t: "ungroup", groups: [liveGroup], dirty: RED.nodes.dirty() });
|
|
1220
|
+
groupsApplied++;
|
|
1221
|
+
} else {
|
|
1222
|
+
var desiredIds = {};
|
|
1223
|
+
memberNodes.forEach(function (n) { desiredIds[n.id] = true; });
|
|
1224
|
+
var currentIds = {};
|
|
1225
|
+
liveGroup.nodes.forEach(function (n) { currentIds[n.id] = true; });
|
|
1226
|
+
var toRemove = liveGroup.nodes.filter(function (n) { return !desiredIds[n.id]; });
|
|
1227
|
+
var toAdd = memberNodes.filter(function (n) { return !currentIds[n.id]; });
|
|
1228
|
+
|
|
1229
|
+
if (toRemove.length) {
|
|
1230
|
+
RED.group.removeFromGroup(liveGroup, toRemove, false);
|
|
1231
|
+
RED.history.push({ t: "removeFromGroup", group: liveGroup, nodes: toRemove, dirty: RED.nodes.dirty() });
|
|
1232
|
+
}
|
|
1233
|
+
if (toAdd.length) {
|
|
1234
|
+
detachFromCurrentGroups(toAdd, liveGroup.id);
|
|
1235
|
+
RED.group.addToGroup(liveGroup, toAdd);
|
|
1236
|
+
RED.history.push({ t: "addToGroup", group: liveGroup, nodes: toAdd, dirty: RED.nodes.dirty() });
|
|
1237
|
+
}
|
|
1238
|
+
if (g.name !== undefined && g.name !== liveGroup.name) {
|
|
1239
|
+
var oldName = liveGroup.name;
|
|
1240
|
+
liveGroup.name = g.name;
|
|
1241
|
+
liveGroup.changed = true;
|
|
1242
|
+
RED.history.push({ t: "edit", node: liveGroup, changes: { name: oldName }, dirty: RED.nodes.dirty() });
|
|
1243
|
+
}
|
|
1244
|
+
groupsApplied++;
|
|
1245
|
+
}
|
|
1246
|
+
} else {
|
|
1247
|
+
try {
|
|
1248
|
+
detachFromCurrentGroups(memberNodes);
|
|
1249
|
+
var newGroup = RED.group.createGroup(memberNodes);
|
|
1250
|
+
if (g.name) { newGroup.name = g.name; }
|
|
1251
|
+
RED.group.markDirty(newGroup);
|
|
1252
|
+
RED.history.push({ t: "createGroup", groups: [newGroup], dirty: RED.nodes.dirty() });
|
|
1253
|
+
groupsApplied++;
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
addMessage("error", "Failed to create group: " + (e.message || e));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
if (groupsApplied) {
|
|
1261
|
+
RED.nodes.dirty(true);
|
|
1262
|
+
RED.view.redraw(true);
|
|
1263
|
+
var groupNote = "Touchdown — created/updated " + groupsApplied +
|
|
1264
|
+
" group(s). Ctrl+Z to undo.";
|
|
1265
|
+
addMessage("assistant", groupNote);
|
|
1266
|
+
pushHistory("assistant", groupNote);
|
|
1267
|
+
}
|
|
1268
|
+
return groupsApplied;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
var CORE_NODE_TYPES = {
|
|
1272
|
+
"inject": true, "debug": true, "complete": true, "catch": true, "status": true,
|
|
1273
|
+
"link in": true, "link out": true, "link call": true, "comment": true,
|
|
1274
|
+
"junction": true, "unknown": true, "group": true,
|
|
1275
|
+
"function": true, "switch": true, "change": true, "range": true, "template": true,
|
|
1276
|
+
"mqtt in": true, "mqtt out": true, "mqtt-broker": true,
|
|
1277
|
+
"http in": true, "http response": true, "http request": true,
|
|
1278
|
+
"websocket in": true, "websocket out": true,
|
|
1279
|
+
"websocket-listener": true, "websocket-client": true,
|
|
1280
|
+
"tcp in": true, "tcp out": true, "tcp request": true,
|
|
1281
|
+
"udp in": true, "udp out": true, "tls-config": true, "httpproxy": true,
|
|
1282
|
+
"split": true, "join": true, "sort": true, "batch": true,
|
|
1283
|
+
"csv": true, "html": true, "json": true, "xml": true, "yaml": true,
|
|
1284
|
+
"file": true, "file in": true, "watch": true, "tail": true,
|
|
1285
|
+
"exec": true, "delay": true, "trigger": true
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
// Checks the two things only the live editor can tell us before import:
|
|
1289
|
+
// (1) wire integrity — every wire target id must exist in the generated
|
|
1290
|
+
// set (the model can hallucinate ids); (2) node-type classification —
|
|
1291
|
+
// core / non-core-but-installed / not-installed, via RED.nodes.getType.
|
|
1292
|
+
// Returns per-node summary entries plus separated warning/problem lists
|
|
1293
|
+
// so the review UI can render them and decide whether import is offered.
|
|
1294
|
+
function validateGeneratedFlow(flow) {
|
|
1295
|
+
var nodes = Array.isArray(flow) ? flow : [];
|
|
1296
|
+
var ids = {};
|
|
1297
|
+
nodes.forEach(function (n) {
|
|
1298
|
+
if (n && n.id) { ids[n.id] = true; }
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
var summary = [];
|
|
1302
|
+
var typeWarnings = [];
|
|
1303
|
+
var brokenWires = [];
|
|
1304
|
+
var realWireCount = 0;
|
|
1305
|
+
|
|
1306
|
+
nodes.forEach(function (n) {
|
|
1307
|
+
if (!n || !n.id || !n.type) { return; }
|
|
1308
|
+
|
|
1309
|
+
var isCore = !!CORE_NODE_TYPES[n.type];
|
|
1310
|
+
var isInstalled = !!RED.nodes.getType(n.type);
|
|
1311
|
+
var status = isCore ? "core" : (isInstalled ? "non-core-installed" : "not-installed");
|
|
1312
|
+
|
|
1313
|
+
var entry = { id: n.id, type: n.type, name: n.name || "", status: status };
|
|
1314
|
+
summary.push(entry);
|
|
1315
|
+
if (status !== "core") { typeWarnings.push(entry); }
|
|
1316
|
+
|
|
1317
|
+
(Array.isArray(n.wires) ? n.wires : []).forEach(function (port) {
|
|
1318
|
+
(Array.isArray(port) ? port : []).forEach(function (targetId) {
|
|
1319
|
+
if (!ids[targetId]) {
|
|
1320
|
+
brokenWires.push({ from: n.id, type: n.type, target: targetId });
|
|
1321
|
+
} else {
|
|
1322
|
+
realWireCount++;
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
// A multi-node flow with zero connections anywhere is almost always
|
|
1329
|
+
// a generation slip (the model omitted "wires" on every node) rather
|
|
1330
|
+
// than something the user actually wanted — flag it, but don't
|
|
1331
|
+
// block import; a handful of genuinely independent nodes is rare
|
|
1332
|
+
// but not impossible.
|
|
1333
|
+
var nonCommentCount = nodes.filter(function (n) { return n && n.type !== "comment"; }).length;
|
|
1334
|
+
var noConnections = nonCommentCount > 1 && realWireCount === 0;
|
|
1335
|
+
|
|
1336
|
+
return { summary: summary, typeWarnings: typeWarnings, brokenWires: brokenWires, noConnections: noConnections };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// The generation prompt deliberately omits x/y ("the editor assigns those
|
|
1340
|
+
// on import"), but RED.view.importNodes does NOT auto-arrange nodes that
|
|
1341
|
+
// lack coordinates — it just places them on top of each other. So we lay
|
|
1342
|
+
// them out ourselves:
|
|
1343
|
+
//
|
|
1344
|
+
// - Non-comment wired nodes are split into connected components (a
|
|
1345
|
+
// component = nodes reachable from each other via wires, in either
|
|
1346
|
+
// direction). Each component gets its own horizontal "band", stacked
|
|
1347
|
+
// top to bottom, ordered by where its first node appears in `flow`.
|
|
1348
|
+
// Within a band, columns are topological depth (longest path from an
|
|
1349
|
+
// in-degree-0 node) and rows stack top-to-bottom within a column —
|
|
1350
|
+
// same approach as before, just scoped to one component at a time so
|
|
1351
|
+
// independent chains (e.g. a scheduler pipeline vs. an HTTP endpoint)
|
|
1352
|
+
// don't get interleaved into the same rows.
|
|
1353
|
+
// - Comment nodes have no wires to anchor them, so each is matched to
|
|
1354
|
+
// the nearest non-comment node adjacent to it in the `flow` array
|
|
1355
|
+
// (forward, then backward) — models place a comment next to the
|
|
1356
|
+
// section it describes — and placed in a header row above that node's
|
|
1357
|
+
// column, in that node's component's band.
|
|
1358
|
+
//
|
|
1359
|
+
// Config nodes (no "wires" array — e.g. mqtt-broker) are left untouched;
|
|
1360
|
+
// they don't live on the canvas and have no x/y/z of their own.
|
|
1361
|
+
function layoutGeneratedFlow(flow) {
|
|
1362
|
+
var COL_WIDTH = 200;
|
|
1363
|
+
var ROW_HEIGHT = 90;
|
|
1364
|
+
var BASE_X = 160;
|
|
1365
|
+
var BASE_Y = 120;
|
|
1366
|
+
var BAND_GAP_ROWS = 1;
|
|
1367
|
+
|
|
1368
|
+
var nodes = Array.isArray(flow) ? flow : [];
|
|
1369
|
+
var wiredNodes = nodes.filter(function (n) { return n && Array.isArray(n.wires) && n.type !== "comment"; });
|
|
1370
|
+
var commentNodes = nodes.filter(function (n) { return n && n.type === "comment"; });
|
|
1371
|
+
|
|
1372
|
+
if (!wiredNodes.length) {
|
|
1373
|
+
// Nothing to anchor comments to — just stack everything.
|
|
1374
|
+
nodes.forEach(function (n, i) {
|
|
1375
|
+
if (!n) { return; }
|
|
1376
|
+
n.x = BASE_X;
|
|
1377
|
+
n.y = BASE_Y + i * ROW_HEIGHT;
|
|
1378
|
+
});
|
|
1379
|
+
return nodes;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
var byId = {};
|
|
1383
|
+
wiredNodes.forEach(function (n) { byId[n.id] = n; });
|
|
1384
|
+
|
|
1385
|
+
// ---- Connected components (undirected: wires in either direction) ----
|
|
1386
|
+
var adjacency = {};
|
|
1387
|
+
wiredNodes.forEach(function (n) { adjacency[n.id] = []; });
|
|
1388
|
+
wiredNodes.forEach(function (n) {
|
|
1389
|
+
n.wires.forEach(function (port) {
|
|
1390
|
+
(Array.isArray(port) ? port : []).forEach(function (targetId) {
|
|
1391
|
+
if (!byId[targetId]) { return; }
|
|
1392
|
+
adjacency[n.id].push(targetId);
|
|
1393
|
+
adjacency[targetId].push(n.id);
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
var orderIndex = {};
|
|
1399
|
+
nodes.forEach(function (n, i) { if (n && n.id) { orderIndex[n.id] = i; } });
|
|
1400
|
+
|
|
1401
|
+
var visited = {};
|
|
1402
|
+
var components = [];
|
|
1403
|
+
wiredNodes.forEach(function (start) {
|
|
1404
|
+
if (visited[start.id]) { return; }
|
|
1405
|
+
var stack = [start.id];
|
|
1406
|
+
var members = [];
|
|
1407
|
+
visited[start.id] = true;
|
|
1408
|
+
while (stack.length) {
|
|
1409
|
+
var id = stack.pop();
|
|
1410
|
+
members.push(id);
|
|
1411
|
+
adjacency[id].forEach(function (neighborId) {
|
|
1412
|
+
if (!visited[neighborId]) { visited[neighborId] = true; stack.push(neighborId); }
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
components.push(members);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
// Order bands by where each component first appears in `flow`, so
|
|
1419
|
+
// the layout roughly follows generation order top to bottom.
|
|
1420
|
+
components.sort(function (a, b) {
|
|
1421
|
+
var minA = Math.min.apply(null, a.map(function (id) { return orderIndex[id]; }));
|
|
1422
|
+
var minB = Math.min.apply(null, b.map(function (id) { return orderIndex[id]; }));
|
|
1423
|
+
return minA - minB;
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
// Column = longest path from an in-degree-0 node, scoped to this
|
|
1427
|
+
// component only (so independent chains don't influence each other).
|
|
1428
|
+
function assignColumns(members) {
|
|
1429
|
+
var memberSet = {};
|
|
1430
|
+
members.forEach(function (id) { memberSet[id] = true; });
|
|
1431
|
+
|
|
1432
|
+
var incoming = {};
|
|
1433
|
+
members.forEach(function (id) { incoming[id] = 0; });
|
|
1434
|
+
members.forEach(function (id) {
|
|
1435
|
+
byId[id].wires.forEach(function (port) {
|
|
1436
|
+
(Array.isArray(port) ? port : []).forEach(function (targetId) {
|
|
1437
|
+
if (memberSet[targetId]) { incoming[targetId] += 1; }
|
|
1438
|
+
});
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
var column = {};
|
|
1443
|
+
members.forEach(function (id) { column[id] = incoming[id] === 0 ? 0 : -1; });
|
|
1444
|
+
var changed = true;
|
|
1445
|
+
var guard = 0;
|
|
1446
|
+
while (changed && guard <= members.length) {
|
|
1447
|
+
changed = false;
|
|
1448
|
+
guard += 1;
|
|
1449
|
+
members.forEach(function (id) {
|
|
1450
|
+
var fromCol = column[id] < 0 ? 0 : column[id];
|
|
1451
|
+
byId[id].wires.forEach(function (port) {
|
|
1452
|
+
(Array.isArray(port) ? port : []).forEach(function (targetId) {
|
|
1453
|
+
if (!memberSet[targetId]) { return; }
|
|
1454
|
+
if (column[targetId] < fromCol + 1) {
|
|
1455
|
+
column[targetId] = fromCol + 1;
|
|
1456
|
+
changed = true;
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
members.forEach(function (id) { if (column[id] < 0) { column[id] = 0; } });
|
|
1463
|
+
return column;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ---- Anchor each comment to the nearest non-comment node adjacent
|
|
1467
|
+
// to it in `flow` (forward, then backward). ----
|
|
1468
|
+
function nearestWiredNeighbor(commentIndex) {
|
|
1469
|
+
var i, candidate;
|
|
1470
|
+
for (i = commentIndex + 1; i < nodes.length; i++) {
|
|
1471
|
+
candidate = nodes[i];
|
|
1472
|
+
if (candidate && byId[candidate.id]) { return candidate.id; }
|
|
1473
|
+
}
|
|
1474
|
+
for (i = commentIndex - 1; i >= 0; i--) {
|
|
1475
|
+
candidate = nodes[i];
|
|
1476
|
+
if (candidate && byId[candidate.id]) { return candidate.id; }
|
|
1477
|
+
}
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
var commentsByAnchor = {}; // wired-node id -> [comment nodes]
|
|
1482
|
+
var unanchoredComments = [];
|
|
1483
|
+
commentNodes.forEach(function (c) {
|
|
1484
|
+
var anchorId = nearestWiredNeighbor(nodes.indexOf(c));
|
|
1485
|
+
if (anchorId) {
|
|
1486
|
+
(commentsByAnchor[anchorId] = commentsByAnchor[anchorId] || []).push(c);
|
|
1487
|
+
} else {
|
|
1488
|
+
unanchoredComments.push(c);
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// ---- Lay out each component in its own band, leaving a header row
|
|
1493
|
+
// for any comments anchored within it. ----
|
|
1494
|
+
var nextBandY = BASE_Y;
|
|
1495
|
+
components.forEach(function (members) {
|
|
1496
|
+
var column = assignColumns(members);
|
|
1497
|
+
|
|
1498
|
+
var headerCols = {};
|
|
1499
|
+
var hasHeader = false;
|
|
1500
|
+
members.forEach(function (id) {
|
|
1501
|
+
(commentsByAnchor[id] || []).forEach(function (c) {
|
|
1502
|
+
headerCols[column[id]] = headerCols[column[id]] || [];
|
|
1503
|
+
headerCols[column[id]].push(c);
|
|
1504
|
+
hasHeader = true;
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
var bodyOffset = hasHeader ? 1 : 0;
|
|
1508
|
+
|
|
1509
|
+
var rowsUsed = {};
|
|
1510
|
+
var maxRows = 0;
|
|
1511
|
+
members.forEach(function (id) {
|
|
1512
|
+
var col = column[id];
|
|
1513
|
+
var row = rowsUsed[col] || 0;
|
|
1514
|
+
rowsUsed[col] = row + 1;
|
|
1515
|
+
maxRows = Math.max(maxRows, row + 1);
|
|
1516
|
+
var n = byId[id];
|
|
1517
|
+
n.x = BASE_X + col * COL_WIDTH;
|
|
1518
|
+
n.y = nextBandY + (bodyOffset + row) * ROW_HEIGHT;
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
Object.keys(headerCols).forEach(function (col) {
|
|
1522
|
+
headerCols[col].forEach(function (c, i) {
|
|
1523
|
+
c.x = BASE_X + Number(col) * COL_WIDTH;
|
|
1524
|
+
c.y = nextBandY + i * Math.round(ROW_HEIGHT / 2);
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
nextBandY += (bodyOffset + maxRows + BAND_GAP_ROWS) * ROW_HEIGHT;
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
// Comments that couldn't be anchored (only possible if `flow` is
|
|
1532
|
+
// entirely comments, which the early-return above already handles)
|
|
1533
|
+
// are stacked below everything else as a fallback.
|
|
1534
|
+
unanchoredComments.forEach(function (c, i) {
|
|
1535
|
+
c.x = BASE_X;
|
|
1536
|
+
c.y = nextBandY + i * ROW_HEIGHT;
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
return nodes;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function importGeneratedFlow(nodes, onImported) {
|
|
1543
|
+
try {
|
|
1544
|
+
// importNodes returns { nodeMap }, mapping each input node's own
|
|
1545
|
+
// id to the real live node object Node-RED just created (ids are
|
|
1546
|
+
// regenerated since generateIds:true) — onImported (the /build
|
|
1547
|
+
// loop) needs this to know what it actually has on the canvas.
|
|
1548
|
+
var importResult = RED.view.importNodes(nodes, { generateIds: true });
|
|
1549
|
+
// Ground follow-up turns in what was just
|
|
1550
|
+
// imported (placement itself happens on the next canvas click).
|
|
1551
|
+
var n = Array.isArray(nodes) ? nodes.length : 0;
|
|
1552
|
+
var importedNote = "Landed — imported " + n + " node(s). Click the canvas to place them.";
|
|
1553
|
+
addMessage("assistant", importedNote);
|
|
1554
|
+
pushHistory("assistant", importedNote);
|
|
1555
|
+
updateSelectionStatus();
|
|
1556
|
+
if (typeof onImported === "function") { onImported(importResult); }
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
addMessage("error", "Import failed: " + (e && e.message ? e.message : String(e)));
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function addGeneratedReview(flow, onImported, buildGoal) {
|
|
1563
|
+
var $box = el("#fp-messages");
|
|
1564
|
+
if (!$box.length) { return; }
|
|
1565
|
+
|
|
1566
|
+
var nodes = Array.isArray(flow) ? flow : [];
|
|
1567
|
+
var v = validateGeneratedFlow(nodes);
|
|
1568
|
+
|
|
1569
|
+
var $msg = $("<div>").addClass("fp-message fp-review");
|
|
1570
|
+
// Pop-out slice 3 (plain Generate/Document, no onImported): tag
|
|
1571
|
+
// with the raw flow data so the relay can wire up a WORKING "Add
|
|
1572
|
+
// to workspace" button in the pop-out. /build's first proposal
|
|
1573
|
+
// (onImported set, buildGoal present) gets its own tag instead —
|
|
1574
|
+
// importing it also needs to start the loop (startBuildLoop),
|
|
1575
|
+
// which the parent does itself once it gets the relayed intent;
|
|
1576
|
+
// see the "applyBuild" handler in initMainWindow.
|
|
1577
|
+
if (!onImported) {
|
|
1578
|
+
$msg.attr("data-fp-apply-flow", JSON.stringify(nodes));
|
|
1579
|
+
} else if (buildGoal) {
|
|
1580
|
+
$msg.attr("data-fp-apply-build", JSON.stringify({ flow: nodes, goal: buildGoal }));
|
|
1581
|
+
}
|
|
1582
|
+
$("<div>").addClass("fp-label").text("GENERATED FLOW — REVIEW").appendTo($msg);
|
|
1583
|
+
|
|
1584
|
+
var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
|
|
1585
|
+
var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
|
|
1586
|
+
$("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
|
|
1587
|
+
|
|
1588
|
+
var $summaryPanel = $("<div>").addClass("fp-tab-panel");
|
|
1589
|
+
var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
|
|
1590
|
+
$msg.append($summaryPanel, $jsonPanel);
|
|
1591
|
+
|
|
1592
|
+
// ---- Summary tab ----
|
|
1593
|
+
$("<div>").addClass("fp-review-count")
|
|
1594
|
+
.text("Generated " + nodes.length + " node" + (nodes.length === 1 ? "" : "s") + ":")
|
|
1595
|
+
.appendTo($summaryPanel);
|
|
1596
|
+
|
|
1597
|
+
var $list = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
|
|
1598
|
+
v.summary.forEach(function (item) {
|
|
1599
|
+
var $li = $("<li>").text(item.type + (item.name ? " — \"" + item.name + "\"" : ""));
|
|
1600
|
+
if (item.status === "not-installed") {
|
|
1601
|
+
$("<span>").addClass("fp-type-flag").text(" ⚠ not installed").appendTo($li);
|
|
1602
|
+
} else if (item.status === "non-core-installed") {
|
|
1603
|
+
$("<span>").addClass("fp-type-flag").text(" ⚠ non-core").appendTo($li);
|
|
1604
|
+
}
|
|
1605
|
+
$list.append($li);
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
if (v.noConnections) {
|
|
1609
|
+
var $wireWarn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
|
|
1610
|
+
$("<strong>").text("⚠ These nodes aren't wired to each other.").appendTo($wireWarn);
|
|
1611
|
+
$("<div>").text("None of the " + nodes.length + " generated nodes connect to one another — " +
|
|
1612
|
+
"they'll land on the canvas disconnected. You can wire them manually, or ask FlowPilot " +
|
|
1613
|
+
"to regenerate.").appendTo($wireWarn);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
if (v.typeWarnings.length) {
|
|
1617
|
+
var $warn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
|
|
1618
|
+
$("<strong>").text("Type warnings — review before adding:").appendTo($warn);
|
|
1619
|
+
var $wlist = $("<ul>").appendTo($warn);
|
|
1620
|
+
v.typeWarnings.forEach(function (w) {
|
|
1621
|
+
var reason = w.status === "not-installed"
|
|
1622
|
+
? "not installed — will appear as a broken placeholder until the module is added"
|
|
1623
|
+
: "installed, but not a core Node-RED type — may be less stable across versions";
|
|
1624
|
+
$("<li>").text(w.type + ": " + reason).appendTo($wlist);
|
|
1625
|
+
});
|
|
1626
|
+
$("<div>").text("You can add it anyway, or ask FlowPilot to regenerate using only core nodes.").appendTo($warn);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ---- JSON tab ----
|
|
1630
|
+
var jsonText = JSON.stringify(nodes, null, 2);
|
|
1631
|
+
var $copyBtn = $("<button>")
|
|
1632
|
+
.addClass("red-ui-button red-ui-button-small")
|
|
1633
|
+
.attr("type", "button")
|
|
1634
|
+
.text("Copy")
|
|
1635
|
+
.on("click", function () { copyToClipboard($copyBtn, jsonText); });
|
|
1636
|
+
$("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
|
|
1637
|
+
$("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
|
|
1638
|
+
|
|
1639
|
+
function activateTab(showSummary) {
|
|
1640
|
+
$tabSummary.toggleClass("fp-tab-active", showSummary);
|
|
1641
|
+
$tabJson.toggleClass("fp-tab-active", !showSummary);
|
|
1642
|
+
$summaryPanel.toggleClass("fp-hidden", !showSummary);
|
|
1643
|
+
$jsonPanel.toggleClass("fp-hidden", showSummary);
|
|
1644
|
+
}
|
|
1645
|
+
$tabSummary.on("click", function () { activateTab(true); });
|
|
1646
|
+
$tabJson.on("click", function () { activateTab(false); });
|
|
1647
|
+
|
|
1648
|
+
// ---- Action row ----
|
|
1649
|
+
var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
|
|
1650
|
+
if (v.brokenWires.length) {
|
|
1651
|
+
$("<div>").addClass("fp-warning").text(
|
|
1652
|
+
"This flow has " + v.brokenWires.length + " wire(s) pointing to node ids " +
|
|
1653
|
+
"that don't exist in the generated set, so it can't be safely imported. " +
|
|
1654
|
+
"Try asking FlowPilot to regenerate it."
|
|
1655
|
+
).appendTo($actions);
|
|
1656
|
+
} else if (!nodes.length) {
|
|
1657
|
+
$("<div>").addClass("fp-warning").text("No nodes were generated — nothing to add.").appendTo($actions);
|
|
1658
|
+
} else {
|
|
1659
|
+
var $addBtn = $("<button>")
|
|
1660
|
+
.addClass("red-ui-button red-ui-button-primary")
|
|
1661
|
+
.attr("type", "button")
|
|
1662
|
+
.text("Add to workspace")
|
|
1663
|
+
.on("click", function () {
|
|
1664
|
+
$addBtn.prop("disabled", true).text("Click the canvas to place…");
|
|
1665
|
+
importGeneratedFlow(nodes, onImported);
|
|
1666
|
+
});
|
|
1667
|
+
$actions.append($addBtn);
|
|
1668
|
+
$("<span>").addClass("fp-review-hint")
|
|
1669
|
+
.text("Opens Node-RED's normal place-at-cursor import — click the canvas to drop the nodes.")
|
|
1670
|
+
.appendTo($actions);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
$box.append($msg);
|
|
1674
|
+
scrollMessagesToBottom();
|
|
1675
|
+
return $msg;
|
|
1676
|
+
}
|
|
1677
|
+
|