From efebd5fc7c22e115f33c29e2d2696fbaa4826992 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Thu, 27 Nov 2025 22:55:57 +0900 Subject: [PATCH] [feat] Add ZIP download support for multiple assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- package.json | 1 + pnpm-lock.yaml | 17 +++ pnpm-workspace.yaml | 1 + src/locales/en/main.json | 4 + .../composables/useMediaAssetActions.ts | 144 ++++++++++++++---- 5 files changed, 141 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index ea7558c0ea..35774787ae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3db096f7be..ecd32b5063 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03b59cd17d..14d891e4c4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/locales/en/main.json b/src/locales/en/main.json index c31c85f53e..de7f8b8835 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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" diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index e2fb92736e..a68444a280 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -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 - - // 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) - }) + const { downloadZip } = await import('client-zip') + + // Track filename usage to handle duplicates + const nameCount = new Map() + + // 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) + } - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('mediaAsset.selection.downloadsStarted', { - count: assets.length - }), - life: 2000 - }) + 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 }) }