/** * Workflow Serialization * * Export and import workflows as JSON for sharing, backup, * and loading templates. Compatible with Flowy JSON format. */ import { Editor, TLShapeId, createShapeId } from 'tldraw' import { PortBinding, WorkflowBlockProps } from './types' import { IWorkflowBlock } from '@/shapes/WorkflowBlockShapeUtil' import { buildWorkflowGraph, getPortBinding, setPortBinding, getAllWorkflowBlocks, } from './portBindings' import { hasBlockDefinition, getBlockDefinition } from './blockRegistry' // ============================================================================= // Serialized Types // ============================================================================= /** * Serialized block format */ interface SerializedBlock { id: string type: string x: number y: number w: number h: number blockType: string blockConfig: Record inputValues?: Record tags?: string[] } /** * Serialized connection format */ interface SerializedConnection { id: string from: { blockId: string portId: string } to: { blockId: string portId: string } } /** * Full workflow export format */ export interface SerializedWorkflow { version: string name: string description?: string createdAt: string blocks: SerializedBlock[] connections: SerializedConnection[] metadata?: Record } // ============================================================================= // Export Functions // ============================================================================= /** * Export a workflow to JSON format */ export function exportWorkflow( editor: Editor, options: { name?: string description?: string includeInputValues?: boolean blockIds?: TLShapeId[] } = {} ): SerializedWorkflow { const { name = 'Untitled Workflow', description, includeInputValues = false, blockIds, } = options // Get blocks to export let blocks = getAllWorkflowBlocks(editor) as IWorkflowBlock[] if (blockIds && blockIds.length > 0) { const blockIdSet = new Set(blockIds) blocks = blocks.filter(b => blockIdSet.has(b.id)) } // Serialize blocks const serializedBlocks: SerializedBlock[] = blocks.map(block => ({ id: block.id, type: block.type, x: block.x, y: block.y, w: block.props.w, h: block.props.h, blockType: block.props.blockType, blockConfig: block.props.blockConfig, inputValues: includeInputValues ? block.props.inputValues : undefined, tags: block.props.tags, })) // Get connections between exported blocks const blockIdSet = new Set(blocks.map(b => b.id)) const connections: SerializedConnection[] = [] // Find all arrows connecting our blocks const arrows = editor.getCurrentPageShapes().filter(s => s.type === 'arrow') for (const arrow of arrows) { const binding = getPortBinding(editor, arrow.id) if ( binding && blockIdSet.has(binding.fromShapeId) && blockIdSet.has(binding.toShapeId) ) { connections.push({ id: arrow.id, from: { blockId: binding.fromShapeId, portId: binding.fromPortId, }, to: { blockId: binding.toShapeId, portId: binding.toPortId, }, }) } } return { version: '1.0.0', name, description, createdAt: new Date().toISOString(), blocks: serializedBlocks, connections, } } /** * Export workflow to JSON string */ export function exportWorkflowToJSON( editor: Editor, options?: Parameters[1] ): string { return JSON.stringify(exportWorkflow(editor, options), null, 2) } /** * Download workflow as JSON file */ export function downloadWorkflow( editor: Editor, options?: Parameters[1] ): void { const workflow = exportWorkflow(editor, options) const json = JSON.stringify(workflow, null, 2) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) const filename = `${workflow.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json` const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } // ============================================================================= // Import Functions // ============================================================================= /** * Import a workflow from JSON format */ export function importWorkflow( editor: Editor, workflow: SerializedWorkflow, options: { offset?: { x: number; y: number } preserveIds?: boolean } = {} ): { success: boolean blockIds: TLShapeId[] errors: string[] } { const { offset = { x: 0, y: 0 }, preserveIds = false } = options const errors: string[] = [] const blockIdMap = new Map() const newBlockIds: TLShapeId[] = [] // Calculate bounds for centering let minX = Infinity let minY = Infinity for (const block of workflow.blocks) { minX = Math.min(minX, block.x) minY = Math.min(minY, block.y) } // Create blocks for (const block of workflow.blocks) { // Validate block type if (!hasBlockDefinition(block.blockType)) { errors.push(`Unknown block type: ${block.blockType}`) continue } const definition = getBlockDefinition(block.blockType) // Generate or preserve ID const newId = preserveIds && block.id.startsWith('shape:') ? block.id as TLShapeId : createShapeId() blockIdMap.set(block.id, newId) // Calculate position with offset const x = block.x - minX + offset.x const y = block.y - minY + offset.y // Calculate height based on ports const maxPorts = Math.max(definition.inputs.length, definition.outputs.length) const height = Math.max(block.h, 36 + 24 + maxPorts * 28 + 60) // Create the block try { editor.createShape({ id: newId, type: 'WorkflowBlock', x, y, props: { w: block.w, h: height, blockType: block.blockType, blockConfig: block.blockConfig || {}, inputValues: block.inputValues || {}, outputValues: {}, executionState: 'idle', tags: block.tags || ['workflow'], pinnedToView: false, }, }) newBlockIds.push(newId) } catch (error) { errors.push(`Failed to create block: ${(error as Error).message}`) } } // Create connections (arrows) for (const conn of workflow.connections) { const fromId = blockIdMap.get(conn.from.blockId) const toId = blockIdMap.get(conn.to.blockId) if (!fromId || !toId) { errors.push(`Connection references missing block`) continue } const fromBlock = editor.getShape(fromId) as IWorkflowBlock | undefined const toBlock = editor.getShape(toId) as IWorkflowBlock | undefined if (!fromBlock || !toBlock) continue try { // Create arrow between blocks const arrowId = createShapeId() // Get port positions for arrow endpoints const fromDef = getBlockDefinition(fromBlock.props.blockType) const toDef = getBlockDefinition(toBlock.props.blockType) const fromPortIndex = fromDef.outputs.findIndex(p => p.id === conn.from.portId) const toPortIndex = toDef.inputs.findIndex(p => p.id === conn.to.portId) if (fromPortIndex === -1 || toPortIndex === -1) { errors.push(`Invalid port in connection`) continue } // Calculate port positions const fromX = fromBlock.x + fromBlock.props.w const fromY = fromBlock.y + 36 + 12 + fromPortIndex * 28 + 6 const toX = toBlock.x const toY = toBlock.y + 36 + 12 + toPortIndex * 28 + 6 // Create arrow with bindings editor.createShape({ id: arrowId, type: 'arrow', x: 0, y: 0, props: { start: { x: fromX, y: fromY }, end: { x: toX, y: toY }, color: 'black', }, meta: { fromPortId: conn.from.portId, toPortId: conn.to.portId, validated: true, }, }) // Create bindings for the arrow editor.createBinding({ type: 'arrow', fromId: arrowId, toId: fromId, props: { terminal: 'start', normalizedAnchor: { x: 1, y: 0.5 }, isPrecise: false, isExact: false, }, }) editor.createBinding({ type: 'arrow', fromId: arrowId, toId: toId, props: { terminal: 'end', normalizedAnchor: { x: 0, y: 0.5 }, isPrecise: false, isExact: false, }, }) } catch (error) { errors.push(`Failed to create connection: ${(error as Error).message}`) } } return { success: errors.length === 0, blockIds: newBlockIds, errors, } } /** * Import workflow from JSON string */ export function importWorkflowFromJSON( editor: Editor, json: string, options?: Parameters[2] ): ReturnType { try { const workflow = JSON.parse(json) as SerializedWorkflow return importWorkflow(editor, workflow, options) } catch (error) { return { success: false, blockIds: [], errors: [`Invalid JSON: ${(error as Error).message}`], } } } /** * Load workflow from file */ export async function loadWorkflowFromFile( editor: Editor, file: File, options?: Parameters[2] ): Promise> { return new Promise((resolve) => { const reader = new FileReader() reader.onload = (e) => { const json = e.target?.result as string resolve(importWorkflowFromJSON(editor, json, options)) } reader.onerror = () => { resolve({ success: false, blockIds: [], errors: ['Failed to read file'], }) } reader.readAsText(file) }) } // ============================================================================= // Workflow Templates // ============================================================================= /** * Pre-built workflow templates */ export const WORKFLOW_TEMPLATES: Record = { 'api-transform-display': { version: '1.0.0', name: 'API Transform Display', description: 'Fetch data from an API, transform it, and display the result', createdAt: new Date().toISOString(), blocks: [ { id: 'block-1', type: 'WorkflowBlock', x: 100, y: 100, w: 220, h: 180, blockType: 'trigger.manual', blockConfig: {}, tags: ['workflow', 'trigger'], }, { id: 'block-2', type: 'WorkflowBlock', x: 400, y: 100, w: 220, h: 200, blockType: 'action.http', blockConfig: { url: 'https://api.example.com/data', method: 'GET' }, tags: ['workflow', 'action'], }, { id: 'block-3', type: 'WorkflowBlock', x: 700, y: 100, w: 220, h: 180, blockType: 'transformer.jsonParse', blockConfig: {}, tags: ['workflow', 'transformer'], }, { id: 'block-4', type: 'WorkflowBlock', x: 1000, y: 100, w: 220, h: 180, blockType: 'output.display', blockConfig: { format: 'json' }, tags: ['workflow', 'output'], }, ], connections: [ { id: 'conn-1', from: { blockId: 'block-1', portId: 'timestamp' }, to: { blockId: 'block-2', portId: 'trigger' }, }, { id: 'conn-2', from: { blockId: 'block-2', portId: 'response' }, to: { blockId: 'block-3', portId: 'input' }, }, { id: 'conn-3', from: { blockId: 'block-3', portId: 'output' }, to: { blockId: 'block-4', portId: 'value' }, }, ], }, 'llm-chain': { version: '1.0.0', name: 'LLM Chain', description: 'Chain multiple LLM prompts together', createdAt: new Date().toISOString(), blocks: [ { id: 'block-1', type: 'WorkflowBlock', x: 100, y: 100, w: 220, h: 180, blockType: 'trigger.manual', blockConfig: {}, tags: ['workflow', 'trigger'], }, { id: 'block-2', type: 'WorkflowBlock', x: 400, y: 100, w: 220, h: 200, blockType: 'ai.llm', blockConfig: { systemPrompt: 'You are a helpful assistant.' }, inputValues: { prompt: 'Summarize the following topic:' }, tags: ['workflow', 'ai'], }, { id: 'block-3', type: 'WorkflowBlock', x: 700, y: 100, w: 220, h: 200, blockType: 'ai.llm', blockConfig: { systemPrompt: 'You are a creative writer.' }, inputValues: { prompt: 'Expand on this summary:' }, tags: ['workflow', 'ai'], }, { id: 'block-4', type: 'WorkflowBlock', x: 1000, y: 100, w: 220, h: 180, blockType: 'output.display', blockConfig: { format: 'text' }, tags: ['workflow', 'output'], }, ], connections: [ { id: 'conn-1', from: { blockId: 'block-1', portId: 'timestamp' }, to: { blockId: 'block-2', portId: 'trigger' }, }, { id: 'conn-2', from: { blockId: 'block-2', portId: 'response' }, to: { blockId: 'block-3', portId: 'context' }, }, { id: 'conn-3', from: { blockId: 'block-3', portId: 'response' }, to: { blockId: 'block-4', portId: 'value' }, }, ], }, 'conditional-branch': { version: '1.0.0', name: 'Conditional Branch', description: 'Branch workflow based on a condition', createdAt: new Date().toISOString(), blocks: [ { id: 'block-1', type: 'WorkflowBlock', x: 100, y: 200, w: 220, h: 180, blockType: 'trigger.manual', blockConfig: {}, tags: ['workflow', 'trigger'], }, { id: 'block-2', type: 'WorkflowBlock', x: 400, y: 200, w: 220, h: 200, blockType: 'condition.if', blockConfig: {}, tags: ['workflow', 'condition'], }, { id: 'block-3', type: 'WorkflowBlock', x: 700, y: 50, w: 220, h: 180, blockType: 'output.log', blockConfig: { level: 'info' }, inputValues: { message: 'Condition was TRUE' }, tags: ['workflow', 'output'], }, { id: 'block-4', type: 'WorkflowBlock', x: 700, y: 300, w: 220, h: 180, blockType: 'output.log', blockConfig: { level: 'warn' }, inputValues: { message: 'Condition was FALSE' }, tags: ['workflow', 'output'], }, ], connections: [ { id: 'conn-1', from: { blockId: 'block-1', portId: 'timestamp' }, to: { blockId: 'block-2', portId: 'value' }, }, { id: 'conn-2', from: { blockId: 'block-2', portId: 'true' }, to: { blockId: 'block-3', portId: 'message' }, }, { id: 'conn-3', from: { blockId: 'block-2', portId: 'false' }, to: { blockId: 'block-4', portId: 'message' }, }, ], }, } /** * Load a workflow template */ export function loadTemplate( editor: Editor, templateId: string, options?: Parameters[2] ): ReturnType { const template = WORKFLOW_TEMPLATES[templateId] if (!template) { return { success: false, blockIds: [], errors: [`Unknown template: ${templateId}`], } } return importWorkflow(editor, template, options) } /** * Get available template names */ export function getTemplateNames(): Array<{ id: string; name: string; description?: string }> { return Object.entries(WORKFLOW_TEMPLATES).map(([id, template]) => ({ id, name: template.name, description: template.description, })) }