Feature/v2/shared tool hooks (#4134)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
Reece Browne
2025-08-08 16:01:56 +01:00
committed by GitHub
co-authored by Reece Browne
parent b45d3a43d4
commit 507ad1dc61
18 changed files with 1067 additions and 1241 deletions
@@ -0,0 +1,86 @@
import { useCallback, useRef } from 'react';
import axios, { CancelTokenSource } from 'axios';
import { processResponse } from '../../../utils/toolResponseProcessor';
import type { ResponseHandler, ProcessingProgress } from './useToolState';
export interface ApiCallsConfig<TParams = void> {
endpoint: string | ((params: TParams) => string);
buildFormData: (file: File, params: TParams) => FormData;
filePrefix: string;
responseHandler?: ResponseHandler;
}
export const useToolApiCalls = <TParams = void>() => {
const cancelTokenRef = useRef<CancelTokenSource | null>(null);
const processFiles = useCallback(async (
params: TParams,
validFiles: File[],
config: ApiCallsConfig<TParams>,
onProgress: (progress: ProcessingProgress) => void,
onStatus: (status: string) => void
): Promise<File[]> => {
const processedFiles: File[] = [];
const failedFiles: string[] = [];
const total = validFiles.length;
// Create cancel token for this operation
cancelTokenRef.current = axios.CancelToken.source();
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
onProgress({ current: i + 1, total, currentFileName: file.name });
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
try {
const formData = config.buildFormData(file, params);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
cancelToken: cancelTokenRef.current.token,
});
// Forward to shared response processor (uses tool-specific responseHandler if provided)
const responseFiles = await processResponse(
response.data,
[file],
config.filePrefix,
config.responseHandler
);
processedFiles.push(...responseFiles);
} catch (error) {
if (axios.isCancel(error)) {
throw new Error('Operation was cancelled');
}
console.error(`Failed to process ${file.name}:`, error);
failedFiles.push(file.name);
}
}
if (failedFiles.length > 0 && processedFiles.length === 0) {
throw new Error(`Failed to process all files: ${failedFiles.join(', ')}`);
}
if (failedFiles.length > 0) {
onStatus(`Processed ${processedFiles.length}/${total} files. Failed: ${failedFiles.join(', ')}`);
} else {
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
}
return processedFiles;
}, []);
const cancelOperation = useCallback(() => {
if (cancelTokenRef.current) {
cancelTokenRef.current.cancel('Operation cancelled by user');
cancelTokenRef.current = null;
}
}, []);
return {
processFiles,
cancelOperation,
};
};
@@ -0,0 +1,264 @@
import { useCallback } from 'react';
import axios from 'axios';
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 { createOperation } from '../../../utils/toolOperationTracker';
import { type ResponseHandler, processResponse } from '../../../utils/toolResponseProcessor';
export interface ValidationResult {
valid: boolean;
errors?: string[];
}
// Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler };
/**
* 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
*/
export interface ToolOperationConfig<TParams = void> {
/** Operation identifier for tracking and logging */
operationType: string;
/**
* API endpoint for the operation. Can be static string or function for dynamic routing.
* Not used when customProcessor is provided.
*/
endpoint: string | ((params: TParams) => string);
/**
* Builds FormData for API request. Signature determines processing approach:
* - (params, file: File) => FormData: Single-file processing
* - (params, files: File[]) => FormData: Multi-file processing
* Not used when customProcessor is provided.
*/
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData);
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
filePrefix: string;
/**
* Whether this tool uses backends that accept MultipartFile[] arrays.
* - true: Single API call with all files (backend uses MultipartFile[])
* - false/undefined: Individual API calls per file (backend uses single MultipartFile)
* Ignored when customProcessor is provided.
*/
multiFileEndpoint?: boolean;
/** How to handle API responses (e.g., ZIP extraction, single file response) */
responseHandler?: ResponseHandler;
/**
* Custom processing logic that completely bypasses standard file processing.
* When provided, 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<File[]>;
/** Validate parameters before execution. Return validation errors if invalid. */
validateParams?: (params: TParams) => ValidationResult;
/** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string;
}
/**
* Complete tool operation interface with execution capability
*/
export interface ToolOperationHook<TParams = void> {
// 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: File[]) => Promise<void>;
resetResults: () => void;
clearError: () => void;
cancelOperation: () => void;
}
// 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 = <TParams = void>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
const executeOperation = useCallback(async (
params: TParams,
selectedFiles: File[]
): Promise<void> => {
// Validation
if (selectedFiles.length === 0) {
actions.setError(t('noFileSelected', 'No files selected'));
return;
}
if (config.validateParams) {
const validation = config.validateParams(params);
if (!validation.valid) {
actions.setError(validation.errors?.join(', ') || 'Invalid parameters');
return;
}
}
const validFiles = selectedFiles.filter(file => file.size > 0);
if (validFiles.length === 0) {
actions.setError(t('noValidFiles', 'No valid files to process'));
return;
}
// Setup operation tracking
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
recordOperation(fileId, operation);
// Reset state
actions.setLoading(true);
actions.setError(null);
actions.resetResults();
cleanupBlobUrls();
try {
let processedFiles: File[];
if (config.customProcessor) {
actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, validFiles);
} else {
// Use explicit multiFileEndpoint flag to determine processing approach
if (config.multiFileEndpoint) {
// Multi-file processing - single API call with all files
actions.setStatus('Processing files...');
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles);
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
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles);
} 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);
}
}
} else {
// Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint,
buildFormData: (file: File, params: TParams) => (config.buildFormData as (file: File, params: TParams) => FormData)(file, params),
filePrefix: config.filePrefix,
responseHandler: config.responseHandler
};
processedFiles = await processFiles(
params,
validFiles,
apiCallsConfig,
actions.setProgress,
actions.setStatus
);
}
}
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);
// Add to file context
await addFiles(processedFiles);
markOperationApplied(fileId, operationId);
}
} catch (error: any) {
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
actions.setError(errorMessage);
actions.setStatus('');
markOperationFailed(fileId, operationId, errorMessage);
} finally {
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, 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();
}, [cleanupBlobUrls, actions]);
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
};
};
@@ -0,0 +1,114 @@
import { useState, useCallback, useEffect } from 'react';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { zipFileService } from '../../../services/zipFileService';
export const useToolResources = () => {
const [blobUrls, setBlobUrls] = useState<string[]>([]);
const addBlobUrl = useCallback((url: string) => {
setBlobUrls(prev => [...prev, url]);
}, []);
const cleanupBlobUrls = useCallback(() => {
blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
});
setBlobUrls([]);
}, [blobUrls]);
// Cleanup on unmount
useEffect(() => {
return () => {
blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL during cleanup:', error);
}
});
};
}, [blobUrls]);
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
const thumbnails: string[] = [];
for (const file of files) {
try {
const thumbnail = await generateThumbnailForFile(file);
thumbnails.push(thumbnail);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
thumbnails.push('');
}
}
return thumbnails;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try {
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
return extractionResult.success ? extractionResult.extractedFiles : [];
} catch (error) {
console.error('useToolResources.extractZipFiles - Error:', error);
return [];
}
}, []);
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const arrayBuffer = await zipBlob.arrayBuffer();
const zipContent = await zip.loadAsync(arrayBuffer);
const extractedFiles: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const content = await file.async('blob');
const extractedFile = new File([content], filename, { type: 'application/pdf' });
extractedFiles.push(extractedFile);
}
}
return extractedFiles;
} catch (error) {
console.error('Error in extractAllZipFiles:', error);
return [];
}
}, []);
const createDownloadInfo = useCallback(async (
files: File[],
operationType: string
): Promise<{ url: string; filename: string }> => {
if (files.length === 1) {
const url = URL.createObjectURL(files[0]);
addBlobUrl(url);
return { url, filename: files[0].name };
}
// Multiple files - create zip using shared service
const { zipFile } = await zipFileService.createZipFromFiles(files, `${operationType}_results.zip`);
const url = URL.createObjectURL(zipFile);
addBlobUrl(url);
return { url, filename: zipFile.name };
}, [addBlobUrl]);
return {
generateThumbnails,
createDownloadInfo,
extractZipFiles,
extractAllZipFiles,
cleanupBlobUrls,
};
};
@@ -0,0 +1,137 @@
import { useReducer, useCallback } from 'react';
export interface ProcessingProgress {
current: number;
total: number;
currentFileName?: string;
}
export interface OperationState {
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
isLoading: boolean;
status: string;
errorMessage: string | null;
progress: ProcessingProgress | null;
}
type OperationAction =
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_FILES'; payload: File[] }
| { type: 'SET_THUMBNAILS'; payload: string[] }
| { type: 'SET_GENERATING_THUMBNAILS'; payload: boolean }
| { type: 'SET_DOWNLOAD_INFO'; payload: { url: string | null; filename: string } }
| { type: 'SET_STATUS'; payload: string }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_PROGRESS'; payload: ProcessingProgress | null }
| { type: 'RESET_RESULTS' }
| { type: 'CLEAR_ERROR' };
const initialState: OperationState = {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
isLoading: false,
status: '',
errorMessage: null,
progress: null,
};
const operationReducer = (state: OperationState, action: OperationAction): OperationState => {
switch (action.type) {
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_FILES':
return { ...state, files: action.payload };
case 'SET_THUMBNAILS':
return { ...state, thumbnails: action.payload };
case 'SET_GENERATING_THUMBNAILS':
return { ...state, isGeneratingThumbnails: action.payload };
case 'SET_DOWNLOAD_INFO':
return {
...state,
downloadUrl: action.payload.url,
downloadFilename: action.payload.filename
};
case 'SET_STATUS':
return { ...state, status: action.payload };
case 'SET_ERROR':
return { ...state, errorMessage: action.payload };
case 'SET_PROGRESS':
return { ...state, progress: action.payload };
case 'RESET_RESULTS':
return {
...initialState,
isLoading: state.isLoading, // Preserve loading state during reset
};
case 'CLEAR_ERROR':
return { ...state, errorMessage: null };
default:
return state;
}
};
export const useToolState = () => {
const [state, dispatch] = useReducer(operationReducer, initialState);
const setLoading = useCallback((loading: boolean) => {
dispatch({ type: 'SET_LOADING', payload: loading });
}, []);
const setFiles = useCallback((files: File[]) => {
dispatch({ type: 'SET_FILES', payload: files });
}, []);
const setThumbnails = useCallback((thumbnails: string[]) => {
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
}, []);
const setGeneratingThumbnails = useCallback((generating: boolean) => {
dispatch({ type: 'SET_GENERATING_THUMBNAILS', payload: generating });
}, []);
const setDownloadInfo = useCallback((url: string | null, filename: string) => {
dispatch({ type: 'SET_DOWNLOAD_INFO', payload: { url, filename } });
}, []);
const setStatus = useCallback((status: string) => {
dispatch({ type: 'SET_STATUS', payload: status });
}, []);
const setError = useCallback((error: string | null) => {
dispatch({ type: 'SET_ERROR', payload: error });
}, []);
const setProgress = useCallback((progress: ProcessingProgress | null) => {
dispatch({ type: 'SET_PROGRESS', payload: progress });
}, []);
const resetResults = useCallback(() => {
dispatch({ type: 'RESET_RESULTS' });
}, []);
const clearError = useCallback(() => {
dispatch({ type: 'CLEAR_ERROR' });
}, []);
return {
state,
actions: {
setLoading,
setFiles,
setThumbnails,
setGeneratingThumbnails,
setDownloadInfo,
setStatus,
setError,
setProgress,
resetResults,
clearError,
},
};
};