diff --git a/calm-plugins/vscode/package.json b/calm-plugins/vscode/package.json index 28c4a9ba2..fb192ec43 100644 --- a/calm-plugins/vscode/package.json +++ b/calm-plugins/vscode/package.json @@ -37,6 +37,11 @@ "light": "media/calm-canvas.svg", "dark": "media/calm-canvas.svg" } + }, + { + "command": "calm.importSvg", + "title": "Import SVG as CALM Architecture", + "category": "CALM" } ], "keybindings": [ @@ -67,6 +72,11 @@ "command": "calm.openCanvas", "when": "resourceFilename =~ /\\.(calm|architecture|template|solution|standard|guideline)\\.json$/", "group": "navigation" + }, + { + "command": "calm.importSvg", + "when": "resourceExtname == .svg", + "group": "navigation" } ] }, @@ -109,6 +119,8 @@ }, "dependencies": { "@finos/calm-models": "file:../../calm-models", + "svg-parser": "^2.0.4", + "xml2js": "^0.5.0", "react": "^19.1.0", "react-dom": "^19.1.0", "reactflow": "^11.11.4", @@ -130,6 +142,7 @@ "@types/vscode": "^1.88.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "@types/xml2js": "^0.4.14", "typescript": "^5.8.0", "vitest": "^4.1.0", "@vscode/vsce": "^3.0.0" diff --git a/calm-plugins/vscode/src/extension/extension.ts b/calm-plugins/vscode/src/extension/extension.ts index 667972d49..ac7279b67 100644 --- a/calm-plugins/vscode/src/extension/extension.ts +++ b/calm-plugins/vscode/src/extension/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { CanvasPanel } from './webview/canvas-panel'; import { CalmCanvasCodeLensProvider } from './services/codelens-provider'; +import { SvgImportService } from './services/svg-import'; let canvasPanel: CanvasPanel | undefined; let outputChannel: vscode.OutputChannel; @@ -69,6 +70,15 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(openCanvas); + const importService = new SvgImportService(outputChannel); + const importSvg = vscode.commands.registerCommand( + 'calm.importSvg', + async (uri?: vscode.Uri) => { + await importService.importSvgToNewFile(uri); + } + ); + context.subscriptions.push(importSvg); + // Inline "View in CALM Canvas" affordances on CALM documents. const calmSelector: vscode.DocumentSelector = [ { pattern: '**/*.calm.json' }, diff --git a/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-nested.svg b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-nested.svg new file mode 100644 index 000000000..2f6d04952 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-nested.svg @@ -0,0 +1,5 @@ + + + + diff --git a/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-simple.svg b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-simple.svg new file mode 100644 index 000000000..1e73526c7 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-simple.svg @@ -0,0 +1,9 @@ + + + + + Web App + + Database + diff --git a/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/generic-simple.svg b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/generic-simple.svg new file mode 100644 index 000000000..1b3a29a74 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/generic-simple.svg @@ -0,0 +1,22 @@ + + + + + API Service + + + + Payment System + + + + User + + + + + + + + + diff --git a/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.test.ts new file mode 100644 index 000000000..5566eac60 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'vitest'; +import { buildCalmJson } from './calm-builder'; +import type { ParsedSvgGraph } from './types'; + +describe('buildCalmJson', () => { + it('produces valid CALM JSON for a simple graph', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'API Gateway', shapeHint: 'rounded-rectangle', geometry: { x: 100, y: 50, width: 200, height: 60 }, styleProps: {} }, + { id: 'v2', label: 'User DB', shapeHint: 'cylinder', geometry: { x: 400, y: 50, width: 120, height: 80 }, styleProps: {} }, + ], + edges: [ + { id: 'e1', sourceId: 'v1', targetId: 'v2', label: 'JDBC' }, + ], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + + expect(doc.$schema).toBe('https://calm.finos.org/release/1.2/meta/calm.json'); + expect(doc.nodes).toHaveLength(2); + expect(doc.relationships).toHaveLength(1); + expect(result.nodeCount).toBe(2); + expect(result.relationshipCount).toBe(1); + expect(result.warnings).toHaveLength(0); + }); + + it('generates correct node types from shape hints', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Admin', shapeHint: 'person', geometry: { x: 0, y: 0, width: 40, height: 60 }, styleProps: {} }, + { id: 'v2', label: 'Orders', shapeHint: 'cylinder', geometry: { x: 200, y: 0, width: 80, height: 80 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + + expect(doc.nodes[0]['node-type']).toBe('actor'); + expect(doc.nodes[1]['node-type']).toBe('database'); + }); + + it('generates connects relationships from edges', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'a', label: 'Source', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'b', label: 'Target', shapeHint: 'rectangle', geometry: { x: 200, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [ + { id: 'e1', sourceId: 'a', targetId: 'b', label: 'HTTP' }, + ], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + const rel = doc.relationships[0]; + + expect(rel['relationship-type'].connects.source.node).toBe('system-source'); + expect(rel['relationship-type'].connects.destination.node).toBe('system-target'); + expect(rel.description).toBe('HTTP'); + }); + + it('generates deployed-in relationships from containment', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'container', label: 'VPC', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 500, height: 400 }, styleProps: {} }, + { id: 'child1', label: 'Service A', shapeHint: 'rounded-rectangle', geometry: { x: 50, y: 50, width: 150, height: 60 }, parentId: 'container', styleProps: {} }, + { id: 'child2', label: 'Service B', shapeHint: 'rounded-rectangle', geometry: { x: 250, y: 50, width: 150, height: 60 }, parentId: 'container', styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + const deployedIn = doc.relationships.find((r: any) => r['relationship-type']['deployed-in']); + + expect(deployedIn).toBeDefined(); + expect(deployedIn['relationship-type']['deployed-in'].container).toBe('network-vpc'); + expect(deployedIn['relationship-type']['deployed-in'].nodes).toHaveLength(2); + }); + + it('preserves layout in metadata._layout', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Node', shapeHint: 'rectangle', geometry: { x: 123.5, y: 456.7, width: 200, height: 60 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + + expect(doc.metadata._layout['system-node']).toEqual({ x: 124, y: 457, w: 200, h: 60 }); + }); + + it('warns about unresolved edges', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Only Node', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [ + { id: 'e1', sourceId: 'v1', targetId: 'missing' }, + ], + }; + + const result = buildCalmJson(graph); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('could not resolve'); + }); + + it('generates unique IDs from labels', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'My Great Service!', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('service-my-great-service'); + }); + + it('handles nodes with empty labels', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: '', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('system-1'); + expect(doc.nodes[0].name).toBe('Unnamed system 1'); + }); + + it('preserves fill and font colors as fidelity-style metadata', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Styled', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: { fillColor: '#1c4587', fontColor: '#ffffff' } }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0].metadata).toEqual({ + 'fidelity-style': { background: '#1c4587', text: '#ffffff' }, + }); + }); + + it('omits fidelity-style when no colors are set', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Plain', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0].metadata).toBeUndefined(); + }); + + it('detects containers by geometry when no explicit parentId', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'c1', label: 'ECTA Zone', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 600, height: 400 }, styleProps: { dashed: '1', fillColor: 'none' } }, + { id: 'n1', label: 'API', shapeHint: 'rounded-rectangle', geometry: { x: 50, y: 50, width: 150, height: 60 }, styleProps: { fillColor: '#38761d' } }, + { id: 'n2', label: 'DB', shapeHint: 'cylinder', geometry: { x: 300, y: 50, width: 100, height: 80 }, styleProps: { fillColor: '#1c4587' } }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + const deployedIn = doc.relationships.find((r: any) => r['relationship-type']['deployed-in']); + + expect(deployedIn).toBeDefined(); + expect(deployedIn['relationship-type']['deployed-in'].container).toBe('network-ecta-zone'); + expect(deployedIn['relationship-type']['deployed-in'].nodes).toContain('service-api'); + expect(deployedIn['relationship-type']['deployed-in'].nodes).toContain('database-db'); + }); + + it('deduplicates IDs for nodes with the same label', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'v2', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 200, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('service-service'); + expect(doc.nodes[1]['unique-id']).toBe('service-service-2'); + }); + + it('deduplicates IDs for multiple collisions', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'v2', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 200, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'v3', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 400, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('service-service'); + expect(doc.nodes[1]['unique-id']).toBe('service-service-2'); + expect(doc.nodes[2]['unique-id']).toBe('service-service-3'); + }); + + it('types nodes with children as network even without style props', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'generic', + nodes: [ + { id: 'c1', label: 'Container', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 500, height: 400 }, styleProps: {} }, + { id: 'n1', label: 'Inner Service', shapeHint: 'rounded-rectangle', geometry: { x: 50, y: 50, width: 150, height: 60 }, parentId: 'c1', styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['node-type']).toBe('network'); + }); + + it('splits multi-line labels into name and description', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'ECTA\nEnterprise Click to Agree [AP167757]', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0].name).toBe('ECTA'); + expect(doc.nodes[0].description).toBe('Enterprise Click to Agree [AP167757]'); + expect(doc.nodes[0]['unique-id']).toBe('service-ecta'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.ts b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.ts new file mode 100644 index 000000000..68960c31c --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.ts @@ -0,0 +1,203 @@ +import type { ParsedSvgGraph, ImportResult, SvgNode } from './types'; +import { mapShapeToNodeType } from './shape-mapper'; + +export function buildCalmJson(graph: ParsedSvgGraph): ImportResult { + const warnings: string[] = []; + const nodeIdMap = new Map(); + const usedIds = new Set(); + const geometricParentIds = new Set(graph.nodes.filter(n => n.parentId).map(n => n.parentId!)); + + const nodes = graph.nodes.map((n, i) => { + const { name, description } = splitLabel(n.label); + const isContainer = isContainerNode(n) || geometricParentIds.has(n.id); + const nodeType = isContainer ? 'network' : mapShapeToNodeType(n.shapeHint, name); + const calmId = generateCalmId(nodeType, name, i, usedIds); + nodeIdMap.set(n.id, calmId); + + const node: Record = { + 'unique-id': calmId, + 'node-type': nodeType, + name: name || `Unnamed ${nodeType} ${i + 1}`, + description, + }; + + const style = extractNodeStyle(n.styleProps); + if (style) { + node.metadata = { 'fidelity-style': style }; + } + + return node; + }); + + const relationships: Array> = []; + let relIndex = 0; + + // Edges → connects relationships + for (const edge of graph.edges) { + const source = nodeIdMap.get(edge.sourceId); + const target = nodeIdMap.get(edge.targetId); + if (!source || !target) { + warnings.push(`Edge "${edge.id}": could not resolve source or target node`); + continue; + } + const rel: Record = { + 'unique-id': `rel-${++relIndex}`, + 'relationship-type': { + connects: { + source: { node: source }, + destination: { node: target }, + }, + }, + }; + if (edge.label) rel.description = edge.label; + relationships.push(rel); + } + + // Containment → deployed-in relationships + const containerChildren = new Map(); + + // First: use explicit parentId references + for (const node of graph.nodes) { + if (node.parentId) { + const parentCalmId = nodeIdMap.get(node.parentId); + const childCalmId = nodeIdMap.get(node.id); + if (parentCalmId && childCalmId) { + if (!containerChildren.has(parentCalmId)) { + containerChildren.set(parentCalmId, []); + } + containerChildren.get(parentCalmId)!.push(childCalmId); + } + } + } + + // Second: geometry-based containment for nodes without explicit parents + const nodesWithParent = new Set(graph.nodes.filter(n => n.parentId).map(n => n.id)); + const containerCandidates = graph.nodes.filter(n => isContainerNode(n)); + for (const child of graph.nodes) { + if (nodesWithParent.has(child.id)) continue; + const bestContainer = findSmallestContainer(child, containerCandidates); + if (bestContainer) { + const parentCalmId = nodeIdMap.get(bestContainer.id)!; + const childCalmId = nodeIdMap.get(child.id)!; + if (!containerChildren.has(parentCalmId)) { + containerChildren.set(parentCalmId, []); + } + containerChildren.get(parentCalmId)!.push(childCalmId); + } + } + + for (const [container, children] of containerChildren) { + relationships.push({ + 'unique-id': `rel-${++relIndex}`, + 'relationship-type': { + 'deployed-in': { container, nodes: children }, + }, + }); + } + + // Build layout metadata from geometry + const layout: Record = {}; + for (const n of graph.nodes) { + const calmId = nodeIdMap.get(n.id); + if (calmId) { + layout[calmId] = { + x: Math.round(n.geometry.x), + y: Math.round(n.geometry.y), + w: Math.round(n.geometry.width), + h: Math.round(n.geometry.height), + }; + } + } + + const calmDoc = { + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + nodes, + relationships, + metadata: { _layout: layout }, + }; + + return { + json: JSON.stringify(calmDoc, null, 2), + nodeCount: nodes.length, + relationshipCount: relationships.length, + warnings, + }; +} + +function splitLabel(label: string): { name: string; description: string } { + const lines = label.split('\n').map(l => l.trim()).filter(Boolean); + if (lines.length <= 1) return { name: label.trim(), description: '' }; + return { name: lines[0]!, description: lines.slice(1).join(' ') }; +} + +function extractNodeStyle(styleProps: SvgNode['styleProps']): { background?: string; text?: string } | null { + const bg = styleProps.fillColor || styleProps.fill; + const text = styleProps.fontColor || styleProps.color; + + if (!bg && !text) return null; + + const style: { background?: string; text?: string } = {}; + if (bg && bg !== 'none' && bg !== 'default') style.background = bg; + if (text && text !== 'none' && text !== 'default') style.text = text; + + return Object.keys(style).length > 0 ? style : null; +} + +function generateCalmId(nodeType: string, label: string, index: number, usedIds: Set): string { + const slug = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + const candidate = slug ? `${nodeType}-${slug}` : `${nodeType}-${index + 1}`; + + if (!usedIds.has(candidate)) { + usedIds.add(candidate); + return candidate; + } + + let counter = 2; + while (usedIds.has(`${candidate}-${counter}`)) counter++; + const uniqueId = `${candidate}-${counter}`; + usedIds.add(uniqueId); + return uniqueId; +} + +function isContainerNode(node: SvgNode): boolean { + const { fillColor, fill, dashed } = node.styleProps; + const noFill = !fillColor || fillColor === 'none'; + const noFill2 = !fill || fill === 'none'; + const isDashed = dashed === '1'; + // Container: large, dashed border, no fill (boundary-only) + if (isDashed && noFill && noFill2 && node.geometry.width >= 200 && node.geometry.height >= 150) { + return true; + } + return false; +} + +function isFullyInside(child: SvgNode, container: SvgNode): boolean { + if (child.id === container.id) return false; + const cg = child.geometry; + const pg = container.geometry; + const margin = 5; + return ( + cg.x >= pg.x - margin && + cg.y >= pg.y - margin && + cg.x + cg.width <= pg.x + pg.width + margin && + cg.y + cg.height <= pg.y + pg.height + margin + ); +} + +function findSmallestContainer(child: SvgNode, candidates: SvgNode[]): SvgNode | null { + let best: SvgNode | null = null; + let bestArea = Infinity; + for (const candidate of candidates) { + if (!isFullyInside(child, candidate)) continue; + const area = candidate.geometry.width * candidate.geometry.height; + if (area < bestArea) { + bestArea = area; + best = candidate; + } + } + return best; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.test.ts new file mode 100644 index 000000000..8236c49b3 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parseDrawioSvg, parseStyleString, classifyDrawioStyle } from './drawio-parser'; + +const fixture = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf-8'); + +describe('parseDrawioSvg', () => { + it('parses a simple draw.io SVG with nodes and edges', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + expect(result.sourceFormat).toBe('drawio'); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + }); + + it('extracts node labels correctly', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const labels = result.nodes.map(n => n.label).sort(); + expect(labels).toEqual(['Database', 'User', 'Web App']); + }); + + it('classifies shapes from draw.io styles', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const webApp = result.nodes.find(n => n.label === 'Web App'); + const db = result.nodes.find(n => n.label === 'Database'); + const user = result.nodes.find(n => n.label === 'User'); + + expect(webApp?.shapeHint).toBe('rounded-rectangle'); + expect(db?.shapeHint).toBe('cylinder'); + expect(user?.shapeHint).toBe('person'); + }); + + it('extracts geometry from mxGeometry', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const webApp = result.nodes.find(n => n.label === 'Web App'); + expect(webApp?.geometry).toEqual({ x: 50, y: 80, width: 160, height: 60 }); + }); + + it('extracts edge source/target and labels', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const httpsEdge = result.edges.find(e => e.label === 'HTTPS'); + expect(httpsEdge).toBeDefined(); + expect(httpsEdge?.sourceId).toBe('2'); + expect(httpsEdge?.targetId).toBe('3'); + }); + + it('detects parent-child containment', async () => { + const svg = fixture('drawio-nested.svg'); + const result = await parseDrawioSvg(svg); + + const authService = result.nodes.find(n => n.label === 'Auth Service'); + const apiGateway = result.nodes.find(n => n.label === 'API Gateway'); + const usersDb = result.nodes.find(n => n.label === 'Users DB'); + + expect(authService?.parentId).toBe('vpc'); + expect(apiGateway?.parentId).toBe('vpc'); + expect(usersDb?.parentId).toBe('vpc'); + }); + + it('returns empty result for non-draw.io content', async () => { + const svg = ''; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); +}); + +describe('parseStyleString', () => { + it('parses key=value pairs separated by semicolons', () => { + const result = parseStyleString('shape=cylinder;fillColor=#dae8fc;strokeColor=#6c8ebf'); + expect(result).toEqual({ + shape: 'cylinder', + fillColor: '#dae8fc', + strokeColor: '#6c8ebf', + }); + }); + + it('handles bare values (no =) as flags', () => { + const result = parseStyleString('rounded=1;whiteSpace=wrap;html;'); + expect(result.rounded).toBe('1'); + expect(result.html).toBe('1'); + }); + + it('handles empty string', () => { + expect(parseStyleString('')).toEqual({}); + }); +}); + +describe('parseDrawioSvg - edge cases', () => { + it('strips HTML tags from labels', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes[0]?.label).toBe('Bold Text'); + }); + + it('handles inline mxGraphModel (not in content attribute)', async () => { + const svg = ` + + + + + + + + + + `; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Inline Node'); + }); + + it('skips edges with missing source or target', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); + + it('handles edges with no label', async () => { + const svg = fixture('drawio-nested.svg'); + const result = await parseDrawioSvg(svg); + + const unlabeled = result.edges.find(e => !e.label); + expect(unlabeled).toBeDefined(); + expect(unlabeled?.label).toBeUndefined(); + }); + + it('handles single mxCell (non-array in xml2js)', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Solo'); + }); + + it('handles cells with no mxGeometry (skips them)', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(0); + }); +}); + +describe('parseDrawioSvg - AWS/Azure container shapes', () => { + it('keeps AWS group shapes as nodes and preserves child containment', async () => { + const svg = `' + + '' + + '' + + '' + )}">`; + const result = await parseDrawioSvg(svg); + + const cloud = result.nodes.find(n => n.label === 'AWS Cloud'); + const lambda = result.nodes.find(n => n.label === 'Lambda'); + expect(cloud).toBeDefined(); + expect(lambda).toBeDefined(); + expect(lambda?.parentId).toBe('aws-cloud'); + }); + + it('still skips real draw.io groups (group=1 style flag)', async () => { + const svg = `' + + '' + + '' + + '' + )}">`; + const result = await parseDrawioSvg(svg); + + const groupNode = result.nodes.find(n => n.id === 'g1'); + expect(groupNode).toBeUndefined(); + const child = result.nodes.find(n => n.label === 'Child'); + expect(child).toBeDefined(); + expect(child?.parentId).toBeUndefined(); + }); +}); + +describe('classifyDrawioStyle', () => { + it('classifies cylinder shape', () => { + expect(classifyDrawioStyle({ shape: 'cylinder' })).toBe('cylinder'); + }); + + it('classifies actor shape', () => { + expect(classifyDrawioStyle({ shape: 'actor' })).toBe('person'); + }); + + it('classifies cloud shape', () => { + expect(classifyDrawioStyle({ shape: 'cloud' })).toBe('cloud'); + }); + + it('classifies rounded rectangle', () => { + expect(classifyDrawioStyle({ rounded: '1' })).toBe('rounded-rectangle'); + }); + + it('defaults to rectangle', () => { + expect(classifyDrawioStyle({})).toBe('rectangle'); + }); + + it('classifies hexagon shape', () => { + expect(classifyDrawioStyle({ shape: 'hexagon' })).toBe('hexagon'); + }); + + it('classifies rhombus as diamond', () => { + expect(classifyDrawioStyle({ shape: 'rhombus' })).toBe('diamond'); + }); + + it('classifies ellipse style flag', () => { + expect(classifyDrawioStyle({ ellipse: '1' })).toBe('ellipse'); + }); + + it('classifies mxgraph.general.user as person', () => { + expect(classifyDrawioStyle({ shape: 'mxgraph.general.user' })).toBe('person'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.ts b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.ts new file mode 100644 index 000000000..f4343e31f --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.ts @@ -0,0 +1,354 @@ +import { parseStringPromise } from 'xml2js'; +import { inflateRaw } from 'zlib'; +import { promisify } from 'util'; +import type { ParsedSvgGraph, SvgNode, SvgEdge, ShapeHint } from './types'; + +const inflateRawAsync = promisify(inflateRaw); + +interface CellInfo { + id: string; + label: string; + cellAttrs: Record; + wrapperAttrs: Record; + cell: Record; +} + +export async function parseDrawioSvg(svgContent: string): Promise { + const mxXml = await extractMxGraphModel(svgContent); + if (!mxXml) { + return { nodes: [], edges: [], sourceFormat: 'drawio' }; + } + + const parsed = await parseStringPromise(mxXml, { explicitArray: false }); + const root = parsed?.mxGraphModel?.root; + if (!root) return { nodes: [], edges: [], sourceFormat: 'drawio' }; + + const allCells = collectAllCells(root); + const vertexIds = new Set(); + const nodes: SvgNode[] = []; + const edges: SvgEdge[] = []; + + // First pass: collect vertex IDs + for (const info of allCells) { + if (info.cellAttrs.vertex === '1') { + vertexIds.add(info.id); + } + } + + // Collect group IDs (these are container-only elements, not real nodes) + const groupIds = new Set(); + // Decorative parent IDs: groups with connectable=0 (legend boxes, decoration subtrees) + const decorativeParentIds = new Set(); + for (const info of allCells) { + const style = info.cellAttrs.style ?? ''; + if (parseStyleString(style)['group'] === '1') { + groupIds.add(info.id); + if (info.cellAttrs.connectable === '0') { + decorativeParentIds.add(info.id); + } + } + } + + // Second pass: extract nodes and edges + for (const info of allCells) { + if (info.id === '0' || info.id === '1') continue; + // Skip children of decorative groups (legend items, visual-only elements) + const parent = info.cellAttrs.parent ?? ''; + if (decorativeParentIds.has(parent)) continue; + + if (info.cellAttrs.vertex === '1') { + const node = parseVertex(info, vertexIds); + if (node) nodes.push(node); + } else if (info.cellAttrs.edge === '1') { + const edge = parseEdge(info, vertexIds); + if (edge) edges.push(edge); + } + } + + // Remove parentId references to groups (since groups are skipped as nodes) + for (const node of nodes) { + if (node.parentId && groupIds.has(node.parentId)) { + node.parentId = undefined; + } + } + + return { nodes, edges, sourceFormat: 'drawio' }; +} + +function collectAllCells(root: Record): CellInfo[] { + const results: CellInfo[] = []; + + // Direct mxCell elements + for (const cell of normalizeArray(root.mxCell)) { + const attrs = (cell.$ ?? {}) as Record; + results.push({ + id: attrs.id ?? '', + label: attrs.value ?? '', + cellAttrs: attrs, + wrapperAttrs: {}, + cell, + }); + } + + // object and UserObject wrappers (C4 elements, linked elements, etc.) + for (const wrapperTag of ['object', 'UserObject']) { + for (const wrapper of normalizeArray(root[wrapperTag])) { + const wrapperAttrs = (wrapper.$ ?? {}) as Record; + const nestedCell = wrapper.mxCell as Record | undefined; + if (!nestedCell) continue; + + const cellAttrs = (nestedCell.$ ?? {}) as Record; + const id = wrapperAttrs.id ?? cellAttrs.id ?? ''; + const rawLabel = resolveLabel(wrapperAttrs); + + results.push({ + id, + label: rawLabel, + cellAttrs: { ...cellAttrs, id }, + wrapperAttrs, + cell: nestedCell, + }); + } + } + + return results; +} + +function resolveLabel(wrapperAttrs: Record): string { + const label = wrapperAttrs.label ?? ''; + + // Only use Name/c4Name when the label actually contains placeholder patterns + if (wrapperAttrs.placeholders === '1' && label.includes('%')) { + const name = wrapperAttrs.Name ?? wrapperAttrs.c4Name ?? ''; + if (name && (label.includes('%Name%') || label.includes('%c4Name%'))) { + const desc = wrapperAttrs.Description ?? wrapperAttrs.c4Description ?? ''; + return desc ? `${name}\n${desc}` : name; + } + // Resolve any %placeholder% patterns in the template + return label.replace(/%([^%]+)%/g, (_, key: string) => { + return wrapperAttrs[key] ?? ''; + }); + } + + // Label doesn't use placeholders — use it directly (may contain hardcoded HTML) + return label; +} + +async function extractMxGraphModel(svgContent: string): Promise { + // Method 1: content attribute on root SVG (most common in modern draw.io) + const contentMatch = svgContent.match(/]*?\bcontent="([^"]*)"/); + + if (contentMatch) { + const decoded = decodeDrawioContent(contentMatch[1]!); + const mxModel = extractMxGraphModelFromDecoded(decoded); + if (mxModel) return mxModel; + + // Try decompression (some exports base64+deflate the diagram content) + const decompressed = await tryDecompress(decoded); + if (decompressed) { + const mxFromDecompressed = extractMxGraphModelFromDecoded(decompressed); + if (mxFromDecompressed) return mxFromDecompressed; + } + + // The diagram element itself might hold compressed content + const diagramContent = decoded.match(/]*>([\s\S]*?)<\/diagram>/); + if (diagramContent?.[1]) { + const diagramDecompressed = await tryDecompress(diagramContent[1].trim()); + if (diagramDecompressed) { + const mxFromDiagram = extractMxGraphModelFromDecoded(diagramDecompressed); + if (mxFromDiagram) return mxFromDiagram; + } + } + } + + // Method 2: Look for mxGraphModel directly in the SVG text (foreignObject or inline) + const directMatch = svgContent.match(//); + if (directMatch) return directMatch[0]; + + return null; +} + +function extractMxGraphModelFromDecoded(content: string): string | null { + const match = content.match(//); + return match?.[0] ?? null; +} + +function decodeDrawioContent(content: string): string { + // Try URL decoding first (older draw.io exports use %3C etc.) + try { + const urlDecoded = decodeURIComponent(content); + if (urlDecoded !== content) return urlDecoded; + } catch { + // Malformed percent-encoding — decode only valid %XX escapes, + // but only use the result when it actually produced diagram XML + // (mixed-encoding content like HTML entities + percent-encoded font URLs + // should fall through to the HTML entity path below) + const lenient = content.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)) + ); + if (lenient !== content && lenient.includes(' String.fromCharCode(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +const MAX_COMPRESSED_INPUT_BYTES = 5 * 1024 * 1024; +const MAX_DECOMPRESSED_OUTPUT_BYTES = 20 * 1024 * 1024; + +async function tryDecompress(content: string): Promise { + try { + let data = content; + try { data = decodeURIComponent(data); } catch { /* already decoded */ } + + if (Buffer.byteLength(data, 'utf-8') > MAX_COMPRESSED_INPUT_BYTES) return null; + + const buffer = Buffer.from(data, 'base64'); + if (buffer.length === 0) return null; + + const inflated = await inflateRawAsync(buffer, { maxOutputLength: MAX_DECOMPRESSED_OUTPUT_BYTES }); + const result = inflated.toString('utf-8'); + + try { + const finalDecoded = decodeURIComponent(result); + if (finalDecoded.includes('mxGraphModel')) return finalDecoded; + } catch { /* not double-encoded */ } + + if (result.includes('mxGraphModel')) return result; + return null; + } catch { + return null; + } +} + +function parseVertex(info: CellInfo, vertexIds: Set): SvgNode | null { + const { id, label: rawLabel, cellAttrs, cell } = info; + const label = stripHtml(rawLabel); + const style = cellAttrs.style ?? ''; + const styleProps = parseStyleString(style); + + // Skip group containers (they only serve as parent references) + if (styleProps['group'] === '1') return null; + // Skip connectable=0 elements (visual connectors, not real nodes) + if (cellAttrs.connectable === '0') return null; + // Skip text-only elements (labels, annotations, legend text) + if (style.startsWith('text;') || style.includes(';text;')) return null; + // Skip edge labels (annotations attached to edges) + if (style.startsWith('edgeLabel;') || style.includes(';edgeLabel;')) return null; + + const shapeHint = classifyDrawioStyle(styleProps); + const geometry = extractGeometry(cell); + if (!geometry) return null; + + // Skip small unlabeled cells (legend swatches, decoration) + if (!label && geometry.width <= 40 && geometry.height <= 40) return null; + + const parent = cellAttrs.parent ?? '1'; + const parentId = (parent !== '0' && parent !== '1' && vertexIds.has(parent)) + ? parent + : undefined; + + return { id, label, shapeHint, geometry, parentId, styleProps }; +} + +function parseEdge(info: CellInfo, vertexIds: Set): SvgEdge | null { + const { id, label: rawLabel, cellAttrs } = info; + const sourceId = cellAttrs.source ?? ''; + const targetId = cellAttrs.target ?? ''; + const label = stripHtml(rawLabel); + + if (!sourceId || !targetId) return null; + if (!vertexIds.has(sourceId) || !vertexIds.has(targetId)) return null; + + return { id, sourceId, targetId, label: label || undefined }; +} + +function extractGeometry(cell: Record): { x: number; y: number; width: number; height: number } | null { + const geo = cell.mxGeometry as Record | undefined; + if (!geo) return null; + + const geoAttrs = (geo.$ ?? {}) as Record; + const x = parseFloat(geoAttrs.x ?? '0'); + const y = parseFloat(geoAttrs.y ?? '0'); + const width = parseFloat(geoAttrs.width ?? '0'); + const height = parseFloat(geoAttrs.height ?? '0'); + + if (width === 0 && height === 0) return null; + + return { x, y, width, height }; +} + +export function parseStyleString(style: string): Record { + const props: Record = {}; + for (const part of style.split(';')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + props[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } else { + props[trimmed] = '1'; + } + } + return props; +} + +export function classifyDrawioStyle(styleProps: Record): ShapeHint { + const shape = styleProps['shape'] ?? ''; + + if (shape === 'cylinder' || shape === 'cylinder3') return 'cylinder'; + if (shape === 'actor' || shape.includes('general.user') || shape.includes('person')) return 'person'; + if (shape === 'cloud') return 'cloud'; + if (shape === 'hexagon') return 'hexagon'; + if (shape === 'rhombus') return 'diamond'; + if (shape === 'document' || shape === 'mxgraph.basic.document') return 'document'; + if (shape === 'parallelogram') return 'parallelogram'; + if (shape === 'ellipse' || styleProps['ellipse'] === '1') return 'ellipse'; + if (styleProps['rounded'] === '1') return 'rounded-rectangle'; + + return 'rectangle'; +} + +function stripHtml(value: string): string { + let result = value + .replace(//gi, '\n') + .replace(/<\/(?:div|p|h[1-6]|li)>/gi, '\n') + .replace(/]*>/gi, '\n'); + + // Loop to handle nested/split tags (e.g. ipt>) + let prev = ''; + while (prev !== result) { + prev = result; + result = result.replace(/<[^>]*>/g, ''); + } + + return result + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/\n{2,}/g, '\n') + .trim(); +} + +function normalizeArray(val: unknown): Array> { + if (!val) return []; + if (Array.isArray(val)) return val; + return [val as Record]; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts new file mode 100644 index 000000000..0ac57b4b7 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { detectSvgFormat } from './format-detector'; + +describe('detectSvgFormat', () => { + it('detects draw.io SVG with mxGraphModel', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('detects draw.io SVG with mxfile marker', () => { + const svg = 'mxfile host="app.diagrams.net"'; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('detects draw.io SVG with content attribute containing mxCell', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('returns generic for plain SVG', () => { + const svg = 'Node'; + expect(detectSvgFormat(svg)).toBe('generic'); + }); + + it('returns generic for SVG with no diagram metadata', () => { + const svg = ` + + +`; + expect(detectSvgFormat(svg)).toBe('generic'); + }); + + it('returns generic when mxGraphModel appears only in a text label', () => { + const svg = 'Uses mxGraphModel format'; + expect(detectSvgFormat(svg)).toBe('generic'); + }); + + it('detects draw.io when mxGraphModel is a proper XML tag', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('detects draw.io from percent-encoded mxGraphModel in content attribute', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts new file mode 100644 index 000000000..a6850a135 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts @@ -0,0 +1,13 @@ +import type { SvgFormat } from './types'; + +export function detectSvgFormat(svgContent: string): SvgFormat { + if ( + /]/.test(svgContent) || + svgContent.includes('%3CmxGraphModel') || + svgContent.includes('mxfile') || + (svgContent.includes('content="') && svgContent.includes('mxCell')) + ) { + return 'drawio'; + } + return 'generic'; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts new file mode 100644 index 000000000..f244c1dcb --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parseGenericSvg } from './generic-svg-parser'; + +const fixture = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf-8'); + +describe('parseGenericSvg', () => { + it('extracts nodes from grouped shapes with text', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + expect(result.sourceFormat).toBe('generic'); + expect(result.nodes.length).toBeGreaterThanOrEqual(3); + }); + + it('extracts labels from text elements', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + const labels = result.nodes.map(n => n.label); + expect(labels).toContain('API Service'); + expect(labels).toContain('Payment System'); + expect(labels).toContain('User'); + }); + + it('classifies shapes correctly', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + const apiService = result.nodes.find(n => n.label === 'API Service'); + const user = result.nodes.find(n => n.label === 'User'); + + expect(apiService?.shapeHint).toBe('rounded-rectangle'); + expect(user?.shapeHint).toBe('ellipse'); + }); + + it('extracts geometry from shape attributes', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + const apiService = result.nodes.find(n => n.label === 'API Service'); + expect(apiService?.geometry).toEqual({ x: 50, y: 50, width: 180, height: 70 }); + }); + + it('detects edges from line elements', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + expect(result.edges.length).toBeGreaterThanOrEqual(1); + }); + + it('returns empty for SVG with no shapes', () => { + const svg = 'Hello'; + const result = parseGenericSvg(svg); + expect(result.nodes).toHaveLength(0); + }); + + it('detects containment and converts child coordinates to parent-relative', () => { + const svg = ` + Container + Child + `; + const result = parseGenericSvg(svg); + + const child = result.nodes.find(n => n.label === 'Child'); + const container = result.nodes.find(n => n.label === 'Container'); + expect(child?.parentId).toBe(container?.id); + expect(child?.geometry).toEqual({ x: 40, y: 40, width: 100, height: 60 }); + }); + + it('handles tspan text elements', () => { + const svg = ` + + + MultiLine + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Multi Line'); + }); + + it('assigns IDs from group id attribute', () => { + const svg = ` + + + Named + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes[0]?.id).toBe('my-custom-id'); + }); + + it('generates fallback IDs when no id attribute', () => { + const svg = ` + + + NoId + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes[0]?.id).toMatch(/^node-\d+$/); + }); + + it('handles circle shapes', () => { + const svg = ` + + + Hub + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.shapeHint).toBe('ellipse'); + expect(result.nodes[0]?.geometry).toEqual({ x: 60, y: 60, width: 80, height: 80 }); + }); + + it('ignores shapes smaller than minimum size', () => { + const svg = ` + Tiny + Big + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Big'); + }); + + it('detects polyline edges between nodes', () => { + const svg = ` + A + B + + `; + const result = parseGenericSvg(svg); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.sourceId).toBe('a'); + expect(result.edges[0]?.targetId).toBe('b'); + }); + + it('does not create edge when endpoints are too far from nodes', () => { + const svg = ` + A + + `; + const result = parseGenericSvg(svg); + + expect(result.edges).toHaveLength(0); + }); + + it('handles standalone shapes not in groups with nearby text', () => { + const svg = ` + + Standalone + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Standalone'); + }); + + it('extracts nodes from nested groups', () => { + const svg = ` + + + Outer + + + Inner + + + `; + const result = parseGenericSvg(svg); + + const labels = result.nodes.map(n => n.label); + expect(labels).toContain('Outer'); + expect(labels).toContain('Inner'); + }); + + it('assigns child to nearest container, not outermost', () => { + const svg = ` + Grand + Mid + Leaf + `; + const result = parseGenericSvg(svg); + + const leaf = result.nodes.find(n => n.label === 'Leaf'); + const mid = result.nodes.find(n => n.label === 'Mid'); + expect(leaf?.parentId).toBe(mid?.id); + }); + + it('applies translate transform on group to child geometry', () => { + const svg = ` + + + Shifted + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 110, y: 210, width: 150, height: 60 }); + }); + + it('accumulates nested translate transforms', () => { + const svg = ` + + + + Deep + + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 60, y: 110, width: 150, height: 60 }); + }); + + it('ignores non-translate transforms gracefully', () => { + const svg = ` + + + Rotated + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 10, y: 10, width: 150, height: 60 }); + }); + + it('applies scale() transform to geometry', () => { + const svg = ` + + + Scaled + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 20, y: 20, width: 200, height: 100 }); + }); + + it('applies scale(sx, sy) with different axes', () => { + const svg = ` + + + Asym + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 20, y: 30, width: 200, height: 150 }); + }); + + it('extracts scale from matrix() transform', () => { + const svg = ` + + + Matrix + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 70, y: 130, width: 200, height: 150 }); + }); + + it('detects edges from line inside a translated group', () => { + const svg = ` + A + B + + + + `; + const result = parseGenericSvg(svg); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.sourceId).toBe('a'); + expect(result.edges[0]?.targetId).toBe('b'); + }); + + it('detects polyline edges inside a translated group', () => { + const svg = ` + A + B + + + + `; + const result = parseGenericSvg(svg); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.sourceId).toBe('a'); + expect(result.edges[0]?.targetId).toBe('b'); + }); + + it('assigns nearest text to standalone shape, not first within radius', () => { + const svg = ` + + Far Label + Near Label + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Near Label'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts new file mode 100644 index 000000000..b6d8ec47a --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts @@ -0,0 +1,409 @@ +import { parse as parseSvg, type ElementNode, type TextNode } from 'svg-parser'; +import type { ParsedSvgGraph, SvgNode, SvgEdge, ShapeHint, SvgNodeGeometry } from './types'; + +type HastNode = ElementNode | TextNode; + +const MIN_SHAPE_SIZE = 20; +const EDGE_PROXIMITY_THRESHOLD = 15; + +export function parseGenericSvg(svgContent: string): ParsedSvgGraph { + const root = parseSvg(svgContent); + const svg = findElement(root.children, 'svg'); + if (!svg) return { nodes: [], edges: [], sourceFormat: 'generic' }; + + const nodes: SvgNode[] = []; + const edges: SvgEdge[] = []; + + const svgTransform = parseTransform(String(svg.properties.transform ?? '')); + extractNodesFromElement(svg, nodes, svgTransform); + extractEdgesFromElement(svg, nodes, edges, svgTransform); + detectContainment(nodes); + + return { nodes, edges, sourceFormat: 'generic' }; +} + +function findElement(children: HastNode[], tagName: string): ElementNode | null { + for (const child of children) { + if (child.type !== 'element') continue; + if (child.tagName === tagName) return child; + const found = findElement(child.children, tagName); + if (found) return found; + } + return null; +} + +interface Transform2D { + tx: number; + ty: number; + sx: number; + sy: number; +} + +const IDENTITY_TRANSFORM: Transform2D = { tx: 0, ty: 0, sx: 1, sy: 1 }; + +function composeTransforms(outer: Transform2D, inner: Transform2D): Transform2D { + return { + sx: outer.sx * inner.sx, + sy: outer.sy * inner.sy, + tx: outer.sx * inner.tx + outer.tx, + ty: outer.sy * inner.ty + outer.ty, + }; +} + +function applyTransformToGeometry(geo: SvgNodeGeometry, accTransform: Transform2D, localTransform: Transform2D): void { + const localX = localTransform.sx * geo.x + localTransform.tx; + const localY = localTransform.sy * geo.y + localTransform.ty; + geo.x = accTransform.sx * localX + accTransform.tx; + geo.y = accTransform.sy * localY + accTransform.ty; + geo.width = Math.abs(accTransform.sx * localTransform.sx) * geo.width; + geo.height = Math.abs(accTransform.sy * localTransform.sy) * geo.height; +} + +function extractNodesFromElement( + element: ElementNode, + nodes: SvgNode[], + accTransform: Transform2D = IDENTITY_TRANSFORM +): void { + const children = element.children; + + for (const child of children) { + if (child.type !== 'element') continue; + + if (child.tagName === 'g') { + const groupTransform = parseTransform(String(child.properties.transform ?? '')); + const childTransform = composeTransforms(accTransform, groupTransform); + const node = tryExtractNodeFromGroup(child, nodes.length, childTransform); + if (node) { + nodes.push(node); + } + extractNodesFromElement(child, nodes, childTransform); + } + } + + const capturedBounds = nodes.map(n => n.geometry); + for (const child of children) { + if (child.type !== 'element') continue; + const tag = child.tagName; + + if (tag === 'rect' || tag === 'ellipse' || tag === 'circle') { + const geo = getShapeGeometry(tag, child.properties); + if (!geo || geo.width < MIN_SHAPE_SIZE || geo.height < MIN_SHAPE_SIZE) continue; + + const elTransform = parseTransform(String(child.properties.transform ?? '')); + applyTransformToGeometry(geo, accTransform, elTransform); + + if (overlapsExisting(geo, capturedBounds)) continue; + + const label = findNearbyTextInElement(element, geo); + const id = String(child.properties.id ?? `node-${nodes.length}`); + nodes.push({ + id, + label: label ?? '', + shapeHint: classifyTag(tag, child.properties), + geometry: geo, + styleProps: {}, + }); + capturedBounds.push(geo); + } + } +} + +function tryExtractNodeFromGroup( + g: ElementNode, + index: number, + accTransform: Transform2D +): SvgNode | null { + let shapeGeo: SvgNodeGeometry | null = null; + let shapeHint: ShapeHint = 'unknown'; + let label = ''; + + let shapeTransform: Transform2D = IDENTITY_TRANSFORM; + + for (const child of g.children) { + if (child.type !== 'element') continue; + const tag = child.tagName; + + if ((tag === 'rect' || tag === 'ellipse' || tag === 'circle') && !shapeGeo) { + shapeGeo = getShapeGeometry(tag, child.properties); + shapeHint = classifyTag(tag, child.properties); + shapeTransform = parseTransform(String(child.properties.transform ?? '')); + } + + if (tag === 'text' && !label) { + label = extractTextContent(child); + } + } + + if (!shapeGeo || shapeGeo.width < MIN_SHAPE_SIZE || shapeGeo.height < MIN_SHAPE_SIZE) { + return null; + } + + applyTransformToGeometry(shapeGeo, accTransform, shapeTransform); + + const id = String(g.properties.id ?? `node-${index}`); + return { id, label, shapeHint, geometry: shapeGeo, styleProps: {} }; +} + +function applyPoint(point: { x: number; y: number }, t: Transform2D): { x: number; y: number } { + return { x: t.sx * point.x + t.tx, y: t.sy * point.y + t.ty }; +} + +function extractEdgesFromElement( + element: ElementNode, + nodes: SvgNode[], + edges: SvgEdge[], + accTransform: Transform2D = IDENTITY_TRANSFORM +): void { + for (const child of element.children) { + if (child.type !== 'element') continue; + const tag = child.tagName; + const props = child.properties; + const elTransform = composeTransforms(accTransform, parseTransform(String(props.transform ?? ''))); + + if (tag === 'line') { + const edge = tryMatchLine(props, nodes, edges.length, elTransform); + if (edge) edges.push(edge); + } else if (tag === 'polyline') { + const points = String(props.points ?? ''); + const coords = points.split(/\s+/).map(p => p.split(',').map(Number)); + if (coords.length >= 2) { + const start = applyPoint({ x: coords[0]![0]!, y: coords[0]![1]! }, elTransform); + const end = applyPoint({ x: coords[coords.length - 1]![0]!, y: coords[coords.length - 1]![1]! }, elTransform); + const source = findNearestNode(start, nodes); + const target = findNearestNode(end, nodes); + if (source && target && source !== target) { + edges.push({ + id: String(props.id ?? `edge-${edges.length}`), + sourceId: source.id, + targetId: target.id, + }); + } + } + } else if (tag === 'g') { + extractEdgesFromElement(child, nodes, edges, elTransform); + } + } +} + +function tryMatchLine(props: Record, nodes: SvgNode[], index: number, transform: Transform2D = IDENTITY_TRANSFORM): SvgEdge | null { + const x1 = parseFloat(String(props.x1 ?? '')); + const y1 = parseFloat(String(props.y1 ?? '')); + const x2 = parseFloat(String(props.x2 ?? '')); + const y2 = parseFloat(String(props.y2 ?? '')); + + if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) return null; + + const start = applyPoint({ x: x1, y: y1 }, transform); + const end = applyPoint({ x: x2, y: y2 }, transform); + const source = findNearestNode(start, nodes); + const target = findNearestNode(end, nodes); + + if (!source || !target || source === target) return null; + + return { + id: String(props.id ?? `edge-${index}`), + sourceId: source.id, + targetId: target.id, + }; +} + +function findNearestNode(point: { x: number; y: number }, nodes: SvgNode[]): SvgNode | null { + let closest: SvgNode | null = null; + let minDist = EDGE_PROXIMITY_THRESHOLD; + + for (const node of nodes) { + const dist = distanceToNodeBorder(point, node.geometry); + if (dist < minDist) { + minDist = dist; + closest = node; + } + } + + return closest; +} + +function distanceToNodeBorder(point: { x: number; y: number }, geo: SvgNodeGeometry): number { + const { x, y, width, height } = geo; + + if (point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height) { + return 0; + } + + const nearestX = Math.max(x, Math.min(point.x, x + width)); + const nearestY = Math.max(y, Math.min(point.y, y + height)); + + return Math.sqrt((point.x - nearestX) ** 2 + (point.y - nearestY) ** 2); +} + +function detectContainment(nodes: SvgNode[]): void { + const byArea = [...nodes].sort((a, b) => { + const aArea = a.geometry.width * a.geometry.height; + const bArea = b.geometry.width * b.geometry.height; + return aArea - bArea; + }); + + for (const child of nodes) { + if (child.parentId) continue; + for (const parent of byArea) { + if (parent.id === child.id) continue; + if (parent.parentId === child.id) continue; + if (isFullyContained(child.geometry, parent.geometry)) { + child.parentId = parent.id; + break; + } + } + } + + const absoluteGeo = new Map(nodes.map(n => [n.id, { ...n.geometry }])); + for (const child of nodes) { + if (!child.parentId) continue; + const parentGeo = absoluteGeo.get(child.parentId); + if (!parentGeo) continue; + child.geometry = { + ...child.geometry, + x: child.geometry.x - parentGeo.x, + y: child.geometry.y - parentGeo.y, + }; + } +} + +function isFullyContained(inner: SvgNodeGeometry, outer: SvgNodeGeometry): boolean { + const margin = 5; + return ( + inner.x >= outer.x + margin && + inner.y >= outer.y + margin && + inner.x + inner.width <= outer.x + outer.width - margin && + inner.y + inner.height <= outer.y + outer.height - margin + ); +} + +function parseTransform(transform: string | undefined): Transform2D { + if (!transform) return IDENTITY_TRANSFORM; + + let tx = 0, ty = 0, sx = 1, sy = 1; + + const scaleMatch = transform.match(/scale\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)/); + if (scaleMatch) { + sx = parseFloat(scaleMatch[1]!); + sy = scaleMatch[2] ? parseFloat(scaleMatch[2]) : sx; + } + + const translateMatch = transform.match(/translate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)/); + if (translateMatch) { + tx = parseFloat(translateMatch[1]!); + ty = translateMatch[2] ? parseFloat(translateMatch[2]) : 0; + } + + const matrixMatch = transform.match(/matrix\(\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)\s*\)/); + if (matrixMatch) { + const a = parseFloat(matrixMatch[1]!); + const b = parseFloat(matrixMatch[2]!); + const c = parseFloat(matrixMatch[3]!); + const d = parseFloat(matrixMatch[4]!); + tx = parseFloat(matrixMatch[5]!); + ty = parseFloat(matrixMatch[6]!); + // Extract scale from axis-aligned matrices (b ≈ 0, c ≈ 0) + if (Math.abs(b) < 0.001 && Math.abs(c) < 0.001) { + sx = a; + sy = d; + } else { + sx = Math.sqrt(a * a + b * b); + sy = Math.sqrt(c * c + d * d); + } + } + + if (!scaleMatch && !translateMatch && !matrixMatch) return IDENTITY_TRANSFORM; + + return { tx, ty, sx, sy }; +} + +function getShapeGeometry(tag: string, props: Record): SvgNodeGeometry | null { + switch (tag) { + case 'rect': { + const x = parseFloat(String(props.x ?? '0')); + const y = parseFloat(String(props.y ?? '0')); + const width = parseFloat(String(props.width ?? '0')); + const height = parseFloat(String(props.height ?? '0')); + if (width === 0 || height === 0) return null; + return { x, y, width, height }; + } + case 'ellipse': { + const cx = parseFloat(String(props.cx ?? '0')); + const cy = parseFloat(String(props.cy ?? '0')); + const rx = parseFloat(String(props.rx ?? '0')); + const ry = parseFloat(String(props.ry ?? '0')); + if (rx === 0 || ry === 0) return null; + return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }; + } + case 'circle': { + const cx = parseFloat(String(props.cx ?? '0')); + const cy = parseFloat(String(props.cy ?? '0')); + const r = parseFloat(String(props.r ?? '0')); + if (r === 0) return null; + return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 }; + } + default: + return null; + } +} + +function classifyTag(tag: string, props: Record): ShapeHint { + switch (tag) { + case 'ellipse': + case 'circle': + return 'ellipse'; + case 'rect': { + const rx = parseFloat(String(props.rx ?? '0')); + return rx > 0 ? 'rounded-rectangle' : 'rectangle'; + } + default: + return 'unknown'; + } +} + +function extractTextContent(textEl: ElementNode): string { + const parts: string[] = []; + + for (const child of textEl.children) { + if (child.type === 'text' && child.value) { + parts.push(child.value); + } else if (child.type === 'element' && child.tagName === 'tspan') { + const tspanText = extractTextContent(child); + if (tspanText) parts.push(tspanText); + } + } + + return parts.join(' ').trim(); +} + +function findNearbyTextInElement(element: ElementNode, geo: SvgNodeGeometry): string | null { + const cx = geo.x + geo.width / 2; + const cy = geo.y + geo.height / 2; + const threshold = Math.max(geo.width, geo.height); + + let bestLabel: string | null = null; + let bestDist = threshold; + + for (const child of element.children) { + if (child.type !== 'element' || child.tagName !== 'text') continue; + const props = child.properties; + const tx = parseFloat(String(props.x ?? '0')); + const ty = parseFloat(String(props.y ?? '0')); + const dist = Math.sqrt((tx - cx) ** 2 + (ty - cy) ** 2); + if (dist < bestDist) { + bestDist = dist; + bestLabel = extractTextContent(child); + } + } + return bestLabel; +} + +function overlapsExisting(geo: SvgNodeGeometry, existing: SvgNodeGeometry[]): boolean { + for (const e of existing) { + if (Math.abs(geo.x - e.x) < 2 && Math.abs(geo.y - e.y) < 2 && + Math.abs(geo.width - e.width) < 2 && Math.abs(geo.height - e.height) < 2) { + return true; + } + } + return false; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/index.ts b/calm-plugins/vscode/src/extension/services/svg-import/index.ts new file mode 100644 index 000000000..c23a39b2a --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/index.ts @@ -0,0 +1,7 @@ +export { SvgImportService } from './svg-import-service'; +export { detectSvgFormat } from './format-detector'; +export { parseDrawioSvg } from './drawio-parser'; +export { parseGenericSvg } from './generic-svg-parser'; +export { buildCalmJson } from './calm-builder'; +export { mapShapeToNodeType } from './shape-mapper'; +export type { ParsedSvgGraph, SvgNode, SvgEdge, ImportResult, ShapeHint, SvgFormat } from './types'; diff --git a/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts new file mode 100644 index 000000000..f9a356f06 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { mapShapeToNodeType } from './shape-mapper'; + +describe('mapShapeToNodeType', () => { + describe('shape-based mapping', () => { + it('maps cylinder to database', () => { + expect(mapShapeToNodeType('cylinder', 'Orders')).toBe('database'); + }); + + it('maps person to actor', () => { + expect(mapShapeToNodeType('person', 'Admin')).toBe('actor'); + }); + + it('maps cloud to ecosystem', () => { + expect(mapShapeToNodeType('cloud', 'AWS')).toBe('ecosystem'); + }); + + it('maps rounded-rectangle to service', () => { + expect(mapShapeToNodeType('rounded-rectangle', 'Auth')).toBe('service'); + }); + + it('maps rectangle to system', () => { + expect(mapShapeToNodeType('rectangle', 'Backend')).toBe('system'); + }); + + it('maps unknown to system', () => { + expect(mapShapeToNodeType('unknown', 'Thing')).toBe('system'); + }); + + it('maps document to data-asset', () => { + expect(mapShapeToNodeType('document', 'Report')).toBe('data-asset'); + }); + }); + + describe('label-based overrides', () => { + it('overrides shape for database keywords', () => { + expect(mapShapeToNodeType('rectangle', 'PostgreSQL Database')).toBe('database'); + }); + + it('overrides shape for actor keywords', () => { + expect(mapShapeToNodeType('rectangle', 'End User')).toBe('actor'); + }); + + it('overrides shape for webclient keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Web App Frontend')).toBe('webclient'); + }); + + it('overrides shape for network keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Private VPC')).toBe('network'); + }); + + it('overrides shape for ldap keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Active Directory')).toBe('ldap'); + }); + + it('overrides shape for ecosystem keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Third Party API')).toBe('ecosystem'); + }); + + it('does not override when no keyword matches', () => { + expect(mapShapeToNodeType('cylinder', 'Cache Layer')).toBe('database'); + }); + + it('prefers database over actor when both keywords appear', () => { + expect(mapShapeToNodeType('rectangle', 'Customer Database')).toBe('database'); + }); + + it('prefers network over actor for "Client VPC"', () => { + expect(mapShapeToNodeType('rectangle', 'Client VPC')).toBe('network'); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts new file mode 100644 index 000000000..52b61e5d1 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts @@ -0,0 +1,33 @@ +import type { ShapeHint } from './types'; + +const SHAPE_TO_NODE_TYPE: Record = { + 'cylinder': 'database', + 'person': 'actor', + 'cloud': 'ecosystem', + 'ellipse': 'system', + 'hexagon': 'service', + 'diamond': 'service', + 'rectangle': 'system', + 'rounded-rectangle': 'service', + 'document': 'data-asset', + 'parallelogram': 'data-asset', + 'unknown': 'system', +}; + +const LABEL_PATTERNS: Array<[RegExp, string]> = [ + [/\b(db|database|datastore|data.?store|storage|redis|postgres|mysql|mongo|dynamo|cassandra)\b/i, 'database'], + [/\b(browser|web.?app|frontend|ui|spa|portal)\b/i, 'webclient'], + [/\b(network|vpc|subnet|firewall|dmz|zone|vnet)\b/i, 'network'], + [/\b(ldap|active.?directory)\b/i, 'ldap'], + [/\b(ecosystem|external|third.?party|cloud|platform)\b/i, 'ecosystem'], + [/\b(user|actor|person|customer|client|operator)\b/i, 'actor'], +]; + +export function mapShapeToNodeType(shapeHint: ShapeHint, label: string): string { + // Label-based overrides take priority for strong signals + for (const [pattern, nodeType] of LABEL_PATTERNS) { + if (pattern.test(label)) return nodeType; + } + + return SHAPE_TO_NODE_TYPE[shapeHint] ?? 'system'; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts new file mode 100644 index 000000000..2aad91b97 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { SvgImportService } from './svg-import-service'; + +const VALID_SVG = ` + Test Node +`; + +const EMPTY_SVG = ''; + +const SVG_URI = vscode.Uri.file('/workspace/diagram.svg'); +const OUTPUT_URI = vscode.Uri.file('/workspace/diagram.calm.json'); + +function createMockDocument(content = '{}'): vscode.TextDocument { + return { + uri: vscode.Uri.file('/workspace/test.calm.json'), + getText: () => content, + positionAt: (offset: number) => ({ line: 0, character: offset }), + } as unknown as vscode.TextDocument; +} + +function createMockOutputChannel(): vscode.OutputChannel { + return { appendLine: vi.fn() } as unknown as vscode.OutputChannel; +} + +describe('SvgImportService', () => { + let service: SvgImportService; + + beforeEach(() => { + service = new SvgImportService(createMockOutputChannel()); + (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue('Import'); + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue([vscode.Uri.file('/test.svg')]); + (vscode.window as Record).showInformationMessage = vi.fn().mockResolvedValue(undefined); + (vscode.window as Record).showErrorMessage = vi.fn().mockResolvedValue(undefined); + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(VALID_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + (vscode.workspace as Record).applyEdit = vi.fn().mockResolvedValue(true); + }); + + it('returns null when no document is open', async () => { + const result = await service.importSvgIntoDocument(undefined); + expect(result).toBeNull(); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('No CALM document is open.'); + }); + + it('returns null when user cancels confirmation', async () => { + (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue(undefined); + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + }); + + it('returns null when user cancels file picker', async () => { + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue(undefined); + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + }); + + it('returns null when SVG has no nodes', async () => { + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(EMPTY_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + }); + + it('successfully imports SVG and replaces document', async () => { + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).not.toBeNull(); + const doc = JSON.parse(result!); + expect(doc.nodes).toHaveLength(1); + expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + }); + + it('returns null when applyEdit fails', async () => { + (vscode.workspace as Record).applyEdit = vi.fn().mockResolvedValue(false); + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'Failed to apply the imported CALM JSON to the document.' + ); + }); + + it('returns null and shows error on parse failure', async () => { + const invalidSvg = ' }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(invalidSvg)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + }); +}); + +describe('SvgImportService - importSvgToNewFile', () => { + let service: SvgImportService; + + beforeEach(() => { + service = new SvgImportService(createMockOutputChannel()); + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue([SVG_URI]); + (vscode.window as Record).showSaveDialog = vi.fn().mockResolvedValue(OUTPUT_URI); + (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue(undefined); + (vscode.window as Record).showInformationMessage = vi.fn().mockResolvedValue('No'); + (vscode.window as Record).showErrorMessage = vi.fn().mockResolvedValue(undefined); + (vscode.commands as Record).executeCommand = vi.fn().mockResolvedValue(undefined); + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(VALID_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + }); + + it('returns early when user cancels file picker (no sourceUri)', async () => { + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue(undefined); + await service.importSvgToNewFile(); + expect(vscode.workspace.fs.readFile).not.toHaveBeenCalled(); + }); + + it('shows warning and returns when SVG has no nodes', async () => { + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(EMPTY_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + await service.importSvgToNewFile(SVG_URI); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('No nodes found in the SVG.'); + expect(vscode.window.showSaveDialog).not.toHaveBeenCalled(); + }); + + it('returns when user cancels save dialog', async () => { + (vscode.window as Record).showSaveDialog = vi.fn().mockResolvedValue(undefined); + await service.importSvgToNewFile(SVG_URI); + expect(vscode.workspace.fs.writeFile).not.toHaveBeenCalled(); + }); + + it('writes CALM JSON to the chosen file on success', async () => { + await service.importSvgToNewFile(SVG_URI); + expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith( + OUTPUT_URI, + expect.any(Buffer) + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('1 nodes') + ); + }); + + it('opens canvas when user confirms', async () => { + (vscode.window as Record).showInformationMessage = vi.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('Yes'); + await service.importSvgToNewFile(SVG_URI); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('calm.openCanvas', OUTPUT_URI); + }); + + it('uses sourceUri directly when provided (skips open dialog)', async () => { + await service.importSvgToNewFile(SVG_URI); + expect(vscode.window.showOpenDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.ts b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.ts new file mode 100644 index 000000000..75269d313 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { detectSvgFormat } from './format-detector'; +import { parseDrawioSvg } from './drawio-parser'; +import { parseGenericSvg } from './generic-svg-parser'; +import { buildCalmJson } from './calm-builder'; +import type { ImportResult } from './types'; + +export class SvgImportService { + private log: vscode.OutputChannel; + + constructor(outputChannel: vscode.OutputChannel) { + this.log = outputChannel; + } + + async importSvgIntoDocument(currentDocument: vscode.TextDocument | undefined): Promise { + this.log.appendLine(`[SvgImport] importSvgIntoDocument called, document=${currentDocument?.uri.fsPath ?? 'undefined'}`); + + if (!currentDocument) { + vscode.window.showWarningMessage('No CALM document is open.'); + return null; + } + + const confirm = await vscode.window.showWarningMessage( + 'This will replace the current architecture with the imported SVG diagram. Continue?', + { modal: true }, + 'Import' + ); + this.log.appendLine(`[SvgImport] User confirmation: ${confirm}`); + if (confirm !== 'Import') return null; + + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { 'SVG Files': ['svg'] }, + title: 'Select SVG to import as CALM architecture', + }); + if (!uris || uris.length === 0) return null; + + const uri = uris[0]!; + this.log.appendLine(`[SvgImport] Reading SVG: ${uri.fsPath}`); + + const content = Buffer.from( + await vscode.workspace.fs.readFile(uri) + ).toString('utf-8'); + + const result = await this.parseSvg(content); + if (!result) return null; + + if (result.nodeCount === 0) { + vscode.window.showWarningMessage('No nodes found in the SVG. The file may not contain diagram elements.'); + return null; + } + + if (result.warnings.length > 0) { + this.log.appendLine(`[SvgImport] Warnings: ${result.warnings.join('; ')}`); + vscode.window.showWarningMessage( + `Imported with ${result.warnings.length} warning(s). See CALM Canvas output for details.` + ); + } + + // Write to the current document + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + currentDocument.positionAt(0), + currentDocument.positionAt(currentDocument.getText().length) + ); + edit.replace(currentDocument.uri, fullRange, result.json); + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + vscode.window.showErrorMessage('Failed to apply the imported CALM JSON to the document.'); + this.log.appendLine('[SvgImport] ERROR: workspace.applyEdit returned false'); + return null; + } + + vscode.window.showInformationMessage( + `Imported ${result.nodeCount} nodes and ${result.relationshipCount} relationships from SVG.` + ); + + this.log.appendLine( + `[SvgImport] Complete: ${result.nodeCount} nodes, ${result.relationshipCount} relationships` + ); + + return result.json; + } + + async importSvgToNewFile(sourceUri?: vscode.Uri): Promise { + const uri = sourceUri ?? await this.promptForFile(); + if (!uri) return; + + const content = Buffer.from( + await vscode.workspace.fs.readFile(uri) + ).toString('utf-8'); + + const result = await this.parseSvg(content); + if (!result) return; + + if (result.nodeCount === 0) { + vscode.window.showWarningMessage('No nodes found in the SVG.'); + return; + } + + const stem = path.basename(uri.fsPath, '.svg'); + const defaultName = `${stem}.calm.json`; + const defaultUri = vscode.Uri.file( + path.join(path.dirname(uri.fsPath), defaultName) + ); + + const outputUri = await vscode.window.showSaveDialog({ + defaultUri, + filters: { 'CALM JSON': ['json'] }, + title: 'Save imported CALM architecture', + }); + if (!outputUri) return; + + await vscode.workspace.fs.writeFile(outputUri, Buffer.from(result.json, 'utf-8')); + + if (result.warnings.length > 0) { + this.log.appendLine(`[SvgImport] Warnings: ${result.warnings.join('; ')}`); + } + + vscode.window.showInformationMessage( + `Imported ${result.nodeCount} nodes, ${result.relationshipCount} relationships → ${path.basename(outputUri.fsPath)}` + ); + + const open = await vscode.window.showInformationMessage( + 'Open in CALM Canvas?', 'Yes', 'No' + ); + if (open === 'Yes') { + await vscode.commands.executeCommand('calm.openCanvas', outputUri); + } + } + + private async parseSvg(content: string): Promise { + try { + const format = detectSvgFormat(content); + this.log.appendLine(`[SvgImport] Detected format: ${format}`); + + let graph = format === 'drawio' + ? await parseDrawioSvg(content) + : parseGenericSvg(content); + + if (format === 'drawio' && graph.nodes.length === 0) { + this.log.appendLine('[SvgImport] Draw.io parse yielded 0 nodes, falling back to generic parser'); + graph = parseGenericSvg(content); + } + + this.log.appendLine( + `[SvgImport] Parsed: ${graph.nodes.length} nodes, ${graph.edges.length} edges` + ); + + return buildCalmJson(graph); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[SvgImport] ERROR: ${message}`); + vscode.window.showErrorMessage(`Failed to parse SVG: ${message}`); + return null; + } + } + + private async promptForFile(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { 'SVG Files': ['svg'] }, + title: 'Select SVG to import as CALM architecture', + }); + return uris?.[0]; + } +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/types.ts b/calm-plugins/vscode/src/extension/services/svg-import/types.ts new file mode 100644 index 000000000..becea0bd8 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/types.ts @@ -0,0 +1,50 @@ +export type ShapeHint = + | 'rectangle' + | 'rounded-rectangle' + | 'cylinder' + | 'ellipse' + | 'diamond' + | 'person' + | 'cloud' + | 'hexagon' + | 'document' + | 'parallelogram' + | 'unknown'; + +export interface SvgNodeGeometry { + x: number; + y: number; + width: number; + height: number; +} + +export interface SvgNode { + id: string; + label: string; + shapeHint: ShapeHint; + geometry: SvgNodeGeometry; + parentId?: string; + styleProps: Record; +} + +export interface SvgEdge { + id: string; + sourceId: string; + targetId: string; + label?: string; +} + +export type SvgFormat = 'drawio' | 'generic'; + +export interface ParsedSvgGraph { + nodes: SvgNode[]; + edges: SvgEdge[]; + sourceFormat: SvgFormat; +} + +export interface ImportResult { + json: string; + nodeCount: number; + relationshipCount: number; + warnings: string[]; +} diff --git a/calm-plugins/vscode/src/extension/types/messages.ts b/calm-plugins/vscode/src/extension/types/messages.ts index e600c2777..e934e4992 100644 --- a/calm-plugins/vscode/src/extension/types/messages.ts +++ b/calm-plugins/vscode/src/extension/types/messages.ts @@ -20,7 +20,7 @@ export type ExtToWebviewMessage = | { type: 'modelUpdated'; json: string; - source: 'file' | 'ai' | 'text-editor'; + source: 'file' | 'ai' | 'text-editor' | 'import'; } | { type: 'templatesLoaded'; templates: unknown[] } | { type: 'patternsLoaded'; patterns: unknown[] } @@ -44,4 +44,5 @@ export type WebviewToExtMessage = | { type: 'requestStandardProse'; url: string } | { type: 'requestGenerateSpec' } | { type: 'saveBuildingBlock'; filename: string; content: string } - | { type: 'exportDiagram'; format: 'svg' | 'png'; data: string }; + | { type: 'exportDiagram'; format: 'svg' | 'png'; data: string } + | { type: 'requestImportSvg' }; diff --git a/calm-plugins/vscode/src/extension/webview/canvas-panel.ts b/calm-plugins/vscode/src/extension/webview/canvas-panel.ts index 922845347..d7f4dec22 100644 --- a/calm-plugins/vscode/src/extension/webview/canvas-panel.ts +++ b/calm-plugins/vscode/src/extension/webview/canvas-panel.ts @@ -3,6 +3,7 @@ import { getWebviewHtml } from './html-provider'; import { SyncCoordinator } from '../services/sync-coordinator'; import { WorkspaceAssetService } from '../services/workspace-asset-service'; import { DiagramExportService } from '../services/diagram-export-service'; +import { SvgImportService } from '../services/svg-import'; import type { ExtToWebviewMessage, WebviewToExtMessage, @@ -16,6 +17,7 @@ export class CanvasPanel { private syncCoordinator = new SyncCoordinator(); private assetService: WorkspaceAssetService | undefined; private exportService = new DiagramExportService(); + private importService: SvgImportService | undefined; private fileWatcher: vscode.FileSystemWatcher | undefined; private log: vscode.OutputChannel; @@ -28,6 +30,7 @@ export class CanvasPanel { outputChannel: vscode.OutputChannel ) { this.log = outputChannel; + this.importService = new SvgImportService(outputChannel); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; this.log.appendLine( @@ -162,6 +165,9 @@ export class CanvasPanel { message.content ); break; + case 'requestImportSvg': + void this.handleImportSvg(); + break; } } @@ -496,6 +502,24 @@ export class CanvasPanel { await vscode.window.showTextDocument(doc, vscode.ViewColumn.One); } + private async handleImportSvg(): Promise { + this.log.appendLine('[CanvasPanel] handleImportSvg triggered'); + if (!this.importService) { + this.log.appendLine('[CanvasPanel] importService is undefined'); + return; + } + if (!this.currentDocument) { + this.log.appendLine('[CanvasPanel] currentDocument is undefined'); + } + const json = await this.importService.importSvgIntoDocument(this.currentDocument); + if (json) { + this.log.appendLine(`[CanvasPanel] Import successful, updating webview`); + this.postMessage({ type: 'modelUpdated', json, source: 'import' }); + } else { + this.log.appendLine('[CanvasPanel] Import returned null (cancelled or failed)'); + } + } + private handleCanvasChanged(json: string): void { if (!this.currentDocument) return; if (!this.syncCoordinator.canvasChanged()) return; diff --git a/calm-plugins/vscode/src/svg-parser.d.ts b/calm-plugins/vscode/src/svg-parser.d.ts new file mode 100644 index 000000000..74e52f7da --- /dev/null +++ b/calm-plugins/vscode/src/svg-parser.d.ts @@ -0,0 +1,21 @@ +declare module 'svg-parser' { + interface RootNode { + type: 'root'; + children: ElementNode[]; + } + + interface ElementNode { + type: 'element'; + tagName: string; + properties: Record; + children: (ElementNode | TextNode)[]; + metadata?: string; + } + + interface TextNode { + type: 'text'; + value: string; + } + + function parse(source: string): RootNode; +} diff --git a/calm-plugins/vscode/src/test/__mocks__/vscode.ts b/calm-plugins/vscode/src/test/__mocks__/vscode.ts index ea75e73f5..48a9ff09e 100644 --- a/calm-plugins/vscode/src/test/__mocks__/vscode.ts +++ b/calm-plugins/vscode/src/test/__mocks__/vscode.ts @@ -48,26 +48,45 @@ interface WorkspaceFolder { uri: Uri; } +export class WorkspaceEdit { + private _edits: Array<{ uri: unknown; range: unknown; newText: string }> = []; + replace(uri: unknown, range: unknown, newText: string): void { + this._edits.push({ uri, range, newText }); + } +} + export const workspace: { workspaceFolders: WorkspaceFolder[] | undefined; - fs: { readFile: (uri: Uri) => Promise }; + fs: { + readFile: (uri: Uri) => Promise; + writeFile: (uri: Uri, content: Uint8Array) => Promise; + }; getConfiguration: (section?: string) => { get: (key: string) => T | undefined; }; findFiles: (...args: unknown[]) => Promise; + applyEdit: (edit: WorkspaceEdit) => Promise; } = { workspaceFolders: [], fs: { readFile: async () => { throw new Error('ENOENT'); }, + writeFile: async () => {}, }, getConfiguration: () => ({ get: () => undefined }), findFiles: async () => [], + applyEdit: async () => true, +}; + +export const window: Record Promise> = { + showWarningMessage: async () => undefined, + showErrorMessage: async () => undefined, + showInformationMessage: async () => undefined, + showOpenDialog: async () => undefined, + showSaveDialog: async () => undefined, }; -export const window = { - showWarningMessage: () => Promise.resolve(undefined), - showErrorMessage: () => Promise.resolve(undefined), - showInformationMessage: () => Promise.resolve(undefined), +export const commands = { + executeCommand: async () => undefined, }; diff --git a/calm-plugins/vscode/src/webview/App.tsx b/calm-plugins/vscode/src/webview/App.tsx index 25ce8e10b..d54133365 100644 --- a/calm-plugins/vscode/src/webview/App.tsx +++ b/calm-plugins/vscode/src/webview/App.tsx @@ -35,6 +35,7 @@ import { requestStandardProse, notifyRequestGenerateSpec, notifySaveBuildingBlock, + notifyRequestImportSvg, } from './stores/sync-bridge'; import { postMessage } from './vscode-api'; import { @@ -826,6 +827,9 @@ function CanvasApp() { )} + {!store.readonlyMode && ( + + )} {!store.readonlyMode && ( void; type PatternsLoadedCallback = (patterns: unknown[]) => void; type TemplatesLoadedCallback = (templates: unknown[]) => void; @@ -124,3 +124,7 @@ export function notifySaveBuildingBlock( ): void { postMessage({ type: 'saveBuildingBlock', filename, content }); } + +export function notifyRequestImportSvg(): void { + postMessage({ type: 'requestImportSvg' }); +} diff --git a/package-lock.json b/package-lock.json index 7612643e7..54336b22f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -471,6 +471,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "reactflow": "^11.11.4", + "svg-parser": "^2.0.4", + "xml2js": "^0.5.0", "yaml": "^2.7.0", "zustand": "^5.0.0" }, @@ -479,6 +481,7 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@types/vscode": "^1.88.0", + "@types/xml2js": "^0.4.14", "@vitejs/plugin-react": "^4.0.0", "@vscode/vsce": "^3.0.0", "esbuild": "^0.25.0", @@ -1138,7 +1141,7 @@ "@finos/calm-models": "*", "@modelcontextprotocol/sdk": "^1.27.1", "elkjs": "^0.11.1", - "elkjs-svg": "*", + "elkjs-svg": "latest", "zod": "^3.24.0" }, "bin": { @@ -1165,7 +1168,7 @@ }, "devDependencies": { "@types/vscode": "^1.99.0", - "@vscode/vsce": "*", + "@vscode/vsce": "latest", "esbuild": "^0.28.1", "typescript": "^5.7.0", "vitest": "^4.1.0" @@ -1207,7 +1210,7 @@ }, "cli": { "name": "@finos/calm-cli", - "version": "1.60.0", + "version": "1.60.1", "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.0.0", @@ -48381,7 +48384,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", - "dev": true, "license": "MIT", "dependencies": { "sax": ">=0.6.0", @@ -48395,7 +48397,6 @@ "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4.0"