@@ -10,11 +10,64 @@ import type { Variable } from '@/stores/workflows/workflow/types'
1010
1111const 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 - z 0 - 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+
1370interface 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 */
3694function 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 - z 0 - 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 - z 0 - 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