mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
247
apps/sim/app/api/v1/admin/folders/[id]/export/route.ts
Normal file
247
apps/sim/app/api/v1/admin/folders/[id]/export/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
147
apps/sim/app/api/v1/admin/workflows/export/route.ts
Normal file
147
apps/sim/app/api/v1/admin/workflows/export/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -72,7 +72,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
})
|
||||
|
||||
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
|
||||
workspaceId,
|
||||
folderId: folder.id,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user