feat(export): support maintenance of nested folder structure on import/export, added folder export admin route (#2795)

* feat(export): support maintenance of nested folder structure on import/export

* consolidated utils, added admin routes

* remove default tags from A2A
This commit is contained in:
Waleed
2026-01-13 12:26:41 -08:00
committed by GitHub
parent 837405e1ec
commit b49ed2fcd9
13 changed files with 702 additions and 295 deletions

View File

@@ -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<RouteParams>(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')
}
})

View File

@@ -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)

View File

@@ -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<WorkflowExportPayload>
*/

View File

@@ -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<WorkflowExportPayload[]>
*/
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')
}
})

View File

@@ -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<RouteParams>(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, {

View File

@@ -201,7 +201,7 @@ export function A2aDeploy({
const [description, setDescription] = useState('')
const [authScheme, setAuthScheme] = useState<AuthScheme>('apiKey')
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false)
const [skillTags, setSkillTags] = useState<string[]>(['workflow', 'automation'])
const [skillTags, setSkillTags] = useState<string[]>([])
const [language, setLanguage] = useState<CodeLanguage>('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 (

View File

@@ -72,7 +72,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
})
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
workspaceId,
folderId: folder.id,
})

View File

@@ -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

View File

@@ -1,20 +1,21 @@
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')
interface UseExportFolderProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* The folder ID to export
*/
@@ -25,85 +26,80 @@ interface UseExportFolderProps {
onSuccess?: () => void
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflow IDs 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
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): 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
}
/**
* 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<string, WorkflowFolder>
): 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({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
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
*/
const handleExportFolder = useCallback(async () => {
if (isExporting) {
return
}
if (!folderId) {
logger.warn('No folder ID provided for export')
if (isExporting || !folderId) {
return
}
@@ -117,98 +113,57 @@ 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 workflowExportData: WorkflowExportData[] = []
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
for (const collectedWorkflow of workflowsToExport) {
const workflowMeta = workflows[collectedWorkflow.id]
if (!workflowMeta) {
logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`)
continue
}
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const remappedFolderId =
collectedWorkflow.folderId === folderId ? null : collectedWorkflow.folderId
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
const exportData = await fetchWorkflowForExport(collectedWorkflow.id, {
name: workflowMeta.name,
description: workflowMeta.description,
color: workflowMeta.color,
folderId: remappedFolderId,
})
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | 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)
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 seenFilenames = new Set<string>()
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 zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
const zipBlob = await exportFolderToZip(folder.name, workflowExportData, subfolders)
const zipFilename = `${sanitizePathSegment(folder.name)}-export.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const { clearSelection } = useFolderStore.getState()
@@ -217,7 +172,8 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
logger.info('Folder exported successfully', {
folderId,
folderName: folder.name,
workflowCount: exportedWorkflows.length,
workflowCount: workflowExportData.length,
subfolderCount: subfolders.length,
})
onSuccess?.()

View File

@@ -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 workflowMeta = workflows[workflowId]
if (!workflowMeta) {
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 exportData = await fetchWorkflowForExport(workflowId, {
name: workflowMeta.name,
description: workflowMeta.description,
color: workflowMeta.color,
folderId: workflowMeta.folderId,
})
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<string, Variable> | 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)
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<string>()
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')
}

View File

@@ -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 exportData = await fetchWorkflowForExport(workflow.id, {
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
})
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<string, Variable> | 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)
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,

View File

@@ -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]

View File

@@ -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<WorkflowExportData | null> {
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<string, Variable> | 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<Blob> {
const zip = new JSZip()
const seenFilenames = new Set<string>()
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, FolderExportData>
@@ -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<Blob> {
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