mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-02 13:48:15 +02:00
Merge 24162d060b
into 24a9104ebf
This commit is contained in:
commit
df56a2cbb3
@ -25,5 +25,19 @@
|
||||
"web": "Web",
|
||||
"text": "Text",
|
||||
"email": "Email"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"repair": {
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore,correction,recover",
|
||||
"title": "Repair",
|
||||
"header": "Repair PDFs",
|
||||
"submit": "Repair"
|
||||
},
|
||||
"loading": "Loading...",
|
||||
"download": "Download"
|
||||
}
|
@ -1,10 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { FileOperation } from '../../../types/fileContext';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
export interface CompressParameters {
|
||||
compressionLevel: number;
|
||||
@ -37,232 +31,30 @@ export interface CompressOperationHook {
|
||||
}
|
||||
|
||||
export const useCompressOperation = (): CompressOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
markOperationFailed,
|
||||
addFiles
|
||||
} = useFileContext();
|
||||
const toolOperation = useToolOperation<CompressParameters>({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (file: File, parameters: CompressParameters) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// 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);
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// 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();
|
||||
|
||||
formData.append("fileInput", file);
|
||||
|
||||
if (parameters.compressionMethod === 'quality') {
|
||||
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
||||
} else {
|
||||
// File size method
|
||||
const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : '';
|
||||
if (fileSize) {
|
||||
formData.append("expectedOutputSize", fileSize);
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("grayscale", parameters.grayscale.toString());
|
||||
|
||||
const endpoint = "/api/v1/misc/compress-pdf";
|
||||
|
||||
return { formData, endpoint };
|
||||
}, []);
|
||||
|
||||
const createOperation = useCallback((
|
||||
parameters: CompressParameters,
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'compress',
|
||||
timestamp: Date.now(),
|
||||
fileIds: selectedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
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 (parameters.compressionMethod === 'quality') {
|
||||
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
||||
} else {
|
||||
// File size method
|
||||
const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : '';
|
||||
if (fileSize) {
|
||||
formData.append("expectedOutputSize", fileSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0 && compressedFiles.length === 0) {
|
||||
throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`);
|
||||
}
|
||||
formData.append("grayscale", parameters.grayscale.toString());
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
||||
}
|
||||
return formData;
|
||||
},
|
||||
filePrefix: 'compressed_'
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
return toolOperation;
|
||||
};
|
33
frontend/src/hooks/tools/repair/useRepairOperation.ts
Normal file
33
frontend/src/hooks/tools/repair/useRepairOperation.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useToolOperation, ToolOperationHook } from '../shared/useToolOperation';
|
||||
|
||||
export interface RepairOperationState {
|
||||
files: File[];
|
||||
thumbnails: string[];
|
||||
isGeneratingThumbnails: boolean;
|
||||
downloadUrl: string | null;
|
||||
downloadFilename: string;
|
||||
isLoading: boolean;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
executeOperation: (selectedFiles: File[]) => Promise<void>;
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useRepairOperation = (): RepairOperationState => {
|
||||
const toolOperation = useToolOperation({
|
||||
operationType: 'repair',
|
||||
endpoint: '/api/v1/misc/repair',
|
||||
buildFormData: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
return formData;
|
||||
},
|
||||
filePrefix: 'repaired_'
|
||||
});
|
||||
|
||||
return {
|
||||
...toolOperation,
|
||||
executeOperation: (selectedFiles: File[]) => toolOperation.executeOperation(undefined, selectedFiles)
|
||||
};
|
||||
};
|
303
frontend/src/hooks/tools/shared/useToolOperation.ts
Normal file
303
frontend/src/hooks/tools/shared/useToolOperation.ts
Normal file
@ -0,0 +1,303 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { FileOperation } from '../../../types/fileContext';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||
|
||||
/**
|
||||
* Configuration interface for tool operations that defines how files should be processed.
|
||||
* @template TParams - Type of parameters required by the tool (use void for no parameters)
|
||||
*/
|
||||
export interface ToolOperationConfig<TParams = void> {
|
||||
/** Unique identifier for the operation type (e.g., 'compress', 'repair') */
|
||||
operationType: string;
|
||||
/** Backend API endpoint for the operation */
|
||||
endpoint: string;
|
||||
/** Function to build FormData from file and parameters for API submission */
|
||||
buildFormData: (file: File, params: TParams) => FormData;
|
||||
/** Optional custom response processor for operations returning multiple files */
|
||||
processResponse?: (blob: Blob) => Promise<File[]>;
|
||||
/** Prefix added to processed file names (e.g., 'compressed_', 'repaired_') */
|
||||
filePrefix: string;
|
||||
/** If true, processes single files directly; if false, processes files individually */
|
||||
singleFileMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State interface for tool operations containing all operation results and UI state.
|
||||
*/
|
||||
export interface ToolOperationState {
|
||||
/** Array of processed files returned by the operation */
|
||||
files: File[];
|
||||
/** Array of thumbnail URLs corresponding to processed files */
|
||||
thumbnails: string[];
|
||||
/** Whether thumbnails are currently being generated */
|
||||
isGeneratingThumbnails: boolean;
|
||||
/** Blob URL for downloading results (single file or zip) */
|
||||
downloadUrl: string | null;
|
||||
/** Suggested filename for download */
|
||||
downloadFilename: string;
|
||||
/** Whether the operation is currently in progress */
|
||||
isLoading: boolean;
|
||||
/** Current operation status message for user feedback */
|
||||
status: string;
|
||||
/** Error message if operation failed, null if no error */
|
||||
errorMessage: string | null;
|
||||
/** Function to reset all operation results and state */
|
||||
resetResults: () => void;
|
||||
/** Function to clear current error message */
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete hook interface for tool operations, extending state with execution capability.
|
||||
* @template TParams - Type of parameters required by the tool
|
||||
*/
|
||||
export interface ToolOperationHook<TParams = void> extends ToolOperationState {
|
||||
/** Function to execute the tool operation with given parameters and files */
|
||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for implementing tool operations with consistent behavior across all tools.
|
||||
* Handles file processing, thumbnail generation, download URLs, error handling, and FileContext integration.
|
||||
*
|
||||
* @template TParams - Type of parameters required by the tool (use void for no parameters)
|
||||
* @param config - Configuration object defining how the tool should process files
|
||||
* @returns Hook with state and execution function for the tool operation
|
||||
*/
|
||||
export const useToolOperation = <TParams = void>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
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<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// 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 createOperation = useCallback((
|
||||
params: TParams,
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `${config.operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: config.operationType,
|
||||
timestamp: Date.now(),
|
||||
fileIds: selectedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: selectedFiles[0]?.name,
|
||||
outputFileNames: selectedFiles.map(f => f.name),
|
||||
parameters: params,
|
||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||
}
|
||||
};
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
}, [config.operationType]);
|
||||
|
||||
const processMultipleFiles = useCallback(async (
|
||||
params: TParams,
|
||||
validFiles: File[]
|
||||
): Promise<File[]> => {
|
||||
const processedFiles: File[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
setStatus(`Processing ${file.name} (${i + 1}/${validFiles.length})`);
|
||||
|
||||
try {
|
||||
const formData = config.buildFormData(file, params);
|
||||
const response = await axios.post(config.endpoint, formData, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const processedFile = new File([blob], `${config.filePrefix}${file.name}`, { type: contentType });
|
||||
|
||||
processedFiles.push(processedFile);
|
||||
} catch (fileError) {
|
||||
console.error(`Failed to process ${file.name}:`, fileError);
|
||||
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) {
|
||||
setStatus(`Processed ${processedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
}, [config]);
|
||||
|
||||
const handleDownloadAndThumbnails = useCallback(async (
|
||||
processedFiles: File[],
|
||||
originalFiles: File[]
|
||||
) => {
|
||||
setFiles(processedFiles);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
await addFiles(processedFiles);
|
||||
cleanupBlobUrls();
|
||||
|
||||
if (processedFiles.length === 1) {
|
||||
const url = window.URL.createObjectURL(processedFiles[0]);
|
||||
setDownloadUrl(url);
|
||||
setBlobUrls([url]);
|
||||
setDownloadFilename(`${config.filePrefix}${originalFiles[0].name}`);
|
||||
} else {
|
||||
const zipFilename = `${config.filePrefix}${processedFiles.length}_files.zip`;
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(processedFiles, zipFilename);
|
||||
const url = window.URL.createObjectURL(zipFile);
|
||||
setDownloadUrl(url);
|
||||
setBlobUrls([url]);
|
||||
setDownloadFilename(zipFilename);
|
||||
}
|
||||
|
||||
const thumbnails = await Promise.all(
|
||||
processedFiles.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);
|
||||
}, [config.filePrefix, addFiles, cleanupBlobUrls]);
|
||||
|
||||
const executeOperation = useCallback(async (params: TParams, 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 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(params, selectedFiles);
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setStatus(t("loading"));
|
||||
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
|
||||
if (config.singleFileMode && validFiles.length === 1) {
|
||||
// Single file mode - direct API call
|
||||
const formData = config.buildFormData(validFiles[0], params);
|
||||
const response = await axios.post(config.endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
if (config.processResponse) {
|
||||
processedFiles = await config.processResponse(response.data);
|
||||
} else {
|
||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const processedFile = new File([blob], `${config.filePrefix}${validFiles[0].name}`, { type: contentType });
|
||||
processedFiles = [processedFile];
|
||||
}
|
||||
} else {
|
||||
// Multi-file mode - process each file individually
|
||||
processedFiles = await processMultipleFiles(params, validFiles);
|
||||
}
|
||||
|
||||
await handleDownloadAndThumbnails(processedFiles, selectedFiles);
|
||||
|
||||
setStatus(t("downloadComplete"));
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
let errorMsg = `An error occurred while processing the ${config.operationType} operation.`;
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMsg = error.response.data;
|
||||
} else if (error.message) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
setErrorMessage(errorMsg);
|
||||
setStatus(`${config.operationType} failed.`);
|
||||
markOperationFailed(fileId, operationId, errorMsg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t, config, createOperation, recordOperation, markOperationApplied, markOperationFailed, processMultipleFiles, handleDownloadAndThumbnails]);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
cleanupBlobUrls();
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(false);
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, [cleanupBlobUrls]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
files,
|
||||
thumbnails,
|
||||
isGeneratingThumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
isLoading,
|
||||
status,
|
||||
errorMessage,
|
||||
executeOperation,
|
||||
resetResults,
|
||||
clearError
|
||||
};
|
||||
};
|
@ -4,6 +4,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import ApiIcon from "@mui/icons-material/Api";
|
||||
import BuildIcon from "@mui/icons-material/Build";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
||||
|
||||
@ -75,6 +76,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
||||
description: "Extract text from images using OCR",
|
||||
endpoints: ["ocr-pdf"]
|
||||
},
|
||||
repair: {
|
||||
id: "repair",
|
||||
icon: <BuildIcon />,
|
||||
component: React.lazy(() => import("../tools/Repair")),
|
||||
maxFiles: -1,
|
||||
category: "utility",
|
||||
description: "Repair corrupted or broken PDF files",
|
||||
endpoints: ["repair"]
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
158
frontend/src/tools/Repair.tsx
Normal file
158
frontend/src/tools/Repair.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||
|
||||
import { useRepairOperation } from "../hooks/tools/repair/useRepairOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
|
||||
const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setCurrentMode } = useFileContext();
|
||||
const { selectedFiles } = useToolFileSelection();
|
||||
|
||||
const repairOperation = useRepairOperation();
|
||||
|
||||
// Endpoint validation
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("repair");
|
||||
|
||||
useEffect(() => {
|
||||
repairOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [selectedFiles]);
|
||||
|
||||
const handleRepair = async () => {
|
||||
try {
|
||||
await repairOperation.executeOperation(selectedFiles);
|
||||
if (repairOperation.files && onComplete) {
|
||||
onComplete(repairOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error.message : 'Repair operation failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem('previousMode', 'repair');
|
||||
setCurrentMode('viewer');
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
repairOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
setCurrentMode('repair');
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = repairOperation.files.length > 0 || repairOperation.downloadUrl !== null;
|
||||
const filesCollapsed = hasFiles;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
const previewResults = useMemo(() =>
|
||||
repairOperation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: repairOperation.thumbnails[index]
|
||||
})) || [],
|
||||
[repairOperation.files, repairOperation.thumbnails]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolStepContainer>
|
||||
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
||||
{/* Files Step */}
|
||||
<ToolStep
|
||||
title="Files"
|
||||
isVisible={true}
|
||||
isCollapsed={filesCollapsed}
|
||||
isCompleted={filesCollapsed}
|
||||
completedMessage={hasFiles ?
|
||||
selectedFiles.length === 1
|
||||
? `Selected: ${selectedFiles[0].name}`
|
||||
: `Selected: ${selectedFiles.length} files`
|
||||
: undefined}
|
||||
>
|
||||
<FileStatusIndicator
|
||||
selectedFiles={selectedFiles}
|
||||
placeholder="Select PDF files in the main view to get started"
|
||||
/>
|
||||
</ToolStep>
|
||||
|
||||
{/* Repair Step */}
|
||||
<ToolStep
|
||||
title="Repair"
|
||||
isVisible={hasFiles}
|
||||
isCollapsed={settingsCollapsed}
|
||||
isCompleted={settingsCollapsed}
|
||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
||||
completedMessage={settingsCollapsed ? "Repair completed" : undefined}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
This tool attempts to repair corrupted or broken PDF files by fixing structural issues and recovering readable content. Each file is processed individually.
|
||||
</Text>
|
||||
|
||||
<OperationButton
|
||||
onClick={handleRepair}
|
||||
isLoading={repairOperation.isLoading}
|
||||
disabled={!hasFiles || !endpointEnabled}
|
||||
loadingText={t("loading")}
|
||||
submitText={t("repair.submit")}
|
||||
/>
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
|
||||
{/* Results Step */}
|
||||
<ToolStep
|
||||
title="Results"
|
||||
isVisible={hasResults}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{repairOperation.status && (
|
||||
<Text size="sm" c="dimmed">{repairOperation.status}</Text>
|
||||
)}
|
||||
|
||||
<ErrorNotification
|
||||
error={repairOperation.errorMessage}
|
||||
onClose={repairOperation.clearError}
|
||||
/>
|
||||
|
||||
{repairOperation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={repairOperation.downloadUrl}
|
||||
download={repairOperation.downloadFilename}
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
mb="md"
|
||||
>
|
||||
{t("download", "Download")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ResultsPreview
|
||||
files={previewResults}
|
||||
onFileClick={handleThumbnailClick}
|
||||
isGeneratingThumbnails={repairOperation.isGeneratingThumbnails}
|
||||
title="Repair Results"
|
||||
/>
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
</Stack>
|
||||
</ToolStepContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Repair;
|
@ -5,9 +5,9 @@
|
||||
import { ProcessedFile } from './processing';
|
||||
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
||||
|
||||
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr';
|
||||
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'repair';
|
||||
|
||||
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr';
|
||||
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr' | 'repair';
|
||||
|
||||
export interface FileOperation {
|
||||
id: string;
|
||||
|
Loading…
Reference in New Issue
Block a user