import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract, getWorkflowVariablesContract, putWorkflowNormalizedStateContract, type WorkflowStateContractInput, workflowVariablesContract, } from '@/lib/api/contracts/workflows' import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations' import { type ExportWorkflowState, sanitizeForExport, } from '@/lib/workflows/sanitization/json-sanitizer' import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks' import { regenerateWorkflowIds } from '@/stores/workflows/utils' import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowImportExport') function unwrapWorkflowExportEnvelope(data: unknown): unknown { if (!isRecordLike(data)) { return data } const envelopeData = data.data if ( isRecordLike(envelopeData) && (envelopeData.state || envelopeData.version || envelopeData.workflow) ) { return envelopeData } return data } async function getJSZip() { const { default: JSZip } = await import('jszip') return JSZip } export interface WorkflowExportData { workflow: { id: string name: string description?: string folderId?: string | null sortOrder?: number } state: WorkflowState variables?: Record } export interface FolderExportData { id: string name: string parentId: string | null sortOrder?: number } interface WorkspaceExportStructure { workspace: { name: string exportedAt: string } workflows: WorkflowExportData[] folders: FolderExportData[] } /** * Sanitizes a string for use as a path segment in a ZIP file. */ export function sanitizePathSegment(name: string): string { return name.replace(/[^\p{L}\p{N}\-_]/gu, '-').replace(/-+/g, '-') } /** * Downloads a file to the user's device. */ export function downloadFile( content: Blob | string, filename: string, mimeType = 'application/json' ): void { try { const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } catch (error) { logger.error('Failed to download file:', error) } } /** * Fetches a workflow's state and variables for export. * Returns null if the workflow cannot be fetched. */ export async function fetchWorkflowForExport( workflowId: string, workflowMeta: { name: string; description?: string; folderId?: string | null } ): Promise { try { let workflowData: { state?: WorkflowState } try { const response = await requestJson(getWorkflowStateContract, { params: { id: workflowId }, }) workflowData = { state: response.data.state as WorkflowState } } catch { logger.error(`Failed to fetch workflow ${workflowId}`) return null } if (!workflowData?.state) { logger.warn(`Workflow ${workflowId} has no state`) return null } let workflowVariables: Record | undefined try { const { data } = await requestJson(getWorkflowVariablesContract, { params: { id: workflowId }, }) workflowVariables = data as Record } catch (error) { if (error instanceof ApiClientError) { logger.warn(`Failed to fetch workflow ${workflowId} variables for export`, { status: error.status, message: error.message, }) } else { logger.warn(`Failed to fetch workflow ${workflowId} variables for export:`, error) } } return { workflow: { id: workflowId, name: workflowMeta.name, description: workflowMeta.description, folderId: workflowMeta.folderId, }, state: workflowData.state, variables: workflowVariables, } } catch (error) { logger.error(`Failed to fetch workflow ${workflowId} for export:`, error) return null } } /** * Exports a single workflow to a JSON string. */ export function exportWorkflowToJson(workflowData: WorkflowExportData): string { const workflowState = { ...workflowData.state, metadata: { name: workflowData.workflow.name, description: workflowData.workflow.description, exportedAt: new Date().toISOString(), }, variables: workflowData.variables, } const exportState = sanitizeForExport(workflowState) return JSON.stringify(exportState, null, 2) } /** * Exports multiple workflows to a ZIP file. * Workflows are placed at the root level (no folder structure). */ export async function exportWorkflowsToZip(workflows: WorkflowExportData[]): Promise { const JSZip = await getJSZip() const zip = new JSZip() const seenFilenames = new Set() for (const workflow of workflows) { const jsonContent = exportWorkflowToJson(workflow) const baseName = sanitizePathSegment(workflow.workflow.name) let filename = `${baseName}.json` let counter = 1 while (seenFilenames.has(filename.toLowerCase())) { filename = `${baseName}-${counter}.json` counter++ } seenFilenames.add(filename.toLowerCase()) zip.file(filename, jsonContent) } return await zip.generateAsync({ type: 'blob' }) } function buildFolderPath( folderId: string | null | undefined, foldersMap: Map ): string { if (!folderId) return '' const path: string[] = [] let currentId: string | null = folderId while (currentId && foldersMap.has(currentId)) { const folder: FolderExportData = foldersMap.get(currentId)! path.unshift(sanitizePathSegment(folder.name)) currentId = folder.parentId } return path.join('/') } export async function exportWorkspaceToZip( workspaceName: string, workflows: WorkflowExportData[], folders: FolderExportData[] ): Promise { const JSZip = await getJSZip() const zip = new JSZip() const foldersMap = new Map(folders.map((f) => [f.id, f])) const metadata = { workspace: { name: workspaceName, exportedAt: new Date().toISOString(), }, folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId, sortOrder: f.sortOrder, })), } zip.file('_workspace.json', JSON.stringify(metadata, null, 2)) for (const workflow of workflows) { try { const workflowState = { ...workflow.state, metadata: { name: workflow.workflow.name, description: workflow.workflow.description, sortOrder: workflow.workflow.sortOrder, exportedAt: new Date().toISOString(), }, variables: workflow.variables, } const exportState = sanitizeForExport(workflowState) const sanitizedName = sanitizePathSegment(workflow.workflow.name) const filename = `${sanitizedName}-${workflow.workflow.id}.json` const folderPath = buildFolderPath(workflow.workflow.folderId, foldersMap) const fullPath = folderPath ? `${folderPath}/${filename}` : filename zip.file(fullPath, JSON.stringify(exportState, null, 2)) } catch (error) { logger.error(`Failed to export workflow ${workflow.workflow.id}:`, error) } } return await zip.generateAsync({ type: 'blob' }) } /** * Export a folder and its contents to a ZIP file. * Preserves nested folder structure with paths relative to the exported folder. * * @param folderName - Name of the folder being exported * @param workflows - Workflows to export (should be filtered to only those in the folder subtree) * @param folders - Subfolders within the exported folder (parentId should be null for direct children) */ export async function exportFolderToZip( folderName: string, workflows: WorkflowExportData[], folders: FolderExportData[] ): Promise { const JSZip = await getJSZip() const zip = new JSZip() const foldersMap = new Map(folders.map((f) => [f.id, f])) const metadata = { folder: { name: folderName, exportedAt: new Date().toISOString(), }, folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })), } zip.file('_folder.json', JSON.stringify(metadata, null, 2)) for (const workflow of workflows) { try { const workflowState = { ...workflow.state, metadata: { name: workflow.workflow.name, description: workflow.workflow.description, exportedAt: new Date().toISOString(), }, variables: workflow.variables, } const exportState = sanitizeForExport(workflowState) const sanitizedName = sanitizePathSegment(workflow.workflow.name) const filename = `${sanitizedName}-${workflow.workflow.id}.json` const folderPath = buildFolderPath(workflow.workflow.folderId, foldersMap) const fullPath = folderPath ? `${folderPath}/${filename}` : filename zip.file(fullPath, JSON.stringify(exportState, null, 2)) } catch (error) { logger.error(`Failed to export workflow ${workflow.workflow.id}:`, error) } } return await zip.generateAsync({ type: 'blob' }) } export interface ImportedWorkflow { content: string name: string folderPath: string[] sortOrder?: number } export interface WorkspaceImportMetadata { workspaceName: string exportedAt?: string folders?: Array<{ id: string name: string parentId: string | null sortOrder?: number }> } function extractSortOrder(content: string): number | undefined { try { const parsed = unwrapWorkflowExportEnvelope(JSON.parse(content)) as Record return parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder } catch { return undefined } } export async function extractWorkflowsFromZip( zipFile: File ): Promise<{ workflows: ImportedWorkflow[]; metadata?: WorkspaceImportMetadata }> { const JSZip = await getJSZip() const zip = await JSZip.loadAsync(await zipFile.arrayBuffer()) const workflows: ImportedWorkflow[] = [] let metadata: WorkspaceImportMetadata | undefined for (const [path, file] of Object.entries(zip.files)) { if (file.dir) continue if (path === '_workspace.json') { try { const content = await file.async('string') const parsed = JSON.parse(content) metadata = { workspaceName: parsed.workspace?.name || 'Imported Workspace', exportedAt: parsed.workspace?.exportedAt, folders: parsed.folders, } } catch (error) { logger.error('Failed to parse workspace metadata:', error) } continue } if (!path.toLowerCase().endsWith('.json')) continue try { const content = await file.async('string') const pathParts = path.split('/').filter((p) => p.length > 0) const filename = pathParts.pop() || path workflows.push({ content, name: filename, folderPath: pathParts, sortOrder: extractSortOrder(content), }) } catch (error) { logger.error(`Failed to extract ${path}:`, error) } } return { workflows, metadata } } export async function extractWorkflowsFromFiles(files: File[]): Promise { const workflows: ImportedWorkflow[] = [] for (const file of files) { if (!file.name.toLowerCase().endsWith('.json')) continue try { const content = await file.text() workflows.push({ content, name: file.name, folderPath: [], sortOrder: extractSortOrder(content), }) } catch (error) { logger.error(`Failed to read ${file.name}:`, error) } } return workflows } export function extractWorkflowName(content: string, filename: string): string { try { const parsed = unwrapWorkflowExportEnvelope(JSON.parse(content)) as Record if (parsed.state?.metadata?.name && typeof parsed.state.metadata.name === 'string') { return parsed.state.metadata.name.trim() } if (parsed.workflow?.name && typeof parsed.workflow.name === 'string') { return parsed.workflow.name.trim() } } catch { // JSON parse failed, fall through to filename } let name = filename.replace(/\.json$/i, '') name = name.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, '') name = name .replace(/[-_]/g, ' ') .split(' ') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') return name.trim() || 'Imported Workflow' } /** * Normalize subblock values by converting empty strings to null and repairing invalid subblocks. * This provides backwards compatibility for workflows exported before the null sanitization fix, * preventing Zod validation errors like "Expected array, received string". * * Also filters out subBlocks with the literal key "undefined", which cannot be associated * with a stable block field. */ function normalizeSubblockValues(blocks: Record): Record { const { blocks: migratedBlocks } = migrateSubblockIds(blocks) const normalizedBlocks: Record = {} Object.entries(migratedBlocks).forEach(([blockId, block]) => { const normalizedBlock = { ...block } if (block.subBlocks) { const { subBlocks: normalizedSubBlocks } = sanitizeMalformedSubBlocks( { id: typeof block.id === 'string' ? block.id : blockId, type: typeof block.type === 'string' ? block.type : '', subBlocks: block.subBlocks, }, { convertEmptyStringToNull: true } ) normalizedBlock.subBlocks = normalizedSubBlocks } normalizedBlocks[blockId] = normalizedBlock }) return normalizedBlocks } /** * Parse and validate workflow JSON content for import. * Handles both new format (with version/exportedAt/state) and legacy format (blocks/edges at root). */ export function parseWorkflowJson( jsonContent: string, regenerateIdsFlag = true ): { data: WorkflowState | null errors: string[] } { const errors: string[] = [] try { // Parse JSON content let data: any try { data = JSON.parse(jsonContent) } catch (parseError) { errors.push(`Invalid JSON: ${getErrorMessage(parseError, 'Parse error')}`) return { data: null, errors } } // Validate top-level structure if (!data || typeof data !== 'object') { errors.push('Invalid JSON: Root must be an object') return { data: null, errors } } data = unwrapWorkflowExportEnvelope(data) // Handle new export format (version/exportedAt/state) or old format (blocks/edges at root) let workflowData: any if (isRecordLike(data.state)) { // Export/API envelope format with workflow state nested under `state` logger.info('Parsing workflow JSON with version', { version: data.version, exportedAt: data.exportedAt, }) workflowData = data.state } else { // Old format - blocks/edges at root level logger.info('Parsing legacy workflow JSON format') workflowData = data } // Validate required fields if (!workflowData.blocks || typeof workflowData.blocks !== 'object') { errors.push('Missing or invalid field: blocks') return { data: null, errors } } if (!Array.isArray(workflowData.edges)) { errors.push('Missing or invalid field: edges (must be an array)') return { data: null, errors } } // Validate blocks have required fields Object.entries(workflowData.blocks).forEach(([blockId, block]: [string, any]) => { if (!block || typeof block !== 'object') { errors.push(`Invalid block ${blockId}: must be an object`) return } if (!block.id) { errors.push(`Block ${blockId} missing required field: id`) } if (!block.type) { errors.push(`Block ${blockId} missing required field: type`) } if ( !block.position || typeof block.position.x !== 'number' || typeof block.position.y !== 'number' ) { errors.push(`Block ${blockId} missing or invalid position`) } }) // Validate edges have required fields workflowData.edges.forEach((edge: any, index: number) => { if (!edge || typeof edge !== 'object') { errors.push(`Invalid edge at index ${index}: must be an object`) return } if (!edge.id) { errors.push(`Edge at index ${index} missing required field: id`) } if (!edge.source) { errors.push(`Edge at index ${index} missing required field: source`) } if (!edge.target) { errors.push(`Edge at index ${index} missing required field: target`) } }) // If there are errors, return null if (errors.length > 0) { return { data: null, errors } } // Normalize non-string subblock values (convert empty strings to null) // This handles exported workflows that may have empty strings for non-string types const normalizedBlocks = normalizeSubblockValues(workflowData.blocks || {}) // Construct the workflow state with defaults let workflowState: WorkflowState = { blocks: normalizedBlocks, edges: workflowData.edges || [], loops: workflowData.loops || {}, parallels: workflowData.parallels || {}, metadata: workflowData.metadata, variables: workflowData.variables && typeof workflowData.variables === 'object' ? workflowData.variables : undefined, } if (regenerateIdsFlag) { const { idMap: _, ...regeneratedState } = regenerateWorkflowIds(workflowState, { clearTriggerRuntimeValues: true, }) workflowState = { ...regeneratedState, metadata: workflowState.metadata, variables: workflowState.variables, } logger.info('Regenerated IDs for imported workflow to avoid conflicts') } logger.info('Successfully parsed workflow JSON', { blocksCount: Object.keys(workflowState.blocks).length, edgesCount: workflowState.edges.length, loopsCount: Object.keys(workflowState.loops).length, parallelsCount: Object.keys(workflowState.parallels).length, }) return { data: workflowState, errors: [] } } catch (error) { logger.error('Failed to parse workflow JSON:', error) errors.push(`Unexpected error: ${getErrorMessage(error, 'Unknown error')}`) return { data: null, errors } } } interface CreateImportedWorkflowInput { name: string description: string workspaceId: string folderId?: string sortOrder?: number } interface CreatedImportedWorkflow { id: string } interface PersistImportedWorkflowOptions { content: string filename: string workspaceId: string folderId?: string sortOrder?: number nameOverride?: string descriptionOverride?: string createWorkflow: (input: CreateImportedWorkflowInput) => Promise } export async function persistImportedWorkflow({ content, filename, workspaceId, folderId, sortOrder, nameOverride, descriptionOverride, createWorkflow, }: PersistImportedWorkflowOptions): Promise<{ workflowId: string; workflowName: string } | null> { const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content) if (!workflowData || parseErrors.length > 0) { logger.warn(`Failed to parse imported workflow ${filename}:`, parseErrors) return null } const workflowName = nameOverride || extractWorkflowName(content, filename) const createdWorkflow = await createWorkflow({ name: workflowName, description: descriptionOverride || workflowData.metadata?.description || 'Imported from JSON', workspaceId, folderId, sortOrder, }) const newWorkflowId = createdWorkflow.id type ContractEdgeInput = WorkflowStateContractInput['edges'][number] // Imported workflow JSON may carry nullable sourceHandle / targetHandle // values from older exports. The contract input schema rejects nulls // (it expects `string | undefined`), so drop nullish handles before // sending. Pre-validation by `parseWorkflowJson` already ensures the // shape is otherwise contract-compatible. const sanitizedEdges: ContractEdgeInput[] = (workflowData.edges || []).map((edge) => { const { sourceHandle, targetHandle, ...rest } = edge const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput if (typeof sourceHandle === 'string' && sourceHandle.length > 0) { sanitized.sourceHandle = sourceHandle } if (typeof targetHandle === 'string' && targetHandle.length > 0) { sanitized.targetHandle = targetHandle } return sanitized }) const stateBody: WorkflowStateContractInput = { ...workflowData, loops: workflowData.loops || {}, parallels: workflowData.parallels || {}, edges: sanitizedEdges, } try { await requestJson(putWorkflowNormalizedStateContract, { params: { id: newWorkflowId }, body: stateBody, }) } catch { throw new Error(`Failed to save workflow state for ${newWorkflowId}`) } if (workflowData.variables) { const variablesArray = Array.isArray(workflowData.variables) ? workflowData.variables : Object.values(workflowData.variables) if (variablesArray.length > 0) { type WorkflowVariablesBodyInput = NonNullable< Parameters>[1]['body'] > const variablesRecord: WorkflowVariablesBodyInput['variables'] = {} for (const variable of variablesArray) { const id = typeof variable.id === 'string' && variable.id.trim() ? variable.id : generateId() variablesRecord[id] = { id, name: variable.name, type: variable.type, value: variable.value, } } try { await requestJson(workflowVariablesContract, { params: { id: newWorkflowId }, body: { variables: variablesRecord }, }) } catch { throw new Error(`Failed to save variables for ${newWorkflowId}`) } } } logger.info(`Imported workflow: ${workflowName}`) return { workflowId: newWorkflowId, workflowName } } export interface GenerateWorkflowJsonOptions { workflowId: string name?: string description?: string variables?: Array<{ id: string name: string type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' value: any }> } /** * Generate JSON export for a workflow. * This is a pure function that takes the workflow state and metadata and returns the JSON string. */ export function generateWorkflowJson( workflowState: WorkflowState, options: GenerateWorkflowJsonOptions ): string { const variablesRecord: Record | undefined = options.variables?.reduce( (acc, v) => { acc[v.id] = { id: v.id, name: v.name, type: v.type, value: v.value, } return acc }, {} as Record ) const stateWithMetadata: WorkflowState = { ...workflowState, metadata: { name: options.name, description: options.description, exportedAt: new Date().toISOString(), }, variables: variablesRecord, } const exportState: ExportWorkflowState = sanitizeForExport(stateWithMetadata) const jsonString = JSON.stringify(exportState, null, 2) logger.info('Workflow JSON generated successfully', { version: exportState.version, exportedAt: exportState.exportedAt, blocksCount: Object.keys(exportState.state.blocks).length, edgesCount: exportState.state.edges.length, jsonLength: jsonString.length, }) return jsonString }