From 54a8d913f8dcb0cea24151805b520df0a063aaf7 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Mon, 17 Nov 2025 12:34:34 +0900 Subject: [PATCH] [feat] Add copy job ID action and shared asset utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces the copy job ID feature for media assets along with shared utility functions that will be used across multiple asset actions. Features: - Copy job ID to clipboard with OSS/Cloud compatibility - Uses useCopyToClipboard composable for consistent UX Shared Utilities: - assetTypeUtil: Extract asset type from tags - assetUrlUtil: Construct asset URLs for download/view - typeGuardUtil: Add isResultItemType type guard Refactoring: - Unified asset deletion logic with deleteAssetApi helper - Replaced inline URL construction with getAssetUrl utility - Improved error handling with Cloud-specific warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/locales/en/main.json | 3 +- .../composables/useMediaAssetActions.ts | 161 ++++++++---------- src/platform/assets/utils/assetTypeUtil.ts | 24 +++ src/platform/assets/utils/assetUrlUtil.ts | 29 ++++ src/utils/typeGuardUtil.ts | 11 ++ 5 files changed, 139 insertions(+), 89 deletions(-) create mode 100644 src/platform/assets/utils/assetTypeUtil.ts create mode 100644 src/platform/assets/utils/assetUrlUtil.ts diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 5bc2d9ca8..8e2895aa3 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2044,7 +2044,8 @@ "downloadsStarted": "Started downloading {count} file(s)", "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", "failedToDeleteAssets": "Failed to delete selected assets" - } + }, + "noJobIdFound": "No job ID found for this asset" }, "actionbar": { "dockToTop": "Dock to top", diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index d8d7b2436..18c44c9a0 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -4,12 +4,15 @@ import { inject } from 'vue' import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue' import { downloadFile } from '@/base/common/downloadUtil' +import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { api } from '@/scripts/api' import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema' import { useAssetsStore } from '@/stores/assetsStore' import { useDialogStore } from '@/stores/dialogStore' +import { getAssetType } from '../utils/assetTypeUtil' +import { getAssetUrl } from '../utils/assetUrlUtil' import type { AssetItem } from '../schemas/assetSchema' import { MediaAssetKey } from '../schemas/mediaAssetSchema' @@ -19,6 +22,7 @@ export function useMediaAssetActions() { const toast = useToast() const dialogStore = useDialogStore() const mediaContext = inject(MediaAssetKey, null) + const { copyToClipboard } = useCopyToClipboard() const downloadAsset = () => { const asset = mediaContext?.asset.value @@ -33,10 +37,7 @@ export function useMediaAssetActions() { if (isCloud && asset.src) { downloadUrl = asset.src } else { - const assetType = asset.tags?.[0] || 'output' - downloadUrl = api.apiURL( - `/view?filename=${encodeURIComponent(filename)}&type=${assetType}` - ) + downloadUrl = getAssetUrl(asset) } downloadFile(downloadUrl, filename) @@ -74,10 +75,7 @@ export function useMediaAssetActions() { if (isCloud && asset.preview_url) { downloadUrl = asset.preview_url } else { - const assetType = asset.tags?.[0] || 'output' - downloadUrl = api.apiURL( - `/view?filename=${encodeURIComponent(filename)}&type=${assetType}` - ) + downloadUrl = getAssetUrl(asset) } downloadFile(downloadUrl, filename) }) @@ -101,13 +99,38 @@ export function useMediaAssetActions() { } } + /** + * Internal helper to perform the API deletion for a single asset + * Handles both output assets (via history API) and input assets (via asset service) + * @throws Error if deletion fails or is not allowed + */ + const deleteAssetApi = async ( + asset: AssetItem, + assetType: string + ): Promise => { + if (assetType === 'output') { + const promptId = + asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId + if (!promptId) { + throw new Error('Unable to extract prompt ID from asset') + } + await api.deleteItem('history', promptId) + } else { + // Input assets can only be deleted in cloud environment + if (!isCloud) { + throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly')) + } + await assetService.deleteAsset(asset.id) + } + } + /** * Show confirmation dialog and delete asset if confirmed * @param asset The asset to delete * @returns true if the asset was deleted, false otherwise */ const confirmDelete = async (asset: AssetItem): Promise => { - const assetType = asset.tags?.[0] || 'output' + const assetType = getAssetType(asset) return new Promise((resolve) => { dialogStore.showDialog({ @@ -134,61 +157,32 @@ export function useMediaAssetActions() { const assetsStore = useAssetsStore() try { + // Perform the deletion + await deleteAssetApi(asset, assetType) + + // Update the appropriate store based on asset type if (assetType === 'output') { - // For output files, delete from history - const promptId = - asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId - if (!promptId) { - throw new Error('Unable to extract prompt ID from asset') - } - - await api.deleteItem('history', promptId) - - // Update history assets in store after deletion await assetsStore.updateHistory() - - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('mediaAsset.assetDeletedSuccessfully'), - life: 2000 - }) - return true } else { - // For input files, only allow deletion in cloud environment - if (!isCloud) { - toast.add({ - severity: 'warn', - summary: t('g.warning'), - detail: t('mediaAsset.deletingImportedFilesCloudOnly'), - life: 3000 - }) - return false - } - - // In cloud environment, use the assets API to delete - await assetService.deleteAsset(asset.id) - - // Update input assets in store after deletion await assetsStore.updateInputs() - - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('mediaAsset.assetDeletedSuccessfully'), - life: 2000 - }) - return true } + + toast.add({ + severity: 'success', + summary: t('g.success'), + detail: t('mediaAsset.assetDeletedSuccessfully'), + life: 2000 + }) + return true } catch (error) { console.error('Failed to delete asset:', error) + const errorMessage = error instanceof Error ? error.message : '' + const isCloudWarning = errorMessage.includes('Cloud') + toast.add({ - severity: 'error', - summary: t('g.error'), - detail: - error instanceof Error - ? error.message - : t('mediaAsset.failedToDeleteAsset'), + severity: isCloudWarning ? 'warn' : 'error', + summary: isCloudWarning ? t('g.warning') : t('g.error'), + detail: errorMessage || t('mediaAsset.failedToDeleteAsset'), life: 3000 }) return false @@ -203,36 +197,21 @@ export function useMediaAssetActions() { const asset = mediaContext?.asset.value if (!asset) return - // Get promptId from metadata instead of parsing the ID string + // Try asset.id first (OSS), then fall back to metadata (Cloud) const metadata = getOutputAssetMetadata(asset.user_metadata) - const promptId = metadata?.promptId + const promptId = asset.id || metadata?.promptId if (!promptId) { toast.add({ severity: 'warn', summary: t('g.warning'), - detail: 'No job ID found for this asset', + detail: t('mediaAsset.noJobIdFound'), life: 2000 }) return } - try { - await navigator.clipboard.writeText(promptId) - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('mediaAsset.jobIdToast.jobIdCopied'), - life: 2000 - }) - } catch (error) { - toast.add({ - severity: 'error', - summary: t('g.error'), - detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'), - life: 3000 - }) - } + await copyToClipboard(promptId) } const addWorkflow = (assetId: string) => { @@ -273,26 +252,32 @@ export function useMediaAssetActions() { itemList: assets.map((asset) => asset.name), onConfirm: async () => { try { - // Delete all assets + // Delete all assets using the shared helper + // Silently skip assets that can't be deleted (e.g., input assets in non-cloud) await Promise.all( assets.map(async (asset) => { - const assetType = asset.tags?.[0] || 'output' - if (assetType === 'output') { - const promptId = - asset.id || - getOutputAssetMetadata(asset.user_metadata)?.promptId - if (promptId) { - await api.deleteItem('history', promptId) - } - } else if (isCloud) { - await assetService.deleteAsset(asset.id) + const assetType = getAssetType(asset) + try { + await deleteAssetApi(asset, assetType) + } catch (error) { + // Log but don't fail the entire batch for individual errors + console.warn(`Failed to delete asset ${asset.name}:`, error) } }) ) // Update stores after deletions - await assetsStore.updateHistory() - if (assets.some((a) => a.tags?.[0] === 'input')) { + const hasOutputAssets = assets.some( + (a) => getAssetType(a) === 'output' + ) + const hasInputAssets = assets.some( + (a) => getAssetType(a) === 'input' + ) + + if (hasOutputAssets) { + await assetsStore.updateHistory() + } + if (hasInputAssets) { await assetsStore.updateInputs() } diff --git a/src/platform/assets/utils/assetTypeUtil.ts b/src/platform/assets/utils/assetTypeUtil.ts new file mode 100644 index 000000000..b0da4a0bf --- /dev/null +++ b/src/platform/assets/utils/assetTypeUtil.ts @@ -0,0 +1,24 @@ +/** + * Utilities for working with asset types + */ + +import type { AssetItem } from '../schemas/assetSchema' + +/** + * Extract asset type from an asset's tags array + * Falls back to a default type if tags are not present + * + * @param asset The asset to extract type from + * @param defaultType Default type to use if tags are empty (default: 'output') + * @returns The asset type ('input', 'output', 'temp', etc.) + * + * @example + * getAssetType(asset) // Returns 'output' or first tag + * getAssetType(asset, 'input') // Returns 'input' if no tags + */ +export function getAssetType( + asset: AssetItem, + defaultType: 'input' | 'output' = 'output' +): string { + return asset.tags?.[0] || defaultType +} diff --git a/src/platform/assets/utils/assetUrlUtil.ts b/src/platform/assets/utils/assetUrlUtil.ts new file mode 100644 index 000000000..60956eb5f --- /dev/null +++ b/src/platform/assets/utils/assetUrlUtil.ts @@ -0,0 +1,29 @@ +/** + * Utilities for constructing asset URLs + */ + +import { api } from '@/scripts/api' +import type { AssetItem } from '../schemas/assetSchema' +import { getAssetType } from './assetTypeUtil' + +/** + * Get the download/view URL for an asset + * Constructs the proper URL with filename encoding and type parameter + * + * @param asset The asset to get URL for + * @param defaultType Default type if asset doesn't have tags (default: 'output') + * @returns Full URL for viewing/downloading the asset + * + * @example + * const url = getAssetUrl(asset) + * downloadFile(url, asset.name) + */ +export function getAssetUrl( + asset: AssetItem, + defaultType: 'input' | 'output' = 'output' +): string { + const assetType = getAssetType(asset, defaultType) + return api.apiURL( + `/view?filename=${encodeURIComponent(asset.name)}&type=${assetType}` + ) +} diff --git a/src/utils/typeGuardUtil.ts b/src/utils/typeGuardUtil.ts index d9e2aeb0b..d97a28374 100644 --- a/src/utils/typeGuardUtil.ts +++ b/src/utils/typeGuardUtil.ts @@ -4,6 +4,7 @@ import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph' +import type { ResultItemType } from '@/schemas/apiSchema' /** * Check if an error is an AbortError triggered by `AbortController#abort` @@ -49,3 +50,13 @@ export const isSlotObject = (obj: unknown): obj is INodeSlot => { 'boundingRect' in obj ) } + +/** + * Type guard to check if a string is a valid ResultItemType + * ResultItemType is used for asset categorization (input/output/temp) + */ +export const isResultItemType = ( + value: string | undefined +): value is ResultItemType => { + return value === 'input' || value === 'output' || value === 'temp' +}