diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index f63ae0f694..c84d921d47 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -103,7 +103,7 @@ function FileContextInner({ useEffect(() => { const previousIds = observedFileIdsRef.current; - const nextIds = new Set(state.files.ids); + const nextIds = new Set(state.files.ids); const newEncryptedIds: FileId[] = []; for (const id of state.files.ids) { diff --git a/frontend/src/core/data/toolsTaxonomy.ts b/frontend/src/core/data/toolsTaxonomy.ts index baebe28cdd..bb22dfeb7b 100644 --- a/frontend/src/core/data/toolsTaxonomy.ts +++ b/frontend/src/core/data/toolsTaxonomy.ts @@ -1,6 +1,6 @@ import { type TFunction } from 'i18next'; import React from 'react'; -import { ToolOperationConfig } from '@app/hooks/tools/shared/useToolOperation'; +import { ToolOperationConfig } from '@app/hooks/tools/shared/toolOperationTypes'; import { BaseToolProps } from '@app/types/tool'; import { WorkbenchType } from '@app/types/workbench'; import { LinkToolId, RegularToolId, SuperToolId, ToolId, ToolKind } from '@app/types/toolId'; diff --git a/frontend/src/core/hooks/tools/shared/toolOperationHelpers.ts b/frontend/src/core/hooks/tools/shared/toolOperationHelpers.ts new file mode 100644 index 0000000000..56551fcc6c --- /dev/null +++ b/frontend/src/core/hooks/tools/shared/toolOperationHelpers.ts @@ -0,0 +1,46 @@ +import { StirlingFile, FileId, StirlingFileStub, createStirlingFile, ProcessedFileMetadata, createNewStirlingFileStub } from '@app/types/fileContext'; + +/** + * Builds parallel inputFileIds and inputStirlingFileStubs arrays from the valid input files. + * Falls back to a fresh stub when the file is not found in the current context state + * (e.g. it was removed between operation start and this point). + */ +export function buildInputTracking( + validFiles: StirlingFile[], + selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined } +): { inputFileIds: FileId[]; inputStirlingFileStubs: StirlingFileStub[] } { + const inputFileIds: FileId[] = []; + const inputStirlingFileStubs: StirlingFileStub[] = []; + for (const file of validFiles) { + const fileId = file.fileId; + const record = selectors.getStirlingFileStub(fileId); + if (record) { + inputFileIds.push(fileId); + inputStirlingFileStubs.push(record); + } else { + console.warn(`No file stub found for file: ${file.name}`); + inputFileIds.push(fileId); + inputStirlingFileStubs.push(createNewStirlingFileStub(file, fileId)); + } + } + return { inputFileIds, inputStirlingFileStubs }; +} + +/** + * Creates parallel outputStirlingFileStubs and outputStirlingFiles arrays from processed files. + * The stubFactory determines how each stub is constructed (child version vs fresh root). + */ +export function buildOutputPairs( + processedFiles: File[], + thumbnails: string[], + metadataArray: Array, + stubFactory: (file: File, thumbnail: string, metadata: ProcessedFileMetadata | undefined, index: number) => StirlingFileStub +): { outputStirlingFileStubs: StirlingFileStub[]; outputStirlingFiles: StirlingFile[] } { + const outputStirlingFileStubs = processedFiles.map((file, index) => + stubFactory(file, thumbnails[index], metadataArray[index], index) + ); + const outputStirlingFiles = processedFiles.map((file, index) => + createStirlingFile(file, outputStirlingFileStubs[index].id) + ); + return { outputStirlingFileStubs, outputStirlingFiles }; +} diff --git a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts new file mode 100644 index 0000000000..eb568b25d4 --- /dev/null +++ b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -0,0 +1,147 @@ +import { StirlingFile } from '@app/types/fileContext'; +import type { ResponseHandler } from '@app/utils/toolResponseProcessor'; +import { ToolId } from '@app/types/toolId'; +import type { ProcessingProgress } from '@app/hooks/tools/shared/useToolState'; + +export type { ProcessingProgress, ResponseHandler }; + +export enum ToolType { + singleFile, + multiFile, + custom, +} + +/** + * Result from custom processor with optional metadata about input consumption. + */ +export interface CustomProcessorResult { + /** Processed output files */ + files: File[]; + /** + * When true, marks all input files as successfully consumed regardless of output count. + * Use when operation combines N inputs into fewer outputs (e.g., 3 images → 1 PDF). + * When false/undefined, uses filename-based mapping to determine which inputs succeeded. + */ + consumedAllInputs?: boolean; +} + +/** + * Configuration for tool operations defining processing behavior and API integration. + * + * Supports three patterns: + * 1. Single-file tools: toolType: singleFile, processes files individually + * 2. Multi-file tools: toolType: multiFile, single API call with all files + * 3. Complex tools: toolType: custom, customProcessor handles all processing logic + */ +interface BaseToolOperationConfig { + /** Operation identifier for tracking and logging */ + operationType: ToolId; + + /** + * Prefix added to processed filenames (e.g., 'compressed_', 'split_'). + * Only generally useful for multiFile interfaces. + */ + filePrefix?: string; + + /** + * Whether to preserve the filename provided by the backend in response headers. + * When true, ignores filePrefix and uses the filename from Content-Disposition header. + * Useful for tools like auto-rename where the backend determines the final filename. + */ + preserveBackendFilename?: boolean; + + /** How to handle API responses (e.g., ZIP extraction, single file response) */ + responseHandler?: ResponseHandler; + + /** Extract user-friendly error messages from API errors */ + getErrorMessage?: (error: any) => string; + + /** Default parameter values for automation */ + defaultParameters?: TParams; + + /** + * For custom tools: if true, success implies all input files were successfully processed. + * Use this for tools like Automate or Merge where Many-to-One relationships exist + * and exact input-output mapping is difficult. + */ + consumesAllInputs?: boolean; +} + +export interface SingleFileToolOperationConfig extends BaseToolOperationConfig { + /** This tool processes one file at a time. */ + toolType: ToolType.singleFile; + + /** Builds FormData for API request. */ + buildFormData: ((params: TParams, file: File) => FormData); + + /** API endpoint for the operation. Can be static string or function for dynamic routing. */ + endpoint: string | ((params: TParams) => string); + + customProcessor?: undefined; +} + +export interface MultiFileToolOperationConfig extends BaseToolOperationConfig { + /** This tool processes multiple files at once. */ + toolType: ToolType.multiFile; + + /** Prefix added to processed filename (e.g., 'merged_', 'split_') */ + filePrefix: string; + + /** Builds FormData for API request. */ + buildFormData: ((params: TParams, files: File[]) => FormData); + + /** API endpoint for the operation. Can be static string or function for dynamic routing. */ + endpoint: string | ((params: TParams) => string); + + customProcessor?: undefined; +} + +export interface CustomToolOperationConfig extends BaseToolOperationConfig { + /** This tool has custom behaviour. */ + toolType: ToolType.custom; + + buildFormData?: undefined; + endpoint?: undefined; + + /** + * Custom processing logic that completely bypasses standard file processing. + * This tool handles all API calls, response processing, and file creation. + * Use for tools with complex routing logic or non-standard processing requirements. + * + * Returns CustomProcessorResult with: + * - files: Processed output files + * - consumedAllInputs: true if operation combines N inputs → fewer outputs + */ + customProcessor: (params: TParams, files: File[]) => Promise; +} + +export type ToolOperationConfig = + | SingleFileToolOperationConfig + | MultiFileToolOperationConfig + | CustomToolOperationConfig; + +/** + * Complete tool operation interface returned by useToolOperation. + */ +export interface ToolOperationHook { + // State + files: File[]; + thumbnails: string[]; + isGeneratingThumbnails: boolean; + downloadUrl: string | null; + downloadFilename: string; + downloadLocalPath?: string | null; + outputFileIds?: string[] | null; + isLoading: boolean; + status: string; + errorMessage: string | null; + progress: ProcessingProgress | null; + willUseCloud?: boolean; + + // Actions + executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise; + resetResults: () => void; + clearError: () => void; + cancelOperation: () => void; + undoOperation: () => Promise; +} diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index 78d71f74a3..9f87387f64 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -2,163 +2,45 @@ import { useCallback, useRef, useEffect } from 'react'; import apiClient from '@app/services/apiClient'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '@app/contexts/FileContext'; -import { useToolState, type ProcessingProgress } from '@app/hooks/tools/shared/useToolState'; +import { useNavigationActions } from '@app/contexts/NavigationContext'; +import { useToolState } from '@app/hooks/tools/shared/useToolState'; import { useToolApiCalls, type ApiCallsConfig } from '@app/hooks/tools/shared/useToolApiCalls'; import { useToolResources } from '@app/hooks/tools/shared/useToolResources'; -import { extractErrorMessage } from '@app/utils/toolErrorHandler'; -import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '@app/types/fileContext'; +import { extractErrorMessage, handle422Error } from '@app/utils/toolErrorHandler'; +import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '@app/types/fileContext'; import { FILE_EVENTS } from '@app/services/errorUtils'; import { getFilenameWithoutExtension } from '@app/utils/fileUtils'; -import { ResponseHandler } from '@app/utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; import { createNewStirlingFileStub } from '@app/types/fileContext'; import { ToolOperation } from '@app/types/file'; -import { ToolId } from '@app/types/toolId'; import { ensureBackendReady } from '@app/services/backendReadinessGuard'; import { useWillUseCloud } from '@app/hooks/useWillUseCloud'; import { useCreditCheck } from '@app/hooks/useCreditCheck'; import { notifyPdfProcessingComplete } from '@app/services/desktopNotificationService'; +import { buildInputTracking, buildOutputPairs } from '@app/hooks/tools/shared/toolOperationHelpers'; +import { + ToolType, + ToolOperationConfig, + ToolOperationHook, + CustomProcessorResult, + SingleFileToolOperationConfig, + MultiFileToolOperationConfig, + CustomToolOperationConfig, + ProcessingProgress, + ResponseHandler, +} from '@app/hooks/tools/shared/toolOperationTypes'; -// Re-export for backwards compatibility -export type { ProcessingProgress, ResponseHandler }; - -export enum ToolType { - singleFile, - multiFile, - custom, -} - -/** - * Result from custom processor with optional metadata about input consumption. - */ -export interface CustomProcessorResult { - /** Processed output files */ - files: File[]; - /** - * When true, marks all input files as successfully consumed regardless of output count. - * Use when operation combines N inputs into fewer outputs (e.g., 3 images → 1 PDF). - * When false/undefined, uses filename-based mapping to determine which inputs succeeded. - */ - consumedAllInputs?: boolean; -} - -/** - * Configuration for tool operations defining processing behavior and API integration. - * - * Supports three patterns: - * 1. Single-file tools: multiFileEndpoint: false, processes files individually - * 2. Multi-file tools: multiFileEndpoint: true, single API call with all files - * 3. Complex tools: customProcessor handles all processing logic - */ -interface BaseToolOperationConfig { - /** Operation identifier for tracking and logging */ - operationType: ToolId; - - /** - * Prefix added to processed filenames (e.g., 'compressed_', 'split_'). - * Only generally useful for multiFile interfaces. - */ - filePrefix?: string; - - /** - * Whether to preserve the filename provided by the backend in response headers. - * When true, ignores filePrefix and uses the filename from Content-Disposition header. - * Useful for tools like auto-rename where the backend determines the final filename. - */ - preserveBackendFilename?: boolean; - - /** How to handle API responses (e.g., ZIP extraction, single file response) */ - responseHandler?: ResponseHandler; - - /** Extract user-friendly error messages from API errors */ - getErrorMessage?: (error: any) => string; - - /** Default parameter values for automation */ - defaultParameters?: TParams; - - /** - * For custom tools: if true, success implies all input files were successfully processed. - * Use this for tools like Automate or Merge where Many-to-One relationships exist - * and exact input-output mapping is difficult. - */ - consumesAllInputs?: boolean; -} - -export interface SingleFileToolOperationConfig extends BaseToolOperationConfig { - /** This tool processes one file at a time. */ - toolType: ToolType.singleFile; - - /** Builds FormData for API request. */ - buildFormData: ((params: TParams, file: File) => FormData); - - /** API endpoint for the operation. Can be static string or function for dynamic routing. */ - endpoint: string | ((params: TParams) => string); - - customProcessor?: undefined; -} - -export interface MultiFileToolOperationConfig extends BaseToolOperationConfig { - /** This tool processes multiple files at once. */ - toolType: ToolType.multiFile; - - /** Prefix added to processed filename (e.g., 'merged_', 'split_') */ - filePrefix: string; - - /** Builds FormData for API request. */ - buildFormData: ((params: TParams, files: File[]) => FormData); - - /** API endpoint for the operation. Can be static string or function for dynamic routing. */ - endpoint: string | ((params: TParams) => string); - - customProcessor?: undefined; -} - -export interface CustomToolOperationConfig extends BaseToolOperationConfig { - /** This tool has custom behaviour. */ - toolType: ToolType.custom; - - buildFormData?: undefined; - endpoint?: undefined; - - /** - * Custom processing logic that completely bypasses standard file processing. - * This tool handles all API calls, response processing, and file creation. - * Use for tools with complex routing logic or non-standard processing requirements. - * - * Returns CustomProcessorResult with: - * - files: Processed output files - * - consumedAllInputs: true if operation combines N inputs → fewer outputs - */ - customProcessor: (params: TParams, files: File[]) => Promise; -} - -export type ToolOperationConfig = SingleFileToolOperationConfig | MultiFileToolOperationConfig | CustomToolOperationConfig; - -/** - * Complete tool operation interface with execution capability - */ -export interface ToolOperationHook { - // State - files: File[]; - thumbnails: string[]; - isGeneratingThumbnails: boolean; - downloadUrl: string | null; - downloadFilename: string; - downloadLocalPath?: string | null; - outputFileIds?: string[] | null; - isLoading: boolean; - status: string; - errorMessage: string | null; - progress: ProcessingProgress | null; - willUseCloud?: boolean; - - // Actions - executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise; - resetResults: () => void; - clearError: () => void; - cancelOperation: () => void; - undoOperation: () => Promise; -} +export { ToolType }; +export type { + ToolOperationConfig, + ToolOperationHook, + CustomProcessorResult, + SingleFileToolOperationConfig, + MultiFileToolOperationConfig, + CustomToolOperationConfig, + ProcessingProgress, + ResponseHandler, +}; // Re-export for backwards compatibility export { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; @@ -180,6 +62,7 @@ export const useToolOperation = ( ): ToolOperationHook => { const { t } = useTranslation(); const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext(); + const { actions: navActions } = useNavigationActions(); // Composed hooks const { state, actions } = useToolState(); @@ -399,7 +282,6 @@ export const useToolOperation = ( if (processedFiles.length > 0) { actions.setFiles(processedFiles); - // Generate thumbnails and download URL concurrently actions.setGeneratingThumbnails(true); const [thumbnails, downloadInfo] = await Promise.all([ @@ -409,127 +291,122 @@ export const useToolOperation = ( actions.setGeneratingThumbnails(false); actions.setThumbnails(thumbnails); - const downloadLocalPath = - validFiles.length === 1 && processedFiles.length === 1 - ? selectors.getStirlingFileStub(validFiles[0].fileId)?.localFilePath ?? null - : null; - // Replace input files with processed files (consumeFiles handles pinning) - const inputFileIds: FileId[] = []; - const inputStirlingFileStubs: StirlingFileStub[] = []; + // Determine whether outputs are new versions of their inputs or independent artifacts. + // A version operation produces exactly one output per successful input, all in the same + // format (e.g. compress, rotate, redact: 1→1 or N→N same extension). + // Everything else — format conversions (ext change), merges (N→1), splits (1→N) — + // produces outputs that have no meaningful parent-child relationship with the inputs. + const isVersionOp = processedFiles.length > 0 + && successSourceIds.length === processedFiles.length + && successSourceIds.every((id, i) => { + const inputFile = validFiles.find(f => f.fileId === id); + const inExt = inputFile?.name.split('.').pop()?.toLowerCase(); + const outExt = processedFiles[i].name.split('.').pop()?.toLowerCase(); + return inExt != null && inExt === outExt; + }); - // Build parallel arrays of IDs and records for undo tracking - for (const file of validFiles) { - const fileId = file.fileId; - const record = selectors.getStirlingFileStub(fileId); - if (record) { - inputFileIds.push(fileId); - inputStirlingFileStubs.push(record); - } else { - console.warn(`No file stub found for file: ${file.name}`); - const fallbackStub = createNewStirlingFileStub(file, fileId); - inputFileIds.push(fileId); - inputStirlingFileStubs.push(fallbackStub); - } - } - - // Create new tool operation - const newToolOperation: ToolOperation = { - toolId: config.operationType, - timestamp: Date.now() - }; - - // Generate fresh processedFileMetadata for all processed files to ensure accuracy actions.setStatus('Generating metadata for processed files...'); const processedFileMetadataArray = await Promise.all( processedFiles.map(file => generateProcessedFileMetadata(file)) ); - // Always create child stubs linking back to the successful source inputs - const successInputStubs = successSourceIds - .map((id) => selectors.getStirlingFileStub(id)) - .filter(Boolean) as StirlingFileStub[]; - if (successInputStubs.length !== processedFiles.length) { - console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', { - successInputStubs: successInputStubs.length, - outputs: processedFiles.length, - }); - } + const { inputFileIds, inputStirlingFileStubs } = buildInputTracking(validFiles, selectors); - const outputStirlingFileStubs = processedFiles.map((resultingFile, index) => - createChildStub( - successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0], - newToolOperation, - resultingFile, - thumbnails[index], - processedFileMetadataArray[index] - ) - ); + if (isVersionOp) { + // Output is a modified version of the input — link it to the input's version chain. + // The input is removed from the workbench and replaced in-place by the output. + const downloadLocalPath = + selectors.getStirlingFileStub(validFiles[0].fileId)?.localFilePath ?? null; - // Create StirlingFile objects from processed files and child stubs - const outputStirlingFiles = processedFiles.map((file, index) => { - const childStub = outputStirlingFileStubs[index]; - return createStirlingFile(file, childStub.id); - }); - // Build consumption arrays aligned to the successful source IDs - const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id)); - // Outputs and stubs are already ordered by success sequence - console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); - const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); + const newToolOperation: ToolOperation = { + toolId: config.operationType, + timestamp: Date.now() + }; - // Notify on desktop when processing completes - await notifyPdfProcessingComplete(outputFileIds.length); + const successInputStubs = successSourceIds + .map((id) => selectors.getStirlingFileStub(id)) + .filter(Boolean) as StirlingFileStub[]; - if (toConsumeInputIds.length === 1 && outputFileIds.length === 1) { - const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]); - if (inputStub?.localFilePath) { - fileActions.updateStirlingFileStub(outputFileIds[0], { - localFilePath: inputStub.localFilePath + if (successInputStubs.length !== processedFiles.length) { + console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', { + successInputStubs: successInputStubs.length, + outputs: processedFiles.length, }); } + + const { outputStirlingFileStubs, outputStirlingFiles } = buildOutputPairs( + processedFiles, thumbnails, processedFileMetadataArray, + (file, thumbnail, metadata, index) => createChildStub( + successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0], + newToolOperation, file, thumbnail, metadata + ) + ); + + // Only consume inputs that successfully produced outputs + const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id)); + console.debug('[useToolOperation] Consuming files (version)', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); + const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); + + // Notify on desktop when processing completes + await notifyPdfProcessingComplete(outputFileIds.length); + + // Carry the desktop save path forward so the output can be saved back to the same file + if (toConsumeInputIds.length === 1 && outputFileIds.length === 1) { + const inputStub = selectors.getStirlingFileStub(toConsumeInputIds[0]); + if (inputStub?.localFilePath) { + fileActions.updateStirlingFileStub(outputFileIds[0], { + localFilePath: inputStub.localFilePath + }); + } + } + + actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename, downloadLocalPath, outputFileIds); + + lastOperationRef.current = { + inputFiles: extractFiles(validFiles), + inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), + outputFileIds + }; + + } else { + // Outputs are independent artifacts (format conversion, merge, split). + // Create fresh root stubs with no parent chain, then swap out only the inputs + // that successfully produced outputs — other workbench files are untouched. + const { outputStirlingFileStubs, outputStirlingFiles } = buildOutputPairs( + processedFiles, thumbnails, processedFileMetadataArray, + (file, thumbnail, metadata) => createNewStirlingFileStub(file, undefined, thumbnail, metadata) + ); + + const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id)); + console.debug('[useToolOperation] Consuming files (independent)', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); + const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); + + // Notify on desktop when processing completes + await notifyPdfProcessingComplete(outputFileIds.length); + + actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename, null, outputFileIds); + + // Send the user to the viewer for a single PDF output, otherwise the file editor + const isSinglePdf = processedFiles.length === 1 + && processedFiles[0].type === 'application/pdf'; + navActions.setWorkbench(isSinglePdf ? 'viewer' : 'fileEditor'); + + lastOperationRef.current = { + inputFiles: extractFiles(validFiles), + inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), + outputFileIds + }; } - // Pass output file IDs to download info for marking clean after save - actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename, downloadLocalPath, outputFileIds); - - // Store operation data for undo (only store what we need to avoid memory bloat) - lastOperationRef.current = { - inputFiles: extractFiles(validFiles), // Convert to File objects for undo - inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues - outputFileIds - }; - } } catch (error: any) { - // Centralized 422 handler: mark provided IDs in errorFileIds try { - const status = error?.response?.status; - if (typeof status === 'number' && status === 422) { - const payload = error?.response?.data; - let parsed: unknown = payload; - if (typeof payload === 'string') { - try { parsed = JSON.parse(payload); } catch { parsed = payload; } - } else if (payload && typeof (payload as Blob).text === 'function') { - // Blob or Response-like object from axios when responseType='blob' - const text = await (payload as Blob).text(); - try { parsed = JSON.parse(text); } catch { parsed = text; } - } - let ids: string[] | undefined = Array.isArray((parsed as { errorFileIds?: unknown })?.errorFileIds) - ? (parsed as { errorFileIds: string[] }).errorFileIds - : undefined; - if (!ids && typeof parsed === 'string') { - const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); - if (match && match.length > 0) ids = Array.from(new Set(match)); - } - if (ids && ids.length > 0) { - for (const badId of ids) { - try { fileActions.markFileError(badId as FileId); } catch (_e) { void _e; } - } - actions.setStatus('Process failed due to invalid/corrupted file(s)'); - // Avoid duplicating toast messaging here - return; - } + const handled = await handle422Error(error, (id) => fileActions.markFileError(id as FileId)); + if (handled) { + actions.setStatus('Process failed due to invalid/corrupted file(s)'); + return; } } catch (_e) { void _e; } @@ -541,7 +418,7 @@ export const useToolOperation = ( actions.setLoading(false); actions.setProgress(null); } - }, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, willUseCloud, checkCredits]); + }, [t, config, actions, addFiles, consumeFiles, navActions, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, willUseCloud, checkCredits]); const cancelOperation = useCallback(() => { cancelApiCalls(); diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index e4e7ffeaa0..7b768ed87a 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -15,6 +15,8 @@ import { renderHook, act } from '@testing-library/react'; import { useConvertOperation } from '@app/hooks/tools/convert/useConvertOperation'; import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; import { FileContextProvider } from '@app/contexts/FileContext'; +import { NavigationProvider } from '@app/contexts/NavigationContext'; +import { ToolRegistryProvider } from '@app/contexts/ToolRegistryProvider'; import { PreferencesProvider } from '@app/contexts/PreferencesContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '@app/i18n/config'; @@ -92,9 +94,13 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - + + + + {children} + + + diff --git a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index a5bea8e887..cbf4fb2e60 100644 --- a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -9,6 +9,8 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { useConvertOperation } from '@app/hooks/tools/convert/useConvertOperation'; import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; import { FileContextProvider } from '@app/contexts/FileContext'; +import { NavigationProvider } from '@app/contexts/NavigationContext'; +import { ToolRegistryProvider } from '@app/contexts/ToolRegistryProvider'; import { PreferencesProvider } from '@app/contexts/PreferencesContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '@app/i18n/config'; @@ -80,9 +82,13 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - + + + + {children} + + + diff --git a/frontend/src/core/types/tool.ts b/frontend/src/core/types/tool.ts index a6a41eefef..ff14056d3e 100644 --- a/frontend/src/core/types/tool.ts +++ b/frontend/src/core/types/tool.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; +import { ToolOperationHook } from '@app/hooks/tools/shared/toolOperationTypes'; export type MaxFiles = number; // 1=single, >1=limited, -1=unlimited export type ToolCategory = 'manipulation' | 'conversion' | 'analysis' | 'utility' | 'optimization' | 'security'; diff --git a/frontend/src/core/utils/toolErrorHandler.ts b/frontend/src/core/utils/toolErrorHandler.ts index 836f1e00c2..270b92017d 100644 --- a/frontend/src/core/utils/toolErrorHandler.ts +++ b/frontend/src/core/utils/toolErrorHandler.ts @@ -34,6 +34,46 @@ export const createStandardErrorHandler = (fallbackMessage: string) => { }; }; +/** + * Parses a 422 response, extracts errored file IDs from the payload (JSON or UUID regex), + * and marks them in the UI. Returns true if IDs were found and handled, false otherwise. + */ +export const handle422Error = async ( + error: any, + markFileError: (fileId: string) => void +): Promise => { + const status = error?.response?.status; + if (typeof status !== 'number' || status !== 422) return false; + + const payload = error?.response?.data; + let parsed: unknown = payload; + + if (typeof payload === 'string') { + try { parsed = JSON.parse(payload); } catch { parsed = payload; } + } else if (payload && typeof (payload as Blob).text === 'function') { + const text = await (payload as Blob).text(); + try { parsed = JSON.parse(text); } catch { parsed = text; } + } + + let ids: string[] | undefined = Array.isArray((parsed as { errorFileIds?: unknown })?.errorFileIds) + ? (parsed as { errorFileIds: string[] }).errorFileIds + : undefined; + + if (!ids && typeof parsed === 'string') { + const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); + if (match && match.length > 0) ids = Array.from(new Set(match)); + } + + if (ids && ids.length > 0) { + for (const id of ids) { + try { markFileError(id); } catch (_e) { void _e; } + } + return true; + } + + return false; +}; + /** * Handles password-related errors with status code checking * @param error - The error object from axios