From 4a0c577312dab36e000c6dc1f506703d16092bc8 Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Thu, 14 Aug 2025 18:07:18 +0100 Subject: [PATCH] Refactor file management and context handling to utilize UUIDs for file identification, enhance file deduplication, and improve content hashing. Update related components and hooks for better performance and stability. --- frontend/src/components/FileManager.tsx | 26 +- .../src/components/fileEditor/FileEditor.tsx | 123 ++++++---- .../fileManager/CompactFileDetails.tsx | 6 +- .../components/fileManager/FileInfoCard.tsx | 4 +- .../components/fileManager/FileListItem.tsx | 4 +- .../components/fileManager/FilePreview.tsx | 4 +- .../src/components/pageEditor/PageEditor.tsx | 232 ++++++++++++++++-- .../components/pageEditor/PageThumbnail.tsx | 35 +-- frontend/src/contexts/FileContext.tsx | 146 +++++++---- frontend/src/contexts/FileManagerContext.tsx | 34 +-- frontend/src/hooks/useFileManager.ts | 70 +++--- frontend/src/hooks/useIndexedDBThumbnail.ts | 4 +- frontend/src/pages/HomePage.tsx | 9 +- .../src/services/fileOperationsService.ts | 39 +-- frontend/src/services/fileStorage.ts | 11 +- frontend/src/types/file.ts | 24 +- frontend/src/types/fileContext.ts | 90 ++++++- frontend/src/utils/fileUtils.ts | 4 +- 18 files changed, 621 insertions(+), 244 deletions(-) diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 02f9af5e4..7f7ca2cec 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -1,9 +1,10 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Modal } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { FileWithUrl } from '../types/file'; +import { FileMetadata } from '../types/file'; import { useFileManager } from '../hooks/useFileManager'; import { useFilesModalContext } from '../contexts/FilesModalContext'; +import { createFileId } from '../types/fileContext'; import { Tool } from '../types/tool'; import MobileLayout from './fileManager/MobileLayout'; import DesktopLayout from './fileManager/DesktopLayout'; @@ -16,12 +17,18 @@ interface FileManagerProps { const FileManager: React.FC = ({ selectedTool }) => { const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext(); - const [recentFiles, setRecentFiles] = useState([]); + const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); + // Wrapper for storeFile that generates UUID + const storeFileWithId = useCallback(async (file: File) => { + const fileId = createFileId(); // Generate UUID for storage + return await storeFile(file, fileId); + }, [storeFile]); + // File management handlers const isFileSupported = useCallback((fileName: string) => { if (!selectedTool?.supportedFormats) return true; @@ -34,7 +41,7 @@ const FileManager: React.FC = ({ selectedTool }) => { setRecentFiles(files); }, [loadRecentFiles]); - const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => { + const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { try { const fileObjects = await Promise.all( files.map(async (fileWithUrl) => { @@ -82,14 +89,11 @@ const FileManager: React.FC = ({ selectedTool }) => { // Cleanup any blob URLs when component unmounts useEffect(() => { return () => { - // Clean up blob URLs from recent files - recentFiles.forEach(file => { - if (file.url && file.url.startsWith('blob:')) { - URL.revokeObjectURL(file.url); - } - }); + // FileMetadata doesn't have blob URLs, so no cleanup needed + // Blob URLs are managed by FileContext and tool operations + console.log('FileManager unmounting - FileContext handles blob URL cleanup'); }; - }, [recentFiles]); + }, []); // Modal size constants for consistent scaling const modalHeight = '80vh'; @@ -152,7 +156,7 @@ const FileManager: React.FC = ({ selectedTool }) => { isOpen={isFilesModalOpen} onFileRemove={handleRemoveFileByIndex} modalHeight={modalHeight} - storeFile={storeFile} + storeFile={storeFileWithId} refreshRecentFiles={refreshRecentFiles} > {isMobile ? : } diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index a17059aed..3c0146b2b 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -10,6 +10,7 @@ import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagemen import { FileOperation, createStableFileId } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; +import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; import styles from '../pageEditor/PageEditor.module.css'; @@ -47,6 +48,9 @@ const FileEditor = ({ }: FileEditorProps) => { const { t } = useTranslation(); + // Thumbnail cache for sharing with PageEditor + const { getThumbnailFromCache, addThumbnailToCache } = useThumbnailGeneration(); + // Utility function to check if a file extension is supported const isFileSupported = useCallback((fileName: string): boolean => { const extension = detectFileExtension(fileName); @@ -60,6 +64,7 @@ const FileEditor = ({ // Extract needed values from state (memoized to prevent infinite loops) const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); + const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; const isProcessing = state.ui.isProcessing; @@ -145,33 +150,48 @@ const FileEditor = ({ // Map context selections to local file IDs for UI display const localSelectedIds = files .filter(file => { - const contextFileId = createStableFileId(file.file); - return contextSelectedIds.includes(contextFileId); + // file.id is already the correct UUID from FileContext + return contextSelectedIds.includes(file.id); }) .map(file => file.id); // Convert shared files to FileEditor format const convertToFileItem = useCallback(async (sharedFile: any): Promise => { - // Generate thumbnail if not already available - const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile); + let thumbnail = sharedFile.thumbnail; + + if (!thumbnail) { + // Check cache first using the file ID + const fileId = sharedFile.id || `file-${Date.now()}-${Math.random()}`; + const page1CacheKey = `${fileId}-page-1`; + thumbnail = getThumbnailFromCache(page1CacheKey); + + if (!thumbnail) { + // Generate and cache thumbnail + thumbnail = await generateThumbnailForFile(sharedFile.file || sharedFile); + if (thumbnail) { + addThumbnailToCache(page1CacheKey, thumbnail); + console.log(`📸 FileEditor: Cached page-1 thumbnail for legacy file (key: ${page1CacheKey})`); + } + } + } return { id: sharedFile.id || `file-${Date.now()}-${Math.random()}`, name: (sharedFile.file?.name || sharedFile.name || 'unknown'), pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown - thumbnail, + thumbnail: thumbnail || '', size: sharedFile.file?.size || sharedFile.size || 0, file: sharedFile.file || sharedFile, }; - }, []); + }, [getThumbnailFromCache, addThumbnailToCache]); // Convert activeFiles to FileItem format using context (async to avoid blocking) useEffect(() => { // Check if the actual content has changed, not just references - const currentActiveFileNames = activeFiles.map(f => f.name); + const currentActiveFileIds = activeFileRecords.map(r => r.id); const currentProcessedFilesSize = processedFiles.processedFiles.size; - const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current); + const activeFilesChanged = JSON.stringify(currentActiveFileIds) !== JSON.stringify(lastActiveFilesRef.current); const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current; if (!activeFilesChanged && !processedFilesChanged) { @@ -179,49 +199,59 @@ const FileEditor = ({ } // Update refs - lastActiveFilesRef.current = currentActiveFileNames; + lastActiveFilesRef.current = currentActiveFileIds; lastProcessedFilesRef.current = currentProcessedFilesSize; const convertActiveFiles = async () => { - if (activeFiles.length > 0) { + if (activeFileRecords.length > 0) { setLocalLoading(true); try { // Process files in chunks to avoid blocking UI const convertedFiles: FileItem[] = []; - for (let i = 0; i < activeFiles.length; i++) { - const file = activeFiles[i]; + for (let i = 0; i < activeFileRecords.length; i++) { + const record = activeFileRecords[i]; + const file = selectors.getFile(record.id); - // Try to get thumbnail from processed file first - const processedFile = processedFiles.processedFiles.get(file); - let thumbnail = processedFile?.pages?.[0]?.thumbnail; - - // If no thumbnail from processed file, try to generate one + if (!file) continue; // Skip if file not found + + // Use record's thumbnail if available, otherwise check cache, then generate + let thumbnail: string | undefined = record.thumbnailUrl; if (!thumbnail) { - try { - thumbnail = await generateThumbnailForFile(file); - } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); - thumbnail = undefined; // Use placeholder + // Check if PageEditor has already cached a page-1 thumbnail for this file + const page1CacheKey = `${record.id}-page-1`; + thumbnail = getThumbnailFromCache(page1CacheKey) || undefined; + + if (!thumbnail) { + try { + thumbnail = await generateThumbnailForFile(file); + // Store in cache for PageEditor to reuse + if (thumbnail) { + addThumbnailToCache(page1CacheKey, thumbnail); + console.log(`📸 FileEditor: Cached page-1 thumbnail for ${file.name} (key: ${page1CacheKey})`); + } + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + thumbnail = undefined; // Use placeholder + } + } else { + console.log(`📸 FileEditor: Reused cached page-1 thumbnail for ${file.name} (key: ${page1CacheKey})`); } } + // Page count estimation for display purposes only + let pageCount = 1; // Default for non-PDFs and display in FileEditor - // Get actual page count from processed file - let pageCount = 1; // Default for non-PDFs - if (processedFile) { - pageCount = processedFile.pages?.length || processedFile.totalPages || 1; - } else if (file.type === 'application/pdf') { - // For PDFs without processed data, try to get a quick page count estimate - // If processing is taking too long, show a reasonable default + if (file.type === 'application/pdf') { + // Quick page count estimation for FileEditor display only + // PageEditor will do its own more thorough page detection try { - // Quick and dirty page count using PDF structure analysis const arrayBuffer = await file.arrayBuffer(); const text = new TextDecoder('latin1').decode(arrayBuffer); const pageMatches = text.match(/\/Type\s*\/Page[^s]/g); pageCount = pageMatches ? pageMatches.length : 1; - console.log(`📄 Quick page count for ${file.name}: ${pageCount} pages (estimated)`); + console.log(`📄 FileEditor estimated page count for ${file.name}: ${pageCount} pages (display only)`); } catch (error) { console.warn(`Failed to estimate page count for ${file.name}:`, error); pageCount = 1; // Safe fallback @@ -229,7 +259,7 @@ const FileEditor = ({ } const convertedFile = { - id: createStableFileId(file), // Use same ID function as context + id: record.id, // Use the record's UUID from FileContext name: file.name, pageCount: pageCount, thumbnail: thumbnail || '', @@ -240,10 +270,10 @@ const FileEditor = ({ convertedFiles.push(convertedFile); // Update progress - setConversionProgress(((i + 1) / activeFiles.length) * 100); + setConversionProgress(((i + 1) / activeFileRecords.length) * 100); // Yield to main thread between files - if (i < activeFiles.length - 1) { + if (i < activeFileRecords.length - 1) { await new Promise(resolve => requestAnimationFrame(resolve)); } } @@ -264,7 +294,7 @@ const FileEditor = ({ }; convertActiveFiles(); - }, [activeFiles, processedFiles]); + }, [activeFileRecords, processedFiles, selectors]); // Process uploaded files using context @@ -422,25 +452,25 @@ const FileEditor = ({ }, [addFiles]); const selectAll = useCallback(() => { - setContextSelectedFiles(files.map(f => (f.file as any).id || f.name)); + setContextSelectedFiles(files.map(f => f.id)); // Use FileEditor file IDs which are now correct UUIDs }, [files, setContextSelectedFiles]); const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]); const closeAllFiles = useCallback(() => { - if (activeFiles.length === 0) return; + if (activeFileRecords.length === 0) return; // Record close all operation for each file // Legacy operation tracking - now handled by FileContext - console.log('Close all operation for', activeFiles.length, 'files'); + console.log('Close all operation for', activeFileRecords.length, 'files'); // Remove all files from context but keep in storage - const fileIds = activeFiles.map(f => createStableFileId(f)); + const fileIds = activeFileRecords.map(r => r.id); // Use record IDs directly removeFiles(fileIds, false); // Clear selections setContextSelectedFiles([]); - }, [activeFiles, removeFiles, setContextSelectedFiles]); + }, [activeFileRecords, removeFiles, setContextSelectedFiles]); const toggleFile = useCallback((fileId: string) => { const currentFiles = filesDataRef.current; @@ -449,7 +479,8 @@ const FileEditor = ({ const targetFile = currentFiles.find(f => f.id === fileId); if (!targetFile) return; - const contextFileId = createStableFileId(targetFile.file); + // The fileId from FileEditor is already the correct UUID from FileContext + const contextFileId = fileId; // No need to create a new ID const isSelected = currentSelectedIds.includes(contextFileId); let newSelection: string[]; @@ -611,7 +642,7 @@ const FileEditor = ({ // Record close operation const fileName = file.file.name; - const fileId = (file.file as any).id || fileName; + const contextFileId = file.id; // Use the correct file ID (UUID from FileContext) const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operation: FileOperation = { id: operationId, @@ -633,13 +664,13 @@ const FileEditor = ({ console.log('Close operation recorded:', operation); // Remove file from context but keep in storage (close, don't delete) - console.log('Calling removeFiles with:', [fileId]); - removeFiles([fileId], false); + console.log('Calling removeFiles with:', [contextFileId]); + removeFiles([contextFileId], false); // Remove from context selections setContextSelectedFiles((prev: string[]) => { const safePrev = Array.isArray(prev) ? prev : []; - return safePrev.filter(id => id !== fileId); + return safePrev.filter(id => id !== contextFileId); }); } else { console.log('File not found for fileId:', fileId); @@ -650,7 +681,7 @@ const FileEditor = ({ const file = files.find(f => f.id === fileId); if (file) { // Set the file as selected in context and switch to viewer for preview - const contextFileId = createStableFileId(file.file); + const contextFileId = file.id; // Use the correct file ID (UUID from FileContext) setContextSelectedFiles([contextFileId]); setCurrentView('viewer'); } diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx index 7f7c410b7..b1b5f0d24 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; import { getFileSize } from '../../utils/fileUtils'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface CompactFileDetailsProps { - currentFile: FileWithUrl | null; + currentFile: FileMetadata | null; thumbnail: string | null; - selectedFiles: FileWithUrl[]; + selectedFiles: FileMetadata[]; currentFileIndex: number; numberOfFiles: number; isAnimating: boolean; diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx index 7e69dd2ed..f8cc84cb8 100644 --- a/frontend/src/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface FileInfoCardProps { - currentFile: FileWithUrl | null; + currentFile: FileMetadata | null; modalHeight: string; } diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 147133009..93e099a2e 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -2,10 +2,10 @@ import React, { useState } from 'react'; import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core'; import DeleteIcon from '@mui/icons-material/Delete'; import { getFileSize, getFileDate } from '../../utils/fileUtils'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface FileListItemProps { - file: FileWithUrl; + file: FileMetadata; isSelected: boolean; isSupported: boolean; onSelect: () => void; diff --git a/frontend/src/components/fileManager/FilePreview.tsx b/frontend/src/components/fileManager/FilePreview.tsx index deb4cc67b..e52c59e57 100644 --- a/frontend/src/components/fileManager/FilePreview.tsx +++ b/frontend/src/components/fileManager/FilePreview.tsx @@ -3,10 +3,10 @@ import { Box, Center, ActionIcon, Image } from '@mantine/core'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { FileWithUrl } from '../../types/file'; +import { FileMetadata } from '../../types/file'; interface FilePreviewProps { - currentFile: FileWithUrl | null; + currentFile: FileMetadata | null; thumbnail: string | null; numberOfFiles: number; isAnimating: boolean; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 784712fb7..b6922d9b2 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -84,6 +84,19 @@ const PageEditor = ({ * Using this instead of direct file arrays prevents unnecessary re-renders. */ + // Thumbnail generation (opt-in for visual tools) - MUST be before mergedPdfDocument + const { + generateThumbnails, + addThumbnailToCache, + getThumbnailFromCache, + stopGeneration, + destroyThumbnails + } = useThumbnailGeneration(); + + // State for discovered page document + const [discoveredDocument, setDiscoveredDocument] = useState(null); + const [isDiscoveringPages, setIsDiscoveringPages] = useState(false); + // Compute merged document with stable signature (prevents infinite loops) const mergedPdfDocument = useMemo((): PDFDocument | null => { if (activeFileIds.length === 0) return null; @@ -107,24 +120,64 @@ const PageEditor = ({ // Get pages from processed file data const processedFile = primaryFileRecord.processedFile; - // Convert processed pages to PageEditor format, or create placeholder if not processed yet - const pages = processedFile?.pages?.length > 0 - ? processedFile.pages.map((page, index) => ({ - id: `${primaryFileId}-page-${index + 1}`, + // Debug logging for processed file data + console.log(`🎬 PageEditor: Building document for ${name}`); + console.log(`🎬 ProcessedFile exists:`, !!processedFile); + console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); + if (processedFile?.pages) { + console.log(`🎬 Pages structure:`, processedFile.pages.map(p => ({ pageNumber: p.pageNumber || 'unknown', hasThumbnail: !!p.thumbnail }))); + } + + // Convert processed pages to PageEditor format, or discover pages if not processed yet + let pages: PDFPage[]; + + if (processedFile?.pages && processedFile.pages.length > 0) { + // Use existing processed data + pages = processedFile.pages.map((page, index) => { + const pageId = `${primaryFileId}-page-${index + 1}`; + // Try multiple sources for thumbnails in order of preference: + // 1. Processed data thumbnail + // 2. Cached thumbnail from previous generation + // 3. For page 1: FileEditor's thumbnailUrl (sharing optimization) + let thumbnail = page.thumbnail || null; + if (!thumbnail) { + thumbnail = getThumbnailFromCache(pageId) || null; + } + if (!thumbnail && index === 0) { + // For page 1, also check if FileEditor has already generated a thumbnail + thumbnail = primaryFileRecord.thumbnailUrl || null; + // If we found a FileEditor thumbnail, cache it for consistency + if (thumbnail) { + addThumbnailToCache(pageId, thumbnail); + console.log(`📸 PageEditor: Reused FileEditor thumbnail for page 1 (${pageId})`); + } + } + + return { + id: pageId, pageNumber: index + 1, - thumbnail: page.thumbnail || null, + thumbnail, rotation: page.rotation || 0, selected: false, splitBefore: page.splitBefore || false, - })) - : [{ - id: `${primaryFileId}-page-1`, - pageNumber: 1, - thumbnail: null, - rotation: 0, - selected: false, - splitBefore: false, - }]; // Fallback: single page placeholder + }; + }); + } else if (discoveredDocument && discoveredDocument.id === (primaryFileId ?? 'unknown')) { + // Use discovered document if available and matches current file + pages = discoveredDocument.pages; + } else { + // No processed data and no discovered data yet - show placeholder while discovering + console.log(`🎬 PageEditor: No processedFile data, showing placeholder while discovering pages for ${name}`); + + pages = [{ + id: `${primaryFileId}-page-1`, + pageNumber: 1, + thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null, + rotation: 0, + selected: false, + splitBefore: false, + }]; + } // Create document with determined pages @@ -136,7 +189,123 @@ const PageEditor = ({ totalPages: pages.length, destroy: () => {} // Optional cleanup function }; - }, [filesSignature, activeFileIds, primaryFileId, selectors]); + }, [filesSignature, activeFileIds, primaryFileId, selectors, getThumbnailFromCache, addThumbnailToCache, discoveredDocument]); + + // Async page discovery effect + useEffect(() => { + const discoverPages = async () => { + if (!primaryFileId) return; + + const record = selectors.getFileRecord(primaryFileId); + const primaryFile = selectors.getFile(primaryFileId); + if (!record || !primaryFile) return; + + // Skip if we already have processed data or are currently discovering + if (record.processedFile?.pages || isDiscoveringPages) return; + + // Only discover for PDF files + if (primaryFile.type !== 'application/pdf') return; + + console.log(`🎬 PageEditor: Starting async page discovery for ${primaryFile.name}`); + setIsDiscoveringPages(true); + + try { + let discoveredPageCount = 1; + + // Try PDF.js first (more accurate) + try { + const arrayBuffer = await primaryFile.arrayBuffer(); + const pdfDoc = await import('pdfjs-dist').then(pdfjs => pdfjs.getDocument({ + data: arrayBuffer, + disableAutoFetch: true, + disableStream: true + }).promise); + + discoveredPageCount = pdfDoc.numPages; + console.log(`🎬 PageEditor: Discovered ${discoveredPageCount} pages using PDF.js`); + + // Clean up PDF document immediately + pdfDoc.destroy(); + } catch (pdfError) { + console.warn(`🎬 PageEditor: PDF.js failed, trying text analysis:`, pdfError); + + // Fallback to text analysis + try { + const arrayBuffer = await primaryFile.arrayBuffer(); + const text = new TextDecoder('latin1').decode(arrayBuffer); + const pageMatches = text.match(/\/Type\s*\/Page[^s]/g); + discoveredPageCount = pageMatches ? pageMatches.length : 1; + console.log(`🎬 PageEditor: Discovered ${discoveredPageCount} pages using text analysis`); + } catch (textError) { + console.warn(`🎬 PageEditor: Text analysis also failed:`, textError); + discoveredPageCount = 1; + } + } + + // Create page structure + const pages = Array.from({ length: discoveredPageCount }, (_, index) => { + const pageId = `${primaryFileId}-page-${index + 1}`; + let thumbnail = getThumbnailFromCache(pageId) || null; + + // For page 1, also check FileEditor's thumbnail + if (!thumbnail && index === 0) { + thumbnail = record.thumbnailUrl || null; + if (thumbnail) { + addThumbnailToCache(pageId, thumbnail); + console.log(`📸 PageEditor: Reused FileEditor thumbnail for page 1 (${pageId})`); + } + } + + return { + id: pageId, + pageNumber: index + 1, + thumbnail, + rotation: 0, + selected: false, + splitBefore: false, + }; + }); + + // Create discovered document + const discoveredDoc: PDFDocument = { + id: primaryFileId, + name: primaryFile.name, + file: primaryFile, + pages, + totalPages: pages.length, + destroy: () => {} + }; + + // Save to state for immediate UI update + setDiscoveredDocument(discoveredDoc); + + // Save to FileContext for persistence + const processedFileData = { + pages: pages.map(page => ({ + pageNumber: page.pageNumber, + thumbnail: page.thumbnail || undefined, + rotation: page.rotation, + splitBefore: page.splitBefore + })), + totalPages: discoveredPageCount, + lastProcessed: Date.now() + }; + + actions.updateFileRecord(primaryFileId, { + processedFile: processedFileData + }); + + console.log(`🎬 PageEditor: Page discovery complete - ${discoveredPageCount} pages saved to FileContext`); + + } catch (error) { + console.error(`🎬 PageEditor: Page discovery failed:`, error); + } finally { + setIsDiscoveringPages(false); + } + }; + + discoverPages(); + }, [primaryFileId, selectors, isDiscoveringPages, getThumbnailFromCache, addThumbnailToCache, actions]); // Display document: Use edited version if exists, otherwise original const displayDocument = editedDocument || mergedPdfDocument; @@ -209,15 +378,6 @@ const PageEditor = ({ */ const thumbnailGenerationStarted = useRef(false); - // Thumbnail generation (opt-in for visual tools) - const { - generateThumbnails, - addThumbnailToCache, - getThumbnailFromCache, - stopGeneration, - destroyThumbnails - } = useThumbnailGeneration(); - // Start thumbnail generation process (guards against re-entry) - stable version const startThumbnailGeneration = useCallback(() => { // Access current values directly - avoid stale closures @@ -280,6 +440,28 @@ const PageEditor = ({ if (!cached) { addThumbnailToCache(pageId, thumbnail); + // Persist thumbnail to FileContext for durability + const fileRecord = selectors.getFileRecord(currentPrimaryFileId); + if (fileRecord) { + const updatedProcessedFile = { + ...fileRecord.processedFile, + pages: fileRecord.processedFile?.pages?.map((page, index) => + index + 1 === pageNumber + ? { ...page, thumbnail } + : page + ) || [{ thumbnail }] // Create pages array if it doesn't exist + }; + + // For page 1, also update the file record's thumbnailUrl so FileEditor can use it directly + const updates: any = { processedFile: updatedProcessedFile }; + if (pageNumber === 1) { + updates.thumbnailUrl = thumbnail; + console.log(`📸 PageEditor: Set thumbnailUrl for FileEditor reuse (${currentPrimaryFileId})`); + } + + actions.updateFileRecord(currentPrimaryFileId, updates); + } + window.dispatchEvent(new CustomEvent('thumbnailReady', { detail: { pageNumber, thumbnail, pageId } })); @@ -304,7 +486,7 @@ const PageEditor = ({ thumbnailGenerationStarted.current = false; } }, 0); // setTimeout with 0ms to defer to next tick - }, [generateThumbnails, getThumbnailFromCache, addThumbnailToCache]); // Only stable function dependencies + }, [generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors, actions]); // Only stable function dependencies // Start thumbnail generation when files change (stable signature prevents loops) useEffect(() => { diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index cd5b47a19..5b5fac4ce 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -10,15 +10,13 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { PDFPage, PDFDocument } from '../../types/pageEditor'; import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands'; import { Command } from '../../hooks/useUndoRedo'; +import { useFileState } from '../../contexts/FileContext'; import styles from './PageEditor.module.css'; import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'; // Ensure PDF.js worker is available if (!GlobalWorkerOptions.workerSrc) { GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; - console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js'); -} else { - console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc); } interface PageThumbnailProps { @@ -81,15 +79,15 @@ const PageThumbnail = React.memo(({ setPdfDocument, }: PageThumbnailProps) => { const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); - const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false); + const { state, selectors } = useFileState(); - // Update thumbnail URL when page prop changes + // Update thumbnail URL when page prop changes - prevent redundant updates useEffect(() => { if (page.thumbnail && page.thumbnail !== thumbnailUrl) { console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...'); setThumbnailUrl(page.thumbnail); } - }, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]); + }, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles // Listen for ready thumbnails from Web Workers (only if no existing thumbnail) useEffect(() => { @@ -100,8 +98,15 @@ const PageThumbnail = React.memo(({ const handleThumbnailReady = (event: CustomEvent) => { const { pageNumber, thumbnail, pageId } = event.detail; + // Guard: check if this component is still mounted and page still exists if (pageNumber === page.pageNumber && pageId === page.id) { - setThumbnailUrl(thumbnail); + // Additional safety: check if the file still exists in FileContext + const fileId = page.id.split('-page-')[0]; // Extract fileId from pageId + const fileExists = selectors.getAllFileIds().includes(fileId); + + if (fileExists) { + setThumbnailUrl(thumbnail); + } } }; @@ -109,7 +114,7 @@ const PageThumbnail = React.memo(({ return () => { window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener); }; - }, [page.pageNumber, page.id, thumbnailUrl]); + }, [page.pageNumber, page.id]); // Remove thumbnailUrl dependency to stabilize effect // Register this component with pageRefs for animations @@ -225,11 +230,6 @@ const PageThumbnail = React.memo(({ transition: 'transform 0.3s ease-in-out' }} /> - ) : isLoadingThumbnail ? ( -
- - Loading... -
) : (
📄 @@ -416,13 +416,20 @@ const PageThumbnail = React.memo(({
); }, (prevProps, nextProps) => { + // Helper for shallow array comparison + const arraysEqual = (a: number[], b: number[]) => { + return a.length === b.length && a.every((val, i) => val === b[i]); + }; + // Only re-render if essential props change return ( prevProps.page.id === nextProps.page.id && prevProps.page.pageNumber === nextProps.page.pageNumber && prevProps.page.rotation === nextProps.page.rotation && prevProps.page.thumbnail === nextProps.page.thumbnail && - prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes + // Shallow compare selectedPages array for better stability + (prevProps.selectedPages === nextProps.selectedPages || + arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) && prevProps.selectionMode === nextProps.selectionMode && prevProps.draggedPage === nextProps.draggedPage && prevProps.dropTarget === nextProps.dropTarget && diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 19f39e8b4..566e8a26a 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -36,23 +36,17 @@ import { FileRecord, toFileRecord, revokeFileResources, - createStableFileId + createFileId, + computeContentHash } from '../types/fileContext'; -// Mock services - these will need proper implementation -const enhancedPDFProcessingService = { - clearAllProcessing: () => {}, - cancelProcessing: (fileId: string) => {} -}; +// Import real services +import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; +import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; +import { fileStorage } from '../services/fileStorage'; -const thumbnailGenerationService = { - destroy: () => {}, - stopGeneration: () => {} -}; - -const fileStorage = { - deleteFile: async (fileId: string) => {} -}; +// Get service instances +const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance(); // Initial state const initialFileContextState: FileContextState = { @@ -76,15 +70,13 @@ const initialFileContextState: FileContextState = { function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { switch (action.type) { case 'ADD_FILES': { - const { files } = action.payload; + const { fileRecords } = action.payload; const newIds: FileId[] = []; const newById: Record = { ...state.files.byId }; - files.forEach(file => { - const stableId = createStableFileId(file); + fileRecords.forEach(record => { // Only add if not already present (dedupe by stable ID) - if (!newById[stableId]) { - const record = toFileRecord(file, stableId); + if (!newById[record.id]) { newIds.push(record.id); newById[record.id] = record; } @@ -131,6 +123,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): const existingRecord = state.files.byId[id]; if (!existingRecord) return state; + // Immutable merge supports all FileRecord fields including contentHash, hashStatus return { ...state, files: { @@ -368,12 +361,14 @@ export function FileContextProvider({ }); pdfDocuments.current.clear(); - // Revoke all blob URLs + // Revoke all blob URLs (only blob: scheme) blobUrls.current.forEach(url => { - try { - URL.revokeObjectURL(url); - } catch (error) { - console.warn('Error revoking blob URL:', error); + if (url.startsWith('blob:')) { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Error revoking blob URL:', error); + } } }); blobUrls.current.clear(); @@ -418,31 +413,75 @@ export function FileContextProvider({ // Action implementations const addFiles = useCallback(async (files: File[]): Promise => { - // Store Files in ref map with stable IDs - const fileIds: FileId[] = []; + // Generate UUID-based IDs and create records + const fileRecords: FileRecord[] = []; + const addedFiles: File[] = []; + for (const file of files) { - const stableId = createStableFileId(file); - // Dedupe - only add if not already present - if (!filesRef.current.has(stableId)) { - filesRef.current.set(stableId, file); - fileIds.push(stableId); + const fileId = createFileId(); // UUID-based, zero collisions + + // Store File in ref map + filesRef.current.set(fileId, file); + + // Create record with pending hash status + const record = toFileRecord(file, fileId); + record.hashStatus = 'pending'; + + fileRecords.push(record); + addedFiles.push(file); + + // Optional: Persist to IndexedDB if enabled + if (enablePersistence) { + try { + // Generate thumbnail and store in IndexedDB with our UUID + import('../utils/thumbnailUtils').then(({ generateThumbnailForFile }) => { + return generateThumbnailForFile(file); + }).then(thumbnail => { + return fileStorage.storeFile(file, fileId, thumbnail); + }).then(() => { + console.log('File persisted to IndexedDB:', fileId); + }).catch(error => { + console.warn('Failed to persist file to IndexedDB:', error); + }); + } catch (error) { + console.warn('Failed to initiate file persistence:', error); + } } + + // Start async content hashing (don't block add operation) + computeContentHash(file).then(contentHash => { + // Only update if file still exists in context + if (filesRef.current.has(fileId)) { + updateFileRecord(fileId, { + contentHash: contentHash || undefined, // Convert null to undefined + hashStatus: contentHash ? 'completed' : 'failed' + }); + } + }).catch(() => { + // Hash failed, update status if file still exists + if (filesRef.current.has(fileId)) { + updateFileRecord(fileId, { hashStatus: 'failed' }); + } + }); } - // Dispatch only the file metadata to state - dispatch({ type: 'ADD_FILES', payload: { files } }); + // Only dispatch if we have new files + if (fileRecords.length > 0) { + dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); + } - // Return files with their IDs assigned - return files; - }, [enablePersistence]); + // Return only the newly added files + return addedFiles; + }, [enablePersistence]); // Include enablePersistence for persistence logic const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => { - // Clean up Files from ref map + // Clean up Files from ref map first fileIds.forEach(fileId => { filesRef.current.delete(fileId); cleanupFile(fileId); }); + // Update state dispatch({ type: 'REMOVE_FILES', payload: { fileIds } }); // Remove from IndexedDB only if requested @@ -457,13 +496,19 @@ export function FileContextProvider({ } }, [enablePersistence, cleanupFile]); + const updateFileRecord = useCallback((id: FileId, updates: Partial) => { + // Ensure immutable merge by dispatching action + dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id, updates } }); + }, []); + // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []); const requestNavigation = useCallback((navigationFn: () => void): boolean => { - if (state.ui.hasUnsavedChanges) { + // Use stateRef to avoid stale closure issues with rapid state changes + if (stateRef.current.ui.hasUnsavedChanges) { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); return false; @@ -471,15 +516,16 @@ export function FileContextProvider({ navigationFn(); return true; } - }, [state.ui.hasUnsavedChanges]); + }, []); // No dependencies - uses stateRef for current state const confirmNavigation = useCallback(() => { - if (state.ui.pendingNavigation) { - state.ui.pendingNavigation(); + // Use stateRef to get current navigation function + if (stateRef.current.ui.pendingNavigation) { + stateRef.current.ui.pendingNavigation(); dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); } dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); - }, [state.ui.pendingNavigation]); + }, []); // No dependencies - uses stateRef const cancelNavigation = useCallback(() => { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); @@ -490,6 +536,7 @@ export function FileContextProvider({ const actions = useMemo(() => ({ addFiles, removeFiles, + updateFileRecord, clearAllFiles: () => { cleanupAllFiles(); filesRef.current.clear(); @@ -530,6 +577,7 @@ export function FileContextProvider({ // Action compatibility layer addFiles, removeFiles, + updateFileRecord, clearAllFiles: actions.clearAllFiles, setCurrentMode: actions.setCurrentMode, setSelectedFiles: actions.setSelectedFiles, @@ -551,7 +599,7 @@ export function FileContextProvider({ get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render // Selectors ...selectors - }), [state, actions, addFiles, removeFiles, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency + }), [state, actions, addFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency // Cleanup on unmount useEffect(() => { @@ -654,12 +702,12 @@ export function useProcessedFiles() { const compatibilityMap = { size: state.files.ids.length, get: (file: File) => { - const id = createStableFileId(file); - return selectors.getFileRecord(id)?.processedFile; + console.warn('useProcessedFiles.get is deprecated - File objects no longer have stable IDs'); + return null; }, has: (file: File) => { - const id = createStableFileId(file); - return !!selectors.getFileRecord(id)?.processedFile; + console.warn('useProcessedFiles.has is deprecated - File objects no longer have stable IDs'); + return false; }, set: () => { console.warn('processedFiles.set is deprecated - use FileRecord updates instead'); @@ -669,8 +717,8 @@ export function useProcessedFiles() { return { processedFiles: compatibilityMap, // Map-like interface for backward compatibility getProcessedFile: (file: File) => { - const id = createStableFileId(file); - return selectors.getFileRecord(id)?.processedFile; + console.warn('getProcessedFile is deprecated - File objects no longer have stable IDs'); + return null; }, updateProcessedFile: () => { console.warn('updateProcessedFile is deprecated - processed files are now stored in FileRecord'); diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 53737ab01..a115d10b3 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { FileWithUrl } from '../types/file'; +import { FileWithUrl, FileMetadata } from '../types/file'; import { StoredFile } from '../services/fileStorage'; // Type for the context value - now contains everything directly @@ -8,22 +8,22 @@ interface FileManagerContextValue { activeSource: 'recent' | 'local' | 'drive'; selectedFileIds: string[]; searchTerm: string; - selectedFiles: FileWithUrl[]; - filteredFiles: FileWithUrl[]; + selectedFiles: FileMetadata[]; + filteredFiles: FileMetadata[]; fileInputRef: React.RefObject; // Handlers onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onLocalFileClick: () => void; - onFileSelect: (file: FileWithUrl) => void; + onFileSelect: (file: FileMetadata) => void; onFileRemove: (index: number) => void; - onFileDoubleClick: (file: FileWithUrl) => void; + onFileDoubleClick: (file: FileMetadata) => void; onOpenFiles: () => void; onSearchChange: (value: string) => void; onFileInputChange: (event: React.ChangeEvent) => void; // External props - recentFiles: FileWithUrl[]; + recentFiles: FileMetadata[]; isFileSupported: (fileName: string) => boolean; modalHeight: string; } @@ -34,14 +34,14 @@ const FileManagerContext = createContext(null); // Provider component props interface FileManagerProviderProps { children: React.ReactNode; - recentFiles: FileWithUrl[]; - onFilesSelected: (files: FileWithUrl[]) => void; + recentFiles: FileMetadata[]; + onFilesSelected: (files: FileMetadata[]) => void; onClose: () => void; isFileSupported: (fileName: string) => boolean; isOpen: boolean; onFileRemove: (index: number) => void; modalHeight: string; - storeFile: (file: File) => Promise; + storeFile: (file: File, fileId: string) => Promise; refreshRecentFiles: () => Promise; } @@ -83,7 +83,7 @@ export const FileManagerProvider: React.FC = ({ fileInputRef.current?.click(); }, []); - const handleFileSelect = useCallback((file: FileWithUrl) => { + const handleFileSelect = useCallback((file: FileMetadata) => { setSelectedFileIds(prev => { if (file.id) { if (prev.includes(file.id)) { @@ -105,7 +105,7 @@ export const FileManagerProvider: React.FC = ({ onFileRemove(index); }, [filteredFiles, onFileRemove]); - const handleFileDoubleClick = useCallback((file: FileWithUrl) => { + const handleFileDoubleClick = useCallback((file: FileMetadata) => { if (isFileSupported(file.name)) { onFilesSelected([file]); onClose(); @@ -127,22 +127,22 @@ export const FileManagerProvider: React.FC = ({ const files = Array.from(event.target.files || []); if (files.length > 0) { try { - // Create FileWithUrl objects - FileContext will handle storage and ID assignment - const fileWithUrls = files.map(file => { + // Create FileMetadata objects - FileContext will handle storage and ID assignment + const fileMetadatas = files.map(file => { const url = URL.createObjectURL(file); createdBlobUrls.current.add(url); return { - // No ID assigned here - FileContext will handle storage and ID assignment + id: `temp-${Date.now()}-${Math.random()}`, // Temporary ID until stored name: file.name, - file, - url, size: file.size, lastModified: file.lastModified, + type: file.type, + thumbnail: undefined, }; }); - onFilesSelected(fileWithUrls as any /* FIX ME */); + onFilesSelected(fileMetadatas); await refreshRecentFiles(); onClose(); } catch (error) { diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index dfa2d1dec..74843a65c 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -1,49 +1,46 @@ import { useState, useCallback } from 'react'; import { fileStorage } from '../services/fileStorage'; -import { FileWithUrl } from '../types/file'; -import { createEnhancedFileFromStored } from '../utils/fileUtils'; +import { FileWithUrl, FileMetadata } from '../types/file'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; export const useFileManager = () => { const [loading, setLoading] = useState(false); - const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise => { - if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) { - const response = await fetch(fileWithUrl.url); - const data = await response.arrayBuffer(); - const file = new File([data], fileWithUrl.name, { - type: fileWithUrl.type || 'application/pdf', - lastModified: fileWithUrl.lastModified || Date.now() - }); - // Preserve the ID if it exists - if (fileWithUrl.id) { - Object.defineProperty(file, 'id', { value: fileWithUrl.id, writable: false }); - } - return file; + const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise => { + // Always use ID - no fallback to names to prevent identity drift + if (!fileMetadata.id) { + throw new Error('File ID is required - cannot convert file without stable ID'); } - - // Always use ID first, fallback to name only if ID doesn't exist - const lookupKey = fileWithUrl.id || fileWithUrl.name; - const storedFile = await fileStorage.getFile(lookupKey); + const storedFile = await fileStorage.getFile(fileMetadata.id); if (storedFile) { const file = new File([storedFile.data], storedFile.name, { type: storedFile.type, lastModified: storedFile.lastModified }); - // Add the ID to the file object - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + // NO FILE MUTATION - Return clean File, let FileContext manage ID return file; } throw new Error('File not found in storage'); }, []); - const loadRecentFiles = useCallback(async (): Promise => { + const loadRecentFiles = useCallback(async (): Promise => { setLoading(true); try { - const files = await fileStorage.getAllFiles(); - const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); - return sortedFiles.map(file => createEnhancedFileFromStored(file)); + // Get metadata only (no file data) for performance + const storedFileMetadata = await fileStorage.getAllFileMetadata(); + const sortedFiles = storedFileMetadata.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); + + // Convert StoredFile metadata to FileMetadata format + return sortedFiles.map(stored => ({ + id: stored.id, // UUID from FileContext + name: stored.name, + type: stored.type, + size: stored.size, + lastModified: stored.lastModified, + thumbnail: stored.thumbnail, + storedInIndexedDB: true + })); } catch (error) { console.error('Failed to load recent files:', error); return []; @@ -52,10 +49,13 @@ export const useFileManager = () => { } }, []); - const handleRemoveFile = useCallback(async (index: number, files: FileWithUrl[], setFiles: (files: FileWithUrl[]) => void) => { + const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => { const file = files[index]; + if (!file.id) { + throw new Error('File ID is required for removal'); + } try { - await fileStorage.deleteFile(file.id || file.name); + await fileStorage.deleteFile(file.id); setFiles(files.filter((_, i) => i !== index)); } catch (error) { console.error('Failed to remove file:', error); @@ -63,16 +63,15 @@ export const useFileManager = () => { } }, []); - const storeFile = useCallback(async (file: File) => { + const storeFile = useCallback(async (file: File, fileId: string) => { try { // Generate thumbnail for the file const thumbnail = await generateThumbnailForFile(file); - // Store file with thumbnail - const storedFile = await fileStorage.storeFile(file, thumbnail); + // Store file with provided UUID from FileContext + const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); - // Add the ID to the file object - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + // NO FILE MUTATION - Return StoredFile, FileContext manages mapping return storedFile; } catch (error) { console.error('Failed to store file:', error); @@ -96,14 +95,15 @@ export const useFileManager = () => { setSelectedFiles([]); }; - const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => { + const selectMultipleFiles = async (files: FileMetadata[], onFilesSelect: (files: File[]) => void) => { if (selectedFiles.length === 0) return; try { - const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name)); + // Filter by UUID and convert to File objects + const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id)); const filePromises = selectedFileObjects.map(convertToFile); const convertedFiles = await Promise.all(filePromises); - onFilesSelect(convertedFiles); + onFilesSelect(convertedFiles); // FileContext will assign new UUIDs clearSelection(); } catch (error) { console.error('Failed to load selected files:', error); diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index 03a9e92e9..d681f81e6 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { FileWithUrl } from "../types/file"; +import { FileMetadata } from "../types/file"; import { fileStorage } from "../services/fileStorage"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; @@ -22,7 +22,7 @@ function calculateThumbnailScale(pageViewport: { width: number; height: number } * Hook for IndexedDB-aware thumbnail loading * Handles thumbnail generation for files not in IndexedDB */ -export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): { +export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { thumbnail: string | null; isGenerating: boolean } { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index c6dc33fe3..bf10da80e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -29,7 +29,7 @@ function HomePageContent() { } else { setMaxFiles(-1); setIsToolMode(false); - setSelectedFiles([]); + // Don't clear selections when exiting tool mode - preserve selections for file/page editor } }, [selectedTool]); // Remove action dependencies to prevent loops @@ -48,8 +48,9 @@ function HomePageContent() { ); } -export default function HomePage() { +function HomePageWithProviders() { const { actions } = useFileActions(); + return ( @@ -58,3 +59,7 @@ export default function HomePage() { ); } + +export default function HomePage() { + return ; +} diff --git a/frontend/src/services/fileOperationsService.ts b/frontend/src/services/fileOperationsService.ts index d13965837..fea7cb4a5 100644 --- a/frontend/src/services/fileOperationsService.ts +++ b/frontend/src/services/fileOperationsService.ts @@ -1,8 +1,9 @@ -import { FileWithUrl } from "../types/file"; +import { FileWithUrl, FileMetadata } from "../types/file"; import { fileStorage, StorageStats } from "./fileStorage"; -import { loadFilesFromIndexedDB, createEnhancedFileFromStored, cleanupFileUrls } from "../utils/fileUtils"; +import { loadFilesFromIndexedDB, cleanupFileUrls } from "../utils/fileUtils"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { updateStorageStatsIncremental } from "../utils/storageUtils"; +import { createFileId } from "../types/fileContext"; /** * Service for file storage operations @@ -79,31 +80,35 @@ export const fileOperationsService = { // Generate thumbnail only during upload const thumbnail = await generateThumbnailForFile(file); - const storedFile = await fileStorage.storeFile(file, thumbnail); + const fileId = createFileId(); // Generate UUID + const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); console.log('File stored with ID:', storedFile.id); - const baseFile = fileStorage.createFileFromStored(storedFile); - const enhancedFile = createEnhancedFileFromStored(storedFile, thumbnail); - - // Copy File interface methods from baseFile - enhancedFile.arrayBuffer = baseFile.arrayBuffer.bind(baseFile); - enhancedFile.slice = baseFile.slice.bind(baseFile); - enhancedFile.stream = baseFile.stream.bind(baseFile); - enhancedFile.text = baseFile.text.bind(baseFile); + // Create FileWithUrl that extends the original File + const enhancedFile: FileWithUrl = Object.assign(file, { + id: fileId, + url: URL.createObjectURL(file), + thumbnail, + storedInIndexedDB: true + }); newFiles.push(enhancedFile); } catch (error) { console.error('Failed to store file in IndexedDB:', error); - // Fallback to RAM storage + // Fallback to RAM storage with UUID + const fileId = createFileId(); const enhancedFile: FileWithUrl = Object.assign(file, { + id: fileId, url: URL.createObjectURL(file), storedInIndexedDB: false }); newFiles.push(enhancedFile); } } else { - // IndexedDB disabled - use RAM + // IndexedDB disabled - use RAM with UUID + const fileId = createFileId(); const enhancedFile: FileWithUrl = Object.assign(file, { + id: fileId, url: URL.createObjectURL(file), storedInIndexedDB: false }); @@ -167,7 +172,13 @@ export const fileOperationsService = { } } - // Fallback for files not in IndexedDB + // Fallback for files not in IndexedDB - use existing URL if available + if (file.url) { + return file.url; + } + + // Last resort - create new blob URL (but this shouldn't happen with FileWithUrl) + console.warn('Creating blob URL for file without existing URL - this may indicate a type issue'); return URL.createObjectURL(file); }, diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts index 1e7a73ee5..0412f1a20 100644 --- a/frontend/src/services/fileStorage.ts +++ b/frontend/src/services/fileStorage.ts @@ -81,16 +81,15 @@ class FileStorageService { } /** - * Store a file in IndexedDB + * Store a file in IndexedDB with external UUID */ - async storeFile(file: File, thumbnail?: string): Promise { + async storeFile(file: File, fileId: string, thumbnail?: string): Promise { if (!this.db) await this.init(); - const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const arrayBuffer = await file.arrayBuffer(); const storedFile: StoredFile = { - id, + id: fileId, // Use provided UUID name: file.name, type: file.type, size: file.size, @@ -106,8 +105,8 @@ class FileStorageService { // Debug logging console.log('Object store keyPath:', store.keyPath); - console.log('Storing file:', { - id: storedFile.id, + console.log('Storing file with UUID:', { + id: storedFile.id, // Now a UUID from FileContext name: storedFile.name, hasData: !!storedFile.data, dataSize: storedFile.data.byteLength diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index c887c093b..0b5513c2f 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -1,11 +1,29 @@ /** - * Enhanced file types for IndexedDB storage + * Enhanced file types for IndexedDB storage with UUID system + * Extends File interface for compatibility with existing utilities */ export interface FileWithUrl extends File { - id?: string; - url?: string; + id: string; // Required UUID from FileContext + url?: string; // Blob URL for display thumbnail?: string; + contentHash?: string; // SHA-256 content hash + hashStatus?: 'pending' | 'completed' | 'failed'; + storedInIndexedDB?: boolean; +} + +/** + * Metadata-only version for efficient recent files loading + */ +export interface FileMetadata { + id: string; + name: string; + type: string; + size: number; + lastModified: number; + thumbnail?: string; + contentHash?: string; + hashStatus?: 'pending' | 'completed' | 'failed'; storedInIndexedDB?: boolean; } diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 0037287d8..ddaae067d 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -19,6 +19,8 @@ export interface FileRecord { thumbnailUrl?: string; blobUrl?: string; createdAt: number; + contentHash?: string; // Optional content hash for deduplication + hashStatus?: 'pending' | 'completed' | 'failed'; // Hash computation status processedFile?: { pages: Array<{ thumbnail?: string; @@ -34,10 +36,66 @@ export interface FileContextNormalizedFiles { byId: Record; } -// Helper functions +// Helper functions - UUID-based primary keys (zero collisions, synchronous) +export function createFileId(): FileId { + // Use crypto.randomUUID for authoritative primary key + if (typeof window !== 'undefined' && window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + // Fallback for environments without randomUUID + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +// Legacy support - now just delegates to createFileId export function createStableFileId(file: File): FileId { - // Use existing ID if file already has one, otherwise create stable ID from metadata - return (file as any).id || `${file.name}-${file.size}-${file.lastModified}`; + // Don't mutate File objects - always return new UUID + return createFileId(); +} + +// Multi-region content hash for deduplication (head + middle + tail) +export async function computeContentHash(file: File): Promise { + try { + const fileSize = file.size; + const chunkSize = 32 * 1024; // 32KB chunks + const chunks: ArrayBuffer[] = []; + + // Head chunk (first 32KB) + chunks.push(await file.slice(0, Math.min(chunkSize, fileSize)).arrayBuffer()); + + // Middle chunk (if file is large enough) + if (fileSize > chunkSize * 2) { + const middleStart = Math.floor(fileSize / 2) - Math.floor(chunkSize / 2); + chunks.push(await file.slice(middleStart, middleStart + chunkSize).arrayBuffer()); + } + + // Tail chunk (last 32KB, if different from head) + if (fileSize > chunkSize) { + const tailStart = Math.max(chunkSize, fileSize - chunkSize); + chunks.push(await file.slice(tailStart, fileSize).arrayBuffer()); + } + + // Combine all chunks + const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const combined = new Uint8Array(totalSize); + let offset = 0; + + for (const chunk of chunks) { + combined.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + // Hash the combined chunks + const hashBuffer = await window.crypto.subtle.digest('SHA-256', combined); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } catch (error) { + console.warn('Content hash calculation failed:', error); + return null; + } } export function toFileRecord(file: File, id?: FileId): FileRecord { @@ -53,17 +111,30 @@ export function toFileRecord(file: File, id?: FileId): FileRecord { } export function revokeFileResources(record: FileRecord): void { - if (record.thumbnailUrl) { - URL.revokeObjectURL(record.thumbnailUrl); + // Only revoke blob: URLs to prevent errors on other schemes + if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(record.thumbnailUrl); + } catch (error) { + console.warn('Failed to revoke thumbnail URL:', error); + } } - if (record.blobUrl) { - URL.revokeObjectURL(record.blobUrl); + if (record.blobUrl && record.blobUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(record.blobUrl); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } } // Clean up processed file thumbnails if (record.processedFile?.pages) { record.processedFile.pages.forEach(page => { if (page.thumbnail && page.thumbnail.startsWith('blob:')) { - URL.revokeObjectURL(page.thumbnail); + try { + URL.revokeObjectURL(page.thumbnail); + } catch (error) { + console.warn('Failed to revoke page thumbnail URL:', error); + } } }); } @@ -132,7 +203,7 @@ export interface FileContextState { // Action types for reducer pattern export type FileContextAction = // File management actions - | { type: 'ADD_FILES'; payload: { files: File[] } } + | { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } } | { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } } | { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial } } @@ -155,6 +226,7 @@ export interface FileContextActions { // File management - lightweight actions only addFiles: (files: File[]) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void; + updateFileRecord: (id: FileId, updates: Partial) => void; clearAllFiles: () => void; // Navigation diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index 040fc14c7..a6e53c174 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -19,7 +19,7 @@ export function formatFileSize(bytes: number): string { /** * Get file date as string */ -export function getFileDate(file: File): string { +export function getFileDate(file: File | { lastModified: number }): string { if (file.lastModified) { return new Date(file.lastModified).toLocaleString(); } @@ -29,7 +29,7 @@ export function getFileDate(file: File): string { /** * Get file size as string (legacy method for backward compatibility) */ -export function getFileSize(file: File): string { +export function getFileSize(file: File | { size: number }): string { if (!file.size) return "Unknown"; return formatFileSize(file.size); }