This commit is contained in:
Anthony Stirling 2025-08-01 22:35:21 +00:00 committed by GitHub
commit df56a2cbb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 542 additions and 232 deletions

View File

@ -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"
}

View File

@ -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;
};

View 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)
};
};

View 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
};
};

View File

@ -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"]
},
};

View 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;

View File

@ -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;