mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 22:37:32 +00:00
[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 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,7 @@
|
||||
"algoliasearch": "catalog:",
|
||||
"axios": "catalog:",
|
||||
"chart.js": "^4.5.0",
|
||||
"client-zip": "catalog:",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "catalog:",
|
||||
"es-toolkit": "^1.39.9",
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -15,9 +15,15 @@ catalogs:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.178
|
||||
version: 1.2.66
|
||||
'@iconify/json':
|
||||
specifier: ^2.2.380
|
||||
version: 2.2.380
|
||||
'@iconify/tailwind':
|
||||
specifier: ^1.1.3
|
||||
version: 1.2.0
|
||||
'@intlify/eslint-plugin-vue-i18n':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -129,6 +135,9 @@ catalogs:
|
||||
axios:
|
||||
specifier: ^1.8.2
|
||||
version: 1.11.0
|
||||
client-zip:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
@@ -410,6 +419,9 @@ importers:
|
||||
chart.js:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0
|
||||
client-zip:
|
||||
specifier: 'catalog:'
|
||||
version: 2.5.0
|
||||
dompurify:
|
||||
specifier: ^3.2.5
|
||||
version: 3.2.5
|
||||
@@ -4260,6 +4272,9 @@ packages:
|
||||
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
client-zip@2.5.0:
|
||||
resolution: {integrity: sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -12063,6 +12078,8 @@ snapshots:
|
||||
slice-ansi: 5.0.0
|
||||
string-width: 7.2.0
|
||||
|
||||
client-zip@2.5.0: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
|
||||
@@ -46,6 +46,7 @@ catalog:
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.8.2
|
||||
client-zip: ^2.5.0
|
||||
cross-env: ^10.1.0
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^9.34.0
|
||||
|
||||
@@ -2184,6 +2184,10 @@
|
||||
"deleteSelected": "Delete",
|
||||
"downloadStarted": "Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"preparingZip": "Preparing ZIP with {count} file(s)...",
|
||||
"zipDownloadStarted": "Started downloading {count} file(s) as ZIP",
|
||||
"zipDownloadFailed": "Failed to download assets as ZIP",
|
||||
"partialZipSuccess": "ZIP created with {succeeded} file(s). {failed} file(s) failed to download",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets",
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { downloadBlob, downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -93,41 +93,133 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple assets at once
|
||||
* Download multiple assets at once as a zip file
|
||||
* @param assets Array of assets to download
|
||||
*/
|
||||
const downloadMultipleAssets = (assets: AssetItem[]) => {
|
||||
const downloadMultipleAssets = async (assets: AssetItem[]) => {
|
||||
if (!assets || assets.length === 0) return
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = {
|
||||
severity: 'info' as const,
|
||||
summary: t('g.loading'),
|
||||
detail: t('mediaAsset.selection.preparingZip', { count: assets.length }),
|
||||
life: 0 // Keep until manually removed
|
||||
}
|
||||
toast.add(loadingToast)
|
||||
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
const filename = asset.name
|
||||
let downloadUrl: string
|
||||
const { downloadZip } = await import('client-zip')
|
||||
|
||||
// In cloud, use preview_url directly (from GCS or other cloud storage)
|
||||
// In OSS/localhost, use the /view endpoint
|
||||
if (isCloud && asset.preview_url) {
|
||||
downloadUrl = asset.preview_url
|
||||
} else {
|
||||
downloadUrl = getAssetUrl(asset)
|
||||
}
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
// Track filename usage to handle duplicates
|
||||
const nameCount = new Map<string, number>()
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', {
|
||||
count: assets.length
|
||||
}),
|
||||
life: 2000
|
||||
})
|
||||
// Fetch all assets and prepare files for zip (handle partial failures)
|
||||
const results = await Promise.allSettled(
|
||||
assets.map(async (asset) => {
|
||||
try {
|
||||
let filename = asset.name
|
||||
let downloadUrl: string
|
||||
|
||||
// In cloud, use preview_url directly (from GCS or other cloud storage)
|
||||
// In OSS/localhost, use the /view endpoint
|
||||
if (isCloud && asset.preview_url) {
|
||||
downloadUrl = asset.preview_url
|
||||
} else {
|
||||
downloadUrl = getAssetUrl(asset)
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl)
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to fetch ${filename}: ${response.status} ${response.statusText}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle duplicate filenames by adding a number suffix
|
||||
if (nameCount.has(filename)) {
|
||||
const count = nameCount.get(filename)! + 1
|
||||
nameCount.set(filename, count)
|
||||
const parts = filename.split('.')
|
||||
const ext = parts.length > 1 ? parts.pop() : ''
|
||||
filename = ext
|
||||
? `${parts.join('.')}_${count}.${ext}`
|
||||
: `${filename}_${count}`
|
||||
} else {
|
||||
nameCount.set(filename, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
name: filename,
|
||||
input: response
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error fetching ${asset.name}:`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Filter out failed downloads
|
||||
const files = results
|
||||
.map((result) => (result.status === 'fulfilled' ? result.value : null))
|
||||
.filter(
|
||||
(file): file is { name: string; input: Response } => file !== null
|
||||
)
|
||||
|
||||
// Check if any files were successfully fetched
|
||||
if (files.length === 0) {
|
||||
throw new Error('No assets could be downloaded')
|
||||
}
|
||||
|
||||
// Generate zip and get blob
|
||||
const zipBlob = await downloadZip(files).blob()
|
||||
|
||||
// Create zip filename with timestamp to avoid collisions
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.slice(0, -5)
|
||||
const zipFilename = `comfyui-assets-${timestamp}.zip`
|
||||
|
||||
// Download using existing utility
|
||||
downloadBlob(zipFilename, zipBlob)
|
||||
|
||||
// Remove loading toast
|
||||
toast.remove(loadingToast)
|
||||
|
||||
// Show appropriate success message based on results
|
||||
const failedCount = assets.length - files.length
|
||||
if (failedCount > 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.partialZipSuccess', {
|
||||
succeeded: files.length,
|
||||
failed: failedCount
|
||||
}),
|
||||
life: 4000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.zipDownloadStarted', {
|
||||
count: assets.length
|
||||
}),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download assets:', error)
|
||||
// Remove loading toast on error
|
||||
toast.remove(loadingToast)
|
||||
|
||||
console.error('Failed to download assets as zip:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
detail: t('mediaAsset.selection.zipDownloadFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user