From 6d403630fb09f821abf6998052a4ac23068f40b8 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 13 Jan 2026 12:02:59 -0800 Subject: [PATCH 1/3] feat(export): support maintenance of nested folder structure on import/export --- .../components/folder-item/folder-item.tsx | 1 - .../w/hooks/use-export-folder.ts | 161 ++++++++++++++---- 2 files changed, 129 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index b105472bee..7bef1cee4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -72,7 +72,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { }) const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({ - workspaceId, folderId: folder.id, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index a35da0616f..480d9d2c5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -10,11 +10,64 @@ import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportFolder') +/** + * Sanitizes a string for use as a path segment in a ZIP file. + */ +function sanitizePathSegment(name: string): string { + return name.replace(/[^a-z0-9-_]/gi, '-') +} + +/** + * Builds a folder path relative to a root folder. + * Returns an empty string if the folder is the root folder itself. + */ +function buildRelativeFolderPath( + folderId: string | null | undefined, + folders: Record, + rootFolderId: string +): string { + if (!folderId || folderId === rootFolderId) return '' + + const path: string[] = [] + let currentId: string | null = folderId + + while (currentId && currentId !== rootFolderId) { + const folder: WorkflowFolder | undefined = folders[currentId] + if (!folder) break + path.unshift(sanitizePathSegment(folder.name)) + currentId = folder.parentId + } + + return path.join('/') +} + +/** + * Collects all subfolders recursively under a root folder. + */ +function collectSubfolders( + rootFolderId: string, + folders: Record +): Array<{ id: string; name: string; parentId: string | null }> { + const subfolders: Array<{ id: string; name: string; parentId: string | null }> = [] + + function collect(parentId: string) { + for (const folder of Object.values(folders)) { + if (folder.parentId === parentId) { + subfolders.push({ + id: folder.id, + name: folder.name, + parentId: folder.parentId === rootFolderId ? null : folder.parentId, + }) + collect(folder.id) + } + } + } + + collect(rootFolderId) + return subfolders +} + interface UseExportFolderProps { - /** - * Current workspace ID - */ - workspaceId: string /** * The folder ID to export */ @@ -25,35 +78,40 @@ interface UseExportFolderProps { onSuccess?: () => void } +interface CollectedWorkflow { + id: string + folderId: string | null +} + /** - * Recursively collects all workflow IDs within a folder and its subfolders. + * Recursively collects all workflows within a folder and its subfolders. * * @param folderId - The folder ID to collect workflows from * @param workflows - All workflows in the workspace * @param folders - All folders in the workspace - * @returns Array of workflow IDs + * @returns Array of workflow objects with id and folderId */ function collectWorkflowsInFolder( folderId: string, workflows: Record, folders: Record -): string[] { - const workflowIds: string[] = [] +): CollectedWorkflow[] { + const collectedWorkflows: CollectedWorkflow[] = [] for (const workflow of Object.values(workflows)) { if (workflow.folderId === folderId) { - workflowIds.push(workflow.id) + collectedWorkflows.push({ id: workflow.id, folderId: workflow.folderId ?? null }) } } for (const folder of Object.values(folders)) { if (folder.parentId === folderId) { - const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders) - workflowIds.push(...childWorkflowIds) + const childWorkflows = collectWorkflowsInFolder(folder.id, workflows, folders) + collectedWorkflows.push(...childWorkflows) } } - return workflowIds + return collectedWorkflows } /** @@ -62,7 +120,7 @@ function collectWorkflowsInFolder( * @param props - Hook configuration * @returns Export folder handlers and state */ -export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) { +export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { const { workflows } = useWorkflowRegistry() const { folders } = useFolderStore() const [isExporting, setIsExporting] = useState(false) @@ -95,7 +153,8 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF } /** - * Export all workflows in the folder (including nested subfolders) to ZIP + * Export all workflows in the folder (including nested subfolders) to ZIP. + * Preserves the nested folder structure within the ZIP file. */ const handleExportFolder = useCallback(async () => { if (isExporting) { @@ -117,42 +176,50 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF return } - const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders) + const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders) - if (workflowIdsToExport.length === 0) { + if (workflowsToExport.length === 0) { logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name }) return } + const subfolders = collectSubfolders(folderId, folderStore.folders) + logger.info('Starting folder export', { folderId, folderName: folder.name, - workflowCount: workflowIdsToExport.length, + workflowCount: workflowsToExport.length, + subfolderCount: subfolders.length, }) - const exportedWorkflows: Array<{ name: string; content: string }> = [] + const exportedWorkflows: Array<{ + name: string + content: string + folderId: string | null + folderPath: string + }> = [] - for (const workflowId of workflowIdsToExport) { + for (const collectedWorkflow of workflowsToExport) { try { - const workflow = workflows[workflowId] + const workflow = workflows[collectedWorkflow.id] if (!workflow) { - logger.warn(`Workflow ${workflowId} not found in registry`) + logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`) continue } - const workflowResponse = await fetch(`/api/workflows/${workflowId}`) + const workflowResponse = await fetch(`/api/workflows/${collectedWorkflow.id}`) if (!workflowResponse.ok) { - logger.error(`Failed to fetch workflow ${workflowId}`) + logger.error(`Failed to fetch workflow ${collectedWorkflow.id}`) continue } const { data: workflowData } = await workflowResponse.json() if (!workflowData?.state) { - logger.warn(`Workflow ${workflowId} has no state`) + logger.warn(`Workflow ${collectedWorkflow.id} has no state`) continue } - const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`) + const variablesResponse = await fetch(`/api/workflows/${collectedWorkflow.id}/variables`) let workflowVariables: Record | undefined if (variablesResponse.ok) { const variablesData = await variablesResponse.json() @@ -173,14 +240,24 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF const exportState = sanitizeForExport(workflowState) const jsonString = JSON.stringify(exportState, null, 2) + const relativeFolderPath = buildRelativeFolderPath( + collectedWorkflow.folderId, + folderStore.folders, + folderId + ) + exportedWorkflows.push({ name: workflow.name, content: jsonString, + folderId: collectedWorkflow.folderId, + folderPath: relativeFolderPath, }) - logger.info(`Workflow ${workflowId} exported successfully`) + logger.info(`Workflow ${collectedWorkflow.id} exported successfully`, { + folderPath: relativeFolderPath || '(root)', + }) } catch (error) { - logger.error(`Failed to export workflow ${workflowId}:`, error) + logger.error(`Failed to export workflow ${collectedWorkflow.id}:`, error) } } @@ -193,22 +270,41 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF } const zip = new JSZip() + + const folderMetadata = { + folder: { + name: folder.name, + exportedAt: new Date().toISOString(), + }, + folders: subfolders, + } + zip.file('_folder.json', JSON.stringify(folderMetadata, null, 2)) + const seenFilenames = new Set() for (const exportedWorkflow of exportedWorkflows) { - const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-') + const baseName = sanitizePathSegment(exportedWorkflow.name) let filename = `${baseName}.json` let counter = 1 - while (seenFilenames.has(filename.toLowerCase())) { + + const fullPath = exportedWorkflow.folderPath + ? `${exportedWorkflow.folderPath}/${filename}` + : filename + + let uniqueFullPath = fullPath + while (seenFilenames.has(uniqueFullPath.toLowerCase())) { filename = `${baseName}-${counter}.json` + uniqueFullPath = exportedWorkflow.folderPath + ? `${exportedWorkflow.folderPath}/${filename}` + : filename counter++ } - seenFilenames.add(filename.toLowerCase()) - zip.file(filename, exportedWorkflow.content) + seenFilenames.add(uniqueFullPath.toLowerCase()) + zip.file(uniqueFullPath, exportedWorkflow.content) } const zipBlob = await zip.generateAsync({ type: 'blob' }) - const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip` + const zipFilename = `${sanitizePathSegment(folder.name)}-export.zip` downloadFile(zipBlob, zipFilename, 'application/zip') const { clearSelection } = useFolderStore.getState() @@ -218,6 +314,7 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF folderId, folderName: folder.name, workflowCount: exportedWorkflows.length, + subfolderCount: subfolders.length, }) onSuccess?.() From 2859f90e62ef426b4547317d84c2846577d20d68 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 13 Jan 2026 12:18:30 -0800 Subject: [PATCH 2/3] consolidated utils, added admin routes --- .../api/v1/admin/folders/[id]/export/route.ts | 247 +++++++++++++++++ apps/sim/app/api/v1/admin/index.ts | 4 + .../v1/admin/workflows/[id]/export/route.ts | 2 +- .../api/v1/admin/workflows/export/route.ts | 147 ++++++++++ .../v1/admin/workspaces/[id]/export/route.ts | 6 +- .../workflow-item/workflow-item.tsx | 2 +- .../w/hooks/use-export-folder.ts | 258 ++++-------------- .../w/hooks/use-export-workflow.ts | 136 +++------ .../w/hooks/use-export-workspace.ts | 70 +---- .../lib/workflows/operations/import-export.ts | 172 +++++++++++- 10 files changed, 678 insertions(+), 366 deletions(-) create mode 100644 apps/sim/app/api/v1/admin/folders/[id]/export/route.ts create mode 100644 apps/sim/app/api/v1/admin/workflows/export/route.ts diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts new file mode 100644 index 0000000000..101d96896e --- /dev/null +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -0,0 +1,247 @@ +/** + * GET /api/v1/admin/folders/[id]/export + * + * Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore). + * + * Query Parameters: + * - format: 'zip' (default) or 'json' + * + * Response: + * - ZIP file download (Content-Type: application/zip) + * - JSON: FolderExportFullPayload + */ + +import { db } from '@sim/db' +import { workflow, workflowFolder } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import { + type FolderExportPayload, + parseWorkflowVariables, + type WorkflowExportState, +} from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminFolderExportAPI') + +interface RouteParams { + id: string +} + +interface CollectedWorkflow { + id: string + folderId: string | null +} + +/** + * Recursively collects all workflows within a folder and its subfolders. + */ +function collectWorkflowsInFolder( + folderId: string, + allWorkflows: Array<{ id: string; folderId: string | null }>, + allFolders: Array<{ id: string; parentId: string | null }> +): CollectedWorkflow[] { + const collected: CollectedWorkflow[] = [] + + for (const wf of allWorkflows) { + if (wf.folderId === folderId) { + collected.push({ id: wf.id, folderId: wf.folderId }) + } + } + + for (const folder of allFolders) { + if (folder.parentId === folderId) { + const childWorkflows = collectWorkflowsInFolder(folder.id, allWorkflows, allFolders) + collected.push(...childWorkflows) + } + } + + return collected +} + +/** + * Collects all subfolders recursively under a root folder. + * Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null. + */ +function collectSubfolders( + rootFolderId: string, + allFolders: Array<{ id: string; name: string; parentId: string | null }> +): FolderExportPayload[] { + const subfolders: FolderExportPayload[] = [] + + function collect(parentId: string) { + for (const folder of allFolders) { + if (folder.parentId === parentId) { + subfolders.push({ + id: folder.id, + name: folder.name, + parentId: folder.parentId === rootFolderId ? null : folder.parentId, + }) + collect(folder.id) + } + } + } + + collect(rootFolderId) + return subfolders +} + +export const GET = withAdminAuthParams(async (request, context) => { + const { id: folderId } = await context.params + const url = new URL(request.url) + const format = url.searchParams.get('format') || 'zip' + + try { + const [folderData] = await db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + workspaceId: workflowFolder.workspaceId, + }) + .from(workflowFolder) + .where(eq(workflowFolder.id, folderId)) + .limit(1) + + if (!folderData) { + return notFoundResponse('Folder') + } + + const allWorkflows = await db + .select({ id: workflow.id, folderId: workflow.folderId }) + .from(workflow) + .where(eq(workflow.workspaceId, folderData.workspaceId)) + + const allFolders = await db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + parentId: workflowFolder.parentId, + }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, folderData.workspaceId)) + + const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders) + const subfolders = collectSubfolders(folderId, allFolders) + + const workflowExports: Array<{ + workflow: { + id: string + name: string + description: string | null + color: string | null + folderId: string | null + } + state: WorkflowExportState + }> = [] + + for (const collectedWf of workflowsInFolder) { + try { + const [wfData] = await db + .select() + .from(workflow) + .where(eq(workflow.id, collectedWf.id)) + .limit(1) + + if (!wfData) { + logger.warn(`Skipping workflow ${collectedWf.id} - not found`) + continue + } + + const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id) + + if (!normalizedData) { + logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`) + continue + } + + const variables = parseWorkflowVariables(wfData.variables) + + const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId + + const state: WorkflowExportState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: wfData.name, + description: wfData.description ?? undefined, + color: wfData.color, + exportedAt: new Date().toISOString(), + }, + variables, + } + + workflowExports.push({ + workflow: { + id: wfData.id, + name: wfData.name, + description: wfData.description, + color: wfData.color, + folderId: remappedFolderId, + }, + state, + }) + } catch (error) { + logger.error(`Failed to load workflow ${collectedWf.id}:`, { error }) + } + } + + logger.info( + `Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders` + ) + + if (format === 'json') { + const exportPayload = { + version: '1.0', + exportedAt: new Date().toISOString(), + folder: { + id: folderData.id, + name: folderData.name, + }, + workflows: workflowExports, + folders: subfolders, + } + + return singleResponse(exportPayload) + } + + const zipWorkflows = workflowExports.map((wf) => ({ + workflow: { + id: wf.workflow.id, + name: wf.workflow.name, + description: wf.workflow.description ?? undefined, + color: wf.workflow.color ?? undefined, + folderId: wf.workflow.folderId, + }, + state: wf.state, + variables: wf.state.variables, + })) + + const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders) + const arrayBuffer = await zipBlob.arrayBuffer() + + const sanitizedName = sanitizePathSegment(folderData.name) + const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` + + return new NextResponse(arrayBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': arrayBuffer.byteLength.toString(), + }, + }) + } catch (error) { + logger.error('Admin API: Failed to export folder', { error, folderId }) + return internalErrorResponse('Failed to export folder') + } +}) diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index e76bece6eb..720c897d82 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -34,12 +34,16 @@ * GET /api/v1/admin/workflows/:id - Get workflow details * DELETE /api/v1/admin/workflows/:id - Delete workflow * GET /api/v1/admin/workflows/:id/export - Export workflow (JSON) + * POST /api/v1/admin/workflows/export - Export multiple workflows (ZIP/JSON) * POST /api/v1/admin/workflows/import - Import single workflow * POST /api/v1/admin/workflows/:id/deploy - Deploy workflow * DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow * GET /api/v1/admin/workflows/:id/versions - List deployment versions * POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version * + * Folders: + * GET /api/v1/admin/folders/:id/export - Export folder with contents (ZIP/JSON) + * * Organizations: * GET /api/v1/admin/organizations - List all organizations * POST /api/v1/admin/organizations - Create organization (requires ownerId) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts index 3570cc9f31..565467444b 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts @@ -1,7 +1,7 @@ /** * GET /api/v1/admin/workflows/[id]/export * - * Export a single workflow as JSON. + * Export a single workflow as JSON (raw, unsanitized for admin backup/restore). * * Response: AdminSingleResponse */ diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts new file mode 100644 index 0000000000..d7cc28babd --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -0,0 +1,147 @@ +/** + * POST /api/v1/admin/workflows/export + * + * Export multiple workflows as a ZIP file or JSON array (raw, unsanitized for admin backup/restore). + * + * Request Body: + * - ids: string[] - Array of workflow IDs to export + * + * Query Parameters: + * - format: 'zip' (default) or 'json' + * + * Response: + * - ZIP file download (Content-Type: application/zip) - each workflow as JSON in root + * - JSON: AdminListResponse + */ + +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { inArray } from 'drizzle-orm' +import JSZip from 'jszip' +import { NextResponse } from 'next/server' +import { sanitizePathSegment } from '@/lib/workflows/operations/import-export' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + listResponse, +} from '@/app/api/v1/admin/responses' +import { + parseWorkflowVariables, + type WorkflowExportPayload, + type WorkflowExportState, +} from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminWorkflowsExportAPI') + +interface ExportRequest { + ids: string[] +} + +export const POST = withAdminAuth(async (request) => { + const url = new URL(request.url) + const format = url.searchParams.get('format') || 'zip' + + let body: ExportRequest + try { + body = await request.json() + } catch { + return badRequestResponse('Invalid JSON body') + } + + if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) { + return badRequestResponse('ids must be a non-empty array of workflow IDs') + } + + try { + const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids)) + + if (workflows.length === 0) { + return badRequestResponse('No workflows found with the provided IDs') + } + + const workflowExports: WorkflowExportPayload[] = [] + + for (const wf of workflows) { + try { + const normalizedData = await loadWorkflowFromNormalizedTables(wf.id) + + if (!normalizedData) { + logger.warn(`Skipping workflow ${wf.id} - no normalized data found`) + continue + } + + const variables = parseWorkflowVariables(wf.variables) + + const state: WorkflowExportState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: wf.name, + description: wf.description ?? undefined, + color: wf.color, + exportedAt: new Date().toISOString(), + }, + variables, + } + + const exportPayload: WorkflowExportPayload = { + version: '1.0', + exportedAt: new Date().toISOString(), + workflow: { + id: wf.id, + name: wf.name, + description: wf.description, + color: wf.color, + workspaceId: wf.workspaceId, + folderId: wf.folderId, + }, + state, + } + + workflowExports.push(exportPayload) + } catch (error) { + logger.error(`Failed to load workflow ${wf.id}:`, { error }) + } + } + + logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) + + if (format === 'json') { + return listResponse(workflowExports, { + total: workflowExports.length, + limit: workflowExports.length, + offset: 0, + hasMore: false, + }) + } + + const zip = new JSZip() + + for (const exportPayload of workflowExports) { + const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json` + zip.file(filename, JSON.stringify(exportPayload, null, 2)) + } + + const zipBlob = await zip.generateAsync({ type: 'blob' }) + const arrayBuffer = await zipBlob.arrayBuffer() + + const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip` + + return new NextResponse(arrayBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': arrayBuffer.byteLength.toString(), + }, + }) + } catch (error) { + logger.error('Admin API: Failed to export workflows', { error, ids: body.ids }) + return internalErrorResponse('Failed to export workflows') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index f7e60502ad..6cd9055630 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -1,7 +1,7 @@ /** * GET /api/v1/admin/workspaces/[id]/export * - * Export an entire workspace as a ZIP file or JSON. + * Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore). * * Query Parameters: * - format: 'zip' (default) or 'json' @@ -16,7 +16,7 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export' +import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -146,7 +146,7 @@ export const GET = withAdminAuthParams(async (request, context) => const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports) const arrayBuffer = await zipBlob.arrayBuffer() - const sanitizedName = workspaceData.name.replace(/[^a-z0-9-_]/gi, '-') + const sanitizedName = sanitizePathSegment(workspaceData.name) const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` return new NextResponse(arrayBuffer, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index eaad0371a4..ec12c8b294 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -80,7 +80,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf const { handleDuplicateWorkflow: duplicateWorkflow } = useDuplicateWorkflow({ workspaceId }) - const { handleExportWorkflow: exportWorkflow } = useExportWorkflow({ workspaceId }) + const { handleExportWorkflow: exportWorkflow } = useExportWorkflow() const handleDuplicateWorkflow = useCallback(() => { const workflowIds = capturedSelectionRef.current?.workflowIds || [] if (workflowIds.length === 0) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index 480d9d2c5c..83d4f7d234 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -1,72 +1,20 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import JSZip from 'jszip' -import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { + downloadFile, + exportFolderToZip, + type FolderExportData, + fetchWorkflowForExport, + sanitizePathSegment, + type WorkflowExportData, +} from '@/lib/workflows/operations/import-export' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportFolder') -/** - * Sanitizes a string for use as a path segment in a ZIP file. - */ -function sanitizePathSegment(name: string): string { - return name.replace(/[^a-z0-9-_]/gi, '-') -} - -/** - * Builds a folder path relative to a root folder. - * Returns an empty string if the folder is the root folder itself. - */ -function buildRelativeFolderPath( - folderId: string | null | undefined, - folders: Record, - rootFolderId: string -): string { - if (!folderId || folderId === rootFolderId) return '' - - const path: string[] = [] - let currentId: string | null = folderId - - while (currentId && currentId !== rootFolderId) { - const folder: WorkflowFolder | undefined = folders[currentId] - if (!folder) break - path.unshift(sanitizePathSegment(folder.name)) - currentId = folder.parentId - } - - return path.join('/') -} - -/** - * Collects all subfolders recursively under a root folder. - */ -function collectSubfolders( - rootFolderId: string, - folders: Record -): Array<{ id: string; name: string; parentId: string | null }> { - const subfolders: Array<{ id: string; name: string; parentId: string | null }> = [] - - function collect(parentId: string) { - for (const folder of Object.values(folders)) { - if (folder.parentId === parentId) { - subfolders.push({ - id: folder.id, - name: folder.name, - parentId: folder.parentId === rootFolderId ? null : folder.parentId, - }) - collect(folder.id) - } - } - } - - collect(rootFolderId) - return subfolders -} - interface UseExportFolderProps { /** * The folder ID to export @@ -85,11 +33,6 @@ interface CollectedWorkflow { /** * Recursively collects all workflows within a folder and its subfolders. - * - * @param folderId - The folder ID to collect workflows from - * @param workflows - All workflows in the workspace - * @param folders - All folders in the workspace - * @returns Array of workflow objects with id and folderId */ function collectWorkflowsInFolder( folderId: string, @@ -114,55 +57,49 @@ function collectWorkflowsInFolder( return collectedWorkflows } +/** + * Collects all subfolders recursively under a root folder. + * Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null. + */ +function collectSubfolders( + rootFolderId: string, + folders: Record +): FolderExportData[] { + const subfolders: FolderExportData[] = [] + + function collect(parentId: string) { + for (const folder of Object.values(folders)) { + if (folder.parentId === parentId) { + subfolders.push({ + id: folder.id, + name: folder.name, + // Direct children of root become top-level in export (parentId: null) + parentId: folder.parentId === rootFolderId ? null : folder.parentId, + }) + collect(folder.id) + } + } + } + + collect(rootFolderId) + return subfolders +} + /** * Hook for managing folder export to ZIP. - * - * @param props - Hook configuration - * @returns Export folder handlers and state */ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { const { workflows } = useWorkflowRegistry() const { folders } = useFolderStore() const [isExporting, setIsExporting] = useState(false) - /** - * Check if the folder has any workflows (recursively) - */ const hasWorkflows = useMemo(() => { if (!folderId) return false return collectWorkflowsInFolder(folderId, workflows, folders).length > 0 }, [folderId, workflows, folders]) - /** - * Download file helper - */ - const downloadFile = (content: Blob, filename: string, mimeType = 'application/zip') => { - 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) - } - } - - /** - * Export all workflows in the folder (including nested subfolders) to ZIP. - * Preserves the nested folder structure within the ZIP file. - */ const handleExportFolder = useCallback(async () => { - if (isExporting) { - return - } - - if (!folderId) { - logger.warn('No folder ID provided for export') + if (isExporting || !folderId) { return } @@ -192,118 +129,41 @@ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { subfolderCount: subfolders.length, }) - const exportedWorkflows: Array<{ - name: string - content: string - folderId: string | null - folderPath: string - }> = [] + const workflowExportData: WorkflowExportData[] = [] for (const collectedWorkflow of workflowsToExport) { - try { - const workflow = workflows[collectedWorkflow.id] - if (!workflow) { - logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`) - continue - } - - const workflowResponse = await fetch(`/api/workflows/${collectedWorkflow.id}`) - if (!workflowResponse.ok) { - logger.error(`Failed to fetch workflow ${collectedWorkflow.id}`) - continue - } - - const { data: workflowData } = await workflowResponse.json() - if (!workflowData?.state) { - logger.warn(`Workflow ${collectedWorkflow.id} has no state`) - continue - } - - const variablesResponse = await fetch(`/api/workflows/${collectedWorkflow.id}/variables`) - let workflowVariables: Record | undefined - if (variablesResponse.ok) { - const variablesData = await variablesResponse.json() - workflowVariables = variablesData?.data - } - - const workflowState = { - ...workflowData.state, - metadata: { - name: workflow.name, - description: workflow.description, - color: workflow.color, - exportedAt: new Date().toISOString(), - }, - variables: workflowVariables, - } - - const exportState = sanitizeForExport(workflowState) - const jsonString = JSON.stringify(exportState, null, 2) + const workflowMeta = workflows[collectedWorkflow.id] + if (!workflowMeta) { + logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`) + continue + } - const relativeFolderPath = buildRelativeFolderPath( - collectedWorkflow.folderId, - folderStore.folders, - folderId - ) + // Remap folderId: if workflow is in root folder, set to null; otherwise keep original + const remappedFolderId = + collectedWorkflow.folderId === folderId ? null : collectedWorkflow.folderId - exportedWorkflows.push({ - name: workflow.name, - content: jsonString, - folderId: collectedWorkflow.folderId, - folderPath: relativeFolderPath, - }) + const exportData = await fetchWorkflowForExport(collectedWorkflow.id, { + name: workflowMeta.name, + description: workflowMeta.description, + color: workflowMeta.color, + folderId: remappedFolderId, + }) - logger.info(`Workflow ${collectedWorkflow.id} exported successfully`, { - folderPath: relativeFolderPath || '(root)', - }) - } catch (error) { - logger.error(`Failed to export workflow ${collectedWorkflow.id}:`, error) + if (exportData) { + workflowExportData.push(exportData) + logger.info(`Workflow ${collectedWorkflow.id} prepared for export`) } } - if (exportedWorkflows.length === 0) { - logger.warn('No workflows were successfully exported from folder', { + if (workflowExportData.length === 0) { + logger.warn('No workflows were successfully prepared for export', { folderId, folderName: folder.name, }) return } - const zip = new JSZip() - - const folderMetadata = { - folder: { - name: folder.name, - exportedAt: new Date().toISOString(), - }, - folders: subfolders, - } - zip.file('_folder.json', JSON.stringify(folderMetadata, null, 2)) - - const seenFilenames = new Set() - - for (const exportedWorkflow of exportedWorkflows) { - const baseName = sanitizePathSegment(exportedWorkflow.name) - let filename = `${baseName}.json` - let counter = 1 - - const fullPath = exportedWorkflow.folderPath - ? `${exportedWorkflow.folderPath}/${filename}` - : filename - - let uniqueFullPath = fullPath - while (seenFilenames.has(uniqueFullPath.toLowerCase())) { - filename = `${baseName}-${counter}.json` - uniqueFullPath = exportedWorkflow.folderPath - ? `${exportedWorkflow.folderPath}/${filename}` - : filename - counter++ - } - seenFilenames.add(uniqueFullPath.toLowerCase()) - zip.file(uniqueFullPath, exportedWorkflow.content) - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }) + const zipBlob = await exportFolderToZip(folder.name, workflowExportData, subfolders) const zipFilename = `${sanitizePathSegment(folder.name)}-export.zip` downloadFile(zipBlob, zipFilename, 'application/zip') @@ -313,7 +173,7 @@ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { logger.info('Folder exported successfully', { folderId, folderName: folder.name, - workflowCount: exportedWorkflows.length, + workflowCount: workflowExportData.length, subfolderCount: subfolders.length, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index dbfa1b8524..e3fa6507ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -1,18 +1,18 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' -import JSZip from 'jszip' -import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { + downloadFile, + exportWorkflowsToZip, + exportWorkflowToJson, + fetchWorkflowForExport, + sanitizePathSegment, +} from '@/lib/workflows/operations/import-export' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportWorkflow') interface UseExportWorkflowProps { - /** - * Current workspace ID - */ - workspaceId: string /** * Optional callback after successful export */ @@ -20,44 +20,16 @@ interface UseExportWorkflowProps { } /** - * Hook for managing workflow export to JSON. - * - * @param props - Hook configuration - * @returns Export workflow handlers and state + * Hook for managing workflow export to JSON or ZIP. */ -export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) { +export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { const { workflows } = useWorkflowRegistry() const [isExporting, setIsExporting] = useState(false) - /** - * Download file helper - */ - const downloadFile = ( - content: Blob | string, - filename: string, - mimeType = 'application/json' - ) => { - 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) - } - } - /** * Export the workflow(s) to JSON or ZIP * - Single workflow: exports as JSON file * - Multiple workflows: exports as ZIP file containing all JSON files - * Fetches workflow data from API to support bulk export of non-active workflows - * @param workflowIds - The workflow ID(s) to export */ const handleExportWorkflow = useCallback( async (workflowIds: string | string[]) => { @@ -78,85 +50,39 @@ export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowP count: workflowIdsToExport.length, }) - const exportedWorkflows: Array<{ name: string; content: string }> = [] + const exportedWorkflows = [] for (const workflowId of workflowIdsToExport) { - try { - const workflow = workflows[workflowId] - if (!workflow) { - logger.warn(`Workflow ${workflowId} not found in registry`) - continue - } - - const workflowResponse = await fetch(`/api/workflows/${workflowId}`) - if (!workflowResponse.ok) { - logger.error(`Failed to fetch workflow ${workflowId}`) - continue - } - - const { data: workflowData } = await workflowResponse.json() - if (!workflowData?.state) { - logger.warn(`Workflow ${workflowId} has no state`) - continue - } - - const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`) - let workflowVariables: Record | undefined - if (variablesResponse.ok) { - const variablesData = await variablesResponse.json() - workflowVariables = variablesData?.data - } - - const workflowState = { - ...workflowData.state, - metadata: { - name: workflow.name, - description: workflow.description, - color: workflow.color, - exportedAt: new Date().toISOString(), - }, - variables: workflowVariables, - } - - const exportState = sanitizeForExport(workflowState) - const jsonString = JSON.stringify(exportState, null, 2) - - exportedWorkflows.push({ - name: workflow.name, - content: jsonString, - }) - - logger.info(`Workflow ${workflowId} exported successfully`) - } catch (error) { - logger.error(`Failed to export workflow ${workflowId}:`, error) + const workflowMeta = workflows[workflowId] + if (!workflowMeta) { + logger.warn(`Workflow ${workflowId} not found in registry`) + continue + } + + const exportData = await fetchWorkflowForExport(workflowId, { + name: workflowMeta.name, + description: workflowMeta.description, + color: workflowMeta.color, + folderId: workflowMeta.folderId, + }) + + if (exportData) { + exportedWorkflows.push(exportData) + logger.info(`Workflow ${workflowId} prepared for export`) } } if (exportedWorkflows.length === 0) { - logger.warn('No workflows were successfully exported') + logger.warn('No workflows were successfully prepared for export') return } if (exportedWorkflows.length === 1) { - const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json` - downloadFile(exportedWorkflows[0].content, filename, 'application/json') + const jsonContent = exportWorkflowToJson(exportedWorkflows[0]) + const filename = `${sanitizePathSegment(exportedWorkflows[0].workflow.name)}.json` + downloadFile(jsonContent, filename, 'application/json') } else { - const zip = new JSZip() - const seenFilenames = new Set() - - for (const exportedWorkflow of exportedWorkflows) { - const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-') - let filename = `${baseName}.json` - let counter = 1 - while (seenFilenames.has(filename.toLowerCase())) { - filename = `${baseName}-${counter}.json` - counter++ - } - seenFilenames.add(filename.toLowerCase()) - zip.file(filename, exportedWorkflow.content) - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }) + const zipBlob = await exportWorkflowsToZip(exportedWorkflows) const zipFilename = `workflows-export-${Date.now()}.zip` downloadFile(zipBlob, zipFilename, 'application/zip') } diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts index af663c92d0..1f855d99c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts @@ -1,11 +1,13 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { + downloadFile, exportWorkspaceToZip, type FolderExportData, + fetchWorkflowForExport, + sanitizePathSegment, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' -import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportWorkspace') @@ -18,24 +20,10 @@ interface UseExportWorkspaceProps { /** * Hook for managing workspace export to ZIP. - * - * Handles: - * - Fetching all workflows and folders from workspace - * - Fetching workflow states and variables - * - Creating ZIP file with all workspace data - * - Downloading the ZIP file - * - Loading state management - * - Error handling and logging - * - * @param props - Hook configuration - * @returns Export workspace handlers and state */ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) { const [isExporting, setIsExporting] = useState(false) - /** - * Export workspace to ZIP file - */ const handleExportWorkspace = useCallback( async (workspaceId: string, workspaceName: string) => { if (isExporting) return @@ -59,39 +47,15 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) const workflowsToExport: WorkflowExportData[] = [] for (const workflow of workflows) { - try { - const workflowResponse = await fetch(`/api/workflows/${workflow.id}`) - if (!workflowResponse.ok) { - logger.warn(`Failed to fetch workflow ${workflow.id}`) - continue - } - - const { data: workflowData } = await workflowResponse.json() - if (!workflowData?.state) { - logger.warn(`Workflow ${workflow.id} has no state`) - continue - } - - const variablesResponse = await fetch(`/api/workflows/${workflow.id}/variables`) - let workflowVariables: Record | undefined - if (variablesResponse.ok) { - const variablesData = await variablesResponse.json() - workflowVariables = variablesData?.data - } - - workflowsToExport.push({ - workflow: { - id: workflow.id, - name: workflow.name, - description: workflow.description, - color: workflow.color, - folderId: workflow.folderId, - }, - state: workflowData.state, - variables: workflowVariables, - }) - } catch (error) { - logger.error(`Failed to export workflow ${workflow.id}:`, error) + const exportData = await fetchWorkflowForExport(workflow.id, { + name: workflow.name, + description: workflow.description, + color: workflow.color, + folderId: workflow.folderId, + }) + + if (exportData) { + workflowsToExport.push(exportData) } } @@ -109,14 +73,8 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) foldersToExport ) - const blobUrl = URL.createObjectURL(zipBlob) - const a = document.createElement('a') - a.href = blobUrl - a.download = `${workspaceName.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.zip` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(blobUrl) + const zipFilename = `${sanitizePathSegment(workspaceName)}-${Date.now()}.zip` + downloadFile(zipBlob, zipFilename, 'application/zip') logger.info('Workspace exported successfully', { workspaceId, diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index bfb96b63e8..d2fb95628c 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -36,10 +36,125 @@ export interface WorkspaceExportStructure { folders: FolderExportData[] } -function sanitizePathSegment(name: string): string { +/** + * Sanitizes a string for use as a path segment in a ZIP file. + */ +export function sanitizePathSegment(name: string): string { return name.replace(/[^a-z0-9-_]/gi, '-') } +/** + * 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; color?: string; folderId?: string | null } +): Promise { + try { + const workflowResponse = await fetch(`/api/workflows/${workflowId}`) + if (!workflowResponse.ok) { + logger.error(`Failed to fetch workflow ${workflowId}`) + return null + } + + const { data: workflowData } = await workflowResponse.json() + if (!workflowData?.state) { + logger.warn(`Workflow ${workflowId} has no state`) + return null + } + + const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`) + let workflowVariables: Record | undefined + if (variablesResponse.ok) { + const variablesData = await variablesResponse.json() + workflowVariables = variablesData?.data + } + + return { + workflow: { + id: workflowId, + name: workflowMeta.name, + description: workflowMeta.description, + color: workflowMeta.color, + 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, + color: workflowData.workflow.color, + 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 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 @@ -105,6 +220,61 @@ export async function exportWorkspaceToZip( 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 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, + color: workflow.workflow.color, + 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 From 3f68a22df87af86eb763b579c648c636d34cfb0c Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 13 Jan 2026 12:25:57 -0800 Subject: [PATCH 3/3] remove default tags from A2A --- .../deploy/components/deploy-modal/components/a2a/a2a.tsx | 8 ++++---- .../workspace/[workspaceId]/w/hooks/use-export-folder.ts | 1 - apps/sim/lib/a2a/agent-card.ts | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx index f72c96bc00..38e59dcbcb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx @@ -201,7 +201,7 @@ export function A2aDeploy({ const [description, setDescription] = useState('') const [authScheme, setAuthScheme] = useState('apiKey') const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false) - const [skillTags, setSkillTags] = useState(['workflow', 'automation']) + const [skillTags, setSkillTags] = useState([]) const [language, setLanguage] = useState('curl') const [useStreamingExample, setUseStreamingExample] = useState(false) const [copied, setCopied] = useState(false) @@ -220,7 +220,7 @@ export function A2aDeploy({ } const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined const savedTags = skills?.[0]?.tags - setSkillTags(savedTags?.length ? savedTags : ['workflow', 'automation']) + setSkillTags(savedTags?.length ? savedTags : []) } else { setName(workflowName) setDescription( @@ -228,7 +228,7 @@ export function A2aDeploy({ ) setAuthScheme('apiKey') setPushNotificationsEnabled(false) - setSkillTags(['workflow', 'automation']) + setSkillTags([]) } }, [existingAgent, workflowName, workflowDescription]) @@ -247,7 +247,7 @@ export function A2aDeploy({ const savedDesc = existingAgent.description || '' const normalizedSavedDesc = isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined - const savedTags = skills?.[0]?.tags || ['workflow', 'automation'] + const savedTags = skills?.[0]?.tags || [] const tagsChanged = skillTags.length !== savedTags.length || skillTags.some((t, i) => t !== savedTags[i]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index 83d4f7d234..402b13535f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -138,7 +138,6 @@ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { continue } - // Remap folderId: if workflow is in root folder, set to null; otherwise keep original const remappedFolderId = collectedWorkflow.folderId === folderId ? null : collectedWorkflow.folderId diff --git a/apps/sim/lib/a2a/agent-card.ts b/apps/sim/lib/a2a/agent-card.ts index 6be2654ca1..b80fd6476f 100644 --- a/apps/sim/lib/a2a/agent-card.ts +++ b/apps/sim/lib/a2a/agent-card.ts @@ -63,7 +63,7 @@ export function generateAgentCard(agent: AgentData, workflow: WorkflowData): App id: 'execute', name: `Execute ${workflow.name}`, description: workflow.description || `Execute the ${workflow.name} workflow`, - tags: ['workflow', 'automation'], + tags: [], }, ], defaultInputModes: [...A2A_DEFAULT_INPUT_MODES], @@ -80,7 +80,7 @@ export function generateSkillsFromWorkflow( id: 'execute', name: `Execute ${workflowName}`, description: workflowDescription || `Execute the ${workflowName} workflow`, - tags: tags?.length ? tags : ['workflow', 'automation'], + tags: tags || [], } return [skill]