mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-22 04:09:22 +00:00
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 <you@example.com>
This commit is contained in:
parent
b45d3a43d4
commit
507ad1dc61
80
CLAUDE.md
80
CLAUDE.md
@ -59,12 +59,73 @@ Frontend designed for **stateful document processing**:
|
|||||||
Without cleanup: browser crashes with memory leaks.
|
Without cleanup: browser crashes with memory leaks.
|
||||||
|
|
||||||
#### Tool Development
|
#### Tool Development
|
||||||
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
|
|
||||||
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection)
|
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||||
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
|
|
||||||
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking
|
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||||
- **Parameters**: Tool parameter handling patterns still being standardized
|
- Coordinates all tool operations with consistent interface
|
||||||
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview)
|
- Integrates with FileContext for operation tracking
|
||||||
|
- Handles validation, error handling, and UI state management
|
||||||
|
|
||||||
|
- **Supporting Hooks**:
|
||||||
|
- **useToolState**: UI state management (loading, progress, error, files)
|
||||||
|
- **useToolApiCalls**: HTTP requests and file processing
|
||||||
|
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||||
|
|
||||||
|
- **Utilities**:
|
||||||
|
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||||
|
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||||
|
- **toolOperationTracker**: FileContext integration utilities
|
||||||
|
|
||||||
|
**Three Tool Patterns**:
|
||||||
|
|
||||||
|
**Pattern 1: Single-File Tools** (Individual processing)
|
||||||
|
- Backend processes one file per API call
|
||||||
|
- Set `multiFileEndpoint: false`
|
||||||
|
- Examples: Compress, Rotate
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'compress',
|
||||||
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
|
buildFormData: (params, file: File) => { /* single file */ },
|
||||||
|
multiFileEndpoint: false,
|
||||||
|
filePrefix: 'compressed_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||||
|
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||||
|
- Set `multiFileEndpoint: true`
|
||||||
|
- Examples: Split, Merge, Overlay
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'split',
|
||||||
|
endpoint: '/api/v1/general/split-pages',
|
||||||
|
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||||
|
multiFileEndpoint: true,
|
||||||
|
filePrefix: 'split_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 3: Complex Tools** (Custom processing)
|
||||||
|
- Tools with complex routing logic or non-standard processing
|
||||||
|
- Provide `customProcessor` for full control
|
||||||
|
- Examples: Convert, OCR
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'convert',
|
||||||
|
customProcessor: async (params, files) => { /* custom logic */ },
|
||||||
|
filePrefix: 'converted_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||||
|
- **Consistent**: All tools follow same pattern and interface
|
||||||
|
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||||
|
- **i18n Ready**: Built-in internationalization support
|
||||||
|
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||||
|
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
@ -126,7 +187,10 @@ Without cleanup: browser crashes with memory leaks.
|
|||||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||||
- **State Management**: FileContext handles all file operations and tool navigation
|
- **State Management**: FileContext handles all file operations and tool navigation
|
||||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||||
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
|
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||||
|
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||||
|
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||||
|
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||||
|
|
||||||
@ -141,7 +205,7 @@ Without cleanup: browser crashes with memory leaks.
|
|||||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||||
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`)
|
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
||||||
|
|
||||||
export interface SplitParameters {
|
export interface SplitParameters {
|
||||||
|
mode: SplitMode | '';
|
||||||
pages: string;
|
pages: string;
|
||||||
hDiv: string;
|
hDiv: string;
|
||||||
vDiv: string;
|
vDiv: string;
|
||||||
@ -15,16 +16,12 @@ export interface SplitParameters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitSettingsProps {
|
export interface SplitSettingsProps {
|
||||||
mode: SplitMode | '';
|
|
||||||
onModeChange: (mode: SplitMode | '') => void;
|
|
||||||
parameters: SplitParameters;
|
parameters: SplitParameters;
|
||||||
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitSettings = ({
|
const SplitSettings = ({
|
||||||
mode,
|
|
||||||
onModeChange,
|
|
||||||
parameters,
|
parameters,
|
||||||
onParameterChange,
|
onParameterChange,
|
||||||
disabled = false
|
disabled = false
|
||||||
@ -125,8 +122,8 @@ const SplitSettings = ({
|
|||||||
<Select
|
<Select
|
||||||
label="Choose split method"
|
label="Choose split method"
|
||||||
placeholder="Select how to split the PDF"
|
placeholder="Select how to split the PDF"
|
||||||
value={mode}
|
value={parameters.mode}
|
||||||
onChange={(v) => v && onModeChange(v)}
|
onChange={(v) => v && onParameterChange('mode', v)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data={[
|
data={[
|
||||||
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
||||||
@ -137,10 +134,10 @@ const SplitSettings = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Parameter Form */}
|
{/* Parameter Form */}
|
||||||
{mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
|
{parameters.mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
|
||||||
{mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
|
{parameters.mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
|
||||||
{mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
|
{parameters.mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
|
||||||
{mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
|
{parameters.mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
|
||||||
|
|
||||||
export interface CompressParameters {
|
export interface CompressParameters {
|
||||||
compressionLevel: number;
|
compressionLevel: number;
|
||||||
@ -15,66 +11,8 @@ export interface CompressParameters {
|
|||||||
fileSizeUnit: 'KB' | 'MB';
|
fileSizeUnit: 'KB' | 'MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompressOperationHook {
|
const buildFormData = (parameters: CompressParameters, file: File): FormData => {
|
||||||
executeOperation: (
|
|
||||||
parameters: CompressParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files: File[];
|
|
||||||
thumbnails: string[];
|
|
||||||
isGeneratingThumbnails: boolean;
|
|
||||||
downloadUrl: string | null;
|
|
||||||
downloadFilename: string;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCompressOperation = (): CompressOperationHook => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [downloadFilename, setDownloadFilename] = useState<string>('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Track blob URLs for cleanup
|
|
||||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const cleanupBlobUrls = useCallback(() => {
|
|
||||||
blobUrls.forEach(url => {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to revoke blob URL:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setBlobUrls([]);
|
|
||||||
}, [blobUrls]);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
|
||||||
parameters: CompressParameters,
|
|
||||||
file: File
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append("fileInput", file);
|
formData.append("fileInput", file);
|
||||||
|
|
||||||
if (parameters.compressionMethod === 'quality') {
|
if (parameters.compressionMethod === 'quality') {
|
||||||
@ -88,181 +26,24 @@ export const useCompressOperation = (): CompressOperationHook => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formData.append("grayscale", parameters.grayscale.toString());
|
formData.append("grayscale", parameters.grayscale.toString());
|
||||||
|
return formData;
|
||||||
const endpoint = "/api/v1/misc/compress-pdf";
|
};
|
||||||
|
|
||||||
return { formData, endpoint };
|
export const useCompressOperation = () => {
|
||||||
}, []);
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const createOperation = useCallback((
|
return useToolOperation<CompressParameters>({
|
||||||
parameters: CompressParameters,
|
operationType: 'compress',
|
||||||
selectedFiles: File[]
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
buildFormData,
|
||||||
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
filePrefix: 'compressed_',
|
||||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
multiFileEndpoint: false, // Individual API calls per file
|
||||||
|
validateParams: (params) => {
|
||||||
const operation: FileOperation = {
|
if (params.compressionMethod === 'filesize' && !params.fileSizeValue) {
|
||||||
id: operationId,
|
return { valid: false, errors: [t('compress.validation.fileSizeRequired', 'File size value is required when using filesize method')] };
|
||||||
type: 'compress',
|
}
|
||||||
timestamp: Date.now(),
|
return { valid: true };
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
},
|
||||||
status: 'pending',
|
getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.'))
|
||||||
metadata: {
|
});
|
||||||
originalFileNames: selectedFiles.map(f => f.name),
|
|
||||||
parameters: {
|
|
||||||
compressionLevel: parameters.compressionLevel,
|
|
||||||
grayscale: parameters.grayscale,
|
|
||||||
expectedSize: parameters.expectedSize,
|
|
||||||
},
|
|
||||||
totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0),
|
|
||||||
fileCount: selectedFiles.length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
parameters: CompressParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
|
||||||
if (validFiles.length === 0) {
|
|
||||||
setErrorMessage('No valid files to compress. All selected files are empty.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validFiles.length < selectedFiles.length) {
|
|
||||||
console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading"));
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const compressedFiles: File[] = [];
|
|
||||||
|
|
||||||
const failedFiles: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < validFiles.length; i++) {
|
|
||||||
const file = validFiles[i];
|
|
||||||
setStatus(`Compressing ${file.name} (${i + 1}/${validFiles.length})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { formData, endpoint } = buildFormData(parameters, file);
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
|
|
||||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
|
||||||
const blob = new Blob([response.data], { type: contentType });
|
|
||||||
const compressedFile = new File([blob], `compressed_${file.name}`, { type: contentType });
|
|
||||||
|
|
||||||
compressedFiles.push(compressedFile);
|
|
||||||
} catch (fileError) {
|
|
||||||
console.error(`Failed to compress ${file.name}:`, fileError);
|
|
||||||
failedFiles.push(file.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0 && compressedFiles.length === 0) {
|
|
||||||
throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0) {
|
|
||||||
setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles(compressedFiles);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
await addFiles(compressedFiles);
|
|
||||||
|
|
||||||
cleanupBlobUrls();
|
|
||||||
|
|
||||||
if (compressedFiles.length === 1) {
|
|
||||||
const url = window.URL.createObjectURL(compressedFiles[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(`compressed_${selectedFiles[0].name}`);
|
|
||||||
} else {
|
|
||||||
const { zipFile } = await zipFileService.createZipFromFiles(compressedFiles, 'compressed_files.zip');
|
|
||||||
const url = window.URL.createObjectURL(zipFile);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(`compressed_${validFiles.length}_files.zip`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbnails = await Promise.all(
|
|
||||||
compressedFiles.map(async (file) => {
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
|
||||||
return thumbnail || '';
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setThumbnails(thumbnails);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setStatus(t("downloadComplete"));
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
let errorMsg = t("error.pdfPassword", "An error occurred while compressing the PDF.");
|
|
||||||
if (error.response?.data && typeof error.response.data === 'string') {
|
|
||||||
errorMsg = error.response.data;
|
|
||||||
} else if (error.message) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
setStatus(t("error._value", "Compression failed."));
|
|
||||||
markOperationFailed(fileId, operationId, errorMsg);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
cleanupBlobUrls();
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [cleanupBlobUrls]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
executeOperation,
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
downloadFilename,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
@ -1,36 +1,12 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
|
||||||
import { ConvertParameters } from './useConvertParameters';
|
import { ConvertParameters } from './useConvertParameters';
|
||||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||||
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||||
|
|
||||||
export interface ConvertOperationHook {
|
|
||||||
executeOperation: (
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files: File[];
|
|
||||||
thumbnails: string[];
|
|
||||||
isGeneratingThumbnails: boolean;
|
|
||||||
downloadUrl: string | null;
|
|
||||||
downloadFilename: string;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldProcessFilesSeparately = (
|
const shouldProcessFilesSeparately = (
|
||||||
selectedFiles: File[],
|
selectedFiles: File[],
|
||||||
parameters: ConvertParameters
|
parameters: ConvertParameters
|
||||||
@ -53,74 +29,7 @@ const shouldProcessFilesSeparately = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFileFromResponse = (
|
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
||||||
responseData: any,
|
|
||||||
headers: any,
|
|
||||||
originalFileName: string,
|
|
||||||
targetExtension: string
|
|
||||||
): File => {
|
|
||||||
const originalName = originalFileName.split('.')[0];
|
|
||||||
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
|
||||||
|
|
||||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateThumbnailsForFiles = async (files: File[]): Promise<string[]> => {
|
|
||||||
const thumbnails: string[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
|
||||||
thumbnails.push(thumbnail);
|
|
||||||
} catch (error) {
|
|
||||||
thumbnails.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnails;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDownloadInfo = async (files: File[]): Promise<{ url: string; filename: string }> => {
|
|
||||||
if (files.length === 1) {
|
|
||||||
const url = window.URL.createObjectURL(files[0]);
|
|
||||||
return { url, filename: files[0].name };
|
|
||||||
} else {
|
|
||||||
const JSZip = (await import('jszip')).default;
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
zip.file(file.name, file);
|
|
||||||
});
|
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const zipUrl = window.URL.createObjectURL(zipBlob);
|
|
||||||
|
|
||||||
return { url: zipUrl, filename: 'converted_files.zip' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConvertOperation = (): ConvertOperationHook => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [downloadFilename, setDownloadFilename] = useState('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
selectedFiles.forEach(file => {
|
selectedFiles.forEach(file => {
|
||||||
@ -158,271 +67,84 @@ export const useConvertOperation = (): ConvertOperationHook => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const createOperation = useCallback((
|
const createFileFromResponse = (
|
||||||
|
responseData: any,
|
||||||
|
headers: any,
|
||||||
|
originalFileName: string,
|
||||||
|
targetExtension: string
|
||||||
|
): File => {
|
||||||
|
const originalName = originalFileName.split('.')[0];
|
||||||
|
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
||||||
|
|
||||||
|
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConvertOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const customConvertProcessor = useCallback(async (
|
||||||
parameters: ConvertParameters,
|
parameters: ConvertParameters,
|
||||||
selectedFiles: File[]
|
selectedFiles: File[]
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
): Promise<File[]> => {
|
||||||
const operationId = `convert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const fileId = selectedFiles[0].name;
|
|
||||||
|
|
||||||
const operation: FileOperation = {
|
const processedFiles: File[] = [];
|
||||||
id: operationId,
|
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
||||||
type: 'convert',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0].name,
|
|
||||||
parameters: {
|
|
||||||
fromExtension: parameters.fromExtension,
|
|
||||||
toExtension: parameters.toExtension,
|
|
||||||
imageOptions: parameters.imageOptions,
|
|
||||||
htmlOptions: parameters.htmlOptions,
|
|
||||||
emailOptions: parameters.emailOptions,
|
|
||||||
pdfaOptions: parameters.pdfaOptions,
|
|
||||||
},
|
|
||||||
fileSize: selectedFiles[0].size
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
if (!endpoint) {
|
||||||
}, []);
|
throw new Error(t('errorNotSupported', 'Unsupported conversion format'));
|
||||||
|
|
||||||
const processResults = useCallback(async (blob: Blob, filename: string) => {
|
|
||||||
try {
|
|
||||||
// For single file conversions, create a file directly
|
|
||||||
const convertedFile = new File([blob], filename, { type: blob.type });
|
|
||||||
|
|
||||||
// Set local state for preview
|
|
||||||
setFiles([convertedFile]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
// Add converted file to FileContext for future use
|
|
||||||
await addFiles([convertedFile]);
|
|
||||||
|
|
||||||
// Generate thumbnail for preview
|
|
||||||
try {
|
|
||||||
const thumbnail = await generateThumbnailForFile(convertedFile);
|
|
||||||
setThumbnails([thumbnail]);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for ${filename}:`, error);
|
|
||||||
setThumbnails(['']);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to process conversion result:', error);
|
|
||||||
}
|
|
||||||
}, [addFiles]);
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected"));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert-specific routing logic: decide batch vs individual processing
|
||||||
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
||||||
await executeMultipleSeparateFiles(parameters, selectedFiles);
|
// Individual processing for complex cases (PDF→image, smart detection, etc.)
|
||||||
} else {
|
for (const file of selectedFiles) {
|
||||||
await executeSingleCombinedOperation(parameters, selectedFiles);
|
try {
|
||||||
|
const formData = buildFormData(parameters, [file]);
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||||
|
|
||||||
|
processedFiles.push(convertedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to convert file ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Batch processing for simple cases (image→PDF combine)
|
||||||
|
const formData = buildFormData(parameters, selectedFiles);
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
const baseFilename = selectedFiles.length === 1
|
||||||
|
? selectedFiles[0].name
|
||||||
|
: 'converted_files';
|
||||||
|
|
||||||
|
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
|
||||||
|
processedFiles.push(convertedFile);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedFiles;
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const executeMultipleSeparateFiles = async (
|
return useToolOperation<ConvertParameters>({
|
||||||
parameters: ConvertParameters,
|
operationType: 'convert',
|
||||||
selectedFiles: File[]
|
endpoint: '', // Not used with customProcessor but required
|
||||||
) => {
|
buildFormData, // Not used with customProcessor but required
|
||||||
setStatus(t("loading"));
|
filePrefix: 'converted_',
|
||||||
setIsLoading(true);
|
customProcessor: customConvertProcessor, // Convert handles its own routing
|
||||||
setErrorMessage(null);
|
validateParams: (params) => {
|
||||||
|
return { valid: true };
|
||||||
const results: File[] = [];
|
},
|
||||||
|
getErrorMessage: (error) => {
|
||||||
try {
|
|
||||||
// Process each file separately
|
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
|
||||||
const file = selectedFiles[i];
|
|
||||||
setStatus(t("convert.processingFile", `Processing file ${i + 1} of ${selectedFiles.length}...`));
|
|
||||||
|
|
||||||
const fileExtension = detectFileExtension(file.name);
|
|
||||||
let endpoint = getEndpointUrl(fileExtension, parameters.toExtension);
|
|
||||||
let fileSpecificParams = { ...parameters, fromExtension: fileExtension };
|
|
||||||
if (!endpoint && parameters.toExtension === 'pdf') {
|
|
||||||
endpoint = '/api/v1/convert/file/pdf';
|
|
||||||
console.log(`Using file-to-pdf fallback for ${fileExtension} file: ${file.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endpoint) {
|
|
||||||
console.error(`No endpoint available for ${fileExtension} to ${parameters.toExtension}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(fileSpecificParams, [file]);
|
|
||||||
const formData = buildFormData(fileSpecificParams, [file]);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
|
|
||||||
// Use utility function to create file from response
|
|
||||||
const convertedFile = createFileFromResponse(
|
|
||||||
response.data,
|
|
||||||
response.headers,
|
|
||||||
file.name,
|
|
||||||
parameters.toExtension
|
|
||||||
);
|
|
||||||
results.push(convertedFile);
|
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error converting file ${file.name}:`, error);
|
|
||||||
markOperationFailed(fileId, operationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
|
|
||||||
const generatedThumbnails = await generateThumbnailsForFiles(results);
|
|
||||||
|
|
||||||
setFiles(results);
|
|
||||||
setThumbnails(generatedThumbnails);
|
|
||||||
|
|
||||||
await addFiles(results);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { url, filename } = await createDownloadInfo(results);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setDownloadFilename(filename);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create download info:', error);
|
|
||||||
const url = window.URL.createObjectURL(results[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setDownloadFilename(results[0].name);
|
|
||||||
}
|
|
||||||
setStatus(t("convert.multipleFilesComplete", `Converted ${results.length} files successfully`));
|
|
||||||
} else {
|
|
||||||
setErrorMessage(t("convert.errorAllFilesFailed", "All files failed to convert"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in multiple operations:', error);
|
|
||||||
setErrorMessage(t("convert.errorMultipleConversion", "An error occurred while converting multiple files"));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeSingleCombinedOperation = async (
|
|
||||||
parameters: ConvertParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
|
||||||
const formData = buildFormData(parameters, selectedFiles);
|
|
||||||
|
|
||||||
// Get endpoint using utility function
|
|
||||||
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
|
||||||
if (!endpoint) {
|
|
||||||
setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading"));
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
|
|
||||||
// Use utility function to create file from response
|
|
||||||
const originalFileName = selectedFiles.length === 1
|
|
||||||
? selectedFiles[0].name
|
|
||||||
: 'combined_files.pdf'; // Default extension for combined files
|
|
||||||
|
|
||||||
const convertedFile = createFileFromResponse(
|
|
||||||
response.data,
|
|
||||||
response.headers,
|
|
||||||
originalFileName,
|
|
||||||
parameters.toExtension
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(convertedFile);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setDownloadFilename(convertedFile.name);
|
|
||||||
setStatus(t("downloadComplete"));
|
|
||||||
|
|
||||||
// Update local files state for hook consumers
|
|
||||||
setFiles([convertedFile]);
|
|
||||||
|
|
||||||
await addFiles([convertedFile]);
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
let errorMsg = t("convert.errorConversion", "An error occurred while converting the file.");
|
|
||||||
if (error.response?.data && typeof error.response.data === 'string') {
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
errorMsg = error.response.data;
|
return error.response.data;
|
||||||
} else if (error.message) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
}
|
||||||
setErrorMessage(errorMsg);
|
if (error.message) {
|
||||||
markOperationFailed(fileId, operationId, errorMsg);
|
return error.message;
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
return t("convert.errorConversion", "An error occurred while converting the file.");
|
||||||
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
// Clean up blob URLs to prevent memory leaks
|
|
||||||
if (downloadUrl) {
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setDownloadFilename('');
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [downloadUrl]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (downloadUrl) {
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [downloadUrl]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
executeOperation,
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
downloadFilename,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
};
|
@ -1,372 +1,117 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
|
||||||
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
|
||||||
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
import { useToolResources } from '../shared/useToolResources';
|
||||||
|
|
||||||
//Extract files from a ZIP blob
|
// Helper: get MIME type based on file extension
|
||||||
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
|
|
||||||
const JSZip = await import('jszip');
|
|
||||||
const zip = new JSZip.default();
|
|
||||||
|
|
||||||
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: getMimeType(filename) });
|
|
||||||
extractedFiles.push(extractedFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Get MIME type based on file extension
|
|
||||||
function getMimeType(filename: string): string {
|
function getMimeType(filename: string): string {
|
||||||
const ext = filename.toLowerCase().split('.').pop();
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'pdf':
|
case 'pdf': return 'application/pdf';
|
||||||
return 'application/pdf';
|
case 'txt': return 'text/plain';
|
||||||
case 'txt':
|
case 'zip': return 'application/zip';
|
||||||
return 'text/plain';
|
default: return 'application/octet-stream';
|
||||||
case 'zip':
|
|
||||||
return 'application/zip';
|
|
||||||
default:
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OCROperationHook {
|
// Lightweight ZIP extractor (keep or replace with a shared util if you have one)
|
||||||
files: File[];
|
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
|
||||||
thumbnails: string[];
|
const JSZip = await import('jszip');
|
||||||
downloadUrl: string | null;
|
const zip = new JSZip.default();
|
||||||
downloadFilename: string | null;
|
const zipContent = await zip.loadAsync(await zipBlob.arrayBuffer());
|
||||||
isLoading: boolean;
|
const out: File[] = [];
|
||||||
isGeneratingThumbnails: boolean;
|
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||||
status: string;
|
if (!file.dir) {
|
||||||
errorMessage: string | null;
|
const content = await file.async('blob');
|
||||||
executeOperation: (parameters: OCRParameters, selectedFiles: File[]) => Promise<void>;
|
out.push(new File([content], filename, { type: getMimeType(filename) }));
|
||||||
resetResults: () => void;
|
}
|
||||||
clearError: () => void;
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useOCROperation = (): OCROperationHook => {
|
// Helper: strip extension
|
||||||
const { t } = useTranslation();
|
function stripExt(name: string): string {
|
||||||
const {
|
const i = name.lastIndexOf('.');
|
||||||
recordOperation,
|
return i > 0 ? name.slice(0, i) : name;
|
||||||
markOperationApplied,
|
}
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management
|
// Signature must be (file, params)
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const buildFormData = (file: File, parameters: OCRParameters): FormData => {
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [downloadFilename, setDownloadFilename] = useState<string>('');
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Track blob URLs for cleanup
|
|
||||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const cleanupBlobUrls = useCallback(() => {
|
|
||||||
blobUrls.forEach(url => {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to revoke blob URL:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setBlobUrls([]);
|
|
||||||
}, [blobUrls]);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
|
||||||
parameters: OCRParameters,
|
|
||||||
file: File
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
// Add the file
|
|
||||||
formData.append('fileInput', file);
|
formData.append('fileInput', file);
|
||||||
|
parameters.languages.forEach((lang) => formData.append('languages', lang));
|
||||||
// Add languages as multiple parameters with same name (like checkboxes)
|
|
||||||
parameters.languages.forEach(lang => {
|
|
||||||
formData.append('languages', lang);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add other parameters
|
|
||||||
formData.append('ocrType', parameters.ocrType);
|
formData.append('ocrType', parameters.ocrType);
|
||||||
formData.append('ocrRenderType', parameters.ocrRenderType);
|
formData.append('ocrRenderType', parameters.ocrRenderType);
|
||||||
|
|
||||||
// Handle additional options - convert array to individual boolean parameters
|
|
||||||
formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString());
|
formData.append('sidecar', parameters.additionalOptions.includes('sidecar').toString());
|
||||||
formData.append('deskew', parameters.additionalOptions.includes('deskew').toString());
|
formData.append('deskew', parameters.additionalOptions.includes('deskew').toString());
|
||||||
formData.append('clean', parameters.additionalOptions.includes('clean').toString());
|
formData.append('clean', parameters.additionalOptions.includes('clean').toString());
|
||||||
formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString());
|
formData.append('cleanFinal', parameters.additionalOptions.includes('cleanFinal').toString());
|
||||||
formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString());
|
formData.append('removeImagesAfter', parameters.additionalOptions.includes('removeImagesAfter').toString());
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
const endpoint = '/api/v1/misc/ocr-pdf';
|
export const useOCROperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { extractZipFiles } = useToolResources();
|
||||||
|
|
||||||
return { formData, endpoint };
|
// OCR-specific parsing: ZIP (sidecar) vs PDF vs HTML error
|
||||||
}, []);
|
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||||
|
const headBuf = await blob.slice(0, 8).arrayBuffer();
|
||||||
const createOperation = useCallback((
|
const head = new TextDecoder().decode(new Uint8Array(headBuf));
|
||||||
parameters: OCRParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
|
||||||
const operationId = `ocr-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
|
||||||
|
|
||||||
const operation: FileOperation = {
|
|
||||||
id: operationId,
|
|
||||||
type: 'ocr',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
|
||||||
status: 'pending',
|
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0]?.name,
|
|
||||||
parameters: {
|
|
||||||
languages: parameters.languages,
|
|
||||||
ocrType: parameters.ocrType,
|
|
||||||
ocrRenderType: parameters.ocrRenderType,
|
|
||||||
additionalOptions: parameters.additionalOptions,
|
|
||||||
},
|
|
||||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { operation, operationId, fileId };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
parameters: OCRParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected") || "No file selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parameters.languages.length === 0) {
|
|
||||||
setErrorMessage('Please select at least one language for OCR processing.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
|
||||||
if (validFiles.length === 0) {
|
|
||||||
setErrorMessage('No valid files to process. All selected files are empty.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validFiles.length < selectedFiles.length) {
|
|
||||||
console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading") || "Loading...");
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
|
|
||||||
|
// ZIP: sidecar or multi-asset output
|
||||||
|
if (head.startsWith('PK')) {
|
||||||
|
const base = stripExt(originalFiles[0].name);
|
||||||
try {
|
try {
|
||||||
const processedFiles: File[] = [];
|
const extracted = await extractZipFiles(blob);
|
||||||
const failedFiles: string[] = [];
|
if (extracted.length > 0) return extracted;
|
||||||
|
} catch { /* ignore and try local extractor */ }
|
||||||
// OCR typically processes one file at a time
|
|
||||||
for (let i = 0; i < validFiles.length; i++) {
|
|
||||||
const file = validFiles[i];
|
|
||||||
setStatus(`Processing OCR for ${file.name} (${i + 1}/${validFiles.length})`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { formData, endpoint } = buildFormData(parameters, file);
|
const local = await extractZipFile(blob); // local fallback
|
||||||
const response = await axios.post(endpoint, formData, {
|
if (local.length > 0) return local;
|
||||||
responseType: "blob",
|
} catch { /* fall through */ }
|
||||||
timeout: 300000 // 5 minute timeout for OCR
|
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
|
||||||
});
|
|
||||||
|
|
||||||
// Check for HTTP errors
|
|
||||||
if (response.status >= 400) {
|
|
||||||
// Try to read error response as text
|
|
||||||
const errorText = await response.data.text();
|
|
||||||
throw new Error(`OCR service HTTP error ${response.status}: ${errorText.substring(0, 300)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate response
|
// Not a PDF: surface error details if present
|
||||||
if (!response.data || response.data.size === 0) {
|
if (!head.startsWith('%PDF')) {
|
||||||
throw new Error('Empty response from OCR service');
|
const textBuf = await blob.slice(0, 1024).arrayBuffer();
|
||||||
}
|
const text = new TextDecoder().decode(new Uint8Array(textBuf));
|
||||||
|
if (/error|exception|html/i.test(text)) {
|
||||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
|
||||||
|
|
||||||
// Check if response is actually a PDF by examining the first few bytes
|
|
||||||
const arrayBuffer = await response.data.arrayBuffer();
|
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
|
||||||
const header = new TextDecoder().decode(uint8Array.slice(0, 4));
|
|
||||||
|
|
||||||
// Check if it's a ZIP file (OCR service returns ZIP when sidecar is enabled or for multi-file results)
|
|
||||||
if (header.startsWith('PK')) {
|
|
||||||
try {
|
|
||||||
// Extract ZIP file contents
|
|
||||||
const zipFiles = await extractZipFile(response.data);
|
|
||||||
|
|
||||||
// Add extracted files to processed files
|
|
||||||
processedFiles.push(...zipFiles);
|
|
||||||
} catch (extractError) {
|
|
||||||
// Fallback to treating as single ZIP file
|
|
||||||
const blob = new Blob([response.data], { type: 'application/zip' });
|
|
||||||
const processedFile = new File([blob], `ocr_${file.name}.zip`, { type: 'application/zip' });
|
|
||||||
processedFiles.push(processedFile);
|
|
||||||
}
|
|
||||||
continue; // Skip the PDF validation for ZIP files
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!header.startsWith('%PDF')) {
|
|
||||||
// Check if it's an error response
|
|
||||||
const text = new TextDecoder().decode(uint8Array.slice(0, 500));
|
|
||||||
|
|
||||||
if (text.includes('error') || text.includes('Error') || text.includes('exception') || text.includes('html')) {
|
|
||||||
// Check for specific OCR tool unavailable error
|
|
||||||
if (text.includes('OCR tools') && text.includes('not installed')) {
|
if (text.includes('OCR tools') && text.includes('not installed')) {
|
||||||
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
throw new Error('OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.');
|
||||||
}
|
}
|
||||||
throw new Error(`OCR service error: ${text.substring(0, 300)}`);
|
const title =
|
||||||
|
text.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] ||
|
||||||
|
text.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ||
|
||||||
|
t('ocr.error.unknown', 'Unknown error');
|
||||||
|
throw new Error(`OCR service error: ${title}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an HTML error page
|
const base = stripExt(originalFiles[0].name);
|
||||||
if (text.includes('<html') || text.includes('<!DOCTYPE')) {
|
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
|
||||||
// Try to extract error message from HTML
|
}, [t, extractZipFiles]);
|
||||||
const errorMatch = text.match(/<title[^>]*>([^<]+)<\/title>/i) ||
|
|
||||||
text.match(/<h1[^>]*>([^<]+)<\/h1>/i) ||
|
|
||||||
text.match(/<body[^>]*>([^<]+)<\/body>/i);
|
|
||||||
const errorMessage = errorMatch ? errorMatch[1].trim() : 'Unknown error';
|
|
||||||
throw new Error(`OCR service error: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Response is not a valid PDF file. Header: "${header}"`);
|
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
||||||
}
|
operationType: 'ocr',
|
||||||
|
endpoint: '/api/v1/misc/ocr-pdf',
|
||||||
const blob = new Blob([response.data], { type: contentType });
|
buildFormData,
|
||||||
const processedFile = new File([blob], `ocr_${file.name}`, { type: contentType });
|
filePrefix: 'ocr_',
|
||||||
|
multiFileEndpoint: false, // Process files individually
|
||||||
processedFiles.push(processedFile);
|
responseHandler, // use shared flow
|
||||||
} catch (fileError) {
|
validateParams: (params) =>
|
||||||
const errorMessage = fileError instanceof Error ? fileError.message : 'Unknown error';
|
params.languages.length === 0
|
||||||
failedFiles.push(`${file.name} (${errorMessage})`);
|
? { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] }
|
||||||
}
|
: { valid: true },
|
||||||
}
|
getErrorMessage: (error) =>
|
||||||
|
error.message?.includes('OCR tools') && error.message?.includes('not installed')
|
||||||
if (failedFiles.length > 0 && processedFiles.length === 0) {
|
? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'
|
||||||
throw new Error(`Failed to process OCR for all files: ${failedFiles.join(', ')}`);
|
: createStandardErrorHandler(t('ocr.error.failed', 'OCR operation failed'))(error),
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0) {
|
|
||||||
setStatus(`Processed ${processedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
|
||||||
} else {
|
|
||||||
const hasPdfFiles = processedFiles.some(file => file.name.endsWith('.pdf'));
|
|
||||||
const hasTxtFiles = processedFiles.some(file => file.name.endsWith('.txt'));
|
|
||||||
let statusMessage = `OCR completed successfully for ${processedFiles.length} file(s)`;
|
|
||||||
|
|
||||||
if (hasPdfFiles && hasTxtFiles) {
|
|
||||||
statusMessage += ' (Extracted PDF and text files)';
|
|
||||||
} else if (hasPdfFiles) {
|
|
||||||
statusMessage += ' (Extracted PDF files)';
|
|
||||||
} else if (hasTxtFiles) {
|
|
||||||
statusMessage += ' (Extracted text files)';
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(statusMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles(processedFiles);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
await addFiles(processedFiles);
|
|
||||||
|
|
||||||
// Cleanup old blob URLs
|
|
||||||
cleanupBlobUrls();
|
|
||||||
|
|
||||||
// Create download URL - for multiple files, we'll create a new ZIP
|
|
||||||
if (processedFiles.length === 1) {
|
|
||||||
const url = window.URL.createObjectURL(processedFiles[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(processedFiles[0].name);
|
|
||||||
} else {
|
|
||||||
// For multiple files, create a new ZIP containing all extracted files
|
|
||||||
try {
|
|
||||||
const JSZip = await import('jszip');
|
|
||||||
const zip = new JSZip.default();
|
|
||||||
|
|
||||||
for (const file of processedFiles) {
|
|
||||||
zip.file(file.name, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const url = window.URL.createObjectURL(zipBlob);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(`ocr_extracted_files.zip`);
|
|
||||||
} catch (zipError) {
|
|
||||||
// Fallback to first file
|
|
||||||
const url = window.URL.createObjectURL(processedFiles[0]);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setBlobUrls([url]);
|
|
||||||
setDownloadFilename(processedFiles[0].name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OCR operation error:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'OCR operation failed';
|
|
||||||
setErrorMessage(errorMessage);
|
|
||||||
setStatus('');
|
|
||||||
markOperationFailed(fileId, operationId, errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [buildFormData, createOperation, recordOperation, addFiles, cleanupBlobUrls, markOperationApplied, markOperationFailed, t]);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setDownloadFilename('');
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
cleanupBlobUrls();
|
|
||||||
}, [cleanupBlobUrls]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
downloadFilename,
|
|
||||||
isLoading,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
executeOperation,
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return useToolOperation(ocrConfig);
|
||||||
};
|
};
|
86
frontend/src/hooks/tools/shared/useToolApiCalls.ts
Normal file
86
frontend/src/hooks/tools/shared/useToolApiCalls.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
264
frontend/src/hooks/tools/shared/useToolOperation.ts
Normal file
264
frontend/src/hooks/tools/shared/useToolOperation.ts
Normal file
@ -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
|
||||||
|
};
|
||||||
|
};
|
114
frontend/src/hooks/tools/shared/useToolResources.ts
Normal file
114
frontend/src/hooks/tools/shared/useToolResources.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
137
frontend/src/hooks/tools/shared/useToolState.ts
Normal file
137
frontend/src/hooks/tools/shared/useToolState.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -1,79 +1,27 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
import { FileOperation } from '../../../types/fileContext';
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
|
||||||
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
||||||
import { SPLIT_MODES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
|
import { SPLIT_MODES } from '../../../constants/splitConstants';
|
||||||
|
|
||||||
export interface SplitOperationHook {
|
|
||||||
executeOperation: (
|
|
||||||
mode: SplitMode | '',
|
|
||||||
parameters: SplitParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
const buildFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
|
||||||
files: File[];
|
|
||||||
thumbnails: string[];
|
|
||||||
isGeneratingThumbnails: boolean;
|
|
||||||
downloadUrl: string | null;
|
|
||||||
status: string;
|
|
||||||
errorMessage: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSplitOperation = (): SplitOperationHook => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
recordOperation,
|
|
||||||
markOperationApplied,
|
|
||||||
markOperationFailed,
|
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management (replacing useOperationResults)
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
||||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const buildFormData = useCallback((
|
|
||||||
mode: SplitMode | '',
|
|
||||||
parameters: SplitParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
selectedFiles.forEach(file => {
|
selectedFiles.forEach(file => {
|
||||||
formData.append("fileInput", file);
|
formData.append("fileInput", file);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mode) {
|
switch (parameters.mode) {
|
||||||
throw new Error('Split mode is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint = "";
|
|
||||||
|
|
||||||
switch (mode) {
|
|
||||||
case SPLIT_MODES.BY_PAGES:
|
case SPLIT_MODES.BY_PAGES:
|
||||||
formData.append("pageNumbers", parameters.pages);
|
formData.append("pageNumbers", parameters.pages);
|
||||||
endpoint = "/api/v1/general/split-pages";
|
|
||||||
break;
|
break;
|
||||||
case SPLIT_MODES.BY_SECTIONS:
|
case SPLIT_MODES.BY_SECTIONS:
|
||||||
formData.append("horizontalDivisions", parameters.hDiv);
|
formData.append("horizontalDivisions", parameters.hDiv);
|
||||||
formData.append("verticalDivisions", parameters.vDiv);
|
formData.append("verticalDivisions", parameters.vDiv);
|
||||||
formData.append("merge", parameters.merge.toString());
|
formData.append("merge", parameters.merge.toString());
|
||||||
endpoint = "/api/v1/general/split-pdf-by-sections";
|
|
||||||
break;
|
break;
|
||||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||||
formData.append(
|
formData.append(
|
||||||
@ -81,162 +29,54 @@ export const useSplitOperation = (): SplitOperationHook => {
|
|||||||
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
|
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
|
||||||
);
|
);
|
||||||
formData.append("splitValue", parameters.splitValue);
|
formData.append("splitValue", parameters.splitValue);
|
||||||
endpoint = "/api/v1/general/split-by-size-or-count";
|
|
||||||
break;
|
break;
|
||||||
case SPLIT_MODES.BY_CHAPTERS:
|
case SPLIT_MODES.BY_CHAPTERS:
|
||||||
formData.append("bookmarkLevel", parameters.bookmarkLevel);
|
formData.append("bookmarkLevel", parameters.bookmarkLevel);
|
||||||
formData.append("includeMetadata", parameters.includeMetadata.toString());
|
formData.append("includeMetadata", parameters.includeMetadata.toString());
|
||||||
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
||||||
endpoint = "/api/v1/general/split-pdf-by-chapters";
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown split mode: ${mode}`);
|
throw new Error(`Unknown split mode: ${parameters.mode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { formData, endpoint };
|
return formData;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const createOperation = useCallback((
|
const getEndpoint = (parameters: SplitParameters): string => {
|
||||||
mode: SplitMode | '',
|
switch (parameters.mode) {
|
||||||
parameters: SplitParameters,
|
case SPLIT_MODES.BY_PAGES:
|
||||||
selectedFiles: File[]
|
return "/api/v1/general/split-pages";
|
||||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
case SPLIT_MODES.BY_SECTIONS:
|
||||||
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
return "/api/v1/general/split-pdf-by-sections";
|
||||||
const fileId = selectedFiles[0].name;
|
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||||
|
return "/api/v1/general/split-by-size-or-count";
|
||||||
const operation: FileOperation = {
|
case SPLIT_MODES.BY_CHAPTERS:
|
||||||
id: operationId,
|
return "/api/v1/general/split-pdf-by-chapters";
|
||||||
type: 'split',
|
default:
|
||||||
timestamp: Date.now(),
|
throw new Error(`Unknown split mode: ${parameters.mode}`);
|
||||||
fileIds: selectedFiles.map(f => f.name),
|
}
|
||||||
status: 'pending',
|
};
|
||||||
metadata: {
|
|
||||||
originalFileName: selectedFiles[0].name,
|
export const useSplitOperation = () => {
|
||||||
parameters: {
|
const { t } = useTranslation();
|
||||||
mode,
|
|
||||||
pages: mode === SPLIT_MODES.BY_PAGES ? parameters.pages : undefined,
|
return useToolOperation<SplitParameters>({
|
||||||
hDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.hDiv : undefined,
|
operationType: 'split',
|
||||||
vDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.vDiv : undefined,
|
endpoint: (params) => getEndpoint(params),
|
||||||
merge: mode === SPLIT_MODES.BY_SECTIONS ? parameters.merge : undefined,
|
buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData
|
||||||
splitType: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitType : undefined,
|
filePrefix: 'split_',
|
||||||
splitValue: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitValue : undefined,
|
multiFileEndpoint: true, // Single API call with all files
|
||||||
bookmarkLevel: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.bookmarkLevel : undefined,
|
validateParams: (params) => {
|
||||||
includeMetadata: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.includeMetadata : undefined,
|
if (!params.mode) {
|
||||||
allowDuplicates: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.allowDuplicates : undefined,
|
return { valid: false, errors: [t('split.validation.modeRequired', 'Split mode is required')] };
|
||||||
},
|
}
|
||||||
fileSize: selectedFiles[0].size
|
|
||||||
}
|
if (params.mode === SPLIT_MODES.BY_PAGES && !params.pages) {
|
||||||
};
|
return { valid: false, errors: [t('split.validation.pagesRequired', 'Page numbers are required for split by pages')] };
|
||||||
|
}
|
||||||
return { operation, operationId, fileId };
|
|
||||||
}, []);
|
return { valid: true };
|
||||||
|
},
|
||||||
const processResults = useCallback(async (blob: Blob) => {
|
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
|
||||||
try {
|
});
|
||||||
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
|
|
||||||
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
|
||||||
|
|
||||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
|
||||||
// Set local state for preview
|
|
||||||
setFiles(extractionResult.extractedFiles);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(true);
|
|
||||||
|
|
||||||
// Add extracted files to FileContext for future use
|
|
||||||
await addFiles(extractionResult.extractedFiles);
|
|
||||||
|
|
||||||
const thumbnails = await Promise.all(
|
|
||||||
extractionResult.extractedFiles.map(async (file) => {
|
|
||||||
try {
|
|
||||||
return await generateThumbnailForFile(file);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setThumbnails(thumbnails);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
}
|
|
||||||
} catch (extractError) {
|
|
||||||
console.warn('Failed to extract files for preview:', extractError);
|
|
||||||
}
|
|
||||||
}, [addFiles]);
|
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
|
||||||
mode: SplitMode | '',
|
|
||||||
parameters: SplitParameters,
|
|
||||||
selectedFiles: File[]
|
|
||||||
) => {
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
setStatus(t("noFileSelected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { operation, operationId, fileId } = createOperation(mode, parameters, selectedFiles);
|
|
||||||
const { formData, endpoint } = buildFormData(mode, parameters, selectedFiles);
|
|
||||||
|
|
||||||
recordOperation(fileId, operation);
|
|
||||||
|
|
||||||
setStatus(t("loading"));
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
|
||||||
const blob = new Blob([response.data], { type: "application/zip" });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setStatus(t("downloadComplete"));
|
|
||||||
|
|
||||||
await processResults(blob);
|
|
||||||
markOperationApplied(fileId, operationId);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
|
||||||
if (error.response?.data && typeof error.response.data === 'string') {
|
|
||||||
errorMsg = error.response.data;
|
|
||||||
} else if (error.message) {
|
|
||||||
errorMsg = error.message;
|
|
||||||
}
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
setStatus(t("error._value", "Split failed."));
|
|
||||||
markOperationFailed(fileId, operationId, errorMsg);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
|
|
||||||
|
|
||||||
const resetResults = useCallback(() => {
|
|
||||||
setFiles([]);
|
|
||||||
setThumbnails([]);
|
|
||||||
setIsGeneratingThumbnails(false);
|
|
||||||
setDownloadUrl(null);
|
|
||||||
setStatus('');
|
|
||||||
setErrorMessage(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
executeOperation,
|
|
||||||
|
|
||||||
// Flattened result properties for cleaner access
|
|
||||||
files,
|
|
||||||
thumbnails,
|
|
||||||
isGeneratingThumbnails,
|
|
||||||
downloadUrl,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
isLoading,
|
|
||||||
|
|
||||||
// Result management functions
|
|
||||||
resetResults,
|
|
||||||
clearError,
|
|
||||||
};
|
|
||||||
};
|
};
|
@ -3,9 +3,7 @@ import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, type SplitType } f
|
|||||||
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
||||||
|
|
||||||
export interface SplitParametersHook {
|
export interface SplitParametersHook {
|
||||||
mode: SplitMode | '';
|
|
||||||
parameters: SplitParameters;
|
parameters: SplitParameters;
|
||||||
setMode: (mode: SplitMode | '') => void;
|
|
||||||
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||||
resetParameters: () => void;
|
resetParameters: () => void;
|
||||||
validateParameters: () => boolean;
|
validateParameters: () => boolean;
|
||||||
@ -13,6 +11,7 @@ export interface SplitParametersHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialParameters: SplitParameters = {
|
const initialParameters: SplitParameters = {
|
||||||
|
mode: '',
|
||||||
pages: '',
|
pages: '',
|
||||||
hDiv: '2',
|
hDiv: '2',
|
||||||
vDiv: '2',
|
vDiv: '2',
|
||||||
@ -25,7 +24,6 @@ const initialParameters: SplitParameters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useSplitParameters = (): SplitParametersHook => {
|
export const useSplitParameters = (): SplitParametersHook => {
|
||||||
const [mode, setMode] = useState<SplitMode | ''>('');
|
|
||||||
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
|
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
|
||||||
|
|
||||||
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
|
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
|
||||||
@ -34,13 +32,12 @@ export const useSplitParameters = (): SplitParametersHook => {
|
|||||||
|
|
||||||
const resetParameters = () => {
|
const resetParameters = () => {
|
||||||
setParameters(initialParameters);
|
setParameters(initialParameters);
|
||||||
setMode('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateParameters = () => {
|
const validateParameters = () => {
|
||||||
if (!mode) return false;
|
if (!parameters.mode) return false;
|
||||||
|
|
||||||
switch (mode) {
|
switch (parameters.mode) {
|
||||||
case SPLIT_MODES.BY_PAGES:
|
case SPLIT_MODES.BY_PAGES:
|
||||||
return parameters.pages.trim() !== "";
|
return parameters.pages.trim() !== "";
|
||||||
case SPLIT_MODES.BY_SECTIONS:
|
case SPLIT_MODES.BY_SECTIONS:
|
||||||
@ -55,14 +52,12 @@ export const useSplitParameters = (): SplitParametersHook => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getEndpointName = () => {
|
const getEndpointName = () => {
|
||||||
if (!mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
if (!parameters.mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
||||||
return ENDPOINTS[mode as SplitMode];
|
return ENDPOINTS[parameters.mode as SplitMode];
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
|
||||||
parameters,
|
parameters,
|
||||||
setMode,
|
|
||||||
updateParameter,
|
updateParameter,
|
||||||
resetParameters,
|
resetParameters,
|
||||||
validateParameters,
|
validateParameters,
|
||||||
|
@ -28,8 +28,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
thumbnailQuality: 'medium',
|
thumbnailQuality: 'medium',
|
||||||
priorityPageCount: 10,
|
priorityPageCount: 10,
|
||||||
useWebWorker: false,
|
useWebWorker: false,
|
||||||
maxRetries: 3,
|
maxRetries: 3
|
||||||
timeoutMs: 300000 // 5 minutes
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
@ -87,7 +86,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
estimatedTime: number
|
estimatedTime: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Create cancellation token
|
// Create cancellation token
|
||||||
const cancellationToken = ProcessingErrorHandler.createTimeoutController(config.timeoutMs);
|
const cancellationToken = new AbortController();
|
||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
const state: ProcessingState = {
|
const state: ProcessingState = {
|
||||||
|
@ -414,7 +414,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||||
expect(result.current.status).toContain('noFileSelected');
|
expect(result.current.errorMessage).toContain('noFileSelected');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -33,12 +33,11 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
splitOperation.resetResults();
|
splitOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [splitParams.mode, splitParams.parameters, selectedFiles]);
|
}, [splitParams.parameters, selectedFiles]);
|
||||||
|
|
||||||
const handleSplit = async () => {
|
const handleSplit = async () => {
|
||||||
try {
|
try {
|
||||||
await splitOperation.executeOperation(
|
await splitOperation.executeOperation(
|
||||||
splitParams.mode,
|
|
||||||
splitParams.parameters,
|
splitParams.parameters,
|
||||||
selectedFiles
|
selectedFiles
|
||||||
);
|
);
|
||||||
@ -105,14 +104,12 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
>
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<SplitSettings
|
<SplitSettings
|
||||||
mode={splitParams.mode}
|
|
||||||
onModeChange={splitParams.setMode}
|
|
||||||
parameters={splitParams.parameters}
|
parameters={splitParams.parameters}
|
||||||
onParameterChange={splitParams.updateParameter}
|
onParameterChange={splitParams.updateParameter}
|
||||||
disabled={endpointLoading}
|
disabled={endpointLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{splitParams.mode && (
|
{splitParams.parameters.mode && (
|
||||||
<OperationButton
|
<OperationButton
|
||||||
onClick={handleSplit}
|
onClick={handleSplit}
|
||||||
isLoading={splitOperation.isLoading}
|
isLoading={splitOperation.isLoading}
|
||||||
|
@ -69,7 +69,6 @@ export interface ProcessingConfig {
|
|||||||
priorityPageCount: number; // Number of priority pages to process first
|
priorityPageCount: number; // Number of priority pages to process first
|
||||||
useWebWorker: boolean;
|
useWebWorker: boolean;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
timeoutMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileAnalysis {
|
export interface FileAnalysis {
|
||||||
|
33
frontend/src/utils/toolErrorHandler.ts
Normal file
33
frontend/src/utils/toolErrorHandler.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Standardized error handling utilities for tool operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default error extractor that follows the standard pattern
|
||||||
|
*/
|
||||||
|
export const extractErrorMessage = (error: any): string => {
|
||||||
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return 'Operation failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a standardized error handler for tool operations
|
||||||
|
* @param fallbackMessage - Message to show when no specific error can be extracted
|
||||||
|
* @returns Error handler function that follows the standard pattern
|
||||||
|
*/
|
||||||
|
export const createStandardErrorHandler = (fallbackMessage: string) => {
|
||||||
|
return (error: any): string => {
|
||||||
|
if (error.response?.data && typeof error.response.data === 'string') {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return fallbackMessage;
|
||||||
|
};
|
||||||
|
};
|
28
frontend/src/utils/toolOperationTracker.ts
Normal file
28
frontend/src/utils/toolOperationTracker.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { FileOperation } from '../types/fileContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates operation tracking data for FileContext integration
|
||||||
|
*/
|
||||||
|
export const createOperation = <TParams = void>(
|
||||||
|
operationType: string,
|
||||||
|
params: TParams,
|
||||||
|
selectedFiles: File[]
|
||||||
|
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||||
|
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||||
|
|
||||||
|
const operation: FileOperation = {
|
||||||
|
id: operationId,
|
||||||
|
type: operationType,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
fileIds: selectedFiles.map(f => f.name),
|
||||||
|
status: 'pending',
|
||||||
|
metadata: {
|
||||||
|
originalFileName: selectedFiles[0]?.name,
|
||||||
|
parameters: params,
|
||||||
|
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { operation, operationId, fileId };
|
||||||
|
};
|
25
frontend/src/utils/toolResponseProcessor.ts
Normal file
25
frontend/src/utils/toolResponseProcessor.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// Note: This utility should be used with useToolResources for ZIP operations
|
||||||
|
|
||||||
|
export type ResponseHandler = (blob: Blob, originalFiles: File[]) => Promise<File[]> | File[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a blob response into File(s).
|
||||||
|
* - If a tool-specific responseHandler is provided, it is used.
|
||||||
|
* - Otherwise, create a single file using the filePrefix + original name.
|
||||||
|
*/
|
||||||
|
export async function processResponse(
|
||||||
|
blob: Blob,
|
||||||
|
originalFiles: File[],
|
||||||
|
filePrefix: string,
|
||||||
|
responseHandler?: ResponseHandler
|
||||||
|
): Promise<File[]> {
|
||||||
|
if (responseHandler) {
|
||||||
|
const out = await responseHandler(blob, originalFiles);
|
||||||
|
return Array.isArray(out) ? out : [out as unknown as File];
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = originalFiles[0]?.name ?? 'result.pdf';
|
||||||
|
const name = `${filePrefix}${original}`;
|
||||||
|
const type = blob.type || 'application/octet-stream';
|
||||||
|
return [new File([blob], name, { type })];
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user