Skip to content

Commit f35278a

Browse files
committed
feat(file): add Compress operation to bundle files into a .zip archive
1 parent 73c73ff commit f35278a

7 files changed

Lines changed: 398 additions & 4 deletions

File tree

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Buffer, isUtf8 } from 'buffer'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { generateShortId } from '@sim/utils/id'
5+
import JSZip from 'jszip'
56
import { type NextRequest, NextResponse } from 'next/server'
67
import { fileManageContract } from '@/lib/api/contracts/tools/file'
78
import { parseRequest } from '@/lib/api/server'
@@ -133,6 +134,44 @@ const MAX_GET_CONTENT_FILE_BYTES = 64 * 1024 * 1024
133134
/** Combined extracted-text cap so the content array stays within the large-value-ref ceiling. */
134135
const MAX_GET_CONTENT_TOTAL_BYTES = 64 * 1024 * 1024
135136

137+
/** Per-file download cap for the compress operation. */
138+
const MAX_COMPRESS_FILE_BYTES = 100 * 1024 * 1024
139+
/** Combined input cap for the compress operation to bound in-memory archiving. */
140+
const MAX_COMPRESS_TOTAL_BYTES = 100 * 1024 * 1024
141+
142+
/** Ensure an archive name ends with a single `.zip` extension. */
143+
const ensureZipExtension = (name: string): string =>
144+
name.toLowerCase().endsWith('.zip') ? name : `${name}.zip`
145+
146+
/** Strip the trailing extension from a file name (e.g., "report.pdf" -> "report"). */
147+
const stripExtension = (name: string): string => {
148+
const dot = name.lastIndexOf('.')
149+
return dot > 0 ? name.slice(0, dot) : name
150+
}
151+
152+
/**
153+
* Return a zip entry name unique within `usedNames`, appending a numeric suffix
154+
* before the extension on collision (e.g., "data.csv" -> "data (1).csv").
155+
*/
156+
const uniqueZipEntryName = (name: string, usedNames: Set<string>): string => {
157+
if (!usedNames.has(name)) {
158+
usedNames.add(name)
159+
return name
160+
}
161+
162+
const dot = name.lastIndexOf('.')
163+
const base = dot > 0 ? name.slice(0, dot) : name
164+
const ext = dot > 0 ? name.slice(dot) : ''
165+
let counter = 1
166+
let candidate = `${base} (${counter})${ext}`
167+
while (usedNames.has(candidate)) {
168+
counter += 1
169+
candidate = `${base} (${counter})${ext}`
170+
}
171+
usedNames.add(candidate)
172+
return candidate
173+
}
174+
136175
const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0)
137176

138177
/**
@@ -462,6 +501,115 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
462501
await releaseLock(lockKey, lockValue)
463502
}
464503
}
504+
505+
case 'compress': {
506+
const { fileId, fileInput, archiveName } = body
507+
const requestId = generateRequestId()
508+
509+
const selectedFileIds = Array.isArray(fileId)
510+
? fileId.map((id) => id.trim()).filter(Boolean)
511+
: fileId
512+
? normalizeFileIdList(fileId)
513+
: extractFileIdsFromInput(fileInput)
514+
const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput)
515+
516+
const workspaceFiles = await Promise.all(
517+
selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id))
518+
)
519+
const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index])
520+
if (missingFileId) {
521+
return NextResponse.json(
522+
{ success: false, error: `File not found: "${missingFileId}"` },
523+
{ status: 404 }
524+
)
525+
}
526+
527+
const userFiles: UserFile[] = workspaceFiles
528+
.map((file) => workspaceFileToUserFile(file))
529+
.filter((file): file is NonNullable<ReturnType<typeof workspaceFileToUserFile>> =>
530+
Boolean(file)
531+
)
532+
.concat(selectedInputFiles)
533+
534+
if (userFiles.length === 0) {
535+
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
536+
}
537+
538+
const zip = new JSZip()
539+
const usedNames = new Set<string>()
540+
let totalBytes = 0
541+
for (const userFile of userFiles) {
542+
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
543+
if (denied) return denied
544+
545+
const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
546+
maxBytes: MAX_COMPRESS_FILE_BYTES,
547+
})
548+
totalBytes += buffer.length
549+
if (totalBytes > MAX_COMPRESS_TOTAL_BYTES) {
550+
return NextResponse.json(
551+
{
552+
success: false,
553+
error: `Combined input is too large to compress. Maximum is ${
554+
MAX_COMPRESS_TOTAL_BYTES / (1024 * 1024)
555+
} MB.`,
556+
},
557+
{ status: 413 }
558+
)
559+
}
560+
zip.file(uniqueZipEntryName(userFile.name, usedNames), buffer)
561+
}
562+
563+
const zipBuffer = await zip.generateAsync({
564+
type: 'nodebuffer',
565+
compression: 'DEFLATE',
566+
compressionOptions: { level: 6 },
567+
})
568+
569+
const requestedName = typeof archiveName === 'string' ? archiveName.trim() : ''
570+
const targetName = ensureZipExtension(
571+
requestedName || (userFiles.length === 1 ? stripExtension(userFiles[0].name) : 'archive')
572+
)
573+
const { folderSegments, leafName } = splitWorkspaceFilePath(targetName)
574+
const folderId = await ensureWorkspaceFileFolderPath({
575+
workspaceId,
576+
userId,
577+
pathSegments: folderSegments,
578+
})
579+
const result = await uploadWorkspaceFile(
580+
workspaceId,
581+
userId,
582+
zipBuffer,
583+
leafName,
584+
'application/zip',
585+
{ folderId }
586+
)
587+
588+
const compressedFile: UserFile = {
589+
...result,
590+
url: ensureAbsoluteUrl(result.url),
591+
size: zipBuffer.length,
592+
}
593+
594+
logger.info('Files compressed', {
595+
fileId: result.id,
596+
name: result.name,
597+
fileCount: userFiles.length,
598+
size: zipBuffer.length,
599+
})
600+
601+
return NextResponse.json({
602+
success: true,
603+
data: {
604+
id: compressedFile.id,
605+
name: compressedFile.name,
606+
size: compressedFile.size,
607+
url: compressedFile.url,
608+
file: compressedFile,
609+
files: [compressedFile],
610+
},
611+
})
612+
}
465613
}
466614
} catch (error) {
467615
if (isWorkspaceAccessDeniedError(error)) {

apps/sim/blocks/blocks/file.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -822,9 +822,9 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
822822
...FileV4Block,
823823
type: 'file_v5',
824824
name: 'File',
825-
description: 'Read, get content, fetch, write, and append files',
825+
description: 'Read, get content, fetch, write, append, and compress files',
826826
longDescription:
827-
'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.',
827+
'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, or compress files into a .zip archive.',
828828
hideFromToolbar: false,
829829
bestPractices: `
830830
- Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments).
@@ -833,6 +833,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
833833
- Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way.
834834
- Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token.
835835
- Use Write to create a new workspace file and Append to add content to an existing one.
836+
- Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "file"/"files" outputs, which is handy for getting large attachments under provider upload limits.
836837
`,
837838
subBlocks: [
838839
{
@@ -845,6 +846,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
845846
{ label: 'Fetch', id: 'file_fetch' },
846847
{ label: 'Write', id: 'file_write' },
847848
{ label: 'Append', id: 'file_append' },
849+
{ label: 'Compress', id: 'file_compress' },
848850
],
849851
value: () => 'file_read',
850852
},
@@ -962,9 +964,45 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
962964
condition: { field: 'operation', value: 'file_append' },
963965
required: { field: 'operation', value: 'file_append' },
964966
},
967+
{
968+
id: 'compressFile',
969+
title: 'Files',
970+
type: 'file-upload' as SubBlockType,
971+
canonicalParamId: 'compressInput',
972+
acceptedTypes: '*',
973+
placeholder: 'Select workspace files',
974+
multiple: true,
975+
mode: 'basic',
976+
condition: { field: 'operation', value: 'file_compress' },
977+
required: { field: 'operation', value: 'file_compress' },
978+
},
979+
{
980+
id: 'compressFileId',
981+
title: 'File ID',
982+
type: 'short-input' as SubBlockType,
983+
canonicalParamId: 'compressInput',
984+
placeholder: 'Workspace file ID or JSON array of IDs',
985+
mode: 'advanced',
986+
condition: { field: 'operation', value: 'file_compress' },
987+
required: { field: 'operation', value: 'file_compress' },
988+
},
989+
{
990+
id: 'archiveName',
991+
title: 'Archive Name',
992+
type: 'short-input' as SubBlockType,
993+
placeholder: 'archive.zip (auto-named from source if omitted)',
994+
condition: { field: 'operation', value: 'file_compress' },
995+
},
965996
],
966997
tools: {
967-
access: ['file_read', 'file_get_content', 'file_fetch', 'file_write', 'file_append'],
998+
access: [
999+
'file_read',
1000+
'file_get_content',
1001+
'file_fetch',
1002+
'file_write',
1003+
'file_append',
1004+
'file_compress',
1005+
],
9681006
config: {
9691007
tool: (params) => params.operation || 'file_read',
9701008
params: (params) => {
@@ -1005,6 +1043,38 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
10051043
}
10061044
}
10071045

1046+
if (operation === 'file_compress') {
1047+
const compressInput = params.compressInput
1048+
if (!compressInput) {
1049+
throw new Error('File is required for compress')
1050+
}
1051+
1052+
const archiveName =
1053+
typeof params.archiveName === 'string' && params.archiveName.trim()
1054+
? params.archiveName.trim()
1055+
: undefined
1056+
1057+
const fileIds = parseReadFileIds(compressInput)
1058+
if (fileIds) {
1059+
return {
1060+
fileId: fileIds,
1061+
archiveName,
1062+
workspaceId: params._context?.workspaceId,
1063+
}
1064+
}
1065+
1066+
const normalized = normalizeFileInput(compressInput)
1067+
if (!normalized || normalized.length === 0) {
1068+
throw new Error('File is required for compress')
1069+
}
1070+
1071+
return {
1072+
fileInput: normalized,
1073+
archiveName,
1074+
workspaceId: params._context?.workspaceId,
1075+
}
1076+
}
1077+
10081078
if (operation === 'file_fetch') {
10091079
const fileUrl = resolveHttpFileUrl(params.fileUrl)
10101080

@@ -1089,11 +1159,21 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
10891159
contentType: { type: 'string', description: 'MIME content type for write' },
10901160
appendFileInput: { type: 'json', description: 'File to append to' },
10911161
appendContent: { type: 'string', description: 'Content to append to file' },
1162+
compressInput: {
1163+
type: 'json',
1164+
description: 'Selected workspace files or canonical file IDs to compress',
1165+
},
1166+
archiveName: { type: 'string', description: 'Name for the compressed .zip archive' },
10921167
},
10931168
outputs: {
10941169
files: {
10951170
type: 'file[]',
1096-
description: 'Workspace file objects (read) or fetched file objects (fetch)',
1171+
description:
1172+
'Workspace file objects (read), fetched file objects (fetch), or the compressed archive (compress)',
1173+
},
1174+
file: {
1175+
type: 'file',
1176+
description: 'Compressed archive file object (compress)',
10971177
},
10981178
contents: {
10991179
type: 'array',

apps/sim/lib/api/contracts/tools/file.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,26 @@ export const fileManageContentBodySchema = z
6464
message: 'Either fileId or fileInput is required for content operation',
6565
})
6666

67+
export const fileManageCompressBodySchema = z
68+
.object({
69+
operation: z.literal('compress'),
70+
workspaceId: z.string().min(1).optional(),
71+
fileId: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
72+
fileInput: z.unknown().optional(),
73+
archiveName: z.string().min(1).max(255).optional(),
74+
})
75+
.refine((data) => data.fileId !== undefined || data.fileInput !== undefined, {
76+
message: 'Either fileId or fileInput is required for compress operation',
77+
})
78+
6779
export const fileManageBodySchema = z.union([
6880
fileManageWriteBodySchema,
6981
fileManageAppendBodySchema,
7082
fileManageGetBodySchema,
7183
fileManageMoveBodySchema,
7284
fileManageReadBodySchema,
7385
fileManageContentBodySchema,
86+
fileManageCompressBodySchema,
7487
])
7588

7689
export const fileManageContract = defineRouteContract({

0 commit comments

Comments
 (0)