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:
James Brunton 2025-08-11 09:16:16 +01:00 committed by GitHub
parent 507ad1dc61
commit af5a9d1ae1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1141 additions and 919 deletions

View File

@ -9,7 +9,8 @@
"Bash(find:*)",
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)"
"Bash(ls:*)",
"Bash(npx tsc:*)"
],
"deny": []
}

View File

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

View File

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

View File

@ -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 && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,7 +35,7 @@ interface PageEditorControlsProps {
// Selection state
selectionMode: boolean;
selectedPages: string[];
selectedPages: number[];
}
const PageEditorControls = ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,6 @@ const ConvertFromWebSettings = ({
min={0.1}
max={3.0}
step={0.1}
precision={1}
disabled={disabled}
data-testid="zoom-level-input"
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.");
}
});
};
};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
*/
*/

View File

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

View File

@ -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)',
},
}),
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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