Skip to content

Commit 3d4f3b0

Browse files
committed
feat(project-files): add cross-owner copy and bulk transfers
1 parent 219b292 commit 3d4f3b0

68 files changed

Lines changed: 5609 additions & 272 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/cli/files.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,27 @@ sim files mkdir <path>
802802

803803
API request and response schemas: [createFileFolder](/api-reference/files/createFilesFolder).
804804

805+
## Other commands
806+
807+
### Copy file items
808+
809+
OAuth login or personal API key required.
810+
811+
```bash
812+
sim files copy [options]
813+
```
814+
815+
**Options**
816+
817+
<CommandTable>
818+
819+
| Option | Required | Description |
820+
| --- | --- | --- |
821+
| `--source <json\|@file>` | Yes | Source owner and selected fileIds or folderIds (JSON, or @path / @- to read a file or stdin). |
822+
| `--destination <json\|@file>` | Yes | Destination owner and optional folderId (JSON, or @path / @- to read a file or stdin). |
823+
824+
</CommandTable>
825+
805826
## Related docs
806827

807828
- [Files](/files)

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,25 @@ sim files batch-delete [options]
988988

989989
API request and response schemas: [bulkDeleteFiles](/api-reference/files/bulkDeleteFiles).
990990

991+
### sim files copy
992+
993+
Copy file items (OAuth login or personal API key required)
994+
995+
```bash
996+
sim files copy [options]
997+
```
998+
999+
**Options**
1000+
1001+
<CommandTable>
1002+
1003+
| Option | Required | Description |
1004+
| --- | --- | --- |
1005+
| `--source <json\|@file>` | Yes | Source owner and selected fileIds or folderIds (JSON, or @path / @- to read a file or stdin). |
1006+
| `--destination <json\|@file>` | Yes | Destination owner and optional folderId (JSON, or @path / @- to read a file or stdin). |
1007+
1008+
</CommandTable>
1009+
9911010
### sim files create
9921011

9931012
Create file
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { copyFileItemsContract } from '@/lib/api/contracts/file-copy'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { presentCopiedFileItems } from '@/lib/workspace-files/api/copy-presenter'
9+
import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items'
10+
import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation'
11+
12+
export const POST = defineInternalJsonRoute({
13+
contract: copyFileItemsContract,
14+
auth: internalSessionAuth,
15+
operation: fileCopyOperation,
16+
rateLimit: internalRateLimits.user({ bucketName: 'files.copy' }),
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
mapInput: ({ body }) => body,
19+
useCase: copyFileItems,
20+
present: presentCopiedFileItems,
21+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
exportProjectFileSnapshotContract,
3+
MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES,
4+
} from '@/lib/api/contracts/project-file-downloads'
5+
import {
6+
defineInternalBinaryRoute,
7+
internalOrchestrationErrorPolicy,
8+
internalRateLimits,
9+
internalSessionAuth,
10+
} from '@/lib/api/server/routes'
11+
import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter'
12+
import { exportProjectFileSnapshot } from '@/lib/projects/files/application'
13+
14+
export const POST = defineInternalBinaryRoute({
15+
contract: exportProjectFileSnapshotContract,
16+
parseOptions: { maxBodyBytes: MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES },
17+
auth: internalSessionAuth,
18+
operation: exportProjectFileSnapshot.operation,
19+
rateLimit: internalRateLimits.none({ reason: 'Authenticated bounded Markdown snapshot export' }),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }),
22+
useCase: exportProjectFileSnapshot,
23+
present: presentProjectFileDownload,
24+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { extractProjectFileContract } from '@/lib/api/contracts/project-file-extraction'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { extractProjectFile } from '@/lib/projects/files/application'
8+
import { internalFileErrorPolicies } from '@/lib/workspace-files/api'
9+
10+
export const maxDuration = 300
11+
12+
export const POST = defineInternalJsonRoute({
13+
contract: extractProjectFileContract,
14+
auth: internalSessionAuth,
15+
operation: extractProjectFile.operation,
16+
rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }),
17+
errorPolicy: internalFileErrorPolicies.extractArchive,
18+
mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }),
19+
useCase: extractProjectFile,
20+
present: (result) => ({ success: true, ...result }),
21+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { downloadProjectFileItemsContract } from '@/lib/api/contracts/project-file-downloads'
2+
import {
3+
defineInternalBinaryRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter'
9+
import { downloadProjectFileItems } from '@/lib/projects/files/application'
10+
11+
export const GET = defineInternalBinaryRoute({
12+
contract: downloadProjectFileItemsContract,
13+
auth: internalSessionAuth,
14+
operation: downloadProjectFileItems.operation,
15+
rateLimit: internalRateLimits.none({ reason: 'Authenticated bounded Project archive download' }),
16+
errorPolicy: internalOrchestrationErrorPolicy,
17+
mapInput: ({ params, query }) => ({ projectId: params.id, ...query }),
18+
useCase: downloadProjectFileItems,
19+
present: presentProjectFileDownload,
20+
})
Lines changed: 2 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,13 @@
1-
import { Readable } from 'node:stream'
2-
import { createLogger } from '@sim/logger'
3-
import { ZipArchive } from 'archiver'
41
import { v2BulkDownloadFilesContract } from '@/lib/api/contracts/v2/files'
52
import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
6-
import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream'
7-
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
8-
import { downloadFileStream } from '@/lib/uploads/core/storage-service'
9-
import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path'
10-
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
3+
import { presentWorkspaceFileArchive, v2FileErrorPolicies } from '@/lib/workspace-files/api'
114
import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items'
125
import { fileOperations } from '@/lib/workspace-files/application/operations'
136

14-
const logger = createLogger('V2FilesBulkDownloadAPI')
15-
167
export const dynamic = 'force-dynamic'
178
export const revalidate = 0
189

1910
/** Opens each object only as the archiver reaches it, so peak memory stays flat. */
20-
function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable {
21-
return Readable.from(
22-
(async function* () {
23-
yield* await downloadFileStream({
24-
key: file.key,
25-
context: file.storageContext ?? 'workspace',
26-
})
27-
})(),
28-
{ objectMode: false }
29-
)
30-
}
3111

3212
/**
3313
* GET /api/v2/files/bulk-download — stream a selection of files as one zip.
@@ -54,36 +34,5 @@ export const GET = defineV2BinaryRoute({
5434
folderPaths: query.folderPaths,
5535
}),
5636
useCase: downloadWorkspaceFileItems,
57-
present: ({ filesToZip, folderPaths, renderedDocuments, excludedFileIds }) => {
58-
const report = excludedFileIds.length > 0 ? { partial: true, excludedFileIds } : undefined
59-
const entryPaths = buildZipEntryPaths([
60-
...filesToZip.map((file) => ({
61-
name: file.name,
62-
folderPath: file.folderId ? folderPaths.get(file.folderId) : null,
63-
contentType: file.type,
64-
})),
65-
...(report ? [{ name: 'sim-download-report.json', contentType: 'application/json' }] : []),
66-
])
67-
const archive = new ZipArchive({ store: true })
68-
archive.on('warning', (error: Error) => {
69-
logger.warn('Archive warning while streaming workspace files', { error })
70-
})
71-
filesToZip.forEach((file, index) => {
72-
archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), {
73-
name: entryPaths[index],
74-
})
75-
})
76-
if (report) {
77-
archive.append(JSON.stringify(report, null, 2), { name: entryPaths[filesToZip.length] })
78-
}
79-
archive.finalize().catch((error) => {
80-
logger.error('Failed to finalize workspace file archive', { error })
81-
})
82-
83-
return {
84-
body: nodeReadableToWebStream(archive),
85-
contentType: 'application/zip',
86-
contentDisposition: 'attachment; filename="workspace-files.zip"',
87-
}
88-
},
37+
present: presentWorkspaceFileArchive,
8938
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy'
2+
import {
3+
defineV2JsonRoute,
4+
v2ApiKeyAuth,
5+
v2OrchestrationErrorPolicy,
6+
v2RateLimits,
7+
} from '@/lib/api/server/routes'
8+
import { presentCopiedFileItems } from '@/lib/workspace-files/api/copy-presenter'
9+
import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items'
10+
import { fileCopyOperations } from '@/lib/workspace-files/application/operations'
11+
12+
export const POST = defineV2JsonRoute({
13+
contract: v2CopyFileItemsContract,
14+
auth: v2ApiKeyAuth,
15+
operation: fileCopyOperations.copy,
16+
rateLimit: v2RateLimits.publicApi,
17+
errorPolicy: v2OrchestrationErrorPolicy,
18+
mapInput: ({ body }) => body,
19+
useCase: copyFileItems,
20+
present: (result) => ({ data: presentCopiedFileItems(result) }),
21+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES } from '@/lib/api/contracts/project-file-downloads'
2+
import { v2ExportProjectFileSnapshotContract } from '@/lib/api/contracts/v2/project-file-downloads'
3+
import {
4+
defineV2BinaryRoute,
5+
v2ApiKeyAuth,
6+
v2OrchestrationErrorPolicy,
7+
v2RateLimits,
8+
} from '@/lib/api/server/routes'
9+
import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter'
10+
import { exportProjectFileSnapshot } from '@/lib/projects/files/application'
11+
import { projectFileOperations } from '@/lib/projects/files/application/operations'
12+
13+
export const POST = defineV2BinaryRoute({
14+
contract: v2ExportProjectFileSnapshotContract,
15+
parseOptions: { maxBodyBytes: MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES },
16+
auth: v2ApiKeyAuth,
17+
operation: projectFileOperations.exportSnapshot,
18+
rateLimit: v2RateLimits.publicApi,
19+
errorPolicy: v2OrchestrationErrorPolicy,
20+
mapInput: ({ params, body }) => ({ ...params, content: body.content }),
21+
useCase: exportProjectFileSnapshot,
22+
present: presentProjectFileDownload,
23+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { v2UnzipProjectFileContract } from '@/lib/api/contracts/v2/project-file-extraction'
2+
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
3+
import { buildFolderPath } from '@/lib/folders/paths'
4+
import { extractProjectFile } from '@/lib/projects/files/application'
5+
import { projectFileOperations } from '@/lib/projects/files/application/operations'
6+
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
7+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
8+
9+
export const maxDuration = 300
10+
11+
export const POST = defineV2JsonRoute({
12+
contract: v2UnzipProjectFileContract,
13+
auth: v2ApiKeyAuth,
14+
operation: projectFileOperations.extractArchive,
15+
rateLimit: v2RateLimits.publicApi,
16+
errorPolicy: v2FileErrorPolicies.concealExtractionAuthorization,
17+
mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }),
18+
useCase: extractProjectFile,
19+
present: (result) => ({
20+
data: {
21+
folderPath: buildFolderPath(parseWorkspaceFileFolderDisplayPath(result.folderDisplayPath)),
22+
extractedFileCount: result.extractedCount,
23+
skippedFileCount: result.skippedCount,
24+
},
25+
}),
26+
})

0 commit comments

Comments
 (0)