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