Skip to content

Commit 6d40363

Browse files
committed
feat(export): support maintenance of nested folder structure on import/export
1 parent 837405e commit 6d40363

File tree

2 files changed

+129
-33
lines changed

2 files changed

+129
-33
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
7272
})
7373

7474
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
75-
workspaceId,
7675
folderId: folder.id,
7776
})
7877

apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts

Lines changed: 129 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,64 @@ import type { Variable } from '@/stores/workflows/workflow/types'
1010

1111
const logger = createLogger('useExportFolder')
1212

13+
/**
14+
* Sanitizes a string for use as a path segment in a ZIP file.
15+
*/
16+
function sanitizePathSegment(name: string): string {
17+
return name.replace(/[^a-z0-9-_]/gi, '-')
18+
}
19+
20+
/**
21+
* Builds a folder path relative to a root folder.
22+
* Returns an empty string if the folder is the root folder itself.
23+
*/
24+
function buildRelativeFolderPath(
25+
folderId: string | null | undefined,
26+
folders: Record<string, WorkflowFolder>,
27+
rootFolderId: string
28+
): string {
29+
if (!folderId || folderId === rootFolderId) return ''
30+
31+
const path: string[] = []
32+
let currentId: string | null = folderId
33+
34+
while (currentId && currentId !== rootFolderId) {
35+
const folder: WorkflowFolder | undefined = folders[currentId]
36+
if (!folder) break
37+
path.unshift(sanitizePathSegment(folder.name))
38+
currentId = folder.parentId
39+
}
40+
41+
return path.join('/')
42+
}
43+
44+
/**
45+
* Collects all subfolders recursively under a root folder.
46+
*/
47+
function collectSubfolders(
48+
rootFolderId: string,
49+
folders: Record<string, WorkflowFolder>
50+
): Array<{ id: string; name: string; parentId: string | null }> {
51+
const subfolders: Array<{ id: string; name: string; parentId: string | null }> = []
52+
53+
function collect(parentId: string) {
54+
for (const folder of Object.values(folders)) {
55+
if (folder.parentId === parentId) {
56+
subfolders.push({
57+
id: folder.id,
58+
name: folder.name,
59+
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
60+
})
61+
collect(folder.id)
62+
}
63+
}
64+
}
65+
66+
collect(rootFolderId)
67+
return subfolders
68+
}
69+
1370
interface UseExportFolderProps {
14-
/**
15-
* Current workspace ID
16-
*/
17-
workspaceId: string
1871
/**
1972
* The folder ID to export
2073
*/
@@ -25,35 +78,40 @@ interface UseExportFolderProps {
2578
onSuccess?: () => void
2679
}
2780

81+
interface CollectedWorkflow {
82+
id: string
83+
folderId: string | null
84+
}
85+
2886
/**
29-
* Recursively collects all workflow IDs within a folder and its subfolders.
87+
* Recursively collects all workflows within a folder and its subfolders.
3088
*
3189
* @param folderId - The folder ID to collect workflows from
3290
* @param workflows - All workflows in the workspace
3391
* @param folders - All folders in the workspace
34-
* @returns Array of workflow IDs
92+
* @returns Array of workflow objects with id and folderId
3593
*/
3694
function collectWorkflowsInFolder(
3795
folderId: string,
3896
workflows: Record<string, WorkflowMetadata>,
3997
folders: Record<string, WorkflowFolder>
40-
): string[] {
41-
const workflowIds: string[] = []
98+
): CollectedWorkflow[] {
99+
const collectedWorkflows: CollectedWorkflow[] = []
42100

43101
for (const workflow of Object.values(workflows)) {
44102
if (workflow.folderId === folderId) {
45-
workflowIds.push(workflow.id)
103+
collectedWorkflows.push({ id: workflow.id, folderId: workflow.folderId ?? null })
46104
}
47105
}
48106

49107
for (const folder of Object.values(folders)) {
50108
if (folder.parentId === folderId) {
51-
const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders)
52-
workflowIds.push(...childWorkflowIds)
109+
const childWorkflows = collectWorkflowsInFolder(folder.id, workflows, folders)
110+
collectedWorkflows.push(...childWorkflows)
53111
}
54112
}
55113

56-
return workflowIds
114+
return collectedWorkflows
57115
}
58116

59117
/**
@@ -62,7 +120,7 @@ function collectWorkflowsInFolder(
62120
* @param props - Hook configuration
63121
* @returns Export folder handlers and state
64122
*/
65-
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
123+
export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) {
66124
const { workflows } = useWorkflowRegistry()
67125
const { folders } = useFolderStore()
68126
const [isExporting, setIsExporting] = useState(false)
@@ -95,7 +153,8 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
95153
}
96154

97155
/**
98-
* Export all workflows in the folder (including nested subfolders) to ZIP
156+
* Export all workflows in the folder (including nested subfolders) to ZIP.
157+
* Preserves the nested folder structure within the ZIP file.
99158
*/
100159
const handleExportFolder = useCallback(async () => {
101160
if (isExporting) {
@@ -117,42 +176,50 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
117176
return
118177
}
119178

120-
const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
179+
const workflowsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
121180

122-
if (workflowIdsToExport.length === 0) {
181+
if (workflowsToExport.length === 0) {
123182
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
124183
return
125184
}
126185

186+
const subfolders = collectSubfolders(folderId, folderStore.folders)
187+
127188
logger.info('Starting folder export', {
128189
folderId,
129190
folderName: folder.name,
130-
workflowCount: workflowIdsToExport.length,
191+
workflowCount: workflowsToExport.length,
192+
subfolderCount: subfolders.length,
131193
})
132194

133-
const exportedWorkflows: Array<{ name: string; content: string }> = []
195+
const exportedWorkflows: Array<{
196+
name: string
197+
content: string
198+
folderId: string | null
199+
folderPath: string
200+
}> = []
134201

135-
for (const workflowId of workflowIdsToExport) {
202+
for (const collectedWorkflow of workflowsToExport) {
136203
try {
137-
const workflow = workflows[workflowId]
204+
const workflow = workflows[collectedWorkflow.id]
138205
if (!workflow) {
139-
logger.warn(`Workflow ${workflowId} not found in registry`)
206+
logger.warn(`Workflow ${collectedWorkflow.id} not found in registry`)
140207
continue
141208
}
142209

143-
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
210+
const workflowResponse = await fetch(`/api/workflows/${collectedWorkflow.id}`)
144211
if (!workflowResponse.ok) {
145-
logger.error(`Failed to fetch workflow ${workflowId}`)
212+
logger.error(`Failed to fetch workflow ${collectedWorkflow.id}`)
146213
continue
147214
}
148215

149216
const { data: workflowData } = await workflowResponse.json()
150217
if (!workflowData?.state) {
151-
logger.warn(`Workflow ${workflowId} has no state`)
218+
logger.warn(`Workflow ${collectedWorkflow.id} has no state`)
152219
continue
153220
}
154221

155-
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
222+
const variablesResponse = await fetch(`/api/workflows/${collectedWorkflow.id}/variables`)
156223
let workflowVariables: Record<string, Variable> | undefined
157224
if (variablesResponse.ok) {
158225
const variablesData = await variablesResponse.json()
@@ -173,14 +240,24 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
173240
const exportState = sanitizeForExport(workflowState)
174241
const jsonString = JSON.stringify(exportState, null, 2)
175242

243+
const relativeFolderPath = buildRelativeFolderPath(
244+
collectedWorkflow.folderId,
245+
folderStore.folders,
246+
folderId
247+
)
248+
176249
exportedWorkflows.push({
177250
name: workflow.name,
178251
content: jsonString,
252+
folderId: collectedWorkflow.folderId,
253+
folderPath: relativeFolderPath,
179254
})
180255

181-
logger.info(`Workflow ${workflowId} exported successfully`)
256+
logger.info(`Workflow ${collectedWorkflow.id} exported successfully`, {
257+
folderPath: relativeFolderPath || '(root)',
258+
})
182259
} catch (error) {
183-
logger.error(`Failed to export workflow ${workflowId}:`, error)
260+
logger.error(`Failed to export workflow ${collectedWorkflow.id}:`, error)
184261
}
185262
}
186263

@@ -193,22 +270,41 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
193270
}
194271

195272
const zip = new JSZip()
273+
274+
const folderMetadata = {
275+
folder: {
276+
name: folder.name,
277+
exportedAt: new Date().toISOString(),
278+
},
279+
folders: subfolders,
280+
}
281+
zip.file('_folder.json', JSON.stringify(folderMetadata, null, 2))
282+
196283
const seenFilenames = new Set<string>()
197284

198285
for (const exportedWorkflow of exportedWorkflows) {
199-
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
286+
const baseName = sanitizePathSegment(exportedWorkflow.name)
200287
let filename = `${baseName}.json`
201288
let counter = 1
202-
while (seenFilenames.has(filename.toLowerCase())) {
289+
290+
const fullPath = exportedWorkflow.folderPath
291+
? `${exportedWorkflow.folderPath}/${filename}`
292+
: filename
293+
294+
let uniqueFullPath = fullPath
295+
while (seenFilenames.has(uniqueFullPath.toLowerCase())) {
203296
filename = `${baseName}-${counter}.json`
297+
uniqueFullPath = exportedWorkflow.folderPath
298+
? `${exportedWorkflow.folderPath}/${filename}`
299+
: filename
204300
counter++
205301
}
206-
seenFilenames.add(filename.toLowerCase())
207-
zip.file(filename, exportedWorkflow.content)
302+
seenFilenames.add(uniqueFullPath.toLowerCase())
303+
zip.file(uniqueFullPath, exportedWorkflow.content)
208304
}
209305

210306
const zipBlob = await zip.generateAsync({ type: 'blob' })
211-
const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
307+
const zipFilename = `${sanitizePathSegment(folder.name)}-export.zip`
212308
downloadFile(zipBlob, zipFilename, 'application/zip')
213309

214310
const { clearSelection } = useFolderStore.getState()
@@ -218,6 +314,7 @@ export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportF
218314
folderId,
219315
folderName: folder.name,
220316
workflowCount: exportedWorkflows.length,
317+
subfolderCount: subfolders.length,
221318
})
222319

223320
onSuccess?.()

0 commit comments

Comments
 (0)