@aetherwing/fcp-drawio 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/adapter.d.ts +38 -0
  4. package/dist/adapter.js +259 -0
  5. package/dist/adapter.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +6 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/layout/elk-layout.d.ts +49 -0
  10. package/dist/layout/elk-layout.js +144 -0
  11. package/dist/layout/elk-layout.js.map +1 -0
  12. package/dist/lib/drawio-cli.d.ts +22 -0
  13. package/dist/lib/drawio-cli.js +88 -0
  14. package/dist/lib/drawio-cli.js.map +1 -0
  15. package/dist/lib/node-types.d.ts +22 -0
  16. package/dist/lib/node-types.js +174 -0
  17. package/dist/lib/node-types.js.map +1 -0
  18. package/dist/lib/stencils/aws.d.ts +2 -0
  19. package/dist/lib/stencils/aws.js +69 -0
  20. package/dist/lib/stencils/aws.js.map +1 -0
  21. package/dist/lib/stencils/azure.d.ts +2 -0
  22. package/dist/lib/stencils/azure.js +54 -0
  23. package/dist/lib/stencils/azure.js.map +1 -0
  24. package/dist/lib/stencils/cisco.d.ts +2 -0
  25. package/dist/lib/stencils/cisco.js +30 -0
  26. package/dist/lib/stencils/cisco.js.map +1 -0
  27. package/dist/lib/stencils/gcp.d.ts +2 -0
  28. package/dist/lib/stencils/gcp.js +38 -0
  29. package/dist/lib/stencils/gcp.js.map +1 -0
  30. package/dist/lib/stencils/ibm.d.ts +2 -0
  31. package/dist/lib/stencils/ibm.js +32 -0
  32. package/dist/lib/stencils/ibm.js.map +1 -0
  33. package/dist/lib/stencils/index.d.ts +10 -0
  34. package/dist/lib/stencils/index.js +33 -0
  35. package/dist/lib/stencils/index.js.map +1 -0
  36. package/dist/lib/stencils/k8s.d.ts +2 -0
  37. package/dist/lib/stencils/k8s.js +32 -0
  38. package/dist/lib/stencils/k8s.js.map +1 -0
  39. package/dist/lib/stencils/types.d.ts +14 -0
  40. package/dist/lib/stencils/types.js +2 -0
  41. package/dist/lib/stencils/types.js.map +1 -0
  42. package/dist/lib/themes.d.ts +8 -0
  43. package/dist/lib/themes.js +32 -0
  44. package/dist/lib/themes.js.map +1 -0
  45. package/dist/model/defaults.d.ts +3 -0
  46. package/dist/model/defaults.js +26 -0
  47. package/dist/model/defaults.js.map +1 -0
  48. package/dist/model/diagram-model.d.ts +110 -0
  49. package/dist/model/diagram-model.js +938 -0
  50. package/dist/model/diagram-model.js.map +1 -0
  51. package/dist/model/event-log.d.ts +30 -0
  52. package/dist/model/event-log.js +112 -0
  53. package/dist/model/event-log.js.map +1 -0
  54. package/dist/model/id.d.ts +9 -0
  55. package/dist/model/id.js +35 -0
  56. package/dist/model/id.js.map +1 -0
  57. package/dist/model/reference-registry.d.ts +33 -0
  58. package/dist/model/reference-registry.js +143 -0
  59. package/dist/model/reference-registry.js.map +1 -0
  60. package/dist/model/spatial.d.ts +20 -0
  61. package/dist/model/spatial.js +59 -0
  62. package/dist/model/spatial.js.map +1 -0
  63. package/dist/parser/parse-op.d.ts +18 -0
  64. package/dist/parser/parse-op.js +430 -0
  65. package/dist/parser/parse-op.js.map +1 -0
  66. package/dist/parser/resolve-ref.d.ts +27 -0
  67. package/dist/parser/resolve-ref.js +232 -0
  68. package/dist/parser/resolve-ref.js.map +1 -0
  69. package/dist/parser/tokenizer.d.ts +6 -0
  70. package/dist/parser/tokenizer.js +7 -0
  71. package/dist/parser/tokenizer.js.map +1 -0
  72. package/dist/serialization/connector-intelligence.d.ts +35 -0
  73. package/dist/serialization/connector-intelligence.js +336 -0
  74. package/dist/serialization/connector-intelligence.js.map +1 -0
  75. package/dist/serialization/deserialize.d.ts +6 -0
  76. package/dist/serialization/deserialize.js +511 -0
  77. package/dist/serialization/deserialize.js.map +1 -0
  78. package/dist/serialization/serialize.d.ts +15 -0
  79. package/dist/serialization/serialize.js +332 -0
  80. package/dist/serialization/serialize.js.map +1 -0
  81. package/dist/server/intent-layer.d.ts +48 -0
  82. package/dist/server/intent-layer.js +1322 -0
  83. package/dist/server/intent-layer.js.map +1 -0
  84. package/dist/server/mcp-server.d.ts +7 -0
  85. package/dist/server/mcp-server.js +26 -0
  86. package/dist/server/mcp-server.js.map +1 -0
  87. package/dist/server/model-map.d.ts +8 -0
  88. package/dist/server/model-map.js +240 -0
  89. package/dist/server/model-map.js.map +1 -0
  90. package/dist/server/query-handler.d.ts +19 -0
  91. package/dist/server/query-handler.js +148 -0
  92. package/dist/server/query-handler.js.map +1 -0
  93. package/dist/server/response-formatter.d.ts +56 -0
  94. package/dist/server/response-formatter.js +351 -0
  95. package/dist/server/response-formatter.js.map +1 -0
  96. package/dist/server/session-handler.d.ts +6 -0
  97. package/dist/server/session-handler.js +127 -0
  98. package/dist/server/session-handler.js.map +1 -0
  99. package/dist/types/index.d.ts +238 -0
  100. package/dist/types/index.js +3 -0
  101. package/dist/types/index.js.map +1 -0
  102. package/dist/verb-specs.d.ts +6 -0
  103. package/dist/verb-specs.js +144 -0
  104. package/dist/verb-specs.js.map +1 -0
  105. package/package.json +39 -0
@@ -0,0 +1,1322 @@
1
+ import { DiagramModel } from "../model/diagram-model.js";
2
+ import { parseOp, isParseError } from "../parser/parse-op.js";
3
+ import { resolveRef } from "../parser/resolve-ref.js";
4
+ import { isShapeType, inferTypeFromLabel, computeDefaultSize } from "../lib/node-types.js";
5
+ import { isThemeName, resolveColor, THEMES } from "../lib/themes.js";
6
+ import { getModelMap } from "./model-map.js";
7
+ import { formatShapeCreated, formatEdgeCreated, formatShapeModified, formatShapeDeleted, formatGroupCreated, } from "./response-formatter.js";
8
+ import { runElkLayout } from "../layout/elk-layout.js";
9
+ import { QueryHandler } from "./query-handler.js";
10
+ import { SessionHandler } from "./session-handler.js";
11
+ import { getStencilPack, listStencilPacks } from "../lib/stencils/index.js";
12
+ export class IntentLayer {
13
+ model;
14
+ queryHandler;
15
+ sessionHandler;
16
+ /** O(1) lookup for loaded stencil entries by their short ID (e.g., "lambda", "s3"). */
17
+ loadedStencilEntries = new Map();
18
+ drawioCliPath;
19
+ constructor(options) {
20
+ this.model = new DiagramModel();
21
+ this.drawioCliPath = options?.drawioCliPath ?? null;
22
+ this.queryHandler = new QueryHandler(this.model, this.drawioCliPath);
23
+ this.sessionHandler = new SessionHandler(this.model);
24
+ }
25
+ /** Rebuild the stencil entry lookup from loaded packs (e.g., after deserialization). */
26
+ restoreStencilPacks() {
27
+ this.loadedStencilEntries.clear();
28
+ for (const packId of this.model.diagram.loadedStencilPacks) {
29
+ const pack = getStencilPack(packId);
30
+ if (!pack)
31
+ continue;
32
+ for (const entry of pack.entries) {
33
+ if (!this.loadedStencilEntries.has(entry.id)) {
34
+ this.loadedStencilEntries.set(entry.id, entry);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ // ── Suggestion helpers ──────────────────────────────────
40
+ /** Build a suggestion by replacing a bad ref with the suggested label in the raw op string. */
41
+ buildTypoSuggestion(raw, badRef, suggestedLabel) {
42
+ return raw.replace(badRef, suggestedLabel);
43
+ }
44
+ /** Build a type-qualified suggestion for ambiguous references. */
45
+ buildAmbiguousSuggestion(raw, badRef, shapes) {
46
+ const first = shapes[0];
47
+ const qualified = `${first.type}:${badRef}`;
48
+ return raw.replace(badRef, qualified);
49
+ }
50
+ // ── Main entry points ──────────────────────────────────
51
+ async executeOps(ops) {
52
+ const results = [];
53
+ for (const op of ops) {
54
+ results.push(await this.executeSingleOp(op));
55
+ }
56
+ return results;
57
+ }
58
+ executeQuery(query) {
59
+ try {
60
+ return this.queryHandler.dispatch(query.trim());
61
+ }
62
+ catch (err) {
63
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
64
+ }
65
+ }
66
+ executeSession(action) {
67
+ try {
68
+ const result = this.sessionHandler.dispatch(action.trim());
69
+ // After open/new, restore stencil pack entries from diagram metadata
70
+ this.restoreStencilPacks();
71
+ return result;
72
+ }
73
+ catch (err) {
74
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
75
+ }
76
+ }
77
+ getHelp() {
78
+ return getModelMap(this.model.diagram.customTypes, this.model.diagram.customThemes, this.model.diagram.loadedStencilPacks, this.drawioCliPath !== null);
79
+ }
80
+ // ── Single op execution ────────────────────────────────
81
+ async executeSingleOp(opStr) {
82
+ const parsed = parseOp(opStr);
83
+ if (isParseError(parsed)) {
84
+ return { success: false, message: parsed.error };
85
+ }
86
+ try {
87
+ return await this.dispatchOp(parsed);
88
+ }
89
+ catch (err) {
90
+ return {
91
+ success: false,
92
+ message: `Error executing "${parsed.verb}": ${err instanceof Error ? err.message : String(err)}`,
93
+ };
94
+ }
95
+ }
96
+ async dispatchOp(op) {
97
+ switch (op.verb) {
98
+ case "add": return this.handleAdd(op);
99
+ case "connect": return this.handleConnect(op);
100
+ case "disconnect": return this.handleDisconnect(op);
101
+ case "style": return this.handleStyle(op);
102
+ case "remove": return this.handleRemove(op);
103
+ case "label": return this.handleLabel(op);
104
+ case "badge": return this.handleBadge(op);
105
+ case "move": return this.handleMove(op);
106
+ case "resize": return this.handleResize(op);
107
+ case "swap": return this.handleSwap(op);
108
+ case "group": return this.handleGroup(op);
109
+ case "ungroup": return this.handleUngroup(op);
110
+ case "define": return this.handleDefine(op);
111
+ case "checkpoint": return this.handleCheckpoint(op);
112
+ case "title": return this.handleTitle(op);
113
+ case "page": return this.handlePage(op);
114
+ case "layer": return this.handleLayer(op);
115
+ case "layout": return this.handleLayout(op);
116
+ case "orient": return this.handleOrient(op);
117
+ case "load": return this.handleLoad(op);
118
+ default:
119
+ return { success: false, message: `Unhandled verb: ${op.verb}` };
120
+ }
121
+ }
122
+ // ── Add ────────────────────────────────────────────────
123
+ handleAdd(op) {
124
+ const count = op.params.get("count") ? parseInt(op.params.get("count"), 10) : 1;
125
+ if (isNaN(count) || count < 1) {
126
+ return { success: false, message: "Invalid count" };
127
+ }
128
+ // Resolve type
129
+ let resolvedType;
130
+ const customTypes = this.model.diagram.customTypes;
131
+ let theme = op.params.get("theme");
132
+ let badgeText;
133
+ let baseStyleOverride;
134
+ let skipDefaultTheme = false;
135
+ let stencilSize;
136
+ if (op.type) {
137
+ // 1. Check custom types first
138
+ const ct = customTypes.get(op.type);
139
+ if (ct) {
140
+ resolvedType = ct.base;
141
+ if (!theme && ct.theme)
142
+ theme = ct.theme;
143
+ if (ct.badge)
144
+ badgeText = ct.badge;
145
+ }
146
+ else if (isShapeType(op.type)) {
147
+ // 2. Built-in types
148
+ resolvedType = op.type;
149
+ }
150
+ else {
151
+ // 3. Check loaded stencil types
152
+ const stencilEntry = this.loadedStencilEntries.get(op.type);
153
+ if (stencilEntry) {
154
+ resolvedType = "svc"; // fallback base type for the model
155
+ baseStyleOverride = stencilEntry.baseStyle;
156
+ stencilSize = { width: stencilEntry.defaultWidth, height: stencilEntry.defaultHeight };
157
+ skipDefaultTheme = !theme; // only skip if user didn't explicitly set theme
158
+ }
159
+ else {
160
+ // 4. Unknown type — treat as label, shift: type becomes part of the label
161
+ const inferred = inferTypeFromLabel(op.type);
162
+ if (inferred) {
163
+ resolvedType = inferred;
164
+ }
165
+ else {
166
+ resolvedType = "svc";
167
+ }
168
+ }
169
+ }
170
+ }
171
+ else {
172
+ // No type specified — infer from label
173
+ const target = op.target ?? "Untitled";
174
+ const inferred = inferTypeFromLabel(target);
175
+ resolvedType = inferred ?? "svc";
176
+ }
177
+ // Validate theme (check custom themes too)
178
+ let customThemeColors;
179
+ if (theme) {
180
+ const ct = this.model.diagram.customThemes.get(theme);
181
+ if (ct) {
182
+ customThemeColors = ct;
183
+ }
184
+ else if (!isThemeName(theme)) {
185
+ return { success: false, message: `Unknown theme "${theme}"` };
186
+ }
187
+ }
188
+ // Resolve near reference
189
+ let nearId;
190
+ const nearRef = op.params.get("near");
191
+ if (nearRef) {
192
+ const resolved = resolveRef(nearRef, this.model.registry, this.model);
193
+ if (resolved.kind === "single") {
194
+ nearId = resolved.shape.id;
195
+ }
196
+ else if (resolved.kind === "none") {
197
+ const suggestion = resolved.suggestedLabel
198
+ ? this.buildTypoSuggestion(op.raw, nearRef, resolved.suggestedLabel)
199
+ : undefined;
200
+ return { success: false, message: resolved.message, suggestion };
201
+ }
202
+ else {
203
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, nearRef, resolved.shapes);
204
+ return { success: false, message: resolved.message, suggestion };
205
+ }
206
+ }
207
+ // Parse size:WxH (before at: since region resolution needs size)
208
+ let size;
209
+ const sizeParam = op.params.get("size");
210
+ if (sizeParam) {
211
+ const parts = sizeParam.toLowerCase().split("x");
212
+ if (parts.length === 2) {
213
+ size = { width: parseFloat(parts[0]), height: parseFloat(parts[1]) };
214
+ }
215
+ }
216
+ // Parse at:X,Y or at:region-name
217
+ let at;
218
+ const atParam = op.params.get("at");
219
+ if (atParam) {
220
+ // Try region name first
221
+ const computedSize = size ?? computeDefaultSize(resolvedType, op.target ?? "Untitled");
222
+ const regionPos = this.model.resolveCanvasRegion(atParam, computedSize);
223
+ if (regionPos) {
224
+ at = regionPos;
225
+ }
226
+ else {
227
+ const parts = atParam.split(",");
228
+ if (parts.length === 2) {
229
+ at = { x: parseFloat(parts[0]), y: parseFloat(parts[1]) };
230
+ }
231
+ }
232
+ }
233
+ // Resolve in: group
234
+ let inGroup;
235
+ const inRef = op.params.get("in");
236
+ if (inRef) {
237
+ const group = this.model.getGroupByName(inRef);
238
+ if (group) {
239
+ inGroup = group.id;
240
+ }
241
+ }
242
+ const dir = op.params.get("dir");
243
+ const results = [];
244
+ const warnings = [];
245
+ const labelOverride = op.params.get("label");
246
+ for (let i = 0; i < count; i++) {
247
+ const label = labelOverride
248
+ ? (count > 1 ? `${labelOverride}${i + 1}` : labelOverride)
249
+ : (count > 1 ? `${op.target}${i + 1}` : (op.target ?? "Untitled"));
250
+ const shape = this.model.addShape(label, resolvedType, {
251
+ theme: customThemeColors ? undefined : theme,
252
+ near: nearId,
253
+ dir: dir ?? undefined,
254
+ at,
255
+ inGroup,
256
+ size: size ?? stencilSize,
257
+ baseStyleOverride,
258
+ skipDefaultTheme,
259
+ });
260
+ // Apply custom theme colors if using a custom theme
261
+ if (customThemeColors) {
262
+ const style = { ...shape.style };
263
+ style.fillColor = customThemeColors.fill;
264
+ style.strokeColor = customThemeColors.stroke;
265
+ if (customThemeColors.fontColor)
266
+ style.fontColor = customThemeColors.fontColor;
267
+ this.model.modifyShape(shape.id, { style });
268
+ }
269
+ // Apply badge from custom type
270
+ if (badgeText) {
271
+ const badges = [{ text: badgeText, position: "top-right" }];
272
+ this.model.modifyShape(shape.id, { metadata: { ...shape.metadata, badges } });
273
+ }
274
+ results.push(formatShapeCreated(shape));
275
+ // For batch, subsequent shapes position near the previous one
276
+ if (count > 1) {
277
+ nearId = shape.id;
278
+ }
279
+ }
280
+ return {
281
+ success: true,
282
+ message: results.join("\n"),
283
+ warnings: warnings.length > 0 ? warnings : undefined,
284
+ };
285
+ }
286
+ // ── Connect ────────────────────────────────────────────
287
+ handleConnect(op) {
288
+ const targets = op.targets;
289
+ const arrows = op.arrows;
290
+ if (!targets || !arrows || targets.length < 2 || arrows.length < 1) {
291
+ return { success: false, message: "connect requires at least REF ARROW REF" };
292
+ }
293
+ // Parse optional params
294
+ const label = op.params.get("label");
295
+ const styleParam = op.params.get("style");
296
+ const sourceArrowParam = op.params.get("source-arrow");
297
+ const targetArrowParam = op.params.get("target-arrow");
298
+ const results = [];
299
+ const warnings = [];
300
+ // Connect consecutive pairs: A -> B -> C creates A->B and B->C
301
+ for (let i = 0; i < arrows.length; i++) {
302
+ const srcRef = targets[i];
303
+ const tgtRef = targets[i + 1];
304
+ if (!srcRef || !tgtRef)
305
+ break;
306
+ const arrow = arrows[i];
307
+ // Resolve source
308
+ const srcResult = resolveRef(srcRef, this.model.registry, this.model);
309
+ if (srcResult.kind === "none") {
310
+ const suggestion = srcResult.suggestedLabel
311
+ ? this.buildTypoSuggestion(op.raw, srcRef, srcResult.suggestedLabel)
312
+ : undefined;
313
+ return { success: false, message: srcResult.message, suggestion };
314
+ }
315
+ if (srcResult.kind === "multiple") {
316
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, srcRef, srcResult.shapes);
317
+ return { success: false, message: srcResult.message, suggestion };
318
+ }
319
+ // Resolve target
320
+ const tgtResult = resolveRef(tgtRef, this.model.registry, this.model);
321
+ if (tgtResult.kind === "none") {
322
+ const suggestion = tgtResult.suggestedLabel
323
+ ? this.buildTypoSuggestion(op.raw, tgtRef, tgtResult.suggestedLabel)
324
+ : undefined;
325
+ return { success: false, message: tgtResult.message, suggestion };
326
+ }
327
+ if (tgtResult.kind === "multiple") {
328
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, tgtRef, tgtResult.shapes);
329
+ return { success: false, message: tgtResult.message, suggestion };
330
+ }
331
+ // Determine arrow types based on operator
332
+ let sourceArrow = "none";
333
+ let targetArrow = "arrow";
334
+ if (arrow === "<->") {
335
+ sourceArrow = "arrow";
336
+ targetArrow = "arrow";
337
+ }
338
+ else if (arrow === "--") {
339
+ sourceArrow = "none";
340
+ targetArrow = "none";
341
+ }
342
+ // Override with explicit params
343
+ if (sourceArrowParam)
344
+ sourceArrow = sourceArrowParam;
345
+ if (targetArrowParam)
346
+ targetArrow = targetArrowParam;
347
+ // Build edge style
348
+ const edgeStyleOverrides = {};
349
+ if (styleParam) {
350
+ switch (styleParam) {
351
+ case "dashed":
352
+ edgeStyleOverrides.dashed = true;
353
+ break;
354
+ case "dotted":
355
+ edgeStyleOverrides.dashed = true;
356
+ edgeStyleOverrides.dotted = true;
357
+ break;
358
+ case "animated":
359
+ edgeStyleOverrides.flowAnimation = true;
360
+ break;
361
+ case "curved":
362
+ edgeStyleOverrides.curved = true;
363
+ break;
364
+ case "thick": break; // handled at render time
365
+ case "orthogonal":
366
+ edgeStyleOverrides.edgeStyle = "orthogonalEdgeStyle";
367
+ break;
368
+ }
369
+ }
370
+ // Port hints: exit:top/bottom/left/right, entry:top/bottom/left/right
371
+ const exitHint = op.params.get("exit");
372
+ const entryHint = op.params.get("entry");
373
+ const portCoords = {
374
+ top: [0.5, 0],
375
+ bottom: [0.5, 1],
376
+ left: [0, 0.5],
377
+ right: [1, 0.5],
378
+ };
379
+ if (exitHint && portCoords[exitHint]) {
380
+ const [x, y] = portCoords[exitHint];
381
+ edgeStyleOverrides["exitX"] = x;
382
+ edgeStyleOverrides["exitY"] = y;
383
+ }
384
+ if (entryHint && portCoords[entryHint]) {
385
+ const [x, y] = portCoords[entryHint];
386
+ edgeStyleOverrides["entryX"] = x;
387
+ edgeStyleOverrides["entryY"] = y;
388
+ }
389
+ const edge = this.model.addEdge(srcResult.shape.id, tgtResult.shape.id, {
390
+ label: label ?? undefined,
391
+ style: edgeStyleOverrides,
392
+ sourceArrow,
393
+ targetArrow,
394
+ });
395
+ if (!edge) {
396
+ return { success: false, message: `Failed to create edge ${srcRef}->${tgtRef}` };
397
+ }
398
+ results.push(formatEdgeCreated(edge, srcResult.shape.label, tgtResult.shape.label));
399
+ }
400
+ return {
401
+ success: true,
402
+ message: results.join("\n"),
403
+ warnings: warnings.length > 0 ? warnings : undefined,
404
+ };
405
+ }
406
+ // ── Disconnect ─────────────────────────────────────────
407
+ handleDisconnect(op) {
408
+ const targets = op.targets;
409
+ if (!targets || targets.length < 2) {
410
+ return { success: false, message: "disconnect requires REF ARROW REF" };
411
+ }
412
+ const srcResult = resolveRef(targets[0], this.model.registry, this.model);
413
+ if (srcResult.kind !== "single") {
414
+ if (srcResult.kind === "none") {
415
+ const suggestion = srcResult.suggestedLabel
416
+ ? this.buildTypoSuggestion(op.raw, targets[0], srcResult.suggestedLabel)
417
+ : undefined;
418
+ return { success: false, message: srcResult.message, suggestion };
419
+ }
420
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[0], srcResult.shapes);
421
+ return { success: false, message: srcResult.message, suggestion };
422
+ }
423
+ const tgtResult = resolveRef(targets[1], this.model.registry, this.model);
424
+ if (tgtResult.kind !== "single") {
425
+ if (tgtResult.kind === "none") {
426
+ const suggestion = tgtResult.suggestedLabel
427
+ ? this.buildTypoSuggestion(op.raw, targets[1], tgtResult.suggestedLabel)
428
+ : undefined;
429
+ return { success: false, message: tgtResult.message, suggestion };
430
+ }
431
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[1], tgtResult.shapes);
432
+ return { success: false, message: tgtResult.message, suggestion };
433
+ }
434
+ const edge = this.model.findEdge(srcResult.shape.id, tgtResult.shape.id);
435
+ if (!edge) {
436
+ return { success: false, message: `No edge from ${targets[0]} to ${targets[1]}` };
437
+ }
438
+ this.model.removeEdge(edge.id);
439
+ return { success: true, message: `-edge ${srcResult.shape.label}->${tgtResult.shape.label}` };
440
+ }
441
+ // ── Style ──────────────────────────────────────────────
442
+ handleStyle(op) {
443
+ if (!op.target) {
444
+ return { success: false, message: "style requires a target" };
445
+ }
446
+ // Handle @group:NAME — style the group container, not members
447
+ if (op.target.startsWith("@group:")) {
448
+ const groupName = op.target.slice(7);
449
+ const group = this.model.getGroupByName(groupName);
450
+ if (!group) {
451
+ return { success: false, message: `Unknown group "${groupName}"` };
452
+ }
453
+ // Apply style params to group.style
454
+ let groupFontEnable = 0;
455
+ let groupFontDisable = 0;
456
+ for (const [key, value] of op.params) {
457
+ switch (key) {
458
+ case "fill": {
459
+ const color = resolveColor(value);
460
+ if (color)
461
+ group.style.fillColor = color;
462
+ break;
463
+ }
464
+ case "stroke": {
465
+ const color = resolveColor(value);
466
+ if (color)
467
+ group.style.strokeColor = color;
468
+ break;
469
+ }
470
+ case "font":
471
+ case "font-color": {
472
+ const color = resolveColor(value);
473
+ if (color)
474
+ group.style.fontColor = color;
475
+ break;
476
+ }
477
+ case "font-size":
478
+ case "fontSize":
479
+ group.style.fontSize = parseInt(value, 10);
480
+ break;
481
+ case "opacity":
482
+ group.style.opacity = parseInt(value, 10);
483
+ break;
484
+ case "rounded":
485
+ group.style.rounded = value === "true" || value === "1";
486
+ break;
487
+ case "dashed":
488
+ group.style.dashed = value === "true" || value === "1";
489
+ break;
490
+ case "shadow":
491
+ group.style.shadow = value === "true" || value === "1";
492
+ break;
493
+ case "bold":
494
+ groupFontEnable |= 1;
495
+ break;
496
+ case "no-bold":
497
+ groupFontDisable |= 1;
498
+ break;
499
+ case "italic":
500
+ groupFontEnable |= 2;
501
+ break;
502
+ case "no-italic":
503
+ groupFontDisable |= 2;
504
+ break;
505
+ case "underline":
506
+ groupFontEnable |= 4;
507
+ break;
508
+ case "no-underline":
509
+ groupFontDisable |= 4;
510
+ break;
511
+ case "font-family":
512
+ group.style.fontFamily = value;
513
+ break;
514
+ case "align":
515
+ group.style.align = value;
516
+ break;
517
+ case "valign":
518
+ group.style.verticalAlign = value;
519
+ break;
520
+ }
521
+ }
522
+ if (groupFontEnable !== 0 || groupFontDisable !== 0) {
523
+ const base = group.style.fontStyle ?? 0;
524
+ group.style.fontStyle = (base | groupFontEnable) & ~groupFontDisable;
525
+ }
526
+ const propList = [...op.params.entries()]
527
+ .filter(([k]) => k !== "theme")
528
+ .map(([k, v]) => v === "true" ? k : `${k}:${v}`)
529
+ .join(" ");
530
+ return {
531
+ success: true,
532
+ message: `*styled group ${groupName} ${propList}`,
533
+ };
534
+ }
535
+ const resolved = resolveRef(op.target, this.model.registry, this.model);
536
+ if (resolved.kind === "none") {
537
+ const suggestion = resolved.suggestedLabel
538
+ ? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
539
+ : undefined;
540
+ return { success: false, message: resolved.message, suggestion };
541
+ }
542
+ // Ambiguous non-selector reference is an error
543
+ const isSelector = op.target.startsWith("@");
544
+ if (resolved.kind === "multiple" && !isSelector) {
545
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
546
+ return { success: false, message: `error: ${resolved.message}`, suggestion };
547
+ }
548
+ const shapes = resolved.kind === "single" ? [resolved.shape] : resolved.shapes;
549
+ // Map style params
550
+ const styleChanges = {};
551
+ // Handle theme param (built-in and custom)
552
+ const themeParam = op.params.get("theme");
553
+ if (themeParam) {
554
+ const customTheme = this.model.diagram.customThemes.get(themeParam);
555
+ if (customTheme) {
556
+ styleChanges.fillColor = customTheme.fill;
557
+ styleChanges.strokeColor = customTheme.stroke;
558
+ if (customTheme.fontColor)
559
+ styleChanges.fontColor = customTheme.fontColor;
560
+ }
561
+ else if (isThemeName(themeParam)) {
562
+ const colors = resolveColor(themeParam);
563
+ // resolveColor returns fill for theme name — use resolveTheme for full colors
564
+ const themeColors = THEMES[themeParam];
565
+ if (themeColors) {
566
+ styleChanges.fillColor = themeColors.fill;
567
+ styleChanges.strokeColor = themeColors.stroke;
568
+ if (themeColors.fontColor)
569
+ styleChanges.fontColor = themeColors.fontColor;
570
+ }
571
+ }
572
+ }
573
+ // Track fontStyle bitmask operations separately (applied per-shape)
574
+ let fontStyleEnable = 0;
575
+ let fontStyleDisable = 0;
576
+ for (const [key, value] of op.params) {
577
+ switch (key) {
578
+ case "fill": {
579
+ const color = resolveColor(value);
580
+ if (color)
581
+ styleChanges.fillColor = color;
582
+ break;
583
+ }
584
+ case "stroke": {
585
+ const color = resolveColor(value);
586
+ if (color)
587
+ styleChanges.strokeColor = color;
588
+ break;
589
+ }
590
+ case "font":
591
+ case "font-color": {
592
+ const color = resolveColor(value);
593
+ if (color)
594
+ styleChanges.fontColor = color;
595
+ break;
596
+ }
597
+ case "font-size":
598
+ case "fontSize":
599
+ styleChanges.fontSize = parseInt(value, 10);
600
+ break;
601
+ case "opacity":
602
+ styleChanges.opacity = parseInt(value, 10);
603
+ break;
604
+ case "rounded":
605
+ styleChanges.rounded = value === "true" || value === "1";
606
+ break;
607
+ case "dashed":
608
+ styleChanges.dashed = value === "true" || value === "1";
609
+ break;
610
+ case "shadow":
611
+ styleChanges.shadow = value === "true" || value === "1";
612
+ break;
613
+ // Text styling — bare boolean flags
614
+ case "bold":
615
+ fontStyleEnable |= 1;
616
+ break;
617
+ case "no-bold":
618
+ fontStyleDisable |= 1;
619
+ break;
620
+ case "italic":
621
+ fontStyleEnable |= 2;
622
+ break;
623
+ case "no-italic":
624
+ fontStyleDisable |= 2;
625
+ break;
626
+ case "underline":
627
+ fontStyleEnable |= 4;
628
+ break;
629
+ case "no-underline":
630
+ fontStyleDisable |= 4;
631
+ break;
632
+ // Font family and alignment — key:value params
633
+ case "font-family":
634
+ styleChanges.fontFamily = value;
635
+ break;
636
+ case "align":
637
+ styleChanges.align = value;
638
+ break;
639
+ case "valign":
640
+ styleChanges.verticalAlign = value;
641
+ break;
642
+ }
643
+ }
644
+ const hasFontStyleOps = fontStyleEnable !== 0 || fontStyleDisable !== 0;
645
+ let modifiedCount = 0;
646
+ for (const shape of shapes) {
647
+ const newStyle = { ...shape.style, ...styleChanges };
648
+ // Apply fontStyle bitmask operations relative to each shape's existing value
649
+ if (hasFontStyleOps) {
650
+ const base = shape.style.fontStyle ?? 0;
651
+ newStyle.fontStyle = (base | fontStyleEnable) & ~fontStyleDisable;
652
+ }
653
+ const result = this.model.modifyShape(shape.id, { style: newStyle });
654
+ if (result)
655
+ modifiedCount++;
656
+ }
657
+ const propList = [...op.params.entries()]
658
+ .filter(([k]) => k !== "theme")
659
+ .map(([k, v]) => v === "true" ? k : `${k}:${v}`)
660
+ .join(" ");
661
+ return {
662
+ success: true,
663
+ message: `*styled ${op.target} ${propList} (${modifiedCount} shape${modifiedCount !== 1 ? "s" : ""})`,
664
+ };
665
+ }
666
+ // ── Remove ─────────────────────────────────────────────
667
+ handleRemove(op) {
668
+ if (!op.target) {
669
+ return { success: false, message: "remove requires a target" };
670
+ }
671
+ const resolved = resolveRef(op.target, this.model.registry, this.model);
672
+ if (resolved.kind === "none") {
673
+ const suggestion = resolved.suggestedLabel
674
+ ? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
675
+ : undefined;
676
+ return { success: false, message: resolved.message, suggestion };
677
+ }
678
+ const shapes = resolved.kind === "single" ? [resolved.shape] : resolved.shapes;
679
+ const results = [];
680
+ for (const shape of shapes) {
681
+ const removed = this.model.removeShape(shape.id);
682
+ if (removed)
683
+ results.push(formatShapeDeleted(removed));
684
+ }
685
+ return { success: true, message: results.join("\n") };
686
+ }
687
+ // ── Label ──────────────────────────────────────────────
688
+ handleLabel(op) {
689
+ if (!op.target) {
690
+ return { success: false, message: "label requires a target" };
691
+ }
692
+ const newText = op.params.get("text");
693
+ if (!newText) {
694
+ return { success: false, message: "label requires new text" };
695
+ }
696
+ // Edge form: label A -> B "text"
697
+ if (op.targets && op.targets.length >= 2 && op.arrows && op.arrows.length > 0) {
698
+ const srcResolved = resolveRef(op.targets[0], this.model.registry, this.model);
699
+ if (srcResolved.kind !== "single") {
700
+ return { success: false, message: srcResolved.message };
701
+ }
702
+ const tgtResolved = resolveRef(op.targets[1], this.model.registry, this.model);
703
+ if (tgtResolved.kind !== "single") {
704
+ return { success: false, message: tgtResolved.message };
705
+ }
706
+ const edge = this.model.findEdge(srcResolved.shape.id, tgtResolved.shape.id);
707
+ if (!edge) {
708
+ return { success: false, message: `No edge from ${op.targets[0]} to ${op.targets[1]}` };
709
+ }
710
+ const result = this.model.modifyEdge(edge.id, { label: newText });
711
+ if (!result) {
712
+ return { success: false, message: `Failed to relabel edge` };
713
+ }
714
+ return { success: true, message: `~${op.targets[0]}->${op.targets[1]} labeled "${newText}"` };
715
+ }
716
+ // Shape form: label REF "text"
717
+ const resolved = resolveRef(op.target, this.model.registry, this.model);
718
+ if (resolved.kind !== "single") {
719
+ if (resolved.kind === "none") {
720
+ const suggestion = resolved.suggestedLabel
721
+ ? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
722
+ : undefined;
723
+ return { success: false, message: resolved.message, suggestion };
724
+ }
725
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
726
+ return { success: false, message: resolved.message, suggestion };
727
+ }
728
+ const result = this.model.modifyShape(resolved.shape.id, { label: newText });
729
+ if (!result) {
730
+ return { success: false, message: `Failed to relabel ${op.target}` };
731
+ }
732
+ return { success: true, message: formatShapeModified(result, `labeled "${newText}"`) };
733
+ }
734
+ // ── Badge ──────────────────────────────────────────────
735
+ handleBadge(op) {
736
+ if (!op.target) {
737
+ return { success: false, message: "badge requires a target" };
738
+ }
739
+ const text = op.params.get("text");
740
+ if (!text) {
741
+ return { success: false, message: "badge requires text" };
742
+ }
743
+ const position = (op.params.get("pos") ?? "top-right");
744
+ const resolved = resolveRef(op.target, this.model.registry, this.model);
745
+ if (resolved.kind !== "single") {
746
+ if (resolved.kind === "none") {
747
+ const suggestion = resolved.suggestedLabel
748
+ ? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
749
+ : undefined;
750
+ return { success: false, message: resolved.message, suggestion };
751
+ }
752
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
753
+ return { success: false, message: resolved.message, suggestion };
754
+ }
755
+ const shape = resolved.shape;
756
+ const existingBadges = shape.metadata.badges ?? [];
757
+ const newBadge = { text, position };
758
+ const badges = [...existingBadges, newBadge];
759
+ const result = this.model.modifyShape(shape.id, {
760
+ metadata: { ...shape.metadata, badges },
761
+ });
762
+ if (!result) {
763
+ return { success: false, message: `Failed to badge ${op.target}` };
764
+ }
765
+ return { success: true, message: formatShapeModified(result, `badge "${text}"`) };
766
+ }
767
+ // ── Move ───────────────────────────────────────────────
768
+ handleMove(op) {
769
+ if (!op.target) {
770
+ return { success: false, message: "move requires a target" };
771
+ }
772
+ const strict = op.params.get("strict") === "true";
773
+ // Handle @group:Name — move entire group
774
+ if (op.target.startsWith("@group:")) {
775
+ return this.handleMoveGroup(op, strict);
776
+ }
777
+ const resolved = resolveRef(op.target, this.model.registry, this.model);
778
+ if (resolved.kind !== "single") {
779
+ if (resolved.kind === "none") {
780
+ const suggestion = resolved.suggestedLabel
781
+ ? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
782
+ : undefined;
783
+ return { success: false, message: resolved.message, suggestion };
784
+ }
785
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
786
+ return { success: false, message: resolved.message, suggestion };
787
+ }
788
+ const shape = resolved.shape;
789
+ let newX = shape.bounds.x;
790
+ let newY = shape.bounds.y;
791
+ // to:X,Y or to:region-name
792
+ const toParam = op.params.get("to");
793
+ if (toParam) {
794
+ const regionPos = this.model.resolveCanvasRegion(toParam, {
795
+ width: shape.bounds.width,
796
+ height: shape.bounds.height,
797
+ });
798
+ if (regionPos) {
799
+ newX = regionPos.x;
800
+ newY = regionPos.y;
801
+ }
802
+ else {
803
+ const parts = toParam.split(",");
804
+ if (parts.length === 2) {
805
+ newX = parseFloat(parts[0]);
806
+ newY = parseFloat(parts[1]);
807
+ }
808
+ }
809
+ }
810
+ // near:REF dir:DIR — delegate to model's positioning logic
811
+ const nearRef = op.params.get("near");
812
+ if (nearRef) {
813
+ const nearResolved = resolveRef(nearRef, this.model.registry, this.model);
814
+ if (nearResolved.kind === "single") {
815
+ const dir = op.params.get("dir") ?? "below";
816
+ const pos = this.model.positionRelativeTo(nearResolved.shape.bounds, { width: shape.bounds.width, height: shape.bounds.height }, dir);
817
+ newX = pos.x;
818
+ newY = pos.y;
819
+ }
820
+ }
821
+ const result = this.model.modifyShape(shape.id, {
822
+ bounds: { ...shape.bounds, x: newX, y: newY },
823
+ });
824
+ if (!result) {
825
+ return { success: false, message: `Failed to move ${op.target}` };
826
+ }
827
+ // Collision detection (unless strict mode)
828
+ let shifted = 0;
829
+ if (!strict) {
830
+ shifted = this.model.detectAndResolveCollisions(shape.id, false);
831
+ }
832
+ const shiftNote = shifted > 0 ? ` (shifted ${shifted} item${shifted !== 1 ? "s" : ""})` : "";
833
+ return { success: true, message: `@moved ${result.label} to (${newX},${newY})${shiftNote}` };
834
+ }
835
+ handleMoveGroup(op, strict) {
836
+ const groupName = op.target.slice(7); // strip "@group:"
837
+ const group = this.model.getGroupByName(groupName);
838
+ if (!group) {
839
+ return { success: false, message: `Unknown group "${groupName}"` };
840
+ }
841
+ const toParam = op.params.get("to");
842
+ if (!toParam) {
843
+ return { success: false, message: "move @group requires to:X,Y or to:region" };
844
+ }
845
+ // Resolve target position for the group
846
+ let targetX;
847
+ let targetY;
848
+ const regionPos = this.model.resolveCanvasRegion(toParam, {
849
+ width: group.bounds.width,
850
+ height: group.bounds.height,
851
+ });
852
+ if (regionPos) {
853
+ targetX = regionPos.x;
854
+ targetY = regionPos.y;
855
+ }
856
+ else {
857
+ const parts = toParam.split(",");
858
+ if (parts.length !== 2) {
859
+ return { success: false, message: `Invalid move target: ${toParam}` };
860
+ }
861
+ targetX = parseFloat(parts[0]);
862
+ targetY = parseFloat(parts[1]);
863
+ }
864
+ // Compute delta from current group bounds
865
+ const dx = targetX - group.bounds.x;
866
+ const dy = targetY - group.bounds.y;
867
+ const page = this.model.getActivePage();
868
+ let movedCount = 0;
869
+ // Move all member shapes by the delta
870
+ for (const memberId of group.memberIds) {
871
+ const shape = page.shapes.get(memberId);
872
+ if (shape) {
873
+ this.model.modifyShape(shape.id, {
874
+ bounds: { ...shape.bounds, x: shape.bounds.x + dx, y: shape.bounds.y + dy },
875
+ });
876
+ movedCount++;
877
+ }
878
+ }
879
+ // Recompute group bounds
880
+ this.model.recomputeGroupBoundsPublic(group.id);
881
+ // Collision detection for the group
882
+ let shifted = 0;
883
+ if (!strict) {
884
+ shifted = this.model.detectAndResolveCollisions(group.id, true);
885
+ }
886
+ const shiftNote = shifted > 0 ? ` (shifted ${shifted} item${shifted !== 1 ? "s" : ""})` : "";
887
+ return {
888
+ success: true,
889
+ message: `@moved group ${groupName} (${movedCount} shapes)${shiftNote}`,
890
+ };
891
+ }
892
+ // ── Resize ─────────────────────────────────────────────
893
+ handleResize(op) {
894
+ if (!op.target) {
895
+ return { success: false, message: "resize requires a target" };
896
+ }
897
+ const resolved = resolveRef(op.target, this.model.registry, this.model);
898
+ if (resolved.kind !== "single") {
899
+ if (resolved.kind === "none") {
900
+ const suggestion = resolved.suggestedLabel
901
+ ? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
902
+ : undefined;
903
+ return { success: false, message: resolved.message, suggestion };
904
+ }
905
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
906
+ return { success: false, message: resolved.message, suggestion };
907
+ }
908
+ const shape = resolved.shape;
909
+ const toParam = op.params.get("to");
910
+ if (!toParam) {
911
+ return { success: false, message: "resize requires to:WxH" };
912
+ }
913
+ const parts = toParam.toLowerCase().split("x");
914
+ if (parts.length !== 2) {
915
+ return { success: false, message: `Invalid size format: ${toParam}` };
916
+ }
917
+ const width = parseFloat(parts[0]);
918
+ const height = parseFloat(parts[1]);
919
+ const result = this.model.modifyShape(shape.id, {
920
+ bounds: { ...shape.bounds, width, height },
921
+ });
922
+ if (!result) {
923
+ return { success: false, message: `Failed to resize ${op.target}` };
924
+ }
925
+ return { success: true, message: `*resized ${result.label} to ${width}x${height}` };
926
+ }
927
+ // ── Swap ───────────────────────────────────────────────
928
+ handleSwap(op) {
929
+ const targets = op.targets;
930
+ if (!targets || targets.length < 2) {
931
+ return { success: false, message: "swap requires two targets" };
932
+ }
933
+ const r1 = resolveRef(targets[0], this.model.registry, this.model);
934
+ const r2 = resolveRef(targets[1], this.model.registry, this.model);
935
+ if (r1.kind !== "single") {
936
+ if (r1.kind === "none") {
937
+ const suggestion = r1.suggestedLabel
938
+ ? this.buildTypoSuggestion(op.raw, targets[0], r1.suggestedLabel)
939
+ : undefined;
940
+ return { success: false, message: r1.message, suggestion };
941
+ }
942
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[0], r1.shapes);
943
+ return { success: false, message: r1.message, suggestion };
944
+ }
945
+ if (r2.kind !== "single") {
946
+ if (r2.kind === "none") {
947
+ const suggestion = r2.suggestedLabel
948
+ ? this.buildTypoSuggestion(op.raw, targets[1], r2.suggestedLabel)
949
+ : undefined;
950
+ return { success: false, message: r2.message, suggestion };
951
+ }
952
+ const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[1], r2.shapes);
953
+ return { success: false, message: r2.message, suggestion };
954
+ }
955
+ const bounds1 = { ...r1.shape.bounds };
956
+ const bounds2 = { ...r2.shape.bounds };
957
+ this.model.modifyShape(r1.shape.id, { bounds: bounds2 });
958
+ this.model.modifyShape(r2.shape.id, { bounds: bounds1 });
959
+ return {
960
+ success: true,
961
+ message: `@swapped ${r1.shape.label} <-> ${r2.shape.label}`,
962
+ };
963
+ }
964
+ // ── Group ──────────────────────────────────────────────
965
+ handleGroup(op) {
966
+ const targets = op.targets;
967
+ if (!targets || targets.length === 0) {
968
+ return { success: false, message: "group requires targets" };
969
+ }
970
+ const name = op.params.get("as");
971
+ if (!name) {
972
+ return { success: false, message: "group requires as:NAME" };
973
+ }
974
+ // Resolve all targets to shape IDs
975
+ const memberIds = [];
976
+ for (const ref of targets) {
977
+ const resolved = resolveRef(ref, this.model.registry, this.model);
978
+ if (resolved.kind === "single") {
979
+ memberIds.push(resolved.shape.id);
980
+ }
981
+ else if (resolved.kind === "multiple") {
982
+ for (const s of resolved.shapes) {
983
+ memberIds.push(s.id);
984
+ }
985
+ }
986
+ else {
987
+ const suggestion = resolved.suggestedLabel
988
+ ? this.buildTypoSuggestion(op.raw, ref, resolved.suggestedLabel)
989
+ : undefined;
990
+ return { success: false, message: resolved.message, suggestion };
991
+ }
992
+ }
993
+ const group = this.model.createGroup(name, memberIds);
994
+ if (!group) {
995
+ return { success: false, message: "Failed to create group" };
996
+ }
997
+ // Apply label: param as display name (group.name is used as XML value)
998
+ const labelParam = op.params.get("label");
999
+ if (labelParam) {
1000
+ group.name = labelParam.replace(/_/g, " ");
1001
+ }
1002
+ // Apply theme: param to group style
1003
+ const themeParam = op.params.get("theme");
1004
+ if (themeParam) {
1005
+ const customTheme = this.model.diagram.customThemes.get(themeParam);
1006
+ if (customTheme) {
1007
+ group.style.fillColor = customTheme.fill;
1008
+ group.style.strokeColor = customTheme.stroke;
1009
+ if (customTheme.fontColor)
1010
+ group.style.fontColor = customTheme.fontColor;
1011
+ }
1012
+ else if (isThemeName(themeParam)) {
1013
+ const colors = THEMES[themeParam];
1014
+ group.style.fillColor = colors.fill;
1015
+ group.style.strokeColor = colors.stroke;
1016
+ if (colors.fontColor)
1017
+ group.style.fontColor = colors.fontColor;
1018
+ }
1019
+ }
1020
+ return { success: true, message: formatGroupCreated(group) };
1021
+ }
1022
+ // ── Ungroup ────────────────────────────────────────────
1023
+ handleUngroup(op) {
1024
+ if (!op.target) {
1025
+ return { success: false, message: "ungroup requires a group name" };
1026
+ }
1027
+ const group = this.model.getGroupByName(op.target);
1028
+ if (!group) {
1029
+ return { success: false, message: `Unknown group "${op.target}"` };
1030
+ }
1031
+ const dissolved = this.model.dissolveGroup(group.id);
1032
+ if (!dissolved) {
1033
+ return { success: false, message: `Failed to ungroup "${op.target}"` };
1034
+ }
1035
+ return { success: true, message: `!ungrouped ${dissolved.name} (${dissolved.memberIds.size} shapes)` };
1036
+ }
1037
+ // ── Define ─────────────────────────────────────────────
1038
+ handleDefine(op) {
1039
+ if (!op.target) {
1040
+ return { success: false, message: "define requires a name" };
1041
+ }
1042
+ // Handle "define theme NAME fill:# stroke:#"
1043
+ if (op.target === "theme") {
1044
+ const themeName = op.targets && op.targets.length > 1 ? op.targets[1] : op.params.get("name");
1045
+ if (!themeName) {
1046
+ return { success: false, message: "define theme requires a name" };
1047
+ }
1048
+ const fill = op.params.get("fill");
1049
+ const stroke = op.params.get("stroke");
1050
+ if (!fill || !stroke) {
1051
+ return { success: false, message: "define theme requires fill:# and stroke:#" };
1052
+ }
1053
+ const fontColor = op.params.get("font-color");
1054
+ this.model.defineCustomTheme(themeName, fill, stroke, fontColor);
1055
+ return {
1056
+ success: true,
1057
+ message: `defined theme ${themeName} (${fill} / ${stroke}${fontColor ? ` font:${fontColor}` : ""})`,
1058
+ };
1059
+ }
1060
+ const baseName = op.params.get("base");
1061
+ if (!baseName || !isShapeType(baseName)) {
1062
+ return { success: false, message: `define requires base:TYPE (got "${baseName ?? "none"}")` };
1063
+ }
1064
+ const theme = op.params.get("theme");
1065
+ const badge = op.params.get("badge");
1066
+ let size;
1067
+ const sizeParam = op.params.get("size");
1068
+ if (sizeParam) {
1069
+ const parts = sizeParam.toLowerCase().split("x");
1070
+ if (parts.length === 2) {
1071
+ size = { width: parseFloat(parts[0]), height: parseFloat(parts[1]) };
1072
+ }
1073
+ }
1074
+ const ct = this.model.defineCustomType(op.target, baseName, { theme, badge, size });
1075
+ return {
1076
+ success: true,
1077
+ message: `defined ${ct.name} (base:${ct.base}${ct.theme ? ` theme:${ct.theme}` : ""}${ct.badge ? ` badge:${ct.badge}` : ""})`,
1078
+ };
1079
+ }
1080
+ // ── Checkpoint ─────────────────────────────────────────
1081
+ handleCheckpoint(op) {
1082
+ if (!op.target) {
1083
+ return { success: false, message: "checkpoint requires a name" };
1084
+ }
1085
+ this.model.checkpoint(op.target);
1086
+ return { success: true, message: `checkpoint "${op.target}" created` };
1087
+ }
1088
+ // ── Title ──────────────────────────────────────────────
1089
+ handleTitle(op) {
1090
+ if (!op.target) {
1091
+ return { success: false, message: "title requires a name" };
1092
+ }
1093
+ this.model.setTitle(op.target);
1094
+ return { success: true, message: `title set to "${op.target}"` };
1095
+ }
1096
+ // ── Page ───────────────────────────────────────────────
1097
+ handlePage(op) {
1098
+ const sub = op.subcommand;
1099
+ if (!sub) {
1100
+ return { success: false, message: "page requires a subcommand" };
1101
+ }
1102
+ switch (sub) {
1103
+ case "add": {
1104
+ const name = op.target;
1105
+ if (!name)
1106
+ return { success: false, message: "page add requires a name" };
1107
+ const page = this.model.addPage(name);
1108
+ return { success: true, message: `+page ${page.name}` };
1109
+ }
1110
+ case "switch": {
1111
+ const name = op.target;
1112
+ if (!name)
1113
+ return { success: false, message: "page switch requires a name" };
1114
+ const page = this.model.switchPage(name);
1115
+ if (!page)
1116
+ return { success: false, message: `Unknown page "${name}"` };
1117
+ return { success: true, message: `switched to page ${page.name}` };
1118
+ }
1119
+ case "remove": {
1120
+ const name = op.target;
1121
+ if (!name)
1122
+ return { success: false, message: "page remove requires a name" };
1123
+ const ok = this.model.removePage(name);
1124
+ if (!ok)
1125
+ return { success: false, message: `Cannot remove page "${name}"` };
1126
+ return { success: true, message: `-page ${name}` };
1127
+ }
1128
+ case "list": {
1129
+ const activePage = this.model.getActivePage();
1130
+ const lines = this.model.diagram.pages.map((p) => {
1131
+ const markers = [];
1132
+ if (p.id === activePage.id)
1133
+ markers.push("active");
1134
+ markers.push(`${p.shapes.size} shapes`);
1135
+ markers.push(`${p.edges.size} edges`);
1136
+ const suffix = markers.length > 0 ? ` (${markers.join(", ")})` : "";
1137
+ return ` ${p.name}${suffix}`;
1138
+ });
1139
+ return { success: true, message: `pages:\n${lines.join("\n")}` };
1140
+ }
1141
+ case "duplicate": {
1142
+ // Stub — not implemented in model yet
1143
+ return { success: false, message: "page duplicate not yet implemented" };
1144
+ }
1145
+ default:
1146
+ return { success: false, message: `Unknown page subcommand "${sub}"` };
1147
+ }
1148
+ }
1149
+ // ── Layer ──────────────────────────────────────────────
1150
+ handleLayer(op) {
1151
+ const sub = op.subcommand;
1152
+ if (!sub) {
1153
+ return { success: false, message: "layer requires a subcommand" };
1154
+ }
1155
+ const page = this.model.getActivePage();
1156
+ switch (sub) {
1157
+ case "create": {
1158
+ const name = op.target;
1159
+ if (!name)
1160
+ return { success: false, message: "layer create requires a name" };
1161
+ this.model.addLayer(name);
1162
+ return { success: true, message: `+layer ${name}` };
1163
+ }
1164
+ case "show": {
1165
+ const name = op.target;
1166
+ if (!name)
1167
+ return { success: false, message: "layer show requires a name" };
1168
+ const layer = page.layers.find((l) => l.name === name);
1169
+ if (!layer)
1170
+ return { success: false, message: `Unknown layer "${name}"` };
1171
+ this.model.modifyLayer(layer.id, { visible: true });
1172
+ return { success: true, message: `layer ${name} visible` };
1173
+ }
1174
+ case "hide": {
1175
+ const name = op.target;
1176
+ if (!name)
1177
+ return { success: false, message: "layer hide requires a name" };
1178
+ const layer = page.layers.find((l) => l.name === name);
1179
+ if (!layer)
1180
+ return { success: false, message: `Unknown layer "${name}"` };
1181
+ this.model.modifyLayer(layer.id, { visible: false });
1182
+ return { success: true, message: `layer ${name} hidden` };
1183
+ }
1184
+ case "switch": {
1185
+ const name = op.target;
1186
+ if (!name)
1187
+ return { success: false, message: "layer switch requires a name" };
1188
+ const layer = page.layers.find((l) => l.name === name);
1189
+ if (!layer)
1190
+ return { success: false, message: `Unknown layer "${name}"` };
1191
+ page.defaultLayer = layer.id;
1192
+ return { success: true, message: `switched to layer ${name}` };
1193
+ }
1194
+ case "list": {
1195
+ const lines = page.layers.map((l) => {
1196
+ const markers = [];
1197
+ if (l.id === page.defaultLayer)
1198
+ markers.push("active");
1199
+ if (!l.visible)
1200
+ markers.push("hidden");
1201
+ if (l.locked)
1202
+ markers.push("locked");
1203
+ const suffix = markers.length > 0 ? ` (${markers.join(", ")})` : "";
1204
+ return ` ${l.name}${suffix}`;
1205
+ });
1206
+ return { success: true, message: `layers:\n${lines.join("\n")}` };
1207
+ }
1208
+ case "move": {
1209
+ // Stub for moving shapes between layers
1210
+ return { success: false, message: "layer move not yet implemented" };
1211
+ }
1212
+ default:
1213
+ return { success: false, message: `Unknown layer subcommand "${sub}"` };
1214
+ }
1215
+ }
1216
+ // ── Layout ─────────────────────────────────────────────
1217
+ async handleLayout(op) {
1218
+ // Parse algorithm (default: layered)
1219
+ const algoParam = op.params.get("algo") ?? "layered";
1220
+ const validAlgos = new Set(["layered", "force", "tree"]);
1221
+ if (!validAlgos.has(algoParam)) {
1222
+ return { success: false, message: `Unknown algorithm "${algoParam}". Use: layered, force, tree` };
1223
+ }
1224
+ // Parse direction (default: TB)
1225
+ const dirParam = (op.params.get("dir") ?? "TB").toUpperCase();
1226
+ const validDirs = new Set(["TB", "LR", "BT", "RL"]);
1227
+ if (!validDirs.has(dirParam)) {
1228
+ return { success: false, message: `Unknown direction "${dirParam}". Use: TB, LR, BT, RL` };
1229
+ }
1230
+ // Parse spacing
1231
+ const spacing = op.params.has("spacing") ? parseInt(op.params.get("spacing"), 10) : undefined;
1232
+ const options = {
1233
+ algorithm: algoParam,
1234
+ direction: dirParam,
1235
+ spacing,
1236
+ };
1237
+ try {
1238
+ const page = this.model.getActivePage();
1239
+ const result = await runElkLayout(page, options);
1240
+ const count = this.model.applyLayout(result);
1241
+ // Auto-set flow direction to match layout
1242
+ this.model.setFlowDirection(dirParam);
1243
+ return {
1244
+ success: true,
1245
+ message: `@layout ${algoParam} ${dirParam} — repositioned ${count} shapes`,
1246
+ };
1247
+ }
1248
+ catch (err) {
1249
+ return {
1250
+ success: false,
1251
+ message: `Layout failed: ${err instanceof Error ? err.message : String(err)}`,
1252
+ };
1253
+ }
1254
+ }
1255
+ // ── Orient ─────────────────────────────────────────────
1256
+ handleOrient(op) {
1257
+ if (!op.target) {
1258
+ return { success: false, message: "orient requires a direction: TB, LR, BT, RL" };
1259
+ }
1260
+ const dir = op.target.toUpperCase();
1261
+ const validDirs = new Set(["TB", "LR", "BT", "RL"]);
1262
+ if (!validDirs.has(dir)) {
1263
+ return { success: false, message: `Unknown direction "${op.target}". Use: TB, LR, BT, RL` };
1264
+ }
1265
+ this.model.setFlowDirection(dir);
1266
+ return { success: true, message: `@orient ${dir}` };
1267
+ }
1268
+ // ── Load (stencil packs) ────────────────────────────────
1269
+ handleLoad(op) {
1270
+ const target = op.target?.toLowerCase();
1271
+ if (!target) {
1272
+ return { success: false, message: "load requires a target: use 'load list' or 'load PACK'" };
1273
+ }
1274
+ // load list — show available packs
1275
+ if (target === "list") {
1276
+ const packs = listStencilPacks();
1277
+ const lines = packs.map(p => {
1278
+ const loaded = this.model.diagram.loadedStencilPacks.has(p.id) ? " (loaded)" : "";
1279
+ return ` ${p.id.padEnd(8)} ${p.name} (${p.entryCount} types)${loaded}`;
1280
+ });
1281
+ return {
1282
+ success: true,
1283
+ message: "Available stencil packs:\n" + lines.join("\n"),
1284
+ };
1285
+ }
1286
+ // load PACK — activate a stencil pack
1287
+ const pack = getStencilPack(target);
1288
+ if (!pack) {
1289
+ const available = listStencilPacks().map(p => p.id).join(", ");
1290
+ return {
1291
+ success: false,
1292
+ message: `Unknown stencil pack "${target}". Available: ${available}`,
1293
+ };
1294
+ }
1295
+ // Check if already loaded
1296
+ if (this.model.diagram.loadedStencilPacks.has(target)) {
1297
+ return { success: true, message: `Stencil pack "${pack.name}" is already loaded` };
1298
+ }
1299
+ // Register entries (first-loaded wins on conflicts)
1300
+ let newEntries = 0;
1301
+ for (const entry of pack.entries) {
1302
+ if (!this.loadedStencilEntries.has(entry.id)) {
1303
+ this.loadedStencilEntries.set(entry.id, entry);
1304
+ newEntries++;
1305
+ }
1306
+ }
1307
+ this.model.diagram.loadedStencilPacks.add(target);
1308
+ // Build category summary
1309
+ const categories = new Map();
1310
+ for (const entry of pack.entries) {
1311
+ const cat = categories.get(entry.category) ?? [];
1312
+ cat.push(entry.id);
1313
+ categories.set(entry.category, cat);
1314
+ }
1315
+ const catLines = [...categories.entries()].map(([cat, ids]) => ` ${cat}: ${ids.join(", ")}`);
1316
+ return {
1317
+ success: true,
1318
+ message: `Loaded "${pack.name}" (${newEntries} types)\n${catLines.join("\n")}`,
1319
+ };
1320
+ }
1321
+ }
1322
+ //# sourceMappingURL=intent-layer.js.map