mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-11 13:48:37 +02:00
Enforce type checking in CI (#4126)
# Description of Changes Currently, the `tsconfig.json` file enforces strict type checking, but nothing in CI checks that the code is actually correctly typed. [Vite only transpiles TypeScript code](https://vite.dev/guide/features.html#transpile-only) so doesn't ensure that the TS code we're running is correct. This PR adds running of the type checker to CI and fixes the type errors that have already crept into the codebase. Note that many of the changes I've made to 'fix the types' are just using `any` to disable the type checker because the code is under too much churn to fix anything properly at the moment. I still think enabling the type checker now is the best course of action though because otherwise we'll never be able to fix all of them, and it should at least help us not break things when adding new code. Co-authored-by: James <james@crosscourtanalytics.com>
This commit is contained in:
parent
507ad1dc61
commit
af5a9d1ae1
@ -9,7 +9,8 @@
|
||||
"Bash(find:*)",
|
||||
"Bash(npm test)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(npx tsc:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@ -39,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^24.2.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
@ -2384,6 +2385,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
|
||||
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@ -7404,6 +7414,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
|
@ -34,8 +34,8 @@
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "npx tsc --noEmit && vite",
|
||||
"build": "npx tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-licenses": "node scripts/generate-licenses.js",
|
||||
"test": "vitest",
|
||||
@ -65,6 +65,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^24.2.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
|
@ -70,11 +70,11 @@ const FileEditor = ({
|
||||
} = fileContext;
|
||||
|
||||
// Get file selection context
|
||||
const {
|
||||
selectedFiles: toolSelectedFiles,
|
||||
setSelectedFiles: setToolSelectedFiles,
|
||||
maxFiles,
|
||||
isToolMode
|
||||
const {
|
||||
selectedFiles: toolSelectedFiles,
|
||||
setSelectedFiles: setToolSelectedFiles,
|
||||
maxFiles,
|
||||
isToolMode
|
||||
} = useFileSelection();
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
@ -82,7 +82,7 @@ const FileEditor = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [localLoading, setLocalLoading] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
React.useEffect(() => {
|
||||
if (toolMode) {
|
||||
@ -115,7 +115,7 @@ const FileEditor = ({
|
||||
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
|
||||
// Map context selections to local file IDs for UI display
|
||||
const localSelectedIds = files
|
||||
.filter(file => {
|
||||
@ -144,33 +144,33 @@ const FileEditor = ({
|
||||
// Check if the actual content has changed, not just references
|
||||
const currentActiveFileNames = activeFiles.map(f => f.name);
|
||||
const currentProcessedFilesSize = processedFiles.size;
|
||||
|
||||
|
||||
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
|
||||
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
|
||||
|
||||
|
||||
if (!activeFilesChanged && !processedFilesChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Update refs
|
||||
lastActiveFilesRef.current = currentActiveFileNames;
|
||||
lastProcessedFilesRef.current = currentProcessedFilesSize;
|
||||
|
||||
|
||||
const convertActiveFiles = async () => {
|
||||
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
// Process files in chunks to avoid blocking UI
|
||||
const convertedFiles: FileItem[] = [];
|
||||
|
||||
|
||||
for (let i = 0; i < activeFiles.length; i++) {
|
||||
const file = activeFiles[i];
|
||||
|
||||
|
||||
// Try to get thumbnail from processed file first
|
||||
const processedFile = processedFiles.get(file);
|
||||
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
|
||||
|
||||
|
||||
// If no thumbnail from processed file, try to generate one
|
||||
if (!thumbnail) {
|
||||
try {
|
||||
@ -180,28 +180,28 @@ const FileEditor = ({
|
||||
thumbnail = undefined; // Use placeholder
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const convertedFile = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name,
|
||||
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
|
||||
thumbnail,
|
||||
thumbnail: thumbnail || '',
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
|
||||
|
||||
convertedFiles.push(convertedFile);
|
||||
|
||||
|
||||
// Update progress
|
||||
setConversionProgress(((i + 1) / activeFiles.length) * 100);
|
||||
|
||||
|
||||
// Yield to main thread between files
|
||||
if (i < activeFiles.length - 1) {
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
setFiles(convertedFiles);
|
||||
} catch (err) {
|
||||
console.error('Error converting active files:', err);
|
||||
@ -237,7 +237,7 @@ const FileEditor = ({
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await zipFileService.validateZipFile(file);
|
||||
|
||||
|
||||
if (validation.isValid && validation.containsPDFs) {
|
||||
// ZIP contains PDFs - extract them
|
||||
setZipExtractionProgress({
|
||||
@ -269,7 +269,7 @@ const FileEditor = ({
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
@ -289,10 +289,10 @@ const FileEditor = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
@ -344,7 +344,7 @@ const FileEditor = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
}
|
||||
@ -357,7 +357,7 @@ const FileEditor = ({
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
@ -377,7 +377,7 @@ const FileEditor = ({
|
||||
|
||||
const closeAllFiles = useCallback(() => {
|
||||
if (activeFiles.length === 0) return;
|
||||
|
||||
|
||||
// Record close all operation for each file
|
||||
activeFiles.forEach(file => {
|
||||
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
@ -396,14 +396,14 @@ const FileEditor = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
});
|
||||
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
|
||||
|
||||
|
||||
// Clear selections
|
||||
setContextSelectedFiles([]);
|
||||
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
@ -411,12 +411,12 @@ const FileEditor = ({
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
const targetFile = files.find(f => f.id === fileId);
|
||||
if (!targetFile) return;
|
||||
|
||||
|
||||
const contextFileId = (targetFile.file as any).id || targetFile.name;
|
||||
const isSelected = contextSelectedIds.includes(contextFileId);
|
||||
|
||||
|
||||
let newSelection: string[];
|
||||
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
|
||||
@ -433,10 +433,10 @@ const FileEditor = ({
|
||||
newSelection = [...contextSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update context
|
||||
setContextSelectedFiles(newSelection);
|
||||
|
||||
|
||||
// Update tool selection context if in tool mode
|
||||
if (isToolMode || toolMode) {
|
||||
const selectedFiles = files
|
||||
@ -572,12 +572,12 @@ const FileEditor = ({
|
||||
console.log('handleDeleteFile called with fileId:', fileId);
|
||||
const file = files.find(f => f.id === fileId);
|
||||
console.log('Found file:', file);
|
||||
|
||||
|
||||
if (file) {
|
||||
console.log('Attempting to remove file:', file.name);
|
||||
console.log('Actual file object:', file.file);
|
||||
console.log('Actual file.file.name:', file.file.name);
|
||||
|
||||
|
||||
// Record close operation
|
||||
const fileName = file.file.name;
|
||||
const fileId = (file.file as any).id || fileName;
|
||||
@ -597,19 +597,16 @@ const FileEditor = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
recordOperation(fileName, operation);
|
||||
|
||||
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
console.log('Calling removeFiles with:', [fileId]);
|
||||
removeFiles([fileId], false);
|
||||
|
||||
|
||||
// Remove from context selections
|
||||
setContextSelectedFiles(prev => {
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.filter(id => id !== fileId);
|
||||
});
|
||||
|
||||
const newSelection = contextSelectedIds.filter(id => id !== fileId);
|
||||
setContextSelectedFiles(newSelection);
|
||||
// Mark operation as applied
|
||||
markOperationApplied(fileName, operationId);
|
||||
} else {
|
||||
@ -670,7 +667,7 @@ const FileEditor = ({
|
||||
accept={["*/*"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{
|
||||
style={{
|
||||
height: '100vh',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
@ -707,7 +704,7 @@ const FileEditor = ({
|
||||
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
|
||||
{/* ZIP Extraction Progress */}
|
||||
{zipExtractionProgress.isExtracting && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
|
||||
@ -721,10 +718,10 @@ const FileEditor = ({
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
|
||||
</Text>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
@ -737,7 +734,7 @@ const FileEditor = ({
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{/* Processing indicator */}
|
||||
{localLoading && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
@ -745,10 +742,10 @@ const FileEditor = ({
|
||||
<Text size="sm" fw={500}>Loading files...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
@ -761,27 +758,27 @@ const FileEditor = ({
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={localSelectedIds}
|
||||
selectedItems={localSelectedIds as any /* FIX ME */}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiFileDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
onDragStart={handleDragStart as any /* FIX ME */}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter as any /* FIX ME */}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop as any /* FIX ME */}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile as any /* FIX ME */}
|
||||
dropTarget={dropTarget as any /* FIX ME */}
|
||||
multiItemDrag={multiFileDrag as any /* FIX ME */}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
<FileThumbnail
|
||||
file={file}
|
||||
index={index}
|
||||
@ -801,8 +798,6 @@ const FileEditor = ({
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onMergeFromHere={handleMergeFromHere}
|
||||
onSplitFile={handleSplitFile}
|
||||
onSetStatus={setStatus}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
@ -831,7 +826,6 @@ const FileEditor = ({
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
allowMultiple={true}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
|
@ -27,9 +27,10 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
||||
maxHeight = 400
|
||||
}) => {
|
||||
const { getFileHistory, getAppliedOperations } = useFileContext();
|
||||
|
||||
|
||||
const history = getFileHistory(fileId);
|
||||
const operations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
|
||||
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
|
||||
const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[];
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
@ -62,7 +63,7 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const renderOperationDetails = (operation: FileOperation | PageOperation) => {
|
||||
const renderOperationDetails = (operation: FileOperation) => {
|
||||
if ('metadata' in operation && operation.metadata) {
|
||||
const { metadata } = operation;
|
||||
return (
|
||||
@ -142,7 +143,7 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
|
||||
<Badge
|
||||
variant="filled"
|
||||
color={getStatusColor(operation.status)}
|
||||
@ -174,4 +175,4 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileOperationHistory;
|
||||
export default FileOperationHistory;
|
||||
|
@ -18,18 +18,18 @@ import LandingPage from '../shared/LandingPage';
|
||||
export default function Workbench() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { activeFiles, currentView, setCurrentView } = useFileContext();
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
sidebarsVisible,
|
||||
setPreviewFile,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible
|
||||
} = useWorkbenchState();
|
||||
|
||||
|
||||
const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection();
|
||||
const { addToActiveFiles } = useFileHandler();
|
||||
|
||||
@ -142,10 +142,10 @@ export default function Workbench() {
|
||||
{/* Top Controls */}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
setCurrentView={setCurrentView as any /* FIX ME */}
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10"
|
||||
@ -157,4 +157,4 @@ export default function Workbench() {
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ interface DragDropGridProps<T extends DragDropItem> {
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
draggedItem: number | null;
|
||||
dropTarget: number | null;
|
||||
dropTarget: number | 'end' | null;
|
||||
multiItemDrag: {pageNumbers: number[], count: number} | null;
|
||||
dragPosition: {x: number, y: number} | null;
|
||||
}
|
||||
|
@ -345,7 +345,7 @@ const FileThumbnail = ({
|
||||
onClose={() => setShowHistory(false)}
|
||||
title={`Operation History - ${file.name}`}
|
||||
size="lg"
|
||||
scrollAreaComponent="div"
|
||||
scrollAreaComponent={'div' as any}
|
||||
>
|
||||
<FileOperationHistory
|
||||
fileId={file.name}
|
||||
|
@ -43,7 +43,7 @@ export interface PageEditorProps {
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
selectedPages: number[];
|
||||
closePdf: () => void;
|
||||
}) => void;
|
||||
}
|
||||
@ -56,7 +56,7 @@ const PageEditor = ({
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
|
||||
|
||||
|
||||
// Use file context state
|
||||
const {
|
||||
activeFiles,
|
||||
@ -81,12 +81,12 @@ const PageEditor = ({
|
||||
// Simple computed document from processed files (no caching needed)
|
||||
const mergedPdfDocument = useMemo(() => {
|
||||
if (activeFiles.length === 0) return null;
|
||||
|
||||
|
||||
if (activeFiles.length === 1) {
|
||||
// Single file
|
||||
const processedFile = processedFiles.get(activeFiles[0]);
|
||||
if (!processedFile) return null;
|
||||
|
||||
|
||||
return {
|
||||
id: processedFile.id,
|
||||
name: activeFiles[0].name,
|
||||
@ -108,7 +108,7 @@ const PageEditor = ({
|
||||
const processedFile = processedFiles.get(file);
|
||||
if (processedFile) {
|
||||
filenames.push(file.name.replace(/\.pdf$/i, ''));
|
||||
|
||||
|
||||
processedFile.pages.forEach((page, pageIndex) => {
|
||||
const newPage: PDFPage = {
|
||||
...page,
|
||||
@ -119,7 +119,7 @@ const PageEditor = ({
|
||||
};
|
||||
allPages.push(newPage);
|
||||
});
|
||||
|
||||
|
||||
totalPages += processedFile.pages.length;
|
||||
}
|
||||
});
|
||||
@ -140,7 +140,7 @@ const PageEditor = ({
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
|
||||
const [filename, setFilename] = useState<string>("");
|
||||
|
||||
|
||||
|
||||
// Page editor state (use context for selectedPages)
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
@ -149,7 +149,7 @@ const PageEditor = ({
|
||||
|
||||
// Drag and drop state
|
||||
const [draggedPage, setDraggedPage] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<number | 'end' | null>(null);
|
||||
const [multiPageDrag, setMultiPageDrag] = useState<{pageNumbers: number[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
|
||||
@ -200,54 +200,54 @@ const PageEditor = ({
|
||||
const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false);
|
||||
|
||||
// Thumbnail generation (opt-in for visual tools)
|
||||
const {
|
||||
const {
|
||||
generateThumbnails,
|
||||
addThumbnailToCache,
|
||||
getThumbnailFromCache,
|
||||
addThumbnailToCache,
|
||||
getThumbnailFromCache,
|
||||
stopGeneration,
|
||||
destroyThumbnails
|
||||
destroyThumbnails
|
||||
} = useThumbnailGeneration();
|
||||
|
||||
// Start thumbnail generation process (separate from document loading)
|
||||
const startThumbnailGeneration = useCallback(() => {
|
||||
console.log('🎬 PageEditor: startThumbnailGeneration called');
|
||||
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted);
|
||||
|
||||
|
||||
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) {
|
||||
console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const file = activeFiles[0];
|
||||
const totalPages = mergedPdfDocument.totalPages;
|
||||
|
||||
|
||||
console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages');
|
||||
setThumbnailGenerationStarted(true);
|
||||
|
||||
|
||||
// Run everything asynchronously to avoid blocking the main thread
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Load PDF array buffer for Web Workers
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
// Generate page numbers for pages that don't have thumbnails yet
|
||||
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(pageNum => {
|
||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
return !page?.thumbnail; // Only generate for pages without thumbnails
|
||||
});
|
||||
|
||||
|
||||
console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : '');
|
||||
|
||||
|
||||
// If no pages need thumbnails, we're done
|
||||
if (pageNumbers.length === 0) {
|
||||
console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Calculate quality scale based on file size
|
||||
const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2;
|
||||
|
||||
|
||||
// Start parallel thumbnail generation WITHOUT blocking the main thread
|
||||
const generationPromise = generateThumbnails(
|
||||
arrayBuffer,
|
||||
@ -267,11 +267,11 @@ const PageEditor = ({
|
||||
// Check cache first, then send thumbnail
|
||||
const pageId = `${file.name}-page-${pageNumber}`;
|
||||
const cached = getThumbnailFromCache(pageId);
|
||||
|
||||
|
||||
if (!cached) {
|
||||
// Cache and send to component
|
||||
addThumbnailToCache(pageId, thumbnail);
|
||||
|
||||
|
||||
window.dispatchEvent(new CustomEvent('thumbnailReady', {
|
||||
detail: { pageNumber, thumbnail, pageId }
|
||||
}));
|
||||
@ -292,7 +292,7 @@ const PageEditor = ({
|
||||
console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error);
|
||||
setThumbnailGenerationStarted(false);
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to start Web Worker thumbnail generation:', error);
|
||||
setThumbnailGenerationStarted(false);
|
||||
@ -304,25 +304,25 @@ const PageEditor = ({
|
||||
useEffect(() => {
|
||||
console.log('🎬 PageEditor: Thumbnail generation effect triggered');
|
||||
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted);
|
||||
|
||||
|
||||
if (mergedPdfDocument && !thumbnailGenerationStarted) {
|
||||
// Check if ALL pages already have thumbnails from processed files
|
||||
const totalPages = mergedPdfDocument.pages.length;
|
||||
const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length;
|
||||
const hasAllThumbnails = pagesWithThumbnails === totalPages;
|
||||
|
||||
|
||||
console.log('🎬 PageEditor: Thumbnail status:', {
|
||||
totalPages,
|
||||
pagesWithThumbnails,
|
||||
hasAllThumbnails,
|
||||
missingThumbnails: totalPages - pagesWithThumbnails
|
||||
});
|
||||
|
||||
|
||||
if (hasAllThumbnails) {
|
||||
console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist');
|
||||
return; // Skip generation if ALL thumbnails already exist
|
||||
}
|
||||
|
||||
|
||||
console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation');
|
||||
// Small delay to let document render, then start thumbnail generation
|
||||
console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms');
|
||||
@ -394,10 +394,10 @@ const PageEditor = ({
|
||||
|
||||
const togglePage = useCallback((pageNumber: number) => {
|
||||
console.log('🔄 Toggling page', pageNumber);
|
||||
|
||||
|
||||
// Check if currently selected and update accordingly
|
||||
const isCurrentlySelected = selectedPageNumbers.includes(pageNumber);
|
||||
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// Remove from selection
|
||||
console.log('🔄 Removing page', pageNumber);
|
||||
@ -524,24 +524,24 @@ const PageEditor = ({
|
||||
// Update PDF document state with edit tracking
|
||||
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
|
||||
console.log('setPdfDocument called - setting edited state');
|
||||
|
||||
|
||||
// Update local edit state for immediate visual feedback
|
||||
setEditedDocument(updatedDoc);
|
||||
setHasUnsavedChanges(true); // Use global state
|
||||
setHasUnsavedDraft(true); // Mark that we have unsaved draft changes
|
||||
|
||||
|
||||
// Auto-save to drafts (debounced) - only if we have new changes
|
||||
if (autoSaveTimer.current) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
}
|
||||
|
||||
|
||||
autoSaveTimer.current = setTimeout(() => {
|
||||
if (hasUnsavedDraft) {
|
||||
saveDraftToIndexedDB(updatedDoc);
|
||||
setHasUnsavedDraft(false); // Mark draft as saved
|
||||
}
|
||||
}, 30000); // Auto-save after 30 seconds of inactivity
|
||||
|
||||
|
||||
return updatedDoc;
|
||||
}, [setHasUnsavedChanges, hasUnsavedDraft]);
|
||||
|
||||
@ -554,7 +554,7 @@ const PageEditor = ({
|
||||
timestamp: Date.now(),
|
||||
originalFiles: activeFiles.map(f => f.name)
|
||||
};
|
||||
|
||||
|
||||
// Save to 'pdf-drafts' store in IndexedDB
|
||||
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
request.onupgradeneeded = () => {
|
||||
@ -563,7 +563,7 @@ const PageEditor = ({
|
||||
db.createObjectStore('drafts');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction('drafts', 'readwrite');
|
||||
@ -581,7 +581,7 @@ const PageEditor = ({
|
||||
try {
|
||||
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
|
||||
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction('drafts', 'readwrite');
|
||||
@ -596,12 +596,12 @@ const PageEditor = ({
|
||||
// Apply changes to create new processed file
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!editedDocument || !mergedPdfDocument) return;
|
||||
|
||||
|
||||
try {
|
||||
if (activeFiles.length === 1) {
|
||||
const file = activeFiles[0];
|
||||
const currentProcessedFile = processedFiles.get(file);
|
||||
|
||||
|
||||
if (currentProcessedFile) {
|
||||
const updatedProcessedFile = {
|
||||
...currentProcessedFile,
|
||||
@ -614,14 +614,14 @@ const PageEditor = ({
|
||||
totalPages: editedDocument.pages.length,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
|
||||
|
||||
updateProcessedFile(file, updatedProcessedFile);
|
||||
}
|
||||
} else if (activeFiles.length > 1) {
|
||||
setStatus('Apply changes for multiple files not yet supported');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Wait for the processed file update to complete before clearing edit state
|
||||
setTimeout(() => {
|
||||
setEditedDocument(null);
|
||||
@ -630,7 +630,7 @@ const PageEditor = ({
|
||||
cleanupDraft();
|
||||
setStatus('Changes applied successfully');
|
||||
}, 100);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to apply changes:', error);
|
||||
setStatus('Failed to apply changes');
|
||||
@ -653,7 +653,7 @@ const PageEditor = ({
|
||||
|
||||
// Skip animation for large documents (500+ pages) to improve performance
|
||||
const isLargeDocument = displayDocument.pages.length > 500;
|
||||
|
||||
|
||||
if (isLargeDocument) {
|
||||
// For large documents, just execute the command without animation
|
||||
if (pagesToMove.length > 1) {
|
||||
@ -678,7 +678,7 @@ const PageEditor = ({
|
||||
|
||||
// Only capture positions for potentially affected pages
|
||||
const currentPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
|
||||
affectedPageIds.forEach(pageId => {
|
||||
const element = document.querySelector(`[data-page-number="${pageId}"]`);
|
||||
if (element) {
|
||||
@ -728,14 +728,14 @@ const PageEditor = ({
|
||||
|
||||
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
|
||||
elementsToAnimate.push(element);
|
||||
|
||||
|
||||
// Apply initial transform
|
||||
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
element.style.transition = 'none';
|
||||
|
||||
|
||||
// Force reflow
|
||||
element.offsetHeight;
|
||||
|
||||
|
||||
// Animate to final position
|
||||
element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
element.style.transform = 'translate(0px, 0px)';
|
||||
@ -863,13 +863,13 @@ const PageEditor = ({
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
// Convert page numbers to page IDs for export service
|
||||
const exportPageIds = selectedOnly
|
||||
const exportPageIds = selectedOnly
|
||||
? selectedPageNumbers.map(pageNum => {
|
||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
return page?.id || '';
|
||||
}).filter(id => id)
|
||||
: [];
|
||||
|
||||
|
||||
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
|
||||
setExportPreview(preview);
|
||||
setShowExportModal(true);
|
||||
@ -881,16 +881,16 @@ const PageEditor = ({
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Convert page numbers to page IDs for export service
|
||||
const exportPageIds = selectedOnly
|
||||
const exportPageIds = selectedOnly
|
||||
? selectedPageNumbers.map(pageNum => {
|
||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
return page?.id || '';
|
||||
}).filter(id => id)
|
||||
: [];
|
||||
|
||||
|
||||
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join(', '));
|
||||
setStatus(errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -921,7 +921,7 @@ const PageEditor = ({
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
||||
setError(errorMessage);
|
||||
setStatus(errorMessage);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
@ -1009,26 +1009,26 @@ const PageEditor = ({
|
||||
// Check for existing drafts
|
||||
const checkForDrafts = useCallback(async () => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
|
||||
try {
|
||||
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
|
||||
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('drafts')) return;
|
||||
|
||||
|
||||
const transaction = db.transaction('drafts', 'readonly');
|
||||
const store = transaction.objectStore('drafts');
|
||||
const getRequest = store.get(draftKey);
|
||||
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const draft = getRequest.result;
|
||||
if (draft && draft.timestamp) {
|
||||
// Check if draft is recent (within last 24 hours)
|
||||
const draftAge = Date.now() - draft.timestamp;
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
|
||||
if (draftAge < twentyFourHours) {
|
||||
setFoundDraft(draft);
|
||||
setShowResumeModal(true);
|
||||
@ -1066,12 +1066,12 @@ const PageEditor = ({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('PageEditor unmounting - cleaning up resources');
|
||||
|
||||
|
||||
// Clear auto-save timer
|
||||
if (autoSaveTimer.current) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
}
|
||||
|
||||
|
||||
// Clean up draft if component unmounts with unsaved changes
|
||||
if (hasUnsavedChanges) {
|
||||
cleanupDraft();
|
||||
@ -1125,7 +1125,7 @@ const PageEditor = ({
|
||||
{showLoading && (
|
||||
<Box p="md" pt="xl">
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
|
||||
{/* Progress indicator */}
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
@ -1136,10 +1136,10 @@ const PageEditor = ({
|
||||
{Math.round(processingProgress || 0)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
@ -1151,7 +1151,7 @@ const PageEditor = ({
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
|
||||
<SkeletonLoader type="pageGrid" count={8} />
|
||||
</Box>
|
||||
)}
|
||||
@ -1165,10 +1165,10 @@ const PageEditor = ({
|
||||
<Text size="sm" fw={500}>Processing thumbnails...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(processingProgress || 0)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
@ -1210,7 +1210,7 @@ const PageEditor = ({
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Apply Changes Button */}
|
||||
{hasUnsavedChanges && (
|
||||
<Button
|
||||
@ -1233,7 +1233,7 @@ const PageEditor = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<DragDropGrid
|
||||
items={displayedPages}
|
||||
selectedItems={selectedPageNumbers}
|
||||
@ -1259,7 +1259,7 @@ const PageEditor = ({
|
||||
selectedPages={selectedPageNumbers}
|
||||
selectionMode={selectionMode}
|
||||
draggedPage={draggedPage}
|
||||
dropTarget={dropTarget}
|
||||
dropTarget={dropTarget === 'end' ? null : dropTarget}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
pageRefs={refs}
|
||||
@ -1372,13 +1372,13 @@ const PageEditor = ({
|
||||
<Text>
|
||||
We found unsaved changes from a previous session. Would you like to resume where you left off?
|
||||
</Text>
|
||||
|
||||
|
||||
{foundDraft && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Last saved: {new Date(foundDraft.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
@ -1387,7 +1387,7 @@ const PageEditor = ({
|
||||
>
|
||||
Start Fresh
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={resumeWork}
|
||||
|
@ -35,7 +35,7 @@ interface PageEditorControlsProps {
|
||||
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
selectedPages: number[];
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
|
@ -7,9 +7,9 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { PDFPage, PDFDocument } from '../../../types/pageEditor';
|
||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../../commands/pageCommands';
|
||||
import { Command } from '../../../hooks/useUndoRedo';
|
||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
||||
import { Command } from '../../hooks/useUndoRedo';
|
||||
import styles from './PageEditor.module.css';
|
||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||
|
||||
@ -29,7 +29,7 @@ interface PageThumbnailProps {
|
||||
selectedPages: number[];
|
||||
selectionMode: boolean;
|
||||
draggedPage: number | null;
|
||||
dropTarget: number | null;
|
||||
dropTarget: number | 'end' | null;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
@ -82,7 +82,7 @@ const PageThumbnail = React.memo(({
|
||||
}: PageThumbnailProps) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
|
||||
|
||||
|
||||
// Update thumbnail URL when page prop changes
|
||||
useEffect(() => {
|
||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||
@ -97,13 +97,13 @@ const PageThumbnail = React.memo(({
|
||||
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
|
||||
return; // Skip if we already have a thumbnail
|
||||
}
|
||||
|
||||
|
||||
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
|
||||
|
||||
|
||||
const handleThumbnailReady = (event: CustomEvent) => {
|
||||
const { pageNumber, thumbnail, pageId } = event.detail;
|
||||
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
|
||||
|
||||
|
||||
if (pageNumber === page.pageNumber && pageId === page.id) {
|
||||
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
|
||||
setThumbnailUrl(thumbnail);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
@ -9,7 +9,6 @@ import EditIcon from "@mui/icons-material/Edit";
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
|
@ -80,7 +80,7 @@ const FileGrid = ({
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={t("fileManager.searchFiles", "Search files...")}
|
||||
leftSection={<SearchIcon size={16} />}
|
||||
leftSection={<SearchIcon fontSize="small" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
|
||||
@ -96,7 +96,7 @@ const FileGrid = ({
|
||||
]}
|
||||
value={sortBy}
|
||||
onChange={(value) => setSortBy(value as SortOption)}
|
||||
leftSection={<SortIcon size={16} />}
|
||||
leftSection={<SortIcon fontSize="small" />}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
)}
|
||||
@ -130,7 +130,7 @@ const FileGrid = ({
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={file}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
|
||||
onView={onView && supported ? () => onView(file) : undefined}
|
||||
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
|
||||
|
@ -64,19 +64,19 @@ const TopControls = ({
|
||||
}: TopControlsProps) => {
|
||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
|
||||
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
// Show immediate feedback
|
||||
setSwitchingTo(view);
|
||||
|
||||
|
||||
// Defer the heavy view change to next frame so spinner can render
|
||||
requestAnimationFrame(() => {
|
||||
// Give the spinner one more frame to show
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentView(view);
|
||||
|
||||
|
||||
// Clear the loading state after view change completes
|
||||
setTimeout(() => setSwitchingTo(null), 300);
|
||||
});
|
||||
|
@ -16,24 +16,24 @@ export default function ToolPanel() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef } = sidebarRefs;
|
||||
|
||||
|
||||
|
||||
// Use context-based hooks to eliminate prop drilling
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
setSearchQuery,
|
||||
handleBackToTools
|
||||
} = useToolPanelState();
|
||||
|
||||
|
||||
const { selectedToolKey, handleToolSelect } = useToolSelection();
|
||||
const { setPreviewFile } = useWorkbenchState();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-sidebar="tool-panel"
|
||||
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ''
|
||||
}`}
|
||||
@ -77,7 +77,7 @@ export default function ToolPanel() {
|
||||
{/* Tool content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
selectedToolKey={selectedToolKey || ''}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
</div>
|
||||
@ -86,4 +86,4 @@ export default function ToolPanel() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -30,12 +30,12 @@ const ConvertFromImageSettings = ({
|
||||
})}
|
||||
data={[
|
||||
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
|
||||
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
|
||||
{ value: COLOR_TYPES.GRAYSCALE, label: t("convert.grayscale", "Grayscale") },
|
||||
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
|
||||
<Select
|
||||
data-testid="fit-option-select"
|
||||
label={t("convert.fitOption", "Fit Option")}
|
||||
@ -51,7 +51,7 @@ const ConvertFromImageSettings = ({
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
|
||||
<Switch
|
||||
data-testid="auto-rotate-switch"
|
||||
label={t("convert.autoRotate", "Auto Rotate")}
|
||||
@ -63,7 +63,7 @@ const ConvertFromImageSettings = ({
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
|
||||
<Switch
|
||||
data-testid="combine-images-switch"
|
||||
label={t("convert.combineImages", "Combine Images")}
|
||||
@ -79,4 +79,4 @@ const ConvertFromImageSettings = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertFromImageSettings;
|
||||
export default ConvertFromImageSettings;
|
||||
|
@ -31,7 +31,6 @@ const ConvertFromWebSettings = ({
|
||||
min={0.1}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
precision={1}
|
||||
disabled={disabled}
|
||||
data-testid="zoom-level-input"
|
||||
/>
|
||||
|
@ -31,7 +31,7 @@ const ConvertToImageSettings = ({
|
||||
})}
|
||||
data={[
|
||||
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
|
||||
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
|
||||
{ value: COLOR_TYPES.GRAYSCALE, label: t("convert.grayscale", "Grayscale") },
|
||||
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
@ -68,4 +68,4 @@ const ConvertToImageSettings = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertToImageSettings;
|
||||
export default ConvertToImageSettings;
|
||||
|
@ -30,7 +30,7 @@ const ConvertToPdfaSettings = ({
|
||||
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
|
||||
|
||||
{hasDigitalSignatures && (
|
||||
<Alert color="yellow" size="sm">
|
||||
<Alert color="yellow">
|
||||
<Text size="sm">
|
||||
{t("convert.pdfaDigitalSignatureWarning", "The PDF contains a digital signature. This will be removed in the next step.")}
|
||||
</Text>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
||||
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
||||
|
||||
export interface SplitParameters {
|
||||
mode: SplitMode | '';
|
||||
@ -123,7 +123,7 @@ const SplitSettings = ({
|
||||
label="Choose split method"
|
||||
placeholder="Select how to split the PDF"
|
||||
value={parameters.mode}
|
||||
onChange={(v) => v && onParameterChange('mode', v)}
|
||||
onChange={(v) => isSplitMode(v) && onParameterChange('mode', v)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
||||
|
@ -137,7 +137,7 @@ export interface ViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File; // For preview mode - bypasses context
|
||||
previewFile: File | null; // For preview mode - bypasses context
|
||||
}
|
||||
|
||||
const Viewer = ({
|
||||
@ -148,18 +148,13 @@ const Viewer = ({
|
||||
}: ViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
|
||||
// Get current file from FileContext
|
||||
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
|
||||
const currentFile = getCurrentFile();
|
||||
const processedFile = getCurrentProcessedFile();
|
||||
|
||||
// Convert File to FileWithUrl format for viewer
|
||||
const pdfFile = useFileWithUrl(currentFile);
|
||||
|
||||
|
||||
// Tab management for multiple files
|
||||
const [activeTab, setActiveTab] = useState<string>("0");
|
||||
|
||||
|
||||
// Reset PDF state when switching tabs
|
||||
const handleTabChange = (newTab: string) => {
|
||||
setActiveTab(newTab);
|
||||
@ -183,7 +178,7 @@ const Viewer = ({
|
||||
const file2WithUrl = useFileWithUrl(activeFiles[2]);
|
||||
const file3WithUrl = useFileWithUrl(activeFiles[3]);
|
||||
const file4WithUrl = useFileWithUrl(activeFiles[4]);
|
||||
|
||||
|
||||
const filesWithUrls = React.useMemo(() => {
|
||||
return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl]
|
||||
.slice(0, activeFiles.length)
|
||||
@ -197,11 +192,11 @@ const Viewer = ({
|
||||
if (!(previewFile instanceof File)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (previewFile.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return { file: previewFile, url: null };
|
||||
} else {
|
||||
// Use the file from the active tab
|
||||
@ -262,12 +257,12 @@ const Viewer = ({
|
||||
// Progressive preloading function
|
||||
const startProgressivePreload = async () => {
|
||||
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
|
||||
|
||||
|
||||
preloadingRef.current = true;
|
||||
|
||||
|
||||
// Start with first few pages for immediate viewing
|
||||
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
|
||||
|
||||
|
||||
// Render priority pages first
|
||||
for (const pageIndex of priorityPages) {
|
||||
if (pageIndex < numPages && !pageImages[pageIndex]) {
|
||||
@ -276,7 +271,7 @@ const Viewer = ({
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Then render remaining pages in background
|
||||
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
|
||||
if (!pageImages[pageIndex]) {
|
||||
@ -285,7 +280,7 @@ const Viewer = ({
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
preloadingRef.current = false;
|
||||
};
|
||||
|
||||
@ -300,15 +295,15 @@ const Viewer = ({
|
||||
const scrollToPage = (pageNumber: number) => {
|
||||
const el = pageRefs.current[pageNumber - 1];
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
|
||||
|
||||
if (el && scrollArea) {
|
||||
const scrollAreaRect = scrollArea.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const currentScrollTop = scrollArea.scrollTop;
|
||||
|
||||
|
||||
// Position page near top of viewport with some padding
|
||||
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
|
||||
|
||||
|
||||
scrollArea.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: "smooth"
|
||||
@ -364,7 +359,7 @@ const Viewer = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
let pdfData;
|
||||
|
||||
|
||||
// For preview files, use ArrayBuffer directly to avoid blob URL issues
|
||||
if (previewFile && effectiveFile.file === previewFile) {
|
||||
const arrayBuffer = await previewFile.arrayBuffer();
|
||||
@ -446,7 +441,7 @@ const Viewer = ({
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
|
||||
{!effectiveFile ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text c="red">Error: No file provided to viewer</Text>
|
||||
@ -455,8 +450,8 @@ const Viewer = ({
|
||||
<>
|
||||
{/* Tabs for multiple files */}
|
||||
{activeFiles.length > 1 && !previewFile && (
|
||||
<Box
|
||||
style={{
|
||||
<Box
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
position: 'relative',
|
||||
@ -475,7 +470,7 @@ const Viewer = ({
|
||||
</Tabs>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div style={{ flex: 1, padding: '1rem' }}>
|
||||
<SkeletonLoader type="viewer" />
|
||||
|
@ -1,7 +1,7 @@
|
||||
|
||||
export const COLOR_TYPES = {
|
||||
COLOR: 'color',
|
||||
GREYSCALE: 'greyscale',
|
||||
GRAYSCALE: 'grayscale',
|
||||
BLACK_WHITE: 'blackwhite'
|
||||
} as const;
|
||||
|
||||
@ -135,7 +135,7 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
|
||||
'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' },
|
||||
'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' },
|
||||
'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' },
|
||||
'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' },
|
||||
'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' },
|
||||
'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'svg': { 'pdf': 'img-to-pdf' },
|
||||
'html': { 'pdf': 'html-to-pdf' },
|
||||
'zip': { 'pdf': 'html-to-pdf' },
|
||||
@ -146,4 +146,4 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
|
||||
|
||||
export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES];
|
||||
export type OutputOption = typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS];
|
||||
export type FitOption = typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS];
|
||||
export type FitOption = typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS];
|
||||
|
@ -1,6 +1,6 @@
|
||||
export const SPLIT_MODES = {
|
||||
BY_PAGES: 'byPages',
|
||||
BY_SECTIONS: 'bySections',
|
||||
BY_SECTIONS: 'bySections',
|
||||
BY_SIZE_OR_COUNT: 'bySizeOrCount',
|
||||
BY_CHAPTERS: 'byChapters'
|
||||
} as const;
|
||||
@ -19,4 +19,12 @@ export const ENDPOINTS = {
|
||||
} as const;
|
||||
|
||||
export type SplitMode = typeof SPLIT_MODES[keyof typeof SPLIT_MODES];
|
||||
export type SplitType = typeof SPLIT_TYPES[keyof typeof SPLIT_TYPES];
|
||||
export type SplitType = typeof SPLIT_TYPES[keyof typeof SPLIT_TYPES];
|
||||
|
||||
export const isSplitMode = (value: string | null): value is SplitMode => {
|
||||
return Object.values(SPLIT_MODES).includes(value as SplitMode);
|
||||
}
|
||||
|
||||
export const isSplitType = (value: string | null): value is SplitType => {
|
||||
return Object.values(SPLIT_TYPES).includes(value as SplitType);
|
||||
}
|
||||
|
@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
FileContextValue,
|
||||
FileContextState,
|
||||
import {
|
||||
FileContextValue,
|
||||
FileContextState,
|
||||
FileContextProviderProps,
|
||||
ModeType,
|
||||
ViewType,
|
||||
@ -53,7 +53,7 @@ const initialState: FileContextState = {
|
||||
};
|
||||
|
||||
// Action types
|
||||
type FileContextAction =
|
||||
type FileContextAction =
|
||||
| { type: 'SET_ACTIVE_FILES'; payload: File[] }
|
||||
| { type: 'ADD_FILES'; payload: File[] }
|
||||
| { type: 'REMOVE_FILES'; payload: string[] }
|
||||
@ -126,7 +126,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
case 'SET_CURRENT_MODE':
|
||||
const coreViews = ['viewer', 'pageEditor', 'fileEditor'];
|
||||
const isToolMode = !coreViews.includes(action.payload);
|
||||
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentMode: action.payload,
|
||||
@ -193,8 +193,8 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
const existing = newHistory.get(action.payload.fileId);
|
||||
newHistory.set(action.payload.fileId, {
|
||||
fileId: action.payload.fileId,
|
||||
pageOperations: existing ?
|
||||
[...existing.pageOperations, ...action.payload.operations] :
|
||||
pageOperations: existing ?
|
||||
[...existing.pageOperations, ...action.payload.operations] :
|
||||
action.payload.operations,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
@ -213,7 +213,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
const { fileId, operation } = action.payload;
|
||||
const newOperationHistory = new Map(state.fileOperationHistory);
|
||||
const existingHistory = newOperationHistory.get(fileId);
|
||||
|
||||
|
||||
if (existingHistory) {
|
||||
// Add operation to existing history
|
||||
newOperationHistory.set(fileId, {
|
||||
@ -231,7 +231,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: newOperationHistory
|
||||
@ -240,10 +240,10 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
case 'MARK_OPERATION_APPLIED':
|
||||
const appliedHistory = new Map(state.fileOperationHistory);
|
||||
const appliedFileHistory = appliedHistory.get(action.payload.fileId);
|
||||
|
||||
|
||||
if (appliedFileHistory) {
|
||||
const updatedOperations = appliedFileHistory.operations.map(op =>
|
||||
op.id === action.payload.operationId
|
||||
const updatedOperations = appliedFileHistory.operations.map(op =>
|
||||
op.id === action.payload.operationId
|
||||
? { ...op, status: 'applied' as const }
|
||||
: op
|
||||
);
|
||||
@ -253,7 +253,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: appliedHistory
|
||||
@ -262,12 +262,12 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
case 'MARK_OPERATION_FAILED':
|
||||
const failedHistory = new Map(state.fileOperationHistory);
|
||||
const failedFileHistory = failedHistory.get(action.payload.fileId);
|
||||
|
||||
|
||||
if (failedFileHistory) {
|
||||
const updatedOperations = failedFileHistory.operations.map(op =>
|
||||
op.id === action.payload.operationId
|
||||
? {
|
||||
...op,
|
||||
const updatedOperations = failedFileHistory.operations.map(op =>
|
||||
op.id === action.payload.operationId
|
||||
? {
|
||||
...op,
|
||||
status: 'failed' as const,
|
||||
metadata: { ...op.metadata, error: action.payload.error }
|
||||
}
|
||||
@ -279,7 +279,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: failedHistory
|
||||
@ -337,19 +337,19 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
const FileContext = createContext<FileContextValue | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export function FileContextProvider({
|
||||
children,
|
||||
export function FileContextProvider({
|
||||
children,
|
||||
enableUrlSync = true,
|
||||
enablePersistence = true,
|
||||
maxCacheSize = 1024 * 1024 * 1024 // 1GB
|
||||
}: FileContextProviderProps) {
|
||||
const [state, dispatch] = useReducer(fileContextReducer, initialState);
|
||||
|
||||
|
||||
// Cleanup timers and refs
|
||||
const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const blobUrls = useRef<Set<string>>(new Set());
|
||||
const pdfDocuments = useRef<Map<string, PDFDocument>>(new Map());
|
||||
|
||||
|
||||
// Enhanced file processing hook
|
||||
const {
|
||||
processedFiles,
|
||||
@ -367,11 +367,11 @@ export function FileContextProvider({
|
||||
// Update processed files when they change
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_PROCESSED_FILES', payload: processedFiles });
|
||||
dispatch({
|
||||
type: 'SET_PROCESSING',
|
||||
payload: {
|
||||
isProcessing: globalProcessing,
|
||||
progress: processingProgress.overall
|
||||
dispatch({
|
||||
type: 'SET_PROCESSING',
|
||||
payload: {
|
||||
isProcessing: globalProcessing,
|
||||
progress: processingProgress.overall
|
||||
}
|
||||
});
|
||||
}, [processedFiles, globalProcessing, processingProgress.overall]);
|
||||
@ -397,7 +397,7 @@ export function FileContextProvider({
|
||||
|
||||
const cleanupFile = useCallback(async (fileId: string) => {
|
||||
console.log('Cleaning up file:', fileId);
|
||||
|
||||
|
||||
try {
|
||||
// Cancel any pending cleanup timer
|
||||
const timer = cleanupTimers.current.get(fileId);
|
||||
@ -425,7 +425,7 @@ export function FileContextProvider({
|
||||
|
||||
const cleanupAllFiles = useCallback(() => {
|
||||
console.log('Cleaning up all files');
|
||||
|
||||
|
||||
try {
|
||||
// Clear all timers
|
||||
cleanupTimers.current.forEach(timer => clearTimeout(timer));
|
||||
@ -461,7 +461,8 @@ export function FileContextProvider({
|
||||
|
||||
// Force garbage collection hint
|
||||
if (typeof window !== 'undefined' && window.gc) {
|
||||
setTimeout(() => window.gc(), 100);
|
||||
let gc = window.gc
|
||||
setTimeout(() => gc(), 100);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@ -486,14 +487,14 @@ export function FileContextProvider({
|
||||
const timer = setTimeout(() => {
|
||||
cleanupFile(fileId);
|
||||
}, delay);
|
||||
|
||||
|
||||
cleanupTimers.current.set(fileId, timer);
|
||||
}, [cleanupFile]);
|
||||
|
||||
// Action implementations
|
||||
const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
|
||||
dispatch({ type: 'ADD_FILES', payload: files });
|
||||
|
||||
|
||||
// Auto-save to IndexedDB if persistence enabled
|
||||
if (enablePersistence) {
|
||||
for (const file of files) {
|
||||
@ -504,7 +505,7 @@ export function FileContextProvider({
|
||||
// File doesn't have explicit ID, store it with thumbnail
|
||||
try {
|
||||
// Generate thumbnail for better recent files experience
|
||||
const thumbnail = await thumbnailGenerationService.generateThumbnail(file);
|
||||
const thumbnail = await (thumbnailGenerationService as any /* FIX ME */).generateThumbnail(file);
|
||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
@ -520,7 +521,7 @@ export function FileContextProvider({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Return files with their IDs assigned
|
||||
return files;
|
||||
}, [enablePersistence]);
|
||||
@ -532,9 +533,9 @@ export function FileContextProvider({
|
||||
enhancedPDFProcessingService.cancelProcessing(fileId);
|
||||
cleanupFile(fileId);
|
||||
});
|
||||
|
||||
|
||||
dispatch({ type: 'REMOVE_FILES', payload: fileIds });
|
||||
|
||||
|
||||
// Remove from IndexedDB only if requested
|
||||
if (enablePersistence && deleteFromStorage) {
|
||||
fileIds.forEach(async (fileId) => {
|
||||
@ -557,7 +558,7 @@ export function FileContextProvider({
|
||||
const clearAllFiles = useCallback(() => {
|
||||
// Cleanup all memory before clearing files
|
||||
cleanupAllFiles();
|
||||
|
||||
|
||||
dispatch({ type: 'SET_ACTIVE_FILES', payload: [] });
|
||||
dispatch({ type: 'CLEAR_SELECTIONS' });
|
||||
}, [cleanupAllFiles]);
|
||||
@ -594,11 +595,12 @@ export function FileContextProvider({
|
||||
const setCurrentMode = useCallback((mode: ModeType) => {
|
||||
requestNavigation(() => {
|
||||
dispatch({ type: 'SET_CURRENT_MODE', payload: mode });
|
||||
|
||||
|
||||
if (state.currentMode !== mode && state.activeFiles.length > 0) {
|
||||
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
|
||||
let gc = window.gc;
|
||||
window.requestIdleCallback(() => {
|
||||
window.gc();
|
||||
gc();
|
||||
}, { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
@ -608,11 +610,12 @@ export function FileContextProvider({
|
||||
const setCurrentView = useCallback((view: ViewType) => {
|
||||
requestNavigation(() => {
|
||||
dispatch({ type: 'SET_CURRENT_VIEW', payload: view });
|
||||
|
||||
|
||||
if (state.currentView !== view && state.activeFiles.length > 0) {
|
||||
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
|
||||
let gc = window.gc;
|
||||
window.requestIdleCallback(() => {
|
||||
window.gc();
|
||||
gc();
|
||||
}, { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
@ -642,8 +645,8 @@ export function FileContextProvider({
|
||||
}, []);
|
||||
|
||||
const applyPageOperations = useCallback((fileId: string, operations: PageOperation[]) => {
|
||||
dispatch({
|
||||
type: 'ADD_PAGE_OPERATIONS',
|
||||
dispatch({
|
||||
type: 'ADD_PAGE_OPERATIONS',
|
||||
payload: { fileId, operations }
|
||||
});
|
||||
}, []);
|
||||
@ -718,18 +721,18 @@ export function FileContextProvider({
|
||||
// Context persistence
|
||||
const saveContext = useCallback(async () => {
|
||||
if (!enablePersistence) return;
|
||||
|
||||
|
||||
try {
|
||||
const contextData = {
|
||||
currentView: state.currentView,
|
||||
currentTool: state.currentTool,
|
||||
selectedFileIds: state.selectedFileIds,
|
||||
selectedPageIds: state.selectedPageIds,
|
||||
selectedPageNumbers: state.selectedPageNumbers,
|
||||
viewerConfig: state.viewerConfig,
|
||||
lastExportConfig: state.lastExportConfig,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
|
||||
localStorage.setItem('fileContext', JSON.stringify(contextData));
|
||||
} catch (error) {
|
||||
console.error('Failed to save context:', error);
|
||||
@ -738,7 +741,7 @@ export function FileContextProvider({
|
||||
|
||||
const loadContext = useCallback(async () => {
|
||||
if (!enablePersistence) return;
|
||||
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem('fileContext');
|
||||
if (saved) {
|
||||
@ -779,7 +782,7 @@ export function FileContextProvider({
|
||||
const contextValue: FileContextValue = {
|
||||
// State
|
||||
...state,
|
||||
|
||||
|
||||
// Actions
|
||||
addFiles,
|
||||
removeFiles,
|
||||
@ -804,7 +807,7 @@ export function FileContextProvider({
|
||||
saveContext,
|
||||
loadContext,
|
||||
resetContext,
|
||||
|
||||
|
||||
// Operation history management
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
@ -812,13 +815,13 @@ export function FileContextProvider({
|
||||
getFileHistory,
|
||||
getAppliedOperations,
|
||||
clearFileHistory,
|
||||
|
||||
|
||||
// Navigation guard system
|
||||
setHasUnsavedChanges,
|
||||
requestNavigation,
|
||||
confirmNavigation,
|
||||
cancelNavigation,
|
||||
|
||||
|
||||
// Memory management
|
||||
trackBlobUrl,
|
||||
trackPdfDocument,
|
||||
@ -852,17 +855,17 @@ export function useCurrentFile() {
|
||||
}
|
||||
|
||||
export function useFileSelection() {
|
||||
const {
|
||||
selectedFileIds,
|
||||
selectedPageIds,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
clearSelections
|
||||
const {
|
||||
selectedFileIds,
|
||||
selectedPageNumbers,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
clearSelections
|
||||
} = useFileContext();
|
||||
|
||||
|
||||
return {
|
||||
selectedFileIds,
|
||||
selectedPageIds,
|
||||
selectedPageNumbers,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
clearSelections
|
||||
@ -875,4 +878,4 @@ export function useViewerState() {
|
||||
config: viewerConfig,
|
||||
updateConfig: updateViewerConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -10,8 +10,8 @@ interface FileManagerContextValue {
|
||||
searchTerm: string;
|
||||
selectedFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
@ -21,7 +21,7 @@ interface FileManagerContextValue {
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
|
||||
|
||||
// External props
|
||||
recentFiles: FileWithUrl[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
@ -61,7 +61,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
@ -85,10 +85,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
|
||||
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||
setSelectedFileIds(prev => {
|
||||
if (prev.includes(file.id)) {
|
||||
return prev.filter(id => id !== file.id);
|
||||
if (file.id) {
|
||||
if (prev.includes(file.id)) {
|
||||
return prev.filter(id => id !== file.id);
|
||||
} else {
|
||||
return [...prev, file.id];
|
||||
}
|
||||
} else {
|
||||
return [...prev, file.id];
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
@ -127,7 +131,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const fileWithUrls = files.map(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
createdBlobUrls.current.add(url);
|
||||
|
||||
|
||||
return {
|
||||
// No ID assigned here - FileContext will handle storage and ID assignment
|
||||
name: file.name,
|
||||
@ -137,8 +141,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
});
|
||||
|
||||
onFilesSelected(fileWithUrls);
|
||||
|
||||
onFilesSelected(fileWithUrls as any /* FIX ME */);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@ -176,7 +180,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
onLocalFileClick: handleLocalFileClick,
|
||||
@ -186,7 +190,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
onFileInputChange: handleFileInputChange,
|
||||
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
@ -203,16 +207,16 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
// Custom hook to use the context
|
||||
export const useFileManagerContext = (): FileManagerContextValue => {
|
||||
const context = useContext(FileManagerContext);
|
||||
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileManagerContext must be used within a FileManagerProvider. ' +
|
||||
'Make sure you wrap your component with <FileManagerProvider>.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
// Export the context for advanced use cases
|
||||
export { FileManagerContext };
|
||||
export { FileManagerContext };
|
||||
|
@ -7,7 +7,7 @@ interface FilesModalContextType {
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onModalClose: () => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
@ -64,4 +64,4 @@ export const useFilesModalContext = () => {
|
||||
throw new Error('useFilesModalContext must be used within FilesModalProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
@ -8,19 +8,19 @@ import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperatio
|
||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
|
||||
const shouldProcessFilesSeparately = (
|
||||
selectedFiles: File[],
|
||||
selectedFiles: File[],
|
||||
parameters: ConvertParameters
|
||||
): boolean => {
|
||||
return selectedFiles.length > 1 && (
|
||||
// Image to PDF with combineImages = false
|
||||
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
|
||||
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
|
||||
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
|
||||
// PDF to image conversions (each PDF should generate its own image file)
|
||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||
// PDF to PDF/A conversions (each PDF should be processed separately)
|
||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
|
||||
// Web files to PDF conversions (each web file should generate its own PDF)
|
||||
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
||||
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
||||
parameters.toExtension === 'pdf') ||
|
||||
// Web files smart detection
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web') ||
|
||||
@ -31,7 +31,7 @@ const shouldProcessFilesSeparately = (
|
||||
|
||||
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
@ -77,13 +77,13 @@ const createFileFromResponse = (
|
||||
): File => {
|
||||
const originalName = originalFileName.split('.')[0];
|
||||
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
||||
|
||||
|
||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
};
|
||||
|
||||
export const useConvertOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const customConvertProcessor = useCallback(async (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
@ -91,7 +91,7 @@ export const useConvertOperation = () => {
|
||||
|
||||
const processedFiles: File[] = [];
|
||||
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
||||
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error(t('errorNotSupported', 'Unsupported conversion format'));
|
||||
}
|
||||
@ -103,9 +103,9 @@ export const useConvertOperation = () => {
|
||||
try {
|
||||
const formData = buildFormData(parameters, [file]);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||
|
||||
|
||||
processedFiles.push(convertedFile);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to convert file ${file.name}:`, error);
|
||||
@ -115,11 +115,11 @@ export const useConvertOperation = () => {
|
||||
// Batch processing for simple cases (image→PDF combine)
|
||||
const formData = buildFormData(parameters, selectedFiles);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const baseFilename = selectedFiles.length === 1
|
||||
|
||||
const baseFilename = selectedFiles.length === 1
|
||||
? selectedFiles[0].name
|
||||
: 'converted_files';
|
||||
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
|
||||
processedFiles.push(convertedFile);
|
||||
|
||||
@ -131,7 +131,7 @@ export const useConvertOperation = () => {
|
||||
return useToolOperation<ConvertParameters>({
|
||||
operationType: 'convert',
|
||||
endpoint: '', // Not used with customProcessor but required
|
||||
buildFormData, // Not used with customProcessor but required
|
||||
buildFormData, // Not used with customProcessor but required
|
||||
filePrefix: 'converted_',
|
||||
customProcessor: customConvertProcessor, // Convert handles its own routing
|
||||
validateParams: (params) => {
|
||||
@ -147,4 +147,4 @@ export const useConvertOperation = () => {
|
||||
return t("convert.errorConversion", "An error occurred while converting the file.");
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
@ -8,18 +8,18 @@ import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertParameters } from './useConvertParameters';
|
||||
|
||||
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
|
||||
describe('Single File Detection', () => {
|
||||
|
||||
|
||||
test('should detect single file extension and set auto-target', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const pdfFile = [{ name: 'document.pdf' }];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(pdfFile);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
expect(result.current.parameters.toExtension).toBe(''); // No auto-selection for multiple targets
|
||||
expect(result.current.parameters.isSmartDetection).toBe(false);
|
||||
@ -28,13 +28,13 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
test('should handle unknown file types with file-to-pdf fallback', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(unknownFile);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
@ -42,35 +42,35 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
test('should handle files without extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const noExtFile = [{ name: 'document' }];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(noExtFile);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('Multiple Identical Files', () => {
|
||||
|
||||
|
||||
test('should detect multiple PDF files and set auto-target', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const pdfFiles = [
|
||||
{ name: 'doc1.pdf' },
|
||||
{ name: 'doc2.pdf' },
|
||||
{ name: 'doc3.pdf' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(pdfFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
expect(result.current.parameters.toExtension).toBe(''); // Auto-selected
|
||||
expect(result.current.parameters.isSmartDetection).toBe(false);
|
||||
@ -79,37 +79,37 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
test('should handle multiple unknown file types with fallback', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const unknownFiles = [
|
||||
{ name: 'file1.xyz' },
|
||||
{ name: 'file2.xyz' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(unknownFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Smart Detection - All Images', () => {
|
||||
|
||||
|
||||
test('should detect all image files and enable smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.jpg' },
|
||||
{ name: 'photo2.png' },
|
||||
{ name: 'photo3.gif' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('image');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
@ -118,35 +118,35 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
test('should handle mixed case image extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.JPG' },
|
||||
{ name: 'photo2.PNG' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('images');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Smart Detection - All Web Files', () => {
|
||||
|
||||
|
||||
test('should detect all web files and enable web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.html' },
|
||||
{ name: 'archive.zip' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('html');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
@ -155,54 +155,54 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
test('should handle mixed case web extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.HTML' },
|
||||
{ name: 'archive.ZIP' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
|
||||
test('should detect multiple web files and enable web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const zipFiles = [
|
||||
{ name: 'site1.zip' },
|
||||
{ name: 'site2.html' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(zipFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('html');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Smart Detection - Mixed File Types', () => {
|
||||
|
||||
|
||||
test('should detect mixed file types and enable smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'spreadsheet.xlsx' },
|
||||
{ name: 'presentation.pptx' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
@ -211,155 +211,155 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
test('should detect mixed images and documents as mixed type', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'photo.jpg' },
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'text.txt' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
|
||||
test('should handle mixed with unknown file types', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'unknown.xyz' },
|
||||
{ name: 'noextension' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Smart Detection Endpoint Resolution', () => {
|
||||
|
||||
|
||||
test('should return correct endpoint for image smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.jpg' },
|
||||
{ name: 'photo2.png' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('img-to-pdf');
|
||||
expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf');
|
||||
});
|
||||
|
||||
test('should return correct endpoint for web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.html' },
|
||||
{ name: 'archive.zip' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('html-to-pdf');
|
||||
expect(result.current.getEndpoint()).toBe('/api/v1/convert/html/pdf');
|
||||
});
|
||||
|
||||
test('should return correct endpoint for mixed smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'spreadsheet.xlsx' }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('file-to-pdf');
|
||||
expect(result.current.getEndpoint()).toBe('/api/v1/convert/file/pdf');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Auto-Target Selection Logic', () => {
|
||||
|
||||
|
||||
test('should select single available target automatically', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
// Markdown has only one conversion target (PDF)
|
||||
const mdFile = [{ name: 'readme.md' }];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mdFile);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('md');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf'); // Only available target
|
||||
});
|
||||
|
||||
test('should not auto-select when multiple targets available', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
// PDF has multiple conversion targets, so no auto-selection
|
||||
const pdfFile = [{ name: 'document.pdf' }];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(pdfFile);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
// Should NOT auto-select when multiple targets available
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
|
||||
test('should handle empty file names', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
|
||||
const emptyFiles = [{ name: '' }];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(emptyFiles);
|
||||
});
|
||||
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
});
|
||||
|
||||
test('should handle malformed file objects', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const malformedFiles = [
|
||||
|
||||
const malformedFiles: Array<{name: string}> = [
|
||||
{ name: 'valid.pdf' },
|
||||
// @ts-ignore - Testing runtime resilience
|
||||
{ name: null },
|
||||
// @ts-ignore
|
||||
{ name: undefined }
|
||||
];
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(malformedFiles);
|
||||
});
|
||||
|
||||
|
||||
// Should still process the valid file and handle gracefully
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -99,7 +99,7 @@ export const useOCROperation = () => {
|
||||
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
||||
operationType: 'ocr',
|
||||
endpoint: '/api/v1/misc/ocr-pdf',
|
||||
buildFormData,
|
||||
buildFormData: buildFormData as any /* FIX ME */,
|
||||
filePrefix: 'ocr_',
|
||||
multiFileEndpoint: false, // Process files individually
|
||||
responseHandler, // use shared flow
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import axios, { CancelTokenSource } from 'axios';
|
||||
import { processResponse } from '../../../utils/toolResponseProcessor';
|
||||
import type { ResponseHandler, ProcessingProgress } from './useToolState';
|
||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import type { ProcessingProgress } from './useToolState';
|
||||
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
endpoint: string | ((params: TParams) => string);
|
||||
|
@ -7,7 +7,7 @@ import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||
import { type ResponseHandler, processResponse } from '../../../utils/toolResponseProcessor';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
@ -176,7 +176,7 @@ export const useToolOperation = <TParams = void>(
|
||||
} else {
|
||||
// Default: assume ZIP response for multi-file endpoints
|
||||
processedFiles = await extractZipFiles(response.data);
|
||||
|
||||
|
||||
if (processedFiles.length === 0) {
|
||||
// Try the generic extraction as fallback
|
||||
processedFiles = await extractAllZipFiles(response.data);
|
||||
@ -186,7 +186,7 @@ export const useToolOperation = <TParams = void>(
|
||||
// Individual file processing - separate API call per file
|
||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||
endpoint: config.endpoint,
|
||||
buildFormData: (file: File, params: TParams) => (config.buildFormData as (file: File, params: TParams) => FormData)(file, params),
|
||||
buildFormData: (file: File, params: TParams) => (config.buildFormData as any /* FIX ME */)(file, params),
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler
|
||||
};
|
||||
|
@ -36,17 +36,19 @@ export const useToolResources = () => {
|
||||
|
||||
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
|
||||
const thumbnails: string[] = [];
|
||||
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
thumbnails.push(thumbnail);
|
||||
if (thumbnail) {
|
||||
thumbnails.push(thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
thumbnails.push('');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return thumbnails;
|
||||
}, []);
|
||||
|
||||
@ -65,12 +67,12 @@ export const useToolResources = () => {
|
||||
try {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
|
||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||
const zipContent = await zip.loadAsync(arrayBuffer);
|
||||
|
||||
|
||||
const extractedFiles: File[] = [];
|
||||
|
||||
|
||||
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||
if (!file.dir) {
|
||||
const content = await file.async('blob');
|
||||
@ -78,7 +80,7 @@ export const useToolResources = () => {
|
||||
extractedFiles.push(extractedFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return extractedFiles;
|
||||
} catch (error) {
|
||||
console.error('Error in extractAllZipFiles:', error);
|
||||
@ -87,7 +89,7 @@ export const useToolResources = () => {
|
||||
}, []);
|
||||
|
||||
const createDownloadInfo = useCallback(async (
|
||||
files: File[],
|
||||
files: File[],
|
||||
operationType: string
|
||||
): Promise<{ url: string; filename: string }> => {
|
||||
if (files.length === 1) {
|
||||
@ -100,7 +102,7 @@ export const useToolResources = () => {
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(files, `${operationType}_results.zip`);
|
||||
const url = URL.createObjectURL(zipFile);
|
||||
addBlobUrl(url);
|
||||
|
||||
|
||||
return { url, filename: zipFile.name };
|
||||
}, [addBlobUrl]);
|
||||
|
||||
@ -111,4 +113,4 @@ export const useToolResources = () => {
|
||||
extractAllZipFiles,
|
||||
cleanupBlobUrls,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
import { createEnhancedFileFromStored } from '../utils/fileUtils';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
export const useFileManager = () => {
|
||||
@ -42,7 +43,7 @@ export const useFileManager = () => {
|
||||
try {
|
||||
const files = await fileStorage.getAllFiles();
|
||||
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
return sortedFiles;
|
||||
return sortedFiles.map(file => createEnhancedFileFromStored(file));
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent files:', error);
|
||||
return [];
|
||||
@ -66,10 +67,10 @@ export const useFileManager = () => {
|
||||
try {
|
||||
// Generate thumbnail for the file
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
|
||||
|
||||
// Store file with thumbnail
|
||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
||||
|
||||
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
return storedFile;
|
||||
@ -134,4 +135,4 @@ export const useFileManager = () => {
|
||||
touchFile,
|
||||
createFileSelectionHandlers
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -4,25 +4,25 @@ import { useMemo } from 'react';
|
||||
* Hook to convert a File object to { file: File; url: string } format
|
||||
* Creates blob URL on-demand and handles cleanup
|
||||
*/
|
||||
export function useFileWithUrl(file: File | null): { file: File; url: string } | null {
|
||||
export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; url: string } | null {
|
||||
return useMemo(() => {
|
||||
if (!file) return null;
|
||||
|
||||
|
||||
// Validate that file is a proper File or Blob object
|
||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
|
||||
// Return object with cleanup function
|
||||
const result = { file, url };
|
||||
|
||||
|
||||
// Store cleanup function for later use
|
||||
(result as any)._cleanup = () => URL.revokeObjectURL(url);
|
||||
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('useFileWithUrl: Failed to create object URL:', error, file);
|
||||
@ -40,11 +40,11 @@ export function useFileWithUrlAndCleanup(file: File | null): {
|
||||
} {
|
||||
return useMemo(() => {
|
||||
if (!file) return { fileObj: null, cleanup: () => {} };
|
||||
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const fileObj = { file, url };
|
||||
const cleanup = () => URL.revokeObjectURL(url);
|
||||
|
||||
|
||||
return { fileObj, cleanup };
|
||||
}, [file]);
|
||||
}
|
||||
}
|
||||
|
@ -10,10 +10,10 @@ import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
|
||||
const maxWidth = 400; // Max thumbnail width
|
||||
const maxHeight = 600; // Max thumbnail height
|
||||
|
||||
|
||||
const scaleX = maxWidth / pageViewport.width;
|
||||
const scaleY = maxHeight / pageViewport.height;
|
||||
|
||||
|
||||
// Don't upscale, only downscale if needed
|
||||
return Math.min(scaleX, scaleY, 1.0);
|
||||
}
|
||||
@ -22,16 +22,16 @@ function calculateThumbnailScale(pageViewport: { width: number; height: number }
|
||||
* Hook for IndexedDB-aware thumbnail loading
|
||||
* Handles thumbnail generation for files not in IndexedDB
|
||||
*/
|
||||
export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
thumbnail: string | null;
|
||||
isGenerating: boolean
|
||||
export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
thumbnail: string | null;
|
||||
isGenerating: boolean
|
||||
} {
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
|
||||
async function loadThumbnail() {
|
||||
if (!file) {
|
||||
setThumb(null);
|
||||
@ -49,7 +49,7 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
setGenerating(true);
|
||||
try {
|
||||
let fileObject: File;
|
||||
|
||||
|
||||
// Handle IndexedDB files vs regular File objects
|
||||
if (file.storedInIndexedDB && file.id) {
|
||||
// For IndexedDB files, recreate File object from stored data
|
||||
@ -61,9 +61,9 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
} else if (file.file) {
|
||||
} else if ((file as any /* Fix me */).file) {
|
||||
// For FileWithUrl objects that have a File object
|
||||
fileObject = file.file;
|
||||
fileObject = (file as any /* Fix me */).file;
|
||||
} else if (file.id) {
|
||||
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
|
||||
const storedFile = await fileStorage.getFile(file.id);
|
||||
@ -77,7 +77,7 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
} else {
|
||||
throw new Error('File object not available and no ID for IndexedDB lookup');
|
||||
}
|
||||
|
||||
|
||||
// Use the universal thumbnail generator
|
||||
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||
if (!cancelled && thumbnail) {
|
||||
@ -102,4 +102,4 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
||||
}, [file, file?.thumbnail, file?.id]);
|
||||
|
||||
return { thumbnail: thumb, isGenerating: generating };
|
||||
}
|
||||
}
|
||||
|
@ -12,10 +12,10 @@ import FileManager from "../components/FileManager";
|
||||
|
||||
|
||||
function HomePageContent() {
|
||||
const {
|
||||
sidebarRefs,
|
||||
const {
|
||||
sidebarRefs,
|
||||
} = useSidebarContext();
|
||||
|
||||
|
||||
const { quickAccessRef } = sidebarRefs;
|
||||
|
||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
||||
@ -44,7 +44,7 @@ function HomePageContent() {
|
||||
ref={quickAccessRef} />
|
||||
<ToolPanel />
|
||||
<Workbench />
|
||||
<FileManager selectedTool={selectedTool} />
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@ -53,7 +53,7 @@ export default function HomePage() {
|
||||
const { setCurrentView } = useFileContext();
|
||||
return (
|
||||
<FileSelectionProvider>
|
||||
<ToolWorkflowProvider onViewChange={setCurrentView}>
|
||||
<ToolWorkflowProvider onViewChange={setCurrentView as any /* FIX ME */}>
|
||||
<SidebarProvider>
|
||||
<HomePageContent />
|
||||
</SidebarProvider>
|
||||
|
@ -45,32 +45,32 @@ export class EnhancedPDFProcessingService {
|
||||
*/
|
||||
async processFile(file: File, customConfig?: Partial<ProcessingConfig>): Promise<ProcessedFile | null> {
|
||||
const fileKey = await this.generateFileKey(file);
|
||||
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
this.updateMetrics('cacheHit');
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Analyze file to determine optimal strategy
|
||||
const analysis = await FileAnalyzer.analyzeFile(file);
|
||||
if (analysis.isCorrupted) {
|
||||
throw new Error(`File ${file.name} appears to be corrupted`);
|
||||
}
|
||||
|
||||
|
||||
// Create processing config
|
||||
const config: ProcessingConfig = {
|
||||
...this.defaultConfig,
|
||||
strategy: analysis.recommendedStrategy,
|
||||
...customConfig
|
||||
};
|
||||
|
||||
|
||||
// Start processing
|
||||
this.startProcessing(file, fileKey, config, analysis.estimatedProcessingTime);
|
||||
return null;
|
||||
@ -80,14 +80,14 @@ export class EnhancedPDFProcessingService {
|
||||
* Start processing a file with the specified configuration
|
||||
*/
|
||||
private async startProcessing(
|
||||
file: File,
|
||||
fileKey: string,
|
||||
file: File,
|
||||
fileKey: string,
|
||||
config: ProcessingConfig,
|
||||
estimatedTime: number
|
||||
): Promise<void> {
|
||||
// Create cancellation token
|
||||
const cancellationToken = new AbortController();
|
||||
|
||||
|
||||
// Set initial state
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
@ -99,7 +99,7 @@ export class EnhancedPDFProcessingService {
|
||||
estimatedTimeRemaining: estimatedTime,
|
||||
cancellationToken
|
||||
};
|
||||
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('started');
|
||||
@ -117,14 +117,14 @@ export class EnhancedPDFProcessingService {
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('completed', Date.now() - state.startedAt);
|
||||
|
||||
|
||||
// Remove from processing map after brief delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
@ -133,13 +133,13 @@ export class EnhancedPDFProcessingService {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Processing failed for', file.name, ':', error);
|
||||
|
||||
|
||||
const processingError = ProcessingErrorHandler.createProcessingError(error);
|
||||
state.status = 'error';
|
||||
state.error = processingError;
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('failed');
|
||||
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
@ -152,23 +152,23 @@ export class EnhancedPDFProcessingService {
|
||||
* Execute the actual processing based on strategy
|
||||
*/
|
||||
private async executeProcessingStrategy(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
switch (config.strategy) {
|
||||
case 'immediate_full':
|
||||
return this.processImmediateFull(file, config, state);
|
||||
|
||||
|
||||
case 'priority_pages':
|
||||
return this.processPriorityPages(file, config, state);
|
||||
|
||||
|
||||
case 'progressive_chunked':
|
||||
return this.processProgressiveChunked(file, config, state);
|
||||
|
||||
|
||||
case 'metadata_only':
|
||||
return this.processMetadataOnly(file, config, state);
|
||||
|
||||
|
||||
default:
|
||||
return this.processImmediateFull(file, config, state);
|
||||
}
|
||||
@ -178,29 +178,29 @@ export class EnhancedPDFProcessingService {
|
||||
* Process all pages immediately (for small files)
|
||||
*/
|
||||
private async processImmediateFull(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Check for cancellation
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
@ -208,17 +208,17 @@ export class EnhancedPDFProcessingService {
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
|
||||
// Update progress
|
||||
state.progress = 10 + (i / totalPages) * 85;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
@ -226,30 +226,30 @@ export class EnhancedPDFProcessingService {
|
||||
* Process priority pages first, then queue the rest
|
||||
*/
|
||||
private async processPriorityPages(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
const priorityCount = Math.min(config.priorityPageCount, totalPages);
|
||||
|
||||
|
||||
// Process priority pages first
|
||||
for (let i = 1; i <= priorityCount; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
@ -257,12 +257,12 @@ export class EnhancedPDFProcessingService {
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
|
||||
state.progress = 10 + (i / priorityCount) * 60;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
// Create placeholder pages for remaining pages
|
||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
@ -273,11 +273,11 @@ export class EnhancedPDFProcessingService {
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
@ -285,33 +285,33 @@ export class EnhancedPDFProcessingService {
|
||||
* Process in chunks with breaks between chunks
|
||||
*/
|
||||
private async processProgressiveChunked(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
const chunkSize = config.chunkSize;
|
||||
let processedPages = 0;
|
||||
|
||||
|
||||
// Process first chunk immediately
|
||||
const firstChunkEnd = Math.min(chunkSize, totalPages);
|
||||
|
||||
|
||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
@ -319,18 +319,18 @@ export class EnhancedPDFProcessingService {
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
|
||||
processedPages++;
|
||||
state.progress = 10 + (processedPages / totalPages) * 70;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
// Small delay to prevent UI blocking
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create placeholders for remaining pages
|
||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
@ -341,11 +341,11 @@ export class EnhancedPDFProcessingService {
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
@ -353,17 +353,17 @@ export class EnhancedPDFProcessingService {
|
||||
* Process metadata only (for very large files)
|
||||
*/
|
||||
private async processMetadataOnly(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
|
||||
state.progress = 50;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
// Create placeholder pages without thumbnails
|
||||
const pages: PDFPage[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
@ -375,11 +375,11 @@ export class EnhancedPDFProcessingService {
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
@ -389,17 +389,17 @@ export class EnhancedPDFProcessingService {
|
||||
private async renderPageThumbnail(page: any, quality: 'low' | 'medium' | 'high'): Promise<string> {
|
||||
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
|
||||
const scale = scales[quality];
|
||||
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
return canvas.toDataURL('image/jpeg', 0.8); // Use JPEG for better compression
|
||||
}
|
||||
@ -513,14 +513,15 @@ export class EnhancedPDFProcessingService {
|
||||
state.cancellationToken.abort();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Clear processing states
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
// Force memory cleanup hint
|
||||
if (typeof window !== 'undefined' && window.gc) {
|
||||
setTimeout(() => window.gc(), 100);
|
||||
let gc = window.gc;
|
||||
setTimeout(() => gc(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -542,4 +543,4 @@ export class EnhancedPDFProcessingService {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
||||
export const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
||||
|
@ -36,11 +36,11 @@ export class FileAnalyzer {
|
||||
|
||||
// Determine strategy based on file characteristics
|
||||
analysis.recommendedStrategy = this.determineStrategy(file.size, quickAnalysis.pageCount);
|
||||
|
||||
|
||||
// Estimate processing time
|
||||
analysis.estimatedProcessingTime = this.estimateProcessingTime(
|
||||
file.size,
|
||||
quickAnalysis.pageCount,
|
||||
file.size,
|
||||
quickAnalysis.pageCount,
|
||||
analysis.recommendedStrategy
|
||||
);
|
||||
|
||||
@ -66,15 +66,15 @@ export class FileAnalyzer {
|
||||
// For large files, try the whole file first (PDF.js needs the complete structure)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const pdf = await getDocument({
|
||||
const pdf = await getDocument({
|
||||
data: arrayBuffer,
|
||||
stopAtErrors: false, // Don't stop at minor errors
|
||||
verbosity: 0 // Suppress PDF.js warnings
|
||||
}).promise;
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const isEncrypted = pdf.isEncrypted;
|
||||
|
||||
const isEncrypted = (pdf as any).isEncrypted;
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
@ -88,7 +88,7 @@ export class FileAnalyzer {
|
||||
// Try to determine if it's corruption vs encryption
|
||||
const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
|
||||
const isEncrypted = errorMessage.includes('password') || errorMessage.includes('encrypted');
|
||||
|
||||
|
||||
return {
|
||||
pageCount: 0,
|
||||
isEncrypted,
|
||||
@ -129,8 +129,8 @@ export class FileAnalyzer {
|
||||
* Estimate processing time based on file characteristics and strategy
|
||||
*/
|
||||
private static estimateProcessingTime(
|
||||
fileSize: number,
|
||||
pageCount: number = 0,
|
||||
fileSize: number,
|
||||
pageCount: number = 0,
|
||||
strategy: ProcessingStrategy
|
||||
): number {
|
||||
const baseTimes = {
|
||||
@ -145,20 +145,20 @@ export class FileAnalyzer {
|
||||
switch (strategy) {
|
||||
case 'metadata_only':
|
||||
return baseTime;
|
||||
|
||||
|
||||
case 'immediate_full':
|
||||
return pageCount * baseTime;
|
||||
|
||||
|
||||
case 'priority_pages':
|
||||
// Estimate time for priority pages (first 10)
|
||||
const priorityPages = Math.min(pageCount, 10);
|
||||
return priorityPages * baseTime;
|
||||
|
||||
|
||||
case 'progressive_chunked':
|
||||
// Estimate time for first chunk (20 pages)
|
||||
const firstChunk = Math.min(pageCount, 20);
|
||||
return firstChunk * baseTime;
|
||||
|
||||
|
||||
default:
|
||||
return pageCount * baseTime;
|
||||
}
|
||||
@ -209,11 +209,11 @@ export class FileAnalyzer {
|
||||
if (totalSize > this.SIZE_THRESHOLDS.LARGE) {
|
||||
return Math.max(1, Math.floor(fileCount / 4));
|
||||
}
|
||||
|
||||
|
||||
if (totalSize > this.SIZE_THRESHOLDS.MEDIUM) {
|
||||
return Math.max(2, Math.floor(fileCount / 2));
|
||||
}
|
||||
|
||||
|
||||
// Process all at once for smaller total sizes
|
||||
return fileCount;
|
||||
}
|
||||
@ -231,10 +231,10 @@ export class FileAnalyzer {
|
||||
const header = file.slice(0, 8);
|
||||
const headerBytes = new Uint8Array(await header.arrayBuffer());
|
||||
const headerString = String.fromCharCode(...headerBytes);
|
||||
|
||||
|
||||
return headerString.startsWith('%PDF-');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,11 +35,11 @@ class FileStorageService {
|
||||
if (this.db) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
|
||||
this.initPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
@ -47,7 +47,7 @@ class FileStorageService {
|
||||
this.initPromise = null;
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
console.log('IndexedDB connection established');
|
||||
@ -57,9 +57,9 @@ class FileStorageService {
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const oldVersion = (event as any).oldVersion;
|
||||
|
||||
|
||||
console.log('IndexedDB upgrade needed from version', oldVersion, 'to', this.dbVersion);
|
||||
|
||||
|
||||
// Only recreate object store if it doesn't exist or if upgrading from version < 2
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
||||
@ -76,7 +76,7 @@ class FileStorageService {
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ class FileStorageService {
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
const storedFile: StoredFile = {
|
||||
id,
|
||||
name: file.name,
|
||||
@ -103,16 +103,16 @@ class FileStorageService {
|
||||
try {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
|
||||
// Debug logging
|
||||
console.log('Object store keyPath:', store.keyPath);
|
||||
console.log('Storing file:', {
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
console.log('Storing file:', {
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
hasData: !!storedFile.data,
|
||||
dataSize: storedFile.data.byteLength
|
||||
dataSize: storedFile.data.byteLength
|
||||
});
|
||||
|
||||
|
||||
const request = store.add(storedFile);
|
||||
|
||||
request.onerror = () => {
|
||||
@ -161,10 +161,10 @@ class FileStorageService {
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
// Filter out null/corrupted entries
|
||||
const files = request.result.filter(file =>
|
||||
file &&
|
||||
file.data &&
|
||||
file.name &&
|
||||
const files = request.result.filter(file =>
|
||||
file &&
|
||||
file.data &&
|
||||
file.name &&
|
||||
typeof file.size === 'number'
|
||||
);
|
||||
resolve(files);
|
||||
@ -277,7 +277,7 @@ class FileStorageService {
|
||||
let available = 0;
|
||||
let quota: number | undefined;
|
||||
let fileCount = 0;
|
||||
|
||||
|
||||
try {
|
||||
// Get browser quota for context
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
@ -285,17 +285,17 @@ class FileStorageService {
|
||||
quota = estimate.quota;
|
||||
available = estimate.quota || 0;
|
||||
}
|
||||
|
||||
|
||||
// Calculate our actual IndexedDB usage from file metadata
|
||||
const files = await this.getAllFileMetadata();
|
||||
used = files.reduce((total, file) => total + (file?.size || 0), 0);
|
||||
fileCount = files.length;
|
||||
|
||||
|
||||
// Adjust available space
|
||||
if (quota) {
|
||||
available = quota - used;
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not get storage stats:', error);
|
||||
// If we can't read metadata, database might be purged
|
||||
@ -332,12 +332,12 @@ class FileStorageService {
|
||||
*/
|
||||
async debugAllDatabases(): Promise<void> {
|
||||
console.log('=== Checking All IndexedDB Databases ===');
|
||||
|
||||
|
||||
if ('databases' in indexedDB) {
|
||||
try {
|
||||
const databases = await indexedDB.databases();
|
||||
console.log('Found databases:', databases);
|
||||
|
||||
|
||||
for (const dbInfo of databases) {
|
||||
if (dbInfo.name?.includes('stirling') || dbInfo.name?.includes('pdf')) {
|
||||
console.log(`Checking database: ${dbInfo.name} (version: ${dbInfo.version})`);
|
||||
@ -347,7 +347,7 @@ class FileStorageService {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
|
||||
console.log(`Database ${dbInfo.name} object stores:`, Array.from(db.objectStoreNames));
|
||||
db.close();
|
||||
} catch (error) {
|
||||
@ -361,7 +361,7 @@ class FileStorageService {
|
||||
} else {
|
||||
console.log('indexedDB.databases() not supported');
|
||||
}
|
||||
|
||||
|
||||
// Also check our specific database with different versions
|
||||
for (let version = 1; version <= 3; version++) {
|
||||
try {
|
||||
@ -375,9 +375,9 @@ class FileStorageService {
|
||||
request.transaction?.abort();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(`Version ${version} object stores:`, Array.from(db.objectStoreNames));
|
||||
|
||||
|
||||
if (db.objectStoreNames.contains('files')) {
|
||||
const transaction = db.transaction(['files'], 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
@ -386,10 +386,12 @@ class FileStorageService {
|
||||
console.log(`Version ${version} files store has ${countRequest.result} entries`);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.log(`Version ${version} not accessible:`, error.message);
|
||||
if (error instanceof Error) {
|
||||
console.log(`Version ${version} not accessible:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -403,7 +405,7 @@ class FileStorageService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
|
||||
// First try getAll to see if there's anything
|
||||
const getAllRequest = store.getAll();
|
||||
getAllRequest.onsuccess = () => {
|
||||
@ -422,7 +424,7 @@ class FileStorageService {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Then try cursor
|
||||
const cursorRequest = store.openCursor();
|
||||
console.log('=== IndexedDB Cursor Debug ===');
|
||||
@ -432,7 +434,7 @@ class FileStorageService {
|
||||
console.error('Cursor error:', cursorRequest.error);
|
||||
reject(cursorRequest.error);
|
||||
};
|
||||
|
||||
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
@ -464,21 +466,21 @@ class FileStorageService {
|
||||
if (!storedFile || !storedFile.data) {
|
||||
throw new Error('Invalid stored file: missing data');
|
||||
}
|
||||
|
||||
|
||||
if (!storedFile.name || typeof storedFile.size !== 'number') {
|
||||
throw new Error('Invalid stored file: missing metadata');
|
||||
}
|
||||
|
||||
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const file = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
|
||||
|
||||
// Add custom properties for compatibility
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
Object.defineProperty(file, 'thumbnail', { value: storedFile.thumbnail, writable: false });
|
||||
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
@ -509,15 +511,15 @@ class FileStorageService {
|
||||
async createTemporaryBlobUrl(id: string): Promise<string | null> {
|
||||
const data = await this.getFileData(id);
|
||||
if (!data) return null;
|
||||
|
||||
|
||||
const blob = new Blob([data], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
// Auto-revoke after a short delay to free memory
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 10000); // 10 seconds
|
||||
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@ -538,7 +540,7 @@ class FileStorageService {
|
||||
if (storedFile) {
|
||||
storedFile.thumbnail = thumbnail;
|
||||
const updateRequest = store.put(storedFile);
|
||||
|
||||
|
||||
updateRequest.onsuccess = () => {
|
||||
console.log('Thumbnail updated for file:', id);
|
||||
resolve(true);
|
||||
@ -569,7 +571,7 @@ class FileStorageService {
|
||||
async isStorageLow(): Promise<boolean> {
|
||||
const stats = await this.getStorageStats();
|
||||
if (!stats.quota) return false;
|
||||
|
||||
|
||||
const usagePercent = stats.used / stats.quota;
|
||||
return usagePercent > 0.8; // Consider low if over 80% used
|
||||
}
|
||||
@ -579,12 +581,12 @@ class FileStorageService {
|
||||
*/
|
||||
async cleanupOldFiles(maxFiles: number = 50): Promise<void> {
|
||||
const files = await this.getAllFileMetadata();
|
||||
|
||||
|
||||
if (files.length <= maxFiles) return;
|
||||
|
||||
|
||||
// Sort by last modified (oldest first)
|
||||
files.sort((a, b) => a.lastModified - b.lastModified);
|
||||
|
||||
|
||||
// Delete oldest files
|
||||
const filesToDelete = files.slice(0, files.length - maxFiles);
|
||||
for (const file of filesToDelete) {
|
||||
@ -599,4 +601,4 @@ export const fileStorage = new FileStorageService();
|
||||
// Helper hook for React components
|
||||
export function useFileStorage() {
|
||||
return fileStorage;
|
||||
}
|
||||
}
|
||||
|
@ -22,20 +22,20 @@ export class PDFProcessingService {
|
||||
|
||||
async getProcessedFile(file: File): Promise<ProcessedFile | null> {
|
||||
const fileKey = this.generateFileKey(file);
|
||||
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
console.log('Cache hit for:', file.name);
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
console.log('Already processing:', file.name);
|
||||
return null; // Will be available when processing completes
|
||||
}
|
||||
|
||||
|
||||
// Start processing
|
||||
this.startProcessing(file, fileKey);
|
||||
return null;
|
||||
@ -48,9 +48,10 @@ export class PDFProcessingService {
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
startedAt: Date.now()
|
||||
startedAt: Date.now(),
|
||||
strategy: 'immediate_full'
|
||||
};
|
||||
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
|
||||
@ -63,13 +64,13 @@ export class PDFProcessingService {
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
// Remove from processing map after brief delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
@ -79,9 +80,9 @@ export class PDFProcessingService {
|
||||
} catch (error) {
|
||||
console.error('Processing failed for', file.name, ':', error);
|
||||
state.status = 'error';
|
||||
state.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
state.error = (error instanceof Error ? error.message : 'Unknown error') as any;
|
||||
this.notifyListeners();
|
||||
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
@ -91,29 +92,29 @@ export class PDFProcessingService {
|
||||
}
|
||||
|
||||
private async processFileWithProgress(
|
||||
file: File,
|
||||
file: File,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
|
||||
onProgress(10); // PDF loaded
|
||||
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
@ -122,15 +123,15 @@ export class PDFProcessingService {
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Update progress
|
||||
const progress = 10 + (i / totalPages) * 85; // 10-95%
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
|
||||
pdf.destroy();
|
||||
onProgress(100);
|
||||
|
||||
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
pages,
|
||||
@ -185,4 +186,4 @@ export class PDFProcessingService {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfProcessingService = PDFProcessingService.getInstance();
|
||||
export const pdfProcessingService = PDFProcessingService.getInstance();
|
||||
|
@ -1,4 +1,17 @@
|
||||
import JSZip from 'jszip';
|
||||
import JSZip, { JSZipObject } from 'jszip';
|
||||
|
||||
// Undocumented interface in JSZip for JSZipObject._data
|
||||
interface CompressedObject {
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
crc32: number;
|
||||
compression: object;
|
||||
compressedContent: string|ArrayBuffer|Uint8Array|Buffer;
|
||||
}
|
||||
|
||||
const getData = (zipEntry: JSZipObject): CompressedObject | undefined => {
|
||||
return (zipEntry as any)._data as CompressedObject;
|
||||
}
|
||||
|
||||
export interface ZipExtractionResult {
|
||||
success: boolean;
|
||||
@ -68,7 +81,7 @@ export class ZipFileService {
|
||||
}
|
||||
|
||||
fileCount++;
|
||||
const uncompressedSize = zipEntry._data?.uncompressedSize || 0;
|
||||
const uncompressedSize = getData(zipEntry)?.uncompressedSize || 0;
|
||||
totalSize += uncompressedSize;
|
||||
|
||||
// Check if file is a PDF
|
||||
@ -109,25 +122,25 @@ export class ZipFileService {
|
||||
async createZipFromFiles(files: File[], zipFilename: string): Promise<{ zipFile: File; size: number }> {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
|
||||
|
||||
// Add each file to the ZIP
|
||||
for (const file of files) {
|
||||
const content = await file.arrayBuffer();
|
||||
zip.file(file.name, content);
|
||||
}
|
||||
|
||||
|
||||
// Generate ZIP blob
|
||||
const zipBlob = await zip.generateAsync({
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
const zipFile = new File([zipBlob], zipFilename, {
|
||||
|
||||
const zipFile = new File([zipBlob], zipFilename, {
|
||||
type: 'application/zip',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
|
||||
return { zipFile, size: zipFile.size };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@ -162,7 +175,7 @@ export class ZipFileService {
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all PDF files
|
||||
const pdfFiles = Object.entries(zipContents.files).filter(([filename, zipEntry]) =>
|
||||
const pdfFiles = Object.entries(zipContents.files).filter(([filename, zipEntry]) =>
|
||||
!zipEntry.dir && this.isPdfFile(filename)
|
||||
);
|
||||
|
||||
@ -171,7 +184,7 @@ export class ZipFileService {
|
||||
// Extract each PDF file
|
||||
for (let i = 0; i < pdfFiles.length; i++) {
|
||||
const [filename, zipEntry] = pdfFiles[i];
|
||||
|
||||
|
||||
try {
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
@ -185,9 +198,9 @@ export class ZipFileService {
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('uint8array');
|
||||
|
||||
|
||||
// Create File object
|
||||
const extractedFile = new File([content], this.sanitizeFilename(filename), {
|
||||
const extractedFile = new File([content as any], this.sanitizeFilename(filename), {
|
||||
type: 'application/pdf',
|
||||
lastModified: zipEntry.date?.getTime() || Date.now()
|
||||
});
|
||||
@ -235,7 +248,7 @@ export class ZipFileService {
|
||||
|
||||
const validExtensions = ['.zip'];
|
||||
const hasValidType = validTypes.includes(file.type);
|
||||
const hasValidExtension = validExtensions.some(ext =>
|
||||
const hasValidExtension = validExtensions.some(ext =>
|
||||
file.name.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
@ -257,7 +270,7 @@ export class ZipFileService {
|
||||
// Read first few bytes to check PDF header
|
||||
const buffer = await file.slice(0, 8).arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
|
||||
// Check for PDF header: %PDF-
|
||||
return bytes[0] === 0x25 && // %
|
||||
bytes[1] === 0x50 && // P
|
||||
@ -275,7 +288,7 @@ export class ZipFileService {
|
||||
private sanitizeFilename(filename: string): string {
|
||||
// Remove directory path and get just the filename
|
||||
const basename = filename.split('/').pop() || filename;
|
||||
|
||||
|
||||
// Remove or replace unsafe characters
|
||||
return basename
|
||||
.replace(/[<>:"/\\|?*]/g, '_') // Replace unsafe chars with underscore
|
||||
@ -309,15 +322,15 @@ export class ZipFileService {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
await zip.loadAsync(file);
|
||||
|
||||
|
||||
// Check if any files are encrypted
|
||||
for (const [filename, zipEntry] of Object.entries(zip.files)) {
|
||||
if (zipEntry.options?.compression === 'STORE' && zipEntry._data?.compressedSize === 0) {
|
||||
if (zipEntry.options?.compression === 'STORE' && getData(zipEntry)?.compressedSize === 0) {
|
||||
// This might indicate encryption, but JSZip doesn't provide direct encryption detection
|
||||
// We'll handle this in the extraction phase
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false; // JSZip will throw an error if password is required
|
||||
} catch (error) {
|
||||
// If we can't load the ZIP, it might be password protected
|
||||
@ -328,4 +341,4 @@ export class ZipFileService {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const zipFileService = new ZipFileService();
|
||||
export const zipFileService = new ZipFileService();
|
||||
|
@ -75,7 +75,7 @@ Object.defineProperty(globalThis, 'crypto', {
|
||||
}
|
||||
return array;
|
||||
}),
|
||||
} as Crypto,
|
||||
} as unknown as Crypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
@ -120,4 +120,4 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
})
|
||||
|
||||
// Set global test timeout to prevent hangs
|
||||
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 })
|
||||
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 })
|
||||
|
@ -1,16 +1,16 @@
|
||||
/**
|
||||
* End-to-End Tests for Convert Tool
|
||||
*
|
||||
*
|
||||
* These tests dynamically discover available conversion endpoints and test them.
|
||||
* Tests are automatically skipped if the backend endpoint is not available.
|
||||
*
|
||||
*
|
||||
* Run with: npm run test:e2e or npx playwright test
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
conversionDiscovery,
|
||||
type ConversionEndpoint
|
||||
import {
|
||||
conversionDiscovery,
|
||||
type ConversionEndpoint
|
||||
} from '../helpers/conversionEndpointDiscovery';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
@ -25,25 +25,25 @@ const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8080';
|
||||
*/
|
||||
function resolveTestFixturePath(filename: string): string {
|
||||
const cwd = process.cwd();
|
||||
|
||||
|
||||
// Try frontend/src/tests/test-fixtures/ first (from top-level)
|
||||
const topLevelPath = path.join(cwd, 'frontend', 'src', 'tests', 'test-fixtures', filename);
|
||||
if (fs.existsSync(topLevelPath)) {
|
||||
return topLevelPath;
|
||||
}
|
||||
|
||||
|
||||
// Try src/tests/test-fixtures/ (from frontend directory)
|
||||
const frontendPath = path.join(cwd, 'src', 'tests', 'test-fixtures', filename);
|
||||
if (fs.existsSync(frontendPath)) {
|
||||
return frontendPath;
|
||||
}
|
||||
|
||||
|
||||
// Try relative path from current test file location
|
||||
const relativePath = path.join(__dirname, '..', 'test-fixtures', filename);
|
||||
if (fs.existsSync(relativePath)) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
|
||||
// Fallback to the original path format (should work from top-level)
|
||||
return path.join('.', 'frontend', 'src', 'tests', 'test-fixtures', filename);
|
||||
}
|
||||
@ -98,7 +98,7 @@ const getTestFileForFormat = (format: string): string => {
|
||||
'xml': TEST_FILES.xml,
|
||||
'csv': TEST_FILES.csv
|
||||
};
|
||||
|
||||
|
||||
return formatMap[format] || TEST_FILES.pdf; // Fallback to PDF
|
||||
};
|
||||
|
||||
@ -123,7 +123,7 @@ const getExpectedExtension = (toFormat: string): string => {
|
||||
'webp': '.webp',
|
||||
'pdfa': '.pdf'
|
||||
};
|
||||
|
||||
|
||||
return extensionMap[toFormat] || '.pdf';
|
||||
};
|
||||
|
||||
@ -133,17 +133,17 @@ const getExpectedExtension = (toFormat: string): string => {
|
||||
async function uploadFileViaModal(page: Page, filePath: string) {
|
||||
// Click the Files button in the QuickAccessBar to open the modal
|
||||
await page.click('[data-testid="files-button"]');
|
||||
|
||||
|
||||
// Wait for the modal to open
|
||||
await page.waitForSelector('.mantine-Modal-overlay', { state: 'visible' }, { timeout: 5000 });
|
||||
await page.waitForSelector('.mantine-Modal-overlay', { state: 'visible', timeout: 5000 });
|
||||
//await page.waitForSelector('[data-testid="file-upload-modal"]', { timeout: 5000 });
|
||||
|
||||
|
||||
// Upload the file through the modal's file input
|
||||
await page.setInputFiles('input[type="file"]', filePath);
|
||||
|
||||
|
||||
// Wait for the file to be processed and the modal to close
|
||||
await page.waitForSelector('[data-testid="file-upload-modal"]', { state: 'hidden' });
|
||||
|
||||
|
||||
// Wait for the file thumbnail to appear in the main interface
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
}
|
||||
@ -153,33 +153,33 @@ async function uploadFileViaModal(page: Page, filePath: string) {
|
||||
*/
|
||||
async function testConversion(page: Page, conversion: ConversionEndpoint) {
|
||||
const expectedExtension = getExpectedExtension(conversion.toFormat);
|
||||
|
||||
|
||||
console.log(`Testing ${conversion.endpoint}: ${conversion.fromFormat} → ${conversion.toFormat}`);
|
||||
|
||||
|
||||
// File should already be uploaded, click the Convert tool button
|
||||
await page.click('[data-testid="tool-convert"]');
|
||||
|
||||
|
||||
// Wait for the FileEditor to load in convert mode with file thumbnails
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 });
|
||||
|
||||
|
||||
// Click the file thumbnail checkbox to select it in the FileEditor
|
||||
await page.click('[data-testid="file-thumbnail-checkbox"]');
|
||||
|
||||
|
||||
// Wait for the conversion settings to appear after file selection
|
||||
await page.waitForSelector('[data-testid="convert-from-dropdown"]', { timeout: 5000 });
|
||||
|
||||
|
||||
// Select FROM format
|
||||
await page.click('[data-testid="convert-from-dropdown"]');
|
||||
const fromFormatOption = page.locator(`[data-testid="format-option-${conversion.fromFormat}"]`);
|
||||
await fromFormatOption.scrollIntoViewIfNeeded();
|
||||
await fromFormatOption.click();
|
||||
|
||||
|
||||
// Select TO format
|
||||
await page.click('[data-testid="convert-to-dropdown"]');
|
||||
const toFormatOption = page.locator(`[data-testid="format-option-${conversion.toFormat}"]`);
|
||||
await toFormatOption.scrollIntoViewIfNeeded();
|
||||
await toFormatOption.click();
|
||||
|
||||
|
||||
// Handle format-specific options
|
||||
if (conversion.toFormat === 'image' || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'webp'].includes(conversion.toFormat)) {
|
||||
// Set image conversion options if they appear
|
||||
@ -188,17 +188,17 @@ async function testConversion(page: Page, conversion: ConversionEndpoint) {
|
||||
// Click the color type dropdown and select "Color"
|
||||
await page.click('[data-testid="color-type-select"]');
|
||||
await page.getByRole('option', { name: 'Color' }).click();
|
||||
|
||||
|
||||
// Set DPI value
|
||||
await page.fill('[data-testid="dpi-input"]', '150');
|
||||
|
||||
|
||||
// Click the output type dropdown and select "Multiple"
|
||||
await page.click('[data-testid="output-type-select"]');
|
||||
|
||||
|
||||
await page.getByRole('option', { name: 'single' }).click();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (conversion.fromFormat === 'image' && conversion.toFormat === 'pdf') {
|
||||
// Set PDF creation options if they appear
|
||||
const pdfOptionsVisible = await page.locator('[data-testid="pdf-options-section"]').isVisible().catch(() => false);
|
||||
@ -208,7 +208,7 @@ async function testConversion(page: Page, conversion: ConversionEndpoint) {
|
||||
await page.locator('[data-value="color"]').click();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (conversion.fromFormat === 'pdf' && conversion.toFormat === 'csv') {
|
||||
// Set CSV extraction options if they appear
|
||||
const csvOptionsVisible = await page.locator('[data-testid="csv-options-section"]').isVisible().catch(() => false);
|
||||
@ -217,32 +217,32 @@ async function testConversion(page: Page, conversion: ConversionEndpoint) {
|
||||
await page.fill('[data-testid="page-numbers-input"]', '1-2');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Start conversion
|
||||
await page.click('[data-testid="convert-button"]');
|
||||
|
||||
|
||||
// Wait for conversion to complete (with generous timeout)
|
||||
await page.waitForSelector('[data-testid="download-button"]', { timeout: 60000 });
|
||||
|
||||
|
||||
// Verify download is available
|
||||
const downloadButton = page.locator('[data-testid="download-button"]');
|
||||
await expect(downloadButton).toBeVisible();
|
||||
|
||||
|
||||
// Start download and verify file
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await downloadButton.click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
|
||||
// Verify file extension
|
||||
expect(download.suggestedFilename()).toMatch(new RegExp(`\\${expectedExtension}$`));
|
||||
|
||||
|
||||
// Save and verify file is not empty
|
||||
const path = await download.path();
|
||||
if (path) {
|
||||
const fs = require('fs');
|
||||
const stats = fs.statSync(path);
|
||||
expect(stats.size).toBeGreaterThan(0);
|
||||
|
||||
|
||||
// Format-specific validations
|
||||
if (conversion.toFormat === 'pdf' || conversion.toFormat === 'pdfa') {
|
||||
// Verify PDF header
|
||||
@ -250,13 +250,13 @@ async function testConversion(page: Page, conversion: ConversionEndpoint) {
|
||||
const header = buffer.toString('utf8', 0, 4);
|
||||
expect(header).toBe('%PDF');
|
||||
}
|
||||
|
||||
|
||||
if (conversion.toFormat === 'txt') {
|
||||
// Verify text content exists
|
||||
const content = fs.readFileSync(path, 'utf8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
|
||||
if (conversion.toFormat === 'csv') {
|
||||
// Verify CSV content contains separators
|
||||
const content = fs.readFileSync(path, 'utf8');
|
||||
@ -282,18 +282,18 @@ let unavailableConversions: ConversionEndpoint[] = [];
|
||||
})();
|
||||
|
||||
test.describe('Convert Tool E2E Tests', () => {
|
||||
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Re-discover to ensure fresh data at test time
|
||||
console.log('Re-discovering available conversion endpoints...');
|
||||
availableConversions = await conversionDiscovery.getAvailableConversions();
|
||||
unavailableConversions = await conversionDiscovery.getUnavailableConversions();
|
||||
|
||||
|
||||
console.log(`Found ${availableConversions.length} available conversions:`);
|
||||
availableConversions.forEach(conv => {
|
||||
console.log(` ✓ ${conv.endpoint}: ${conv.fromFormat} → ${conv.toFormat}`);
|
||||
});
|
||||
|
||||
|
||||
if (unavailableConversions.length > 0) {
|
||||
console.log(`Found ${unavailableConversions.length} unavailable conversions:`);
|
||||
unavailableConversions.forEach(conv => {
|
||||
@ -301,136 +301,190 @@ test.describe('Convert Tool E2E Tests', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to the homepage
|
||||
// Navigate to the homepage
|
||||
await page.goto(`${BASE_URL}`);
|
||||
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
|
||||
// Wait for the QuickAccessBar to appear
|
||||
await page.waitForSelector('[data-testid="files-button"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.describe('Dynamic Conversion Tests', () => {
|
||||
|
||||
|
||||
// Generate a test for each potentially available conversion
|
||||
// We'll discover all possible conversions and then skip unavailable ones at runtime
|
||||
test('PDF to PNG conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/img', fromFormat: 'pdf', toFormat: 'png' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/img',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'png',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to DOCX conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/word', fromFormat: 'pdf', toFormat: 'docx' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/word',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'docx',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('DOCX to PDF conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/file/pdf', fromFormat: 'docx', toFormat: 'pdf' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/file/pdf',
|
||||
fromFormat: 'docx',
|
||||
toFormat: 'pdf',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('Image to PDF conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/img/pdf', fromFormat: 'png', toFormat: 'pdf' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/img/pdf',
|
||||
fromFormat: 'png',
|
||||
toFormat: 'pdf',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to TXT conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/text', fromFormat: 'pdf', toFormat: 'txt' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/text',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'txt',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to HTML conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/html', fromFormat: 'pdf', toFormat: 'html' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/html',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'html',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to XML conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/xml', fromFormat: 'pdf', toFormat: 'xml' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/xml',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'xml',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to CSV conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/csv', fromFormat: 'pdf', toFormat: 'csv' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/csv',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'csv',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to PDFA conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/pdfa', fromFormat: 'pdf', toFormat: 'pdfa' };
|
||||
const conversion: ConversionEndpoint = {
|
||||
endpoint: '/api/v1/convert/pdf/pdfa',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'pdfa',
|
||||
description: '',
|
||||
apiPath: ''
|
||||
};
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await uploadFileViaModal(page, testFile);
|
||||
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Static Tests', () => {
|
||||
|
||||
|
||||
// Test that disabled conversions don't appear in dropdowns when they shouldn't
|
||||
test('should not show conversion button when no valid conversions available', async ({ page }) => {
|
||||
// This test ensures the convert button is disabled when no valid conversion is possible
|
||||
await uploadFileViaModal(page, TEST_FILES.pdf);
|
||||
|
||||
|
||||
// Click the Convert tool button
|
||||
await page.click('[data-testid="tool-convert"]');
|
||||
|
||||
|
||||
// Wait for convert mode and select file
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 });
|
||||
await page.click('[data-testid="file-thumbnail-checkbox"]');
|
||||
|
||||
|
||||
// Don't select any formats - convert button should not exist
|
||||
const convertButton = page.locator('[data-testid="convert-button"]');
|
||||
await expect(convertButton).toHaveCount(0);
|
||||
|
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Integration tests for Convert Tool - Tests actual conversion functionality
|
||||
*
|
||||
*
|
||||
* These tests verify the integration between frontend components and backend:
|
||||
* 1. useConvertOperation hook makes correct API calls
|
||||
* 2. File upload/download flow functions properly
|
||||
@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, test, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||
import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||
@ -28,8 +28,8 @@ vi.mock('../../services/fileStorage', () => ({
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
return Promise.resolve({
|
||||
id: `mock-id-${file.name}`,
|
||||
return Promise.resolve({
|
||||
id: `mock-id-${file.name}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
@ -70,7 +70,7 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
);
|
||||
|
||||
describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default axios mock
|
||||
@ -82,10 +82,10 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('useConvertOperation Integration', () => {
|
||||
|
||||
|
||||
test('should make correct API call for PDF to PNG conversion', async () => {
|
||||
const mockBlob = new Blob(['fake-image-data'], { type: 'image/png' });
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
@ -108,7 +108,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -123,7 +135,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
);
|
||||
|
||||
// Verify FormData contains correct parameters
|
||||
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('imageFormat')).toBe('png');
|
||||
expect(formDataCall.get('colorType')).toBe('color');
|
||||
expect(formDataCall.get('dpi')).toBe('300');
|
||||
@ -138,7 +150,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should handle API error responses correctly', async () => {
|
||||
const errorMessage = 'Invalid file format';
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 400,
|
||||
data: errorMessage
|
||||
@ -163,7 +175,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -177,7 +201,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
@ -196,7 +220,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -209,10 +245,10 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('API and Hook Integration', () => {
|
||||
|
||||
|
||||
test('should correctly map image conversion parameters to API call', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
headers: {
|
||||
@ -229,7 +265,6 @@ describe('Convert Tool Integration Tests', () => {
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'jpg',
|
||||
pageNumbers: 'all',
|
||||
imageOptions: {
|
||||
colorType: 'grayscale',
|
||||
dpi: 150,
|
||||
@ -239,7 +274,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -247,12 +294,12 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify integration: hook parameters → FormData → axios call → hook state
|
||||
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('imageFormat')).toBe('jpg');
|
||||
expect(formDataCall.get('colorType')).toBe('grayscale');
|
||||
expect(formDataCall.get('dpi')).toBe('150');
|
||||
expect(formDataCall.get('singleOrMultiple')).toBe('single');
|
||||
|
||||
|
||||
// Verify complete workflow: API response → hook state → FileContext integration
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.files).toHaveLength(1);
|
||||
@ -262,7 +309,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should make correct API call for PDF to CSV conversion with simplified workflow', async () => {
|
||||
const mockBlob = new Blob(['fake-csv-data'], { type: 'text/csv' });
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
@ -285,7 +332,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -300,7 +359,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
);
|
||||
|
||||
// Verify FormData contains correct parameters for simplified CSV conversion
|
||||
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('pageNumbers')).toBe('all'); // Always "all" for simplified workflow
|
||||
expect(formDataCall.get('fileInput')).toBe(testFile);
|
||||
|
||||
@ -329,7 +388,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -345,10 +416,10 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('File Upload Integration', () => {
|
||||
|
||||
|
||||
test('should handle multiple file uploads correctly', async () => {
|
||||
const mockBlob = new Blob(['zip-content'], { type: 'application/zip' });
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
@ -369,7 +440,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -377,14 +460,14 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Verify both files were uploaded
|
||||
const calls = mockedAxios.post.mock.calls;
|
||||
const calls = (mockedAxios.post as Mock).mock.calls;
|
||||
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const formData = calls[i][1] as FormData;
|
||||
const fileInputs = formData.getAll('fileInput');
|
||||
expect(fileInputs).toHaveLength(1);
|
||||
expect(fileInputs[0]).toBeInstanceOf(File);
|
||||
expect(fileInputs[0].name).toBe(files[i].name);
|
||||
expect((fileInputs[0] as File).name).toBe(files[i].name);
|
||||
}
|
||||
|
||||
});
|
||||
@ -406,7 +489,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -419,9 +514,9 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('Error Boundary Integration', () => {
|
||||
|
||||
|
||||
test('should handle corrupted file gracefully', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
data: 'Processing failed'
|
||||
@ -445,7 +540,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -457,7 +564,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
test('should handle backend service unavailable', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
(mockedAxios.post as Mock).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 503,
|
||||
data: 'Service unavailable'
|
||||
@ -481,7 +588,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -494,10 +613,10 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('FileContext Integration', () => {
|
||||
|
||||
|
||||
test('should record operation in FileContext', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
headers: {
|
||||
@ -523,7 +642,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -538,7 +669,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
test('should clean up blob URLs on reset', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
(mockedAxios.post as Mock).mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
headers: {
|
||||
@ -564,7 +695,19 @@ describe('Convert Tool Integration Tests', () => {
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
smartDetectionType: 'none',
|
||||
htmlOptions: {
|
||||
zoomLevel: 0
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 0,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: ''
|
||||
}
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
@ -586,35 +729,35 @@ describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
/**
|
||||
* Additional Integration Tests That Require Real Backend
|
||||
*
|
||||
*
|
||||
* These tests would require a running backend server and are better suited
|
||||
* for E2E testing with tools like Playwright or Cypress:
|
||||
*
|
||||
*
|
||||
* 1. **Real File Conversion Tests**
|
||||
* - Upload actual PDF files and verify conversion quality
|
||||
* - Test image format outputs are valid and viewable
|
||||
* - Test CSV/TXT outputs contain expected content
|
||||
* - Test file size limits and memory constraints
|
||||
*
|
||||
*
|
||||
* 2. **Performance Integration Tests**
|
||||
* - Test conversion time for various file sizes
|
||||
* - Test memory usage during large file conversions
|
||||
* - Test concurrent conversion requests
|
||||
* - Test timeout handling for long-running conversions
|
||||
*
|
||||
*
|
||||
* 3. **Authentication Integration**
|
||||
* - Test conversions with and without authentication
|
||||
* - Test rate limiting and user quotas
|
||||
* - Test permission-based endpoint access
|
||||
*
|
||||
*
|
||||
* 4. **File Preview Integration**
|
||||
* - Test that converted files integrate correctly with viewer
|
||||
* - Test thumbnail generation for converted files
|
||||
* - Test file download functionality
|
||||
* - Test FileContext persistence across tool switches
|
||||
*
|
||||
*
|
||||
* 5. **Endpoint Availability Tests**
|
||||
* - Test real endpoint availability checking
|
||||
* - Test graceful degradation when endpoints are disabled
|
||||
* - Test dynamic endpoint configuration updates
|
||||
*/
|
||||
*/
|
||||
|
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, test, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||
import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||
@ -54,12 +54,12 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
);
|
||||
|
||||
describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
||||
// Mock successful API response
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
(mockedAxios.post as Mock).mockResolvedValue({
|
||||
data: new Blob(['fake converted content'], { type: 'application/pdf' })
|
||||
});
|
||||
});
|
||||
@ -74,25 +74,25 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
// Create mock DOCX file
|
||||
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
||||
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes([docxFile]);
|
||||
});
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('docx');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
@ -100,7 +100,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
[docxFile]
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
@ -110,25 +110,25 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
// Create mock unknown file
|
||||
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
|
||||
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes([unknownFile]);
|
||||
});
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('file-xyz');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf'); // Fallback
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
@ -136,7 +136,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
[unknownFile]
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
@ -144,35 +144,35 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('Multi-File Smart Detection Flow', () => {
|
||||
|
||||
|
||||
test('should detect all images and use img-to-pdf endpoint', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
// Create mock image files
|
||||
const imageFiles = [
|
||||
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['png content'], 'photo2.png', { type: 'image/png' }),
|
||||
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
|
||||
];
|
||||
|
||||
|
||||
// Test smart detection for all images
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('image');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(paramsResult.current.parameters.smartDetectionType).toBe('images');
|
||||
});
|
||||
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
@ -180,13 +180,13 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
imageFiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
|
||||
// Should send all files in single request
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
const files = formData.getAll('fileInput');
|
||||
expect(files).toHaveLength(3);
|
||||
});
|
||||
@ -195,30 +195,30 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
// Create mixed file types
|
||||
const mixedFiles = [
|
||||
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
|
||||
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
];
|
||||
|
||||
|
||||
// Test smart detection for mixed types
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('any');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(paramsResult.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
@ -226,7 +226,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
mixedFiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
@ -236,29 +236,29 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
// Create mock web files
|
||||
const webFiles = [
|
||||
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
|
||||
new File(['zip content'], 'site.zip', { type: 'application/zip' })
|
||||
];
|
||||
|
||||
|
||||
// Test smart detection for web files
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('html');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(paramsResult.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
@ -266,29 +266,29 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
webFiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
|
||||
// Should process files separately for web files
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web and Email Conversion Options Integration', () => {
|
||||
|
||||
|
||||
test('should send correct HTML parameters for web-to-pdf conversion', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' });
|
||||
|
||||
|
||||
// Set up HTML conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes([htmlFile]);
|
||||
@ -296,15 +296,15 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
zoomLevel: 1.5
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[htmlFile]
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('zoom')).toBe('1.5');
|
||||
});
|
||||
|
||||
@ -312,13 +312,13 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
|
||||
|
||||
|
||||
// Set up email conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.updateParameter('fromExtension', 'eml');
|
||||
@ -330,15 +330,15 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
includeAllRecipients: true
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[emlFile]
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('includeAttachments')).toBe('false');
|
||||
expect(formData.get('maxAttachmentSizeMB')).toBe('20');
|
||||
expect(formData.get('downloadHtml')).toBe('true');
|
||||
@ -349,13 +349,13 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
|
||||
|
||||
|
||||
// Set up PDF/A conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.updateParameter('fromExtension', 'pdf');
|
||||
@ -364,15 +364,15 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
outputFormat: 'pdfa'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[pdfFile]
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('outputFormat')).toBe('pdfa');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
@ -381,21 +381,21 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('Image Conversion Options Integration', () => {
|
||||
|
||||
|
||||
test('should send correct parameters for image-to-pdf conversion', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
|
||||
|
||||
// Set up image conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(imageFiles);
|
||||
@ -408,15 +408,15 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
combineImages: true
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
imageFiles
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
|
||||
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
|
||||
expect(formData.get('fitOption')).toBe('fitToPage');
|
||||
expect(formData.get('colorType')).toBe('grayscale');
|
||||
expect(formData.get('autoRotate')).toBe('false');
|
||||
@ -426,16 +426,16 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
|
||||
|
||||
// Set up for separate processing
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(imageFiles);
|
||||
@ -444,55 +444,55 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
combineImages: false
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
imageFiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// Should make separate API calls for each file
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Scenarios in Smart Detection', () => {
|
||||
|
||||
|
||||
|
||||
test('should handle partial failures in multi-file processing', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
// Mock one success, one failure
|
||||
mockedAxios.post
|
||||
(mockedAxios.post as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
data: new Blob(['converted1'], { type: 'application/pdf' })
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('File 2 failed'));
|
||||
|
||||
|
||||
const mixedFiles = [
|
||||
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
|
||||
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
|
||||
];
|
||||
|
||||
|
||||
// Set up for separate processing (mixed smart detection)
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
mixedFiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
// Should have processed at least one file successfully
|
||||
expect(operationResult.current.files.length).toBeGreaterThan(0);
|
||||
@ -502,12 +502,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('Real File Extension Detection', () => {
|
||||
|
||||
|
||||
test('should correctly detect various file extensions', async () => {
|
||||
const { result } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
|
||||
const testCases = [
|
||||
{ filename: 'document.PDF', expected: 'pdf' },
|
||||
{ filename: 'image.JPEG', expected: 'jpg' }, // JPEG should normalize to jpg
|
||||
@ -517,11 +517,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
{ filename: '.hidden', expected: 'hidden' },
|
||||
{ filename: 'noextension', expected: '' }
|
||||
];
|
||||
|
||||
|
||||
testCases.forEach(({ filename, expected }) => {
|
||||
const detected = detectFileExtension(filename);
|
||||
expect(detected).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -64,14 +64,6 @@ export const mantineTheme = createTheme({
|
||||
xl: 'var(--shadow-xl)',
|
||||
},
|
||||
|
||||
// Font weights
|
||||
fontWeights: {
|
||||
normal: 'var(--font-weight-normal)',
|
||||
medium: 'var(--font-weight-medium)',
|
||||
semibold: 'var(--font-weight-semibold)',
|
||||
bold: 'var(--font-weight-bold)',
|
||||
},
|
||||
|
||||
// Component customizations
|
||||
components: {
|
||||
Button: {
|
||||
@ -83,7 +75,7 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
variants: {
|
||||
// Custom button variant for PDF tools
|
||||
pdfTool: (theme) => ({
|
||||
pdfTool: (theme: any) => ({
|
||||
root: {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-default)',
|
||||
@ -95,7 +87,7 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
|
||||
Paper: {
|
||||
styles: {
|
||||
@ -287,28 +279,4 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Global styles
|
||||
globalStyles: () => ({
|
||||
// Ensure smooth color transitions
|
||||
'*': {
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
|
||||
},
|
||||
|
||||
// Custom scrollbar styling
|
||||
'*::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
},
|
||||
'*::-webkit-scrollbar-track': {
|
||||
backgroundColor: 'var(--bg-muted)',
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'var(--border-strong)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb:hover': {
|
||||
backgroundColor: 'var(--color-primary-500)',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -5,7 +5,11 @@
|
||||
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' | 'convert' | 'sanitize';
|
||||
|
||||
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
|
||||
|
||||
export type ToolType = 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize';
|
||||
|
||||
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr';
|
||||
|
||||
@ -51,25 +55,27 @@ export interface FileContextState {
|
||||
// Core file management
|
||||
activeFiles: File[];
|
||||
processedFiles: Map<File, ProcessedFile>;
|
||||
|
||||
|
||||
// Current navigation state
|
||||
currentMode: ModeType;
|
||||
|
||||
currentView: ViewType;
|
||||
currentTool: ToolType | null;
|
||||
|
||||
// Edit history and state
|
||||
fileEditHistory: Map<string, FileEditHistory>;
|
||||
globalFileOperations: FileOperation[];
|
||||
// New comprehensive operation history
|
||||
fileOperationHistory: Map<string, FileOperationHistory>;
|
||||
|
||||
|
||||
// UI state that persists across views
|
||||
selectedFileIds: string[];
|
||||
selectedPageNumbers: number[];
|
||||
viewerConfig: ViewerConfig;
|
||||
|
||||
|
||||
// Processing state
|
||||
isProcessing: boolean;
|
||||
processingProgress: number;
|
||||
|
||||
|
||||
// Export state
|
||||
lastExportConfig?: {
|
||||
filename: string;
|
||||
@ -85,24 +91,26 @@ export interface FileContextState {
|
||||
|
||||
export interface FileContextActions {
|
||||
// File management
|
||||
addFiles: (files: File[]) => Promise<void>;
|
||||
addFiles: (files: File[]) => Promise<File[]>;
|
||||
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
||||
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
||||
clearAllFiles: () => void;
|
||||
|
||||
|
||||
// Navigation
|
||||
setCurrentMode: (mode: ModeType) => void;
|
||||
setCurrentView: (view: ViewType) => void;
|
||||
setCurrentTool: (tool: ToolType) => void;
|
||||
// Selection management
|
||||
setSelectedFiles: (fileIds: string[]) => void;
|
||||
setSelectedPages: (pageNumbers: number[]) => void;
|
||||
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
|
||||
clearSelections: () => void;
|
||||
|
||||
|
||||
// Edit operations
|
||||
applyPageOperations: (fileId: string, operations: PageOperation[]) => void;
|
||||
applyFileOperation: (operation: FileOperation) => void;
|
||||
undoLastOperation: (fileId?: string) => void;
|
||||
|
||||
|
||||
// Operation history management
|
||||
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void;
|
||||
markOperationApplied: (fileId: string, operationId: string) => void;
|
||||
@ -110,31 +118,31 @@ export interface FileContextActions {
|
||||
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
|
||||
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
|
||||
clearFileHistory: (fileId: string) => void;
|
||||
|
||||
|
||||
// Viewer state
|
||||
updateViewerConfig: (config: Partial<ViewerConfig>) => void;
|
||||
|
||||
|
||||
// Export configuration
|
||||
setExportConfig: (config: FileContextState['lastExportConfig']) => void;
|
||||
|
||||
|
||||
|
||||
|
||||
// Utility
|
||||
getFileById: (fileId: string) => File | undefined;
|
||||
getProcessedFileById: (fileId: string) => ProcessedFile | undefined;
|
||||
getCurrentFile: () => File | undefined;
|
||||
getCurrentProcessedFile: () => ProcessedFile | undefined;
|
||||
|
||||
|
||||
// Context persistence
|
||||
saveContext: () => Promise<void>;
|
||||
loadContext: () => Promise<void>;
|
||||
resetContext: () => void;
|
||||
|
||||
|
||||
// Navigation guard system
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => boolean;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
|
||||
|
||||
// Memory management
|
||||
trackBlobUrl: (url: string) => void;
|
||||
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
|
||||
@ -163,4 +171,4 @@ export interface FileContextUrlParams {
|
||||
pageIds?: string[];
|
||||
zoom?: number;
|
||||
page?: number;
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export interface PDFDocument {
|
||||
file: File;
|
||||
pages: PDFPage[];
|
||||
totalPages: number;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
export interface PageOperation {
|
||||
@ -43,7 +44,7 @@ export interface PageEditorFunctions {
|
||||
handleRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
handleRotate: () => void;
|
||||
handleRotate: (direction: 'left' | 'right') => void;
|
||||
handleDelete: () => void;
|
||||
handleSplit: () => void;
|
||||
onExportSelected: () => void;
|
||||
|
@ -35,6 +35,11 @@ export interface ToolResult {
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolConfiguration {
|
||||
maxFiles: number;
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -70,4 +75,4 @@ export interface FileSelectionComputed {
|
||||
isMultiFileMode: boolean;
|
||||
}
|
||||
|
||||
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}
|
||||
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}
|
||||
|
@ -49,12 +49,16 @@ export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?:
|
||||
size: storedFile.size,
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified,
|
||||
webkitRelativePath: '',
|
||||
// Lazy-loading File interface methods
|
||||
arrayBuffer: async () => {
|
||||
const data = await fileStorage.getFileData(storedFile.id);
|
||||
if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`);
|
||||
return data;
|
||||
},
|
||||
bytes: async () => {
|
||||
return new Uint8Array();
|
||||
},
|
||||
slice: (start?: number, end?: number, contentType?: string) => {
|
||||
// Return a promise-based slice that loads from IndexedDB
|
||||
return new Blob([], { type: contentType || storedFile.type });
|
||||
@ -66,9 +70,9 @@ export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?:
|
||||
const data = await fileStorage.getFileData(storedFile.id);
|
||||
if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`);
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
},
|
||||
} as FileWithUrl;
|
||||
|
||||
|
||||
return enhancedFile;
|
||||
}
|
||||
|
||||
@ -79,28 +83,28 @@ export async function loadFilesFromIndexedDB(): Promise<FileWithUrl[]> {
|
||||
try {
|
||||
await fileStorage.init();
|
||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
||||
|
||||
|
||||
if (storedFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const restoredFiles: FileWithUrl[] = storedFiles
|
||||
.filter(storedFile => {
|
||||
// Filter out corrupted entries
|
||||
return storedFile &&
|
||||
storedFile.name &&
|
||||
return storedFile &&
|
||||
storedFile.name &&
|
||||
typeof storedFile.size === 'number';
|
||||
})
|
||||
.map(storedFile => {
|
||||
try {
|
||||
return createEnhancedFileFromStored(storedFile);
|
||||
return createEnhancedFileFromStored(storedFile as any);
|
||||
} catch (error) {
|
||||
console.error('Failed to restore file:', storedFile?.name || 'unknown', error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((file): file is FileWithUrl => file !== null);
|
||||
|
||||
|
||||
return restoredFiles;
|
||||
} catch (error) {
|
||||
console.error('Failed to load files from IndexedDB:', error);
|
||||
@ -134,17 +138,17 @@ export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean {
|
||||
*/
|
||||
export function detectFileExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
|
||||
const parts = filename.split('.');
|
||||
// If there's no extension (no dots or only one part), return empty string
|
||||
if (parts.length <= 1) return '';
|
||||
|
||||
|
||||
// Get the last part (extension) in lowercase
|
||||
let extension = parts[parts.length - 1].toLowerCase();
|
||||
|
||||
|
||||
// Normalize common extension variants
|
||||
if (extension === 'jpeg') extension = 'jpg';
|
||||
|
||||
|
||||
return extension;
|
||||
}
|
||||
|
||||
@ -155,10 +159,10 @@ export function detectFileExtension(filename: string): string {
|
||||
*/
|
||||
export function getFilenameWithoutExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
|
||||
const parts = filename.split('.');
|
||||
if (parts.length <= 1) return filename;
|
||||
|
||||
|
||||
// Return all parts except the last one (extension)
|
||||
return parts.slice(0, -1).join('.');
|
||||
}
|
||||
@ -172,4 +176,4 @@ export function getFilenameWithoutExtension(filename: string): string {
|
||||
export function changeFileExtension(filename: string, newExtension: string): string {
|
||||
const nameWithoutExt = getFilenameWithoutExtension(filename);
|
||||
return `${nameWithoutExt}.${newExtension}`;
|
||||
}
|
||||
}
|
||||
|
@ -6,9 +6,9 @@ import { getDocument } from "pdfjs-dist";
|
||||
*/
|
||||
export function calculateScaleFromFileSize(fileSize: number): number {
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
|
||||
if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality
|
||||
if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality
|
||||
if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality
|
||||
if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality
|
||||
if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality
|
||||
return 0.15; // 30MB+: Low quality
|
||||
@ -182,43 +182,41 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
console.log('File is not a PDF or image, generating placeholder:', file.name);
|
||||
return generatePlaceholderThumbnail(file);
|
||||
}
|
||||
|
||||
|
||||
// Calculate quality scale based on file size
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
const scale = calculateScaleFromFileSize(file.size);
|
||||
console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
try {
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
|
||||
// Calculate quality scale based on file size
|
||||
const scale = calculateScaleFromFileSize(file.size);
|
||||
console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
|
||||
// Only read first 2MB for thumbnail generation to save memory
|
||||
const chunkSize = 2 * 1024 * 1024; // 2MB
|
||||
const chunk = file.slice(0, Math.min(chunkSize, file.size));
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
|
||||
const pdf = await getDocument({
|
||||
|
||||
const pdf = await getDocument({
|
||||
data: arrayBuffer,
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
}).promise;
|
||||
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale }); // Dynamic scale based on file size
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
|
||||
// Immediately clean up memory after thumbnail generation
|
||||
pdf.destroy();
|
||||
console.log('Thumbnail generated and PDF destroyed for', file.name);
|
||||
|
||||
|
||||
return thumbnail;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@ -227,27 +225,27 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
// Return a placeholder or try with full file instead of chunk
|
||||
try {
|
||||
const fullArrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({
|
||||
const pdf = await getDocument({
|
||||
data: fullArrayBuffer,
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
verbosity: 0 // Reduce PDF.js warnings
|
||||
}).promise;
|
||||
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
|
||||
pdf.destroy();
|
||||
return thumbnail;
|
||||
} catch (fallbackError) {
|
||||
@ -262,4 +260,4 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
console.warn('Unknown error generating thumbnail for', file.name, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ export const createOperation = <TParams = void>(
|
||||
parameters: params,
|
||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||
}
|
||||
};
|
||||
} as any /* FIX ME*/;
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user