Skip to content

Commit efebd5f

Browse files
viva-jinyiclaude
andcommitted
[feat] Add ZIP download support for multiple assets
## Summary When downloading multiple selected assets, they are now bundled into a single ZIP file instead of downloading each file individually. ## Changes - Use `client-zip` library to generate ZIP files in the browser - Use `Promise.allSettled` to include remaining files in ZIP even if some fail - Automatically add numbers to duplicate filenames - Notify users with toast messages based on download status - Auto-generate filename with timestamp to prevent filename collisions ### Modified Files - `src/platform/assets/composables/useMediaAssetActions.ts`: Implement ZIP download logic - `src/locales/en/main.json`: Add new i18n strings - `package.json`: Add `client-zip` dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 9d131a4 commit efebd5f

File tree

5 files changed

+141
-26
lines changed

5 files changed

+141
-26
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@
159159
"algoliasearch": "catalog:",
160160
"axios": "catalog:",
161161
"chart.js": "^4.5.0",
162+
"client-zip": "catalog:",
162163
"dompurify": "^3.2.5",
163164
"dotenv": "catalog:",
164165
"es-toolkit": "^1.39.9",

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ catalog:
4646
'@webgpu/types': ^0.1.66
4747
algoliasearch: ^5.21.0
4848
axios: ^1.8.2
49+
client-zip: ^2.5.0
4950
cross-env: ^10.1.0
5051
dotenv: ^16.4.5
5152
eslint: ^9.34.0

src/locales/en/main.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2184,6 +2184,10 @@
21842184
"deleteSelected": "Delete",
21852185
"downloadStarted": "Downloading {count} files...",
21862186
"downloadsStarted": "Started downloading {count} file(s)",
2187+
"preparingZip": "Preparing ZIP with {count} file(s)...",
2188+
"zipDownloadStarted": "Started downloading {count} file(s) as ZIP",
2189+
"zipDownloadFailed": "Failed to download assets as ZIP",
2190+
"partialZipSuccess": "ZIP created with {succeeded} file(s). {failed} file(s) failed to download",
21872191
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
21882192
"failedToDeleteAssets": "Failed to delete selected assets",
21892193
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"

src/platform/assets/composables/useMediaAssetActions.ts

Lines changed: 118 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useToast } from 'primevue/usetoast'
22
import { inject } from 'vue'
33

44
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
5-
import { downloadFile } from '@/base/common/downloadUtil'
5+
import { downloadBlob, downloadFile } from '@/base/common/downloadUtil'
66
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
77
import { t } from '@/i18n'
88
import { isCloud } from '@/platform/distribution/types'
@@ -93,41 +93,133 @@ export function useMediaAssetActions() {
9393
}
9494

9595
/**
96-
* Download multiple assets at once
96+
* Download multiple assets at once as a zip file
9797
* @param assets Array of assets to download
9898
*/
99-
const downloadMultipleAssets = (assets: AssetItem[]) => {
99+
const downloadMultipleAssets = async (assets: AssetItem[]) => {
100100
if (!assets || assets.length === 0) return
101101

102+
// Show loading toast
103+
const loadingToast = {
104+
severity: 'info' as const,
105+
summary: t('g.loading'),
106+
detail: t('mediaAsset.selection.preparingZip', { count: assets.length }),
107+
life: 0 // Keep until manually removed
108+
}
109+
toast.add(loadingToast)
110+
102111
try {
103-
assets.forEach((asset) => {
104-
const filename = asset.name
105-
let downloadUrl: string
106-
107-
// In cloud, use preview_url directly (from GCS or other cloud storage)
108-
// In OSS/localhost, use the /view endpoint
109-
if (isCloud && asset.preview_url) {
110-
downloadUrl = asset.preview_url
111-
} else {
112-
downloadUrl = getAssetUrl(asset)
113-
}
114-
downloadFile(downloadUrl, filename)
115-
})
112+
const { downloadZip } = await import('client-zip')
113+
114+
// Track filename usage to handle duplicates
115+
const nameCount = new Map<string, number>()
116+
117+
// Fetch all assets and prepare files for zip (handle partial failures)
118+
const results = await Promise.allSettled(
119+
assets.map(async (asset) => {
120+
try {
121+
let filename = asset.name
122+
let downloadUrl: string
123+
124+
// In cloud, use preview_url directly (from GCS or other cloud storage)
125+
// In OSS/localhost, use the /view endpoint
126+
if (isCloud && asset.preview_url) {
127+
downloadUrl = asset.preview_url
128+
} else {
129+
downloadUrl = getAssetUrl(asset)
130+
}
116131

117-
toast.add({
118-
severity: 'success',
119-
summary: t('g.success'),
120-
detail: t('mediaAsset.selection.downloadsStarted', {
121-
count: assets.length
122-
}),
123-
life: 2000
124-
})
132+
const response = await fetch(downloadUrl)
133+
if (!response.ok) {
134+
console.warn(
135+
`Failed to fetch ${filename}: ${response.status} ${response.statusText}`
136+
)
137+
return null
138+
}
139+
140+
// Handle duplicate filenames by adding a number suffix
141+
if (nameCount.has(filename)) {
142+
const count = nameCount.get(filename)! + 1
143+
nameCount.set(filename, count)
144+
const parts = filename.split('.')
145+
const ext = parts.length > 1 ? parts.pop() : ''
146+
filename = ext
147+
? `${parts.join('.')}_${count}.${ext}`
148+
: `${filename}_${count}`
149+
} else {
150+
nameCount.set(filename, 1)
151+
}
152+
153+
return {
154+
name: filename,
155+
input: response
156+
}
157+
} catch (error) {
158+
console.warn(`Error fetching ${asset.name}:`, error)
159+
return null
160+
}
161+
})
162+
)
163+
164+
// Filter out failed downloads
165+
const files = results
166+
.map((result) => (result.status === 'fulfilled' ? result.value : null))
167+
.filter(
168+
(file): file is { name: string; input: Response } => file !== null
169+
)
170+
171+
// Check if any files were successfully fetched
172+
if (files.length === 0) {
173+
throw new Error('No assets could be downloaded')
174+
}
175+
176+
// Generate zip and get blob
177+
const zipBlob = await downloadZip(files).blob()
178+
179+
// Create zip filename with timestamp to avoid collisions
180+
const timestamp = new Date()
181+
.toISOString()
182+
.replace(/[:.]/g, '-')
183+
.slice(0, -5)
184+
const zipFilename = `comfyui-assets-${timestamp}.zip`
185+
186+
// Download using existing utility
187+
downloadBlob(zipFilename, zipBlob)
188+
189+
// Remove loading toast
190+
toast.remove(loadingToast)
191+
192+
// Show appropriate success message based on results
193+
const failedCount = assets.length - files.length
194+
if (failedCount > 0) {
195+
toast.add({
196+
severity: 'warn',
197+
summary: t('g.warning'),
198+
detail: t('mediaAsset.selection.partialZipSuccess', {
199+
succeeded: files.length,
200+
failed: failedCount
201+
}),
202+
life: 4000
203+
})
204+
} else {
205+
toast.add({
206+
severity: 'success',
207+
summary: t('g.success'),
208+
detail: t('mediaAsset.selection.zipDownloadStarted', {
209+
count: assets.length
210+
}),
211+
life: 2000
212+
})
213+
}
125214
} catch (error) {
126-
console.error('Failed to download assets:', error)
215+
// Remove loading toast on error
216+
toast.remove(loadingToast)
217+
218+
console.error('Failed to download assets as zip:', error)
127219
toast.add({
128220
severity: 'error',
129221
summary: t('g.error'),
130-
detail: t('g.failedToDownloadImage'),
222+
detail: t('mediaAsset.selection.zipDownloadFailed'),
131223
life: 3000
132224
})
133225
}

0 commit comments

Comments
 (0)