/** * Workflow graph <-> xyflow conversion. * * `graphToFlow` turns a {@link WorkflowGraph} (flat nodes + edges) into the * xyflow `{ nodes, edges }` shape an editor renders. * * `flowToGraph` turns xyflow nodes/edges back into `{ nodes, connections }` * where `connections` is the LOCKED backend n8n shape: * * ```json * { "sourceId": { "main": [[{ "node": "targetId", "type": "main", "index": 0 }]] } } * ``` * * Round-trip guarantee: `flowToGraph(graphToFlow(g)).connections` equals the * direct serialization of `g`'s edges into that shape. The xyflow * `sourceHandle` encodes the source output index and `targetHandle` encodes * the target input index, so multi-output (if/else), parallel fan-out, and * non-zero target inputs all survive the round-trip. */ import type { WorkflowGraph, WfNode, WfEdge, BackendConnection, BackendConnections, } from './types'; // ── xyflow shapes (structurally typed; we only depend on the fields used) ── /** Minimal xyflow Node shape produced/consumed here. */ export interface FlowNode { id: string; type?: string; position: { x: number; y: number }; data: { node: WfNode } & Record; width?: number; height?: number; } /** Minimal xyflow Edge shape produced/consumed here. */ export interface FlowEdge { id: string; source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null; type?: string; data?: { connectionType: string } & Record; } const DEFAULT_CONNECTION_TYPE = 'main'; /** Stable edge id encoding source/output -> target/input. */ export function edgeId( source: string, sourceOutput: number, target: string, targetInput: number ): string { return `${source}:${sourceOutput}->${target}:${targetInput}`; } /** * Convert a {@link WorkflowGraph} into xyflow `{ nodes, edges }`. * * Node positions: explicit `node.position` is preserved; otherwise a * placeholder `{ x: 0, y: 0 }` is assigned (run auto-layout for real * coordinates). */ export function graphToFlow(graph: WorkflowGraph): { nodes: FlowNode[]; edges: FlowEdge[]; } { const nodes: FlowNode[] = graph.nodes.map((node) => ({ id: node.id, type: node.type, position: node.position ?? { x: 0, y: 0 }, data: { node }, })); const edges: FlowEdge[] = graph.edges.map((edge) => { const sourceOutput = edge.sourceOutput ?? 0; const targetInput = edge.targetInput ?? 0; const connectionType = edge.type ?? DEFAULT_CONNECTION_TYPE; return { id: edgeId(edge.source, sourceOutput, edge.target, targetInput), source: edge.source, target: edge.target, sourceHandle: String(sourceOutput), targetHandle: String(targetInput), type: 'default', data: { connectionType }, }; }); return { nodes, edges }; } /** Parse a handle id back to its numeric index (null/undefined -> 0). */ function handleIndex(handle: string | null | undefined): number { if (handle === null || handle === undefined || handle === '') return 0; const n = Number.parseInt(handle, 10); return Number.isNaN(n) ? 0 : n; } /** * Convert xyflow nodes/edges back into `{ nodes, connections }`. * * `connections` is the LOCKED backend n8n shape. The `main` (or other * connection type) array is positional by source output index — earlier * unconnected outputs are back-filled with empty arrays so a connection on * output index 1 yields `[[], [...]]`. */ export function flowToGraph( nodes: FlowNode[], edges: FlowEdge[] ): { nodes: WfNode[]; connections: BackendConnections } { const outNodes: WfNode[] = nodes.map((n) => n.data.node); const connections: BackendConnections = {}; for (const edge of edges) { const connectionType = edge.data?.connectionType ?? edge.type ?? DEFAULT_CONNECTION_TYPE; const sourceOutput = handleIndex(edge.sourceHandle); const targetInput = handleIndex(edge.targetHandle); const sourceMap = (connections[edge.source] ??= {}); const outputLists = (sourceMap[connectionType] ??= []); // Back-fill positional output slots up to sourceOutput. while (outputLists.length <= sourceOutput) { outputLists.push([]); } const conn: BackendConnection = { node: edge.target, type: connectionType, index: targetInput, }; outputLists[sourceOutput].push(conn); } return { nodes: outNodes, connections }; } /** * Hydrate flat {@link WfEdge}s from the LOCKED backend connections shape. * Inverse of the connections half of {@link flowToGraph}; useful when * loading a workflow from the API into editor state. */ export function connectionsToEdges(connections: BackendConnections): WfEdge[] { const edges: WfEdge[] = []; for (const [source, outputs] of Object.entries(connections)) { for (const [type, outputLists] of Object.entries(outputs)) { outputLists.forEach((conns, sourceOutput) => { for (const conn of conns) { edges.push({ source, target: conn.node, sourceOutput, targetInput: conn.index, type, }); } }); } } return edges; }