import { useCallback, useRef, useEffect } from 'react'; import axios from '../../../services/http'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolResources } from './useToolResources'; import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext'; import { FILE_EVENTS } from '../../../services/errorUtils'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions'; import { ToolOperation } from '../../../types/file'; import { ToolId } from '../../../types/toolId'; // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; export enum ToolType { singleFile, multiFile, custom, } /** * 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; } 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. */ 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; isLoading: boolean; status: string; errorMessage: string | null; progress: ProcessingProgress | null; // Actions executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise; resetResults: () => void; clearError: () => void; cancelOperation: () => void; undoOperation: () => Promise; } // Re-export for backwards compatibility export { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; /** * Shared hook for tool operations providing consistent error handling, progress tracking, * and FileContext integration. Eliminates boilerplate while maintaining flexibility. * * Supports three tool patterns: * 1. Single-file tools: Set multiFileEndpoint: false, processes files individually * 2. Multi-file tools: Set multiFileEndpoint: true, single API call with all files * 3. Complex tools: Provide customProcessor for full control over processing logic * * @param config - Tool operation configuration * @returns Hook interface with state and execution methods */ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); const { actions: fileActions } = useFileContext(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); // Track last operation for undo functionality const lastOperationRef = useRef<{ inputFiles: File[]; inputStirlingFileStubs: StirlingFileStub[]; outputFileIds: FileId[]; } | null>(null); const executeOperation = useCallback(async ( params: TParams, selectedFiles: StirlingFile[] ): Promise => { // Validation if (selectedFiles.length === 0) { actions.setError(t('noFileSelected', 'No files selected')); return; } // Handle zero-byte inputs explicitly: mark as error and continue with others const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0); if (zeroByteFiles.length > 0) { try { for (const f of zeroByteFiles) { (fileActions.markFileError as any)((f as any).fileId); } } catch (e) { console.log('markFileError', e); } } const validFiles = selectedFiles.filter(file => (file as any)?.size > 0); if (validFiles.length === 0) { actions.setError(t('noValidFiles', 'No valid files to process')); return; } // Reset state actions.setLoading(true); actions.setError(null); actions.resetResults(); cleanupBlobUrls(); // Prepare files with history metadata injection (for PDFs) actions.setStatus('Processing files...'); // Listen for global error file id events from HTTP interceptor during this run let externalErrorFileIds: string[] = []; const errorListener = (e: Event) => { const detail = (e as CustomEvent)?.detail as any; if (detail?.fileIds) { externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : []; } }; window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener); try { let processedFiles: File[]; let successSourceIds: string[] = []; // Use original files directly (no PDF metadata injection - history stored in IndexedDB) const filesForAPI = extractFiles(validFiles); switch (config.toolType) { case ToolType.singleFile: { // Individual file processing - separate API call per file const apiCallsConfig: ApiCallsConfig = { endpoint: config.endpoint, buildFormData: config.buildFormData, filePrefix: config.filePrefix, responseHandler: config.responseHandler, preserveBackendFilename: config.preserveBackendFilename }; console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length }); const result = await processFiles( params, filesForAPI, apiCallsConfig, actions.setProgress, actions.setStatus, fileActions.markFileError as any ); processedFiles = result.outputFiles; successSourceIds = result.successSourceIds as any; console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length }); break; } case ToolType.multiFile: { // Multi-file processing - single API call with all files actions.setStatus('Processing files...'); const formData = config.buildFormData(params, filesForAPI); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const response = await axios.post(endpoint, formData, { responseType: 'blob' }); // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs if (config.responseHandler) { // Use custom responseHandler for multi-file (handles ZIP extraction) processedFiles = await config.responseHandler(response.data, filesForAPI); } else if (response.data.type === 'application/pdf' || (response.headers && response.headers['content-type'] === 'application/pdf')) { // Single PDF response (e.g. split with merge option) - add prefix to first original filename const filename = `${config.filePrefix}${filesForAPI[0]?.name || 'document.pdf'}`; const singleFile = new File([response.data], filename, { type: 'application/pdf' }); processedFiles = [singleFile]; } else { // Default: assume ZIP response for multi-file endpoints processedFiles = await extractZipFiles(response.data); if (processedFiles.length === 0) { // Try the generic extraction as fallback processedFiles = await extractAllZipFiles(response.data); } } // Assume all inputs succeeded together unless server provided an error earlier successSourceIds = validFiles.map(f => (f as any).fileId) as any; break; } case ToolType.custom: { actions.setStatus('Processing files...'); processedFiles = await config.customProcessor(params, filesForAPI); // Try to map outputs back to inputs by filename (before extension) const inputBaseNames = new Map(); for (const f of validFiles) { const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase(); inputBaseNames.set(base, (f as any).fileId); } const mappedSuccess: string[] = []; for (const out of processedFiles) { const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase(); const id = inputBaseNames.get(base); if (id) mappedSuccess.push(id); } // Fallback to naive alignment if names don't match if (mappedSuccess.length === 0) { successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any; } else { successSourceIds = mappedSuccess as any; } break; } } // Normalize error flags across tool types: mark failures, clear successes try { const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[]; const okSet = new Set((successSourceIds as unknown as string[]) || []); // Clear errors on successes for (const okId of okSet) { try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; } } // Mark errors on inputs that didn't succeed for (const id of allInputIds) { if (!okSet.has(id)) { try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; } } } } catch (_e) { void _e; } if (externalErrorFileIds.length > 0) { // If backend told us which sources failed, prefer that mapping successSourceIds = validFiles .map(f => (f as any).fileId) .filter(id => !externalErrorFileIds.includes(id)) as any; // Also mark failed IDs immediately try { for (const badId of externalErrorFileIds) { (fileActions.markFileError as any)(badId); } } catch (_e) { void _e; } } if (processedFiles.length > 0) { actions.setFiles(processedFiles); // Generate thumbnails and download URL concurrently actions.setGeneratingThumbnails(true); const [thumbnails, downloadInfo] = await Promise.all([ generateThumbnails(processedFiles), createDownloadInfo(processedFiles, config.operationType) ]); actions.setGeneratingThumbnails(false); actions.setThumbnails(thumbnails); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); // Replace input files with processed files (consumeFiles handles pinning) const inputFileIds: FileId[] = []; const inputStirlingFileStubs: StirlingFileStub[] = []; // 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}`); } } // 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 as any)) .filter(Boolean) as StirlingFileStub[]; if (successInputStubs.length !== processedFiles.length) { console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', { successInputStubs: successInputStubs.length, outputs: processedFiles.length, }); } const outputStirlingFileStubs = processedFiles.map((resultingFile, index) => createChildStub( successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0], newToolOperation, resultingFile, thumbnails[index], processedFileMetadataArray[index] ) ); // 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: string) => inputFileIds.includes(id as any)) as unknown as FileId[]; // 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); // 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 as number | undefined); if (status === 422) { const payload = error?.response?.data; let parsed: any = payload; if (typeof payload === 'string') { try { parsed = JSON.parse(payload); } catch { parsed = payload; } } else if (payload && typeof (payload as any).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?.errorFileIds) ? parsed.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 as any)(badId); } catch (_e) { void _e; } } actions.setStatus('Process failed due to invalid/corrupted file(s)'); // Avoid duplicating toast messaging here return; } } } catch (_e) { void _e; } const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); actions.setError(errorMessage); actions.setStatus(''); } finally { window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener); actions.setLoading(false); actions.setProgress(null); } }, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]); const cancelOperation = useCallback(() => { cancelApiCalls(); actions.setLoading(false); actions.setProgress(null); actions.setStatus('Operation cancelled'); }, [cancelApiCalls, actions]); const resetResults = useCallback(() => { cleanupBlobUrls(); actions.resetResults(); // Clear undo data when results are reset to prevent memory leaks lastOperationRef.current = null; }, [cleanupBlobUrls, actions]); // Cleanup on unmount to prevent memory leaks useEffect(() => { return () => { lastOperationRef.current = null; }; }, []); const undoOperation = useCallback(async () => { if (!lastOperationRef.current) { actions.setError(t('noOperationToUndo', 'No operation to undo')); return; } const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current; // Validate that we have data to undo if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) { actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data')); return; } if (outputFileIds.length === 0) { actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation')); return; } try { // Undo the consume operation await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds); // Clear results and operation tracking resetResults(); lastOperationRef.current = null; // Show success message actions.setStatus(t('undoSuccess', 'Operation undone successfully')); } catch (error: any) { let errorMessage = extractErrorMessage(error); // Provide more specific error messages based on error type if (error.message?.includes('Mismatch between input files')) { errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted'); } else if (error.message?.includes('IndexedDB')) { errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage'); } else if (error.name === 'QuotaExceededError') { errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space'); } actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`); // Don't clear the operation data if undo failed - user might want to try again } }, [undoConsumeFiles, resetResults, actions, t]); return { // State files: state.files, thumbnails: state.thumbnails, isGeneratingThumbnails: state.isGeneratingThumbnails, downloadUrl: state.downloadUrl, downloadFilename: state.downloadFilename, isLoading: state.isLoading, status: state.status, errorMessage: state.errorMessage, progress: state.progress, // Actions executeOperation, resetResults, clearError: actions.clearError, cancelOperation, undoOperation }; };