Better tool error handling and fix OCR

This commit is contained in:
Reece Browne 2025-08-13 11:37:58 +01:00
parent c942db0515
commit 0cd5c462c5
6 changed files with 168 additions and 53 deletions

View File

@ -99,6 +99,8 @@ export const useConvertOperation = () => {
// Convert-specific routing logic: decide batch vs individual processing // Convert-specific routing logic: decide batch vs individual processing
if (shouldProcessFilesSeparately(selectedFiles, parameters)) { if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
// Individual processing for complex cases (PDF→image, smart detection, etc.) // Individual processing for complex cases (PDF→image, smart detection, etc.)
const failedFiles: { file: string; error: string }[] = [];
for (const file of selectedFiles) { for (const file of selectedFiles) {
try { try {
const formData = buildFormData(parameters, [file]); const formData = buildFormData(parameters, [file]);
@ -108,7 +110,21 @@ export const useConvertOperation = () => {
processedFiles.push(convertedFile); processedFiles.push(convertedFile);
} catch (error) { } catch (error) {
console.warn(`Failed to convert file ${file.name}:`, error); const errorMessage = error instanceof Error ? error.message : String(error);
failedFiles.push({ file: file.name, error: errorMessage });
}
}
// If some files failed but others succeeded, throw detailed error
if (failedFiles.length > 0) {
if (processedFiles.length === 0) {
// All files failed
const errorDetails = failedFiles.map(f => `${f.file}: ${f.error}`).join(', ');
throw new Error(`All files failed to convert: ${errorDetails}`);
} else {
// Partial failure - log warning but continue with successful files
const failedNames = failedFiles.map(f => `${f.file} (${f.error})`).join(', ');
console.warn(`Some files failed to convert: ${failedNames}. Successfully converted ${processedFiles.length} files.`);
} }
} }
} else { } else {

View File

@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { OCRParameters } from '../../../components/tools/ocr/OCRSettings'; import { OCRParameters } from '../../../components/tools/ocr/OCRSettings';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createDockerToolErrorHandler } from '../../../utils/toolErrorHandler';
import { useToolResources } from '../shared/useToolResources'; import { useToolResources } from '../shared/useToolResources';
// Helper: get MIME type based on file extension // Helper: get MIME type based on file extension
@ -16,21 +16,6 @@ function getMimeType(filename: string): string {
} }
} }
// Lightweight ZIP extractor (keep or replace with a shared util if you have one)
async function extractZipFile(zipBlob: Blob): Promise<File[]> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
const zipContent = await zip.loadAsync(await zipBlob.arrayBuffer());
const out: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const content = await file.async('blob');
out.push(new File([content], filename, { type: getMimeType(filename) }));
}
}
return out;
}
// Helper: strip extension // Helper: strip extension
function stripExt(name: string): string { function stripExt(name: string): string {
const i = name.lastIndexOf('.'); const i = name.lastIndexOf('.');
@ -64,14 +49,16 @@ export const useOCROperation = () => {
// ZIP: sidecar or multi-asset output // ZIP: sidecar or multi-asset output
if (head.startsWith('PK')) { if (head.startsWith('PK')) {
const base = stripExt(originalFiles[0].name); const base = stripExt(originalFiles[0].name);
try { try {
const extracted = await extractZipFiles(blob); const extracted = await extractZipFiles(blob);
if (extracted.length > 0) return extracted; if (extracted.length > 0) return extracted;
} catch { /* ignore and try local extractor */ } } catch (error) {
try { // Log extraction failure but don't throw - fall back to raw ZIP
const local = await extractZipFile(blob); // local fallback console.warn(`OCR ZIP extraction failed for ${base}, returning as ZIP file:`, error);
if (local.length > 0) return local; }
} catch { /* fall through */ }
// Fallback: return as ZIP file (this prevents "does nothing" behavior)
return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })]; return [new File([blob], `ocr_${base}.zip`, { type: 'application/zip' })];
} }
@ -107,10 +94,12 @@ export const useOCROperation = () => {
params.languages.length === 0 params.languages.length === 0
? { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] } ? { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] }
: { valid: true }, : { valid: true },
getErrorMessage: (error) => getErrorMessage: createDockerToolErrorHandler(
error.message?.includes('OCR tools') && error.message?.includes('not installed') 'OCR',
? '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.' 'standard or fat',
: createStandardErrorHandler(t('ocr.error.failed', 'OCR operation failed'))(error), t('ocr.error.failed', 'OCR operation failed'),
['OCRmyPDF', 'Tesseract']
),
}; };
return useToolOperation(ocrConfig); return useToolOperation(ocrConfig);

View File

@ -21,7 +21,7 @@ export const useToolApiCalls = <TParams = void>() => {
onStatus: (status: string) => void onStatus: (status: string) => void
): Promise<File[]> => { ): Promise<File[]> => {
const processedFiles: File[] = []; const processedFiles: File[] = [];
const failedFiles: string[] = []; const failedFiles: { file: string; error: string }[] = [];
const total = validFiles.length; const total = validFiles.length;
// Create cancel token for this operation // Create cancel token for this operation
@ -54,17 +54,22 @@ export const useToolApiCalls = <TParams = void>() => {
if (axios.isCancel(error)) { if (axios.isCancel(error)) {
throw new Error('Operation was cancelled'); throw new Error('Operation was cancelled');
} }
console.error(`Failed to process ${file.name}:`, error); const errorMessage = error instanceof Error ? error.message : String(error);
failedFiles.push(file.name); console.error(`Failed to process ${file.name}:`, errorMessage);
failedFiles.push({ file: file.name, error: errorMessage });
} }
} }
if (failedFiles.length > 0 && processedFiles.length === 0) { if (failedFiles.length > 0 && processedFiles.length === 0) {
throw new Error(`Failed to process all files: ${failedFiles.join(', ')}`); // All files failed - provide detailed error information
const errorDetails = failedFiles.map(f => `${f.file}: ${f.error}`).join('; ');
throw new Error(`Failed to process all files: ${errorDetails}`);
} }
if (failedFiles.length > 0) { if (failedFiles.length > 0) {
onStatus(`Processed ${processedFiles.length}/${total} files. Failed: ${failedFiles.join(', ')}`); // Some files failed - provide detailed status with errors
const failedNames = failedFiles.map(f => `${f.file} (${f.error})`).join(', ');
onStatus(`Processed ${processedFiles.length}/${total} files. Failed: ${failedNames}`);
} else { } else {
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`); onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
} }

View File

@ -5,7 +5,7 @@ import { useFileContext } from '../../../contexts/FileContext';
import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources'; import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage, type ToolError } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker'; import { createOperation } from '../../../utils/toolOperationTracker';
import { type ResponseHandler, processResponse } from '../../../utils/toolResponseProcessor'; import { type ResponseHandler, processResponse } from '../../../utils/toolResponseProcessor';
@ -68,7 +68,7 @@ export interface ToolOperationConfig<TParams = void> {
validateParams?: (params: TParams) => ValidationResult; validateParams?: (params: TParams) => ValidationResult;
/** Extract user-friendly error messages from API errors */ /** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string; getErrorMessage?: (error: unknown) => string;
} }
/** /**
@ -220,7 +220,7 @@ export const useToolOperation = <TParams = void>(
markOperationApplied(fileId, operationId); markOperationApplied(fileId, operationId);
} }
} catch (error: any) { } catch (error: unknown) {
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
actions.setError(errorMessage); actions.setError(errorMessage);
actions.setStatus(''); actions.setStatus('');

View File

@ -54,10 +54,15 @@ export const useToolResources = () => {
try { try {
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' }); const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
const extractionResult = await zipFileService.extractPdfFiles(zipFile); const extractionResult = await zipFileService.extractPdfFiles(zipFile);
return extractionResult.success ? extractionResult.extractedFiles : [];
if (!extractionResult.success) {
throw new Error(`ZIP extraction failed: ${extractionResult.error || 'Unknown error'}`);
}
return extractionResult.extractedFiles;
} catch (error) { } catch (error) {
console.error('useToolResources.extractZipFiles - Error:', error); const errorMessage = error instanceof Error ? error.message : `ZIP extraction error: ${error}`;
return []; throw new Error(errorMessage);
} }
}, []); }, []);
@ -74,18 +79,52 @@ export const useToolResources = () => {
for (const [filename, file] of Object.entries(zipContent.files)) { for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) { if (!file.dir) {
const content = await file.async('blob'); const content = await file.async('blob');
const extractedFile = new File([content], filename, { type: 'application/pdf' }); // Determine MIME type based on file extension
const mimeType = getMimeTypeFromFilename(filename);
const extractedFile = new File([content], filename, { type: mimeType });
extractedFiles.push(extractedFile); extractedFiles.push(extractedFile);
} }
} }
if (extractedFiles.length === 0) {
throw new Error('ZIP file contains no extractable files');
}
return extractedFiles; return extractedFiles;
} catch (error) { } catch (error) {
console.error('Error in extractAllZipFiles:', error); const errorMessage = error instanceof Error ? error.message : `ZIP extraction error: ${error}`;
return []; throw new Error(errorMessage);
} }
}, []); }, []);
// Helper function to determine MIME type from filename
const getMimeTypeFromFilename = (filename: string): string => {
const ext = filename.toLowerCase().split('.').pop();
switch (ext) {
case 'pdf': return 'application/pdf';
case 'txt': return 'text/plain';
case 'jpg':
case 'jpeg': return 'image/jpeg';
case 'png': return 'image/png';
case 'gif': return 'image/gif';
case 'svg': return 'image/svg+xml';
case 'html':
case 'htm': return 'text/html';
case 'css': return 'text/css';
case 'js': return 'application/javascript';
case 'json': return 'application/json';
case 'xml': return 'application/xml';
case 'zip': return 'application/zip';
case 'doc': return 'application/msword';
case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'xls': return 'application/vnd.ms-excel';
case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case 'ppt': return 'application/vnd.ms-powerpoint';
case 'pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
default: return 'application/octet-stream';
}
};
const createDownloadInfo = useCallback(async ( const createDownloadInfo = useCallback(async (
files: File[], files: File[],
operationType: string operationType: string

View File

@ -2,15 +2,26 @@
* Standardized error handling utilities for tool operations * Standardized error handling utilities for tool operations
*/ */
/**
* Standard error type that covers common error patterns
*/
export interface ToolError {
message?: string;
response?: {
data?: string | unknown;
};
}
/** /**
* Default error extractor that follows the standard pattern * Default error extractor that follows the standard pattern
*/ */
export const extractErrorMessage = (error: any): string => { export const extractErrorMessage = (error: unknown): string => {
if (error.response?.data && typeof error.response.data === 'string') { const typedError = error as ToolError;
return error.response.data; if (typedError.response?.data && typeof typedError.response.data === 'string') {
return typedError.response.data;
} }
if (error.message) { if (typedError.message) {
return error.message; return typedError.message;
} }
return 'Operation failed'; return 'Operation failed';
}; };
@ -21,13 +32,68 @@ export const extractErrorMessage = (error: any): string => {
* @returns Error handler function that follows the standard pattern * @returns Error handler function that follows the standard pattern
*/ */
export const createStandardErrorHandler = (fallbackMessage: string) => { export const createStandardErrorHandler = (fallbackMessage: string) => {
return (error: any): string => { return (error: unknown): string => {
if (error.response?.data && typeof error.response.data === 'string') { return extractErrorMessage(error) || fallbackMessage;
return error.response.data;
}
if (error.message) {
return error.message;
}
return fallbackMessage;
}; };
};
/**
* Creates error handler for tools that require specific Docker images or system dependencies.
* Detects common "tool not available" patterns and provides user-friendly Docker upgrade messages.
*
* @param toolName - Name of the tool (e.g., "OCR", "LibreOffice")
* @param requiredImages - Docker images that support this tool (e.g., "standard or fat")
* @param defaultMessage - Fallback error message
* @param detectionPatterns - Additional patterns to detect tool unavailability
*
* @example
* // OCR tool
* getErrorMessage: createDockerToolErrorHandler(
* 'OCR',
* 'standard or fat',
* t('ocr.error.failed', 'OCR operation failed'),
* ['OCRmyPDF', 'Tesseract']
* )
*
* // LibreOffice tool
* getErrorMessage: createDockerToolErrorHandler(
* 'LibreOffice',
* 'standard or fat',
* t('convert.error.failed', 'Conversion failed'),
* ['libreoffice', 'soffice']
* )
*/
export const createDockerToolErrorHandler = (
toolName: string,
requiredImages: string,
defaultMessage: string,
detectionPatterns: string[] = []
) => (error: unknown): string => {
const typedError = error as ToolError;
const message = typedError?.message || '';
// Common patterns for tool unavailability
const commonPatterns = [
'not installed',
'not available',
'command not found',
'executable not found',
'dependency not found'
];
const allPatterns = [...commonPatterns, ...detectionPatterns];
// Check if error indicates tool is not available
const isToolUnavailable = allPatterns.some(pattern =>
message.toLowerCase().includes(pattern.toLowerCase())
) && (
message.toLowerCase().includes(toolName.toLowerCase()) ||
detectionPatterns.some(pattern => message.toLowerCase().includes(pattern.toLowerCase()))
);
if (isToolUnavailable) {
return `${toolName} tools are not installed on the server. Use the ${requiredImages} Docker image instead of ultra-lite, or install ${toolName} tools manually.`;
}
return createStandardErrorHandler(defaultMessage)(error);
}; };