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",
|
"web": "Web",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"email": "Email"
|
"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 { useToolOperation } from '../shared/useToolOperation';
|
||||||
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';
|
|
||||||
|
|
||||||
export interface CompressParameters {
|
export interface CompressParameters {
|
||||||
compressionLevel: number;
|
compressionLevel: number;
|
||||||
@ -37,232 +31,30 @@ export interface CompressOperationHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useCompressOperation = (): CompressOperationHook => {
|
export const useCompressOperation = (): CompressOperationHook => {
|
||||||
const { t } = useTranslation();
|
const toolOperation = useToolOperation<CompressParameters>({
|
||||||
const {
|
operationType: 'compress',
|
||||||
recordOperation,
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
markOperationApplied,
|
buildFormData: (file: File, parameters: CompressParameters) => {
|
||||||
markOperationFailed,
|
const formData = new FormData();
|
||||||
addFiles
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
// Internal state management
|
formData.append("fileInput", file);
|
||||||
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
|
if (parameters.compressionMethod === 'quality') {
|
||||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
||||||
|
} else {
|
||||||
const cleanupBlobUrls = useCallback(() => {
|
// File size method
|
||||||
blobUrls.forEach(url => {
|
const fileSize = parameters.fileSizeValue ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` : '';
|
||||||
try {
|
if (fileSize) {
|
||||||
URL.revokeObjectURL(url);
|
formData.append("expectedOutputSize", fileSize);
|
||||||
} 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 (failedFiles.length > 0 && compressedFiles.length === 0) {
|
formData.append("grayscale", parameters.grayscale.toString());
|
||||||
throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedFiles.length > 0) {
|
return formData;
|
||||||
setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
},
|
||||||
}
|
filePrefix: 'compressed_'
|
||||||
|
});
|
||||||
|
|
||||||
setFiles(compressedFiles);
|
return toolOperation;
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
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 ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||||
import ApiIcon from "@mui/icons-material/Api";
|
import ApiIcon from "@mui/icons-material/Api";
|
||||||
|
import BuildIcon from "@mui/icons-material/Build";
|
||||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||||
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
||||||
|
|
||||||
@ -75,6 +76,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|||||||
description: "Extract text from images using OCR",
|
description: "Extract text from images using OCR",
|
||||||
endpoints: ["ocr-pdf"]
|
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 { ProcessedFile } from './processing';
|
||||||
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
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 {
|
export interface FileOperation {
|
||||||
id: string;
|
id: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user