diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index b6b9a7162..0a4081e30 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -154,6 +154,7 @@ const FileManager: React.FC = ({ selectedTool }) => { >(new Map()); @@ -67,17 +72,43 @@ export function FileContextProvider({ dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); }, []); - // File operations using unified addFiles helper + // File operations using unified addFiles helper with persistence const addRawFiles = useCallback(async (files: File[]): Promise => { - return addFiles('raw', { files }, stateRef, filesRef, dispatch); - }, []); + // Get IndexedDB metadata for comprehensive deduplication + let indexedDBMetadata: Array<{ name: string; size: number; lastModified: number }> | undefined; + if (indexedDB && enablePersistence) { + try { + const metadata = await indexedDB.loadAllMetadata(); + indexedDBMetadata = metadata.map(m => ({ name: m.name, size: m.size, lastModified: m.lastModified })); + } catch (error) { + console.warn('Failed to load IndexedDB metadata for deduplication:', error); + } + } + + const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, indexedDBMetadata); + + // Persist to IndexedDB if enabled - pass existing thumbnail to prevent double generation + if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { + await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => { + try { + await indexedDB.saveFile(file, id, thumbnail); + } catch (error) { + console.error('Failed to persist file to IndexedDB:', file.name, error); + } + })); + } + + return addedFilesWithIds.map(({ file }) => file); + }, [indexedDB, enablePersistence]); const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise => { - return addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch); + const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch); + return result.map(({ file }) => file); }, []); const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>): Promise => { - return addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch); + const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch); + return result.map(({ file }) => file); }, []); // Action creators @@ -124,14 +155,34 @@ export function FileContextProvider({ addFiles: addRawFiles, addProcessedFiles, addStoredFiles, - removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => - lifecycleManager.removeFiles(fileIds, stateRef), + removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => { + // Remove from memory and cleanup resources + lifecycleManager.removeFiles(fileIds, stateRef); + + // Remove from IndexedDB if enabled + if (indexedDB && enablePersistence && deleteFromStorage !== false) { + try { + await indexedDB.deleteMultiple(fileIds); + } catch (error) { + console.error('Failed to delete files from IndexedDB:', error); + } + } + }, updateFileRecord: (fileId: FileId, updates: Partial) => lifecycleManager.updateFileRecord(fileId, updates, stateRef), - clearAllFiles: () => { + clearAllFiles: async () => { lifecycleManager.cleanupAllFiles(); filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); + + // Clear IndexedDB if enabled + if (indexedDB && enablePersistence) { + try { + await indexedDB.clearAll(); + } catch (error) { + console.error('Failed to clear IndexedDB:', error); + } + } }, // Pinned files functionality with File object wrappers pinFile: pinFileWrapper, @@ -142,7 +193,46 @@ export function FileContextProvider({ trackPdfDocument: lifecycleManager.trackPdfDocument, cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef), scheduleCleanup: (fileId: string, delay?: number) => - lifecycleManager.scheduleCleanup(fileId, delay, stateRef) + lifecycleManager.scheduleCleanup(fileId, delay, stateRef), + + // Persistence operations - load file list from IndexedDB + loadFromPersistence: async () => { + if (!indexedDB || !enablePersistence) return; + + try { + // Load metadata to populate file list (actual File objects loaded on-demand) + const metadata = await indexedDB.loadAllMetadata(); + if (metadata.length === 0) { + if (DEBUG) console.log('📄 No files found in persistence'); + return; + } + + if (DEBUG) { + console.log(`📄 Loading ${metadata.length} files from persistence`); + } + + // Create FileRecords from metadata - File objects loaded when needed + const fileRecords = metadata.map(meta => ({ + id: meta.id, + name: meta.name, + size: meta.size, + type: meta.type, + lastModified: meta.lastModified, + thumbnailUrl: meta.thumbnail, + isPinned: false, + createdAt: Date.now() + })); + + // Add to state so file manager can show them + dispatch({ + type: 'ADD_FILES', + payload: { fileRecords } + }); + + } catch (error) { + console.error('Failed to load files from persistence:', error); + } + } }), [ baseActions, addRawFiles, @@ -152,7 +242,9 @@ export function FileContextProvider({ setHasUnsavedChanges, consumeFilesWrapper, pinFileWrapper, - unpinFileWrapper + unpinFileWrapper, + indexedDB, + enablePersistence ]); // Split context values to minimize re-renders @@ -166,6 +258,13 @@ export function FileContextProvider({ dispatch }), [actions]); + // Load files from persistence on mount + useEffect(() => { + if (enablePersistence && indexedDB) { + actions.loadFromPersistence(); + } + }, [enablePersistence, indexedDB]); // Only run once on mount + // Cleanup on unmount useEffect(() => { return () => { @@ -183,6 +282,35 @@ export function FileContextProvider({ ); } +// Outer provider component that wraps with IndexedDBProvider +export function FileContextProvider({ + children, + enableUrlSync = true, + enablePersistence = true +}: FileContextProviderProps) { + if (enablePersistence) { + return ( + + + {children} + + + ); + } else { + return ( + + {children} + + ); + } +} + // Export all hooks from the fileHooks module export { useFileState, diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index a115d10b3..b39f04855 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -35,7 +35,8 @@ const FileManagerContext = createContext(null); interface FileManagerProviderProps { children: React.ReactNode; recentFiles: FileMetadata[]; - onFilesSelected: (files: FileMetadata[]) => void; + onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files + onNewFilesSelect: (files: File[]) => void; // For uploading new local files onClose: () => void; isFileSupported: (fileName: string) => boolean; isOpen: boolean; @@ -49,6 +50,7 @@ export const FileManagerProvider: React.FC = ({ children, recentFiles, onFilesSelected, + onNewFilesSelect, onClose, isFileSupported, isOpen, @@ -127,22 +129,8 @@ export const FileManagerProvider: React.FC = ({ const files = Array.from(event.target.files || []); if (files.length > 0) { try { - // 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 { - id: `temp-${Date.now()}-${Math.random()}`, // Temporary ID until stored - name: file.name, - size: file.size, - lastModified: file.lastModified, - type: file.type, - thumbnail: undefined, - }; - }); - - onFilesSelected(fileMetadatas); + // For local file uploads, pass File objects directly to FileContext + onNewFilesSelect(files); await refreshRecentFiles(); onClose(); } catch (error) { @@ -150,7 +138,7 @@ export const FileManagerProvider: React.FC = ({ } } event.target.value = ''; - }, [storeFile, onFilesSelected, refreshRecentFiles, onClose]); + }, [onNewFilesSelect, refreshRecentFiles, onClose]); // Cleanup blob URLs when component unmounts useEffect(() => { diff --git a/frontend/src/contexts/IndexedDBContext.tsx b/frontend/src/contexts/IndexedDBContext.tsx new file mode 100644 index 000000000..55885ca86 --- /dev/null +++ b/frontend/src/contexts/IndexedDBContext.tsx @@ -0,0 +1,206 @@ +/** + * IndexedDBContext - Clean persistence layer for file storage + * Integrates with FileContext to provide transparent file persistence + */ + +import React, { createContext, useContext, useCallback, useRef } from 'react'; + +const DEBUG = process.env.NODE_ENV === 'development'; +import { fileStorage, StoredFile } from '../services/fileStorage'; +import { FileId } from '../types/fileContext'; +import { FileMetadata } from '../types/file'; +import { generateThumbnailForFile } from '../utils/thumbnailUtils'; + +interface IndexedDBContextValue { + // Core CRUD operations + saveFile: (file: File, fileId: FileId, thumbnail?: string) => Promise; + loadFile: (fileId: FileId) => Promise; + loadMetadata: (fileId: FileId) => Promise; + deleteFile: (fileId: FileId) => Promise; + + // Batch operations + loadAllMetadata: () => Promise; + deleteMultiple: (fileIds: FileId[]) => Promise; + clearAll: () => Promise; + + // Utilities + getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>; +} + +const IndexedDBContext = createContext(null); + +interface IndexedDBProviderProps { + children: React.ReactNode; +} + +export function IndexedDBProvider({ children }: IndexedDBProviderProps) { + // LRU File cache to avoid repeated ArrayBuffer→File conversions + const fileCache = useRef(new Map()); + const MAX_CACHE_SIZE = 50; // Maximum number of files to cache + + // LRU cache management + const evictLRUEntries = useCallback(() => { + if (fileCache.current.size <= MAX_CACHE_SIZE) return; + + // Convert to array and sort by last accessed time (oldest first) + const entries = Array.from(fileCache.current.entries()) + .sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed); + + // Remove the least recently used entries + const toRemove = entries.slice(0, fileCache.current.size - MAX_CACHE_SIZE); + toRemove.forEach(([fileId]) => { + fileCache.current.delete(fileId); + }); + + if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`); + }, []); + + const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise => { + // Use existing thumbnail or generate new one + const thumbnail = existingThumbnail || await generateThumbnailForFile(file); + + // Store in IndexedDB + const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); + + // Cache the file object for immediate reuse + fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); + evictLRUEntries(); + + // Return metadata + return { + id: fileId, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + thumbnail + }; + }, []); + + const loadFile = useCallback(async (fileId: FileId): Promise => { + // Check cache first + const cached = fileCache.current.get(fileId); + if (cached) { + // Update last accessed time for LRU + cached.lastAccessed = Date.now(); + return cached.file; + } + + // Load from IndexedDB using the internal fileStorage (which wraps indexedDBManager) + const storedFile = await fileStorage.getFile(fileId); + if (!storedFile) { + if (DEBUG) console.log(`📁 File not found in IndexedDB: ${fileId}`); + return null; + } + + // Reconstruct File object + const file = new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }); + + // Cache for future use with LRU eviction + fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); + evictLRUEntries(); + + return file; + }, [evictLRUEntries]); + + const loadMetadata = useCallback(async (fileId: FileId): Promise => { + // Try to get from cache first (no IndexedDB hit) + const cached = fileCache.current.get(fileId); + if (cached) { + const file = cached.file; + return { + id: fileId, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified + }; + } + + // Load metadata from IndexedDB (efficient - no data field) + const metadata = await fileStorage.getAllFileMetadata(); + const fileMetadata = metadata.find(m => m.id === fileId); + + if (!fileMetadata) return null; + + return { + id: fileMetadata.id, + name: fileMetadata.name, + type: fileMetadata.type, + size: fileMetadata.size, + lastModified: fileMetadata.lastModified, + thumbnail: fileMetadata.thumbnail + }; + }, []); + + const deleteFile = useCallback(async (fileId: FileId): Promise => { + // Remove from cache + fileCache.current.delete(fileId); + + // Remove from IndexedDB + await fileStorage.deleteFile(fileId); + }, []); + + const loadAllMetadata = useCallback(async (): Promise => { + const metadata = await fileStorage.getAllFileMetadata(); + + return metadata.map(m => ({ + id: m.id, + name: m.name, + type: m.type, + size: m.size, + lastModified: m.lastModified, + thumbnail: m.thumbnail + })); + }, []); + + const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise => { + // Remove from cache + fileIds.forEach(id => fileCache.current.delete(id)); + + // Remove from IndexedDB in parallel + await Promise.all(fileIds.map(id => fileStorage.deleteFile(id))); + }, []); + + const clearAll = useCallback(async (): Promise => { + // Clear cache + fileCache.current.clear(); + + // Clear IndexedDB + await fileStorage.clearAll(); + }, []); + + const getStorageStats = useCallback(async () => { + return await fileStorage.getStorageStats(); + }, []); + + // No periodic cleanup needed - LRU eviction happens on-demand when cache fills + + const value: IndexedDBContextValue = { + saveFile, + loadFile, + loadMetadata, + deleteFile, + loadAllMetadata, + deleteMultiple, + clearAll, + getStorageStats + }; + + return ( + + {children} + + ); +} + +export function useIndexedDB() { + const context = useContext(IndexedDBContext); + if (!context) { + throw new Error('useIndexedDB must be used within an IndexedDBProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index b99b84d02..cc251f7bf 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -14,7 +14,7 @@ import { import { FileMetadata } from '../../types/file'; import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; import { fileProcessingService } from '../../services/fileProcessingService'; -import { buildQuickKeySet } from './fileSelectors'; +import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -59,14 +59,21 @@ export async function addFiles( options: AddFileOptions, stateRef: React.MutableRefObject, filesRef: React.MutableRefObject>, - dispatch: React.Dispatch -): Promise { + dispatch: React.Dispatch, + indexedDBMetadata?: Array<{ name: string; size: number; lastModified: number }> +): Promise> { const fileRecords: FileRecord[] = []; - const addedFiles: File[] = []; + const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = []; // Build quickKey lookup from existing files for deduplication const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); + // Add IndexedDB quickKeys if metadata provided for comprehensive deduplication + if (indexedDBMetadata) { + const indexedDBQuickKeys = buildQuickKeySetFromMetadata(indexedDBMetadata); + indexedDBQuickKeys.forEach(key => existingQuickKeys.add(key)); + } + switch (kind) { case 'raw': { const { files = [] } = options; @@ -112,7 +119,7 @@ export async function addFiles( existingQuickKeys.add(quickKey); fileRecords.push(record); - addedFiles.push(file); + addedFiles.push({ file, id: fileId, thumbnail }); // Start background processing for validation only (we already have thumbnail and page count) fileProcessingService.processFile(file, fileId).then(result => { @@ -159,7 +166,7 @@ export async function addFiles( existingQuickKeys.add(quickKey); fileRecords.push(record); - addedFiles.push(file); + addedFiles.push({ file, id: fileId, thumbnail }); } break; } @@ -197,7 +204,7 @@ export async function addFiles( existingQuickKeys.add(quickKey); fileRecords.push(record); - addedFiles.push(file); + addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); } break; } diff --git a/frontend/src/contexts/file/fileSelectors.ts b/frontend/src/contexts/file/fileSelectors.ts index 55ef48640..5579aa3c7 100644 --- a/frontend/src/contexts/file/fileSelectors.ts +++ b/frontend/src/contexts/file/fileSelectors.ts @@ -93,7 +93,22 @@ export function createFileSelectors( export function buildQuickKeySet(fileRecords: Record): Set { const quickKeys = new Set(); Object.values(fileRecords).forEach(record => { - quickKeys.add(record.quickKey); + if (record.quickKey) { + quickKeys.add(record.quickKey); + } + }); + return quickKeys; +} + +/** + * Helper for building quickKey sets from IndexedDB metadata + */ +export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; size: number; lastModified: number }>): Set { + const quickKeys = new Set(); + metadata.forEach(meta => { + // Format: name|size|lastModified (same as createQuickKey) + const quickKey = `${meta.name}|${meta.size}|${meta.lastModified}`; + quickKeys.add(quickKey); }); return quickKeys; } diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index c3330bf23..541b4ead5 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -1,83 +1,91 @@ import { useState, useCallback } from 'react'; -import { fileStorage } from '../services/fileStorage'; +import { useIndexedDB } from '../contexts/IndexedDBContext'; import { FileWithUrl, FileMetadata } from '../types/file'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; export const useFileManager = () => { const [loading, setLoading] = useState(false); + const indexedDB = useIndexedDB(); 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'); + if (!indexedDB) { + throw new Error('IndexedDB context not available'); } - const storedFile = await fileStorage.getFile(fileMetadata.id); - if (storedFile) { - const file = new File([storedFile.data], storedFile.name, { - type: storedFile.type, - lastModified: storedFile.lastModified - }); - // NO FILE MUTATION - Return clean File, let FileContext manage ID - return file; + + // Try ID first (preferred) + if (fileMetadata.id) { + const file = await indexedDB.loadFile(fileMetadata.id); + if (file) { + return file; + } } - - throw new Error('File not found in storage'); - }, []); + throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`); + }, [indexedDB]); const loadRecentFiles = useCallback(async (): Promise => { setLoading(true); try { + if (!indexedDB) { + return []; + } + // Get metadata only (no file data) for performance - const storedFileMetadata = await fileStorage.getAllFileMetadata(); + const storedFileMetadata = await indexedDB.loadAllMetadata(); 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 - })); + // Already in correct FileMetadata format + return sortedFiles; } catch (error) { console.error('Failed to load recent files:', error); return []; } finally { setLoading(false); } - }, []); + }, [indexedDB]); 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'); } + if (!indexedDB) { + throw new Error('IndexedDB context not available'); + } try { - await fileStorage.deleteFile(file.id); + await indexedDB.deleteFile(file.id); setFiles(files.filter((_, i) => i !== index)); } catch (error) { console.error('Failed to remove file:', error); throw error; } - }, []); + }, [indexedDB]); const storeFile = useCallback(async (file: File, fileId: string) => { + if (!indexedDB) { + throw new Error('IndexedDB context not available'); + } try { - // Generate thumbnail for the file - const thumbnail = await generateThumbnailForFile(file); + // Store file with provided UUID from FileContext (thumbnail generated internally) + const metadata = await indexedDB.saveFile(file, fileId); - // Store file with provided UUID from FileContext - const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); - - // NO FILE MUTATION - Return StoredFile, FileContext manages mapping - return storedFile; + // Convert file to ArrayBuffer for StoredFile interface compatibility + const arrayBuffer = await file.arrayBuffer(); + + // Return StoredFile format for compatibility with old API + return { + id: fileId, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + data: arrayBuffer, + thumbnail: metadata.thumbnail + }; } catch (error) { console.error('Failed to store file:', error); throw error; } - }, []); + }, [indexedDB]); const createFileSelectionHandlers = useCallback(( selectedFiles: string[], @@ -134,12 +142,18 @@ export const useFileManager = () => { }, [convertToFile]); const touchFile = useCallback(async (id: string) => { + if (!indexedDB) { + console.warn('IndexedDB context not available for touch operation'); + return; + } try { - await fileStorage.touchFile(id); + // Update access time - this will be handled by the cache in IndexedDBContext + // when the file is loaded, so we can just load it briefly to "touch" it + await indexedDB.loadFile(id); } catch (error) { console.error('Failed to touch file:', error); } - }, []); + }, [indexedDB]); return { loading, diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index d681f81e6..edc7d19d2 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { FileMetadata } from "../types/file"; -import { fileStorage } from "../services/fileStorage"; +import { useIndexedDB } from "../contexts/IndexedDBContext"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; /** @@ -28,6 +28,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { } { const [thumb, setThumb] = useState(null); const [generating, setGenerating] = useState(false); + const indexedDB = useIndexedDB(); useEffect(() => { let cancelled = false; @@ -44,38 +45,21 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { return; } - // Second priority: generate thumbnail for any file type + // Second priority: generate thumbnail for files under 100MB if (file.size < 100 * 1024 * 1024 && !generating) { setGenerating(true); try { let fileObject: File; - // Handle IndexedDB files vs regular File objects - if (file.storedInIndexedDB && file.id) { - // For IndexedDB files, recreate File object from stored data - const storedFile = await fileStorage.getFile(file.id); - if (!storedFile) { + // Try to load file from IndexedDB using new context + if (file.id && indexedDB) { + const loadedFile = await indexedDB.loadFile(file.id); + if (!loadedFile) { throw new Error('File not found in IndexedDB'); } - fileObject = new File([storedFile.data], storedFile.name, { - type: storedFile.type, - lastModified: storedFile.lastModified - }); - } else if ((file as any /* Fix me */).file) { - // For FileWithUrl objects that have a File object - fileObject = (file as any /* Fix me */).file; - } else if (file.id) { - // Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing - const storedFile = await fileStorage.getFile(file.id); - if (!storedFile) { - throw new Error('File not found in IndexedDB and no File object available'); - } - fileObject = new File([storedFile.data], storedFile.name, { - type: storedFile.type, - lastModified: storedFile.lastModified - }); + fileObject = loadedFile; } else { - throw new Error('File object not available and no ID for IndexedDB lookup'); + throw new Error('File ID not available or IndexedDB context not available'); } // Use the universal thumbnail generator @@ -92,14 +76,14 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { if (!cancelled) setGenerating(false); } } else { - // Large files - generate placeholder + // Large files - no thumbnail setThumb(null); } } loadThumbnail(); return () => { cancelled = true; }; - }, [file, file?.thumbnail, file?.id]); + }, [file, file?.thumbnail, file?.id, indexedDB, generating]); return { thumbnail: thumb, isGenerating: generating }; } diff --git a/frontend/src/services/fileOperationsService.ts b/frontend/src/services/fileOperationsService.ts deleted file mode 100644 index 15ac11926..000000000 --- a/frontend/src/services/fileOperationsService.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { FileWithUrl, FileMetadata } from "../types/file"; -import { fileStorage, StorageStats } from "./fileStorage"; -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 - * Contains all IndexedDB operations and file management logic - */ -export const fileOperationsService = { - - /** - * Load storage statistics - */ - async loadStorageStats(): Promise { - try { - return await fileStorage.getStorageStats(); - } catch (error) { - console.error('Failed to load storage stats:', error); - return null; - } - }, - - /** - * Force reload files from IndexedDB - */ - async forceReloadFiles(): Promise { - try { - return await loadFilesFromIndexedDB(); - } catch (error) { - console.error('Failed to force reload files:', error); - return []; - } - }, - - /** - * Load existing files from IndexedDB if not already loaded - */ - async loadExistingFiles( - filesLoaded: boolean, - currentFiles: FileWithUrl[] - ): Promise { - if (filesLoaded && currentFiles.length > 0) { - return currentFiles; - } - - try { - // fileStorage.init() no longer needed - using centralized IndexedDB manager - const storedFiles = await fileStorage.getAllFileMetadata(); - - // Detect if IndexedDB was purged by comparing with current UI state - if (currentFiles.length > 0 && storedFiles.length === 0) { - console.warn('IndexedDB appears to have been purged - clearing UI state'); - return []; - } - - return await loadFilesFromIndexedDB(); - } catch (error) { - console.error('Failed to load existing files:', error); - return []; - } - }, - - /** - * Upload files to IndexedDB with thumbnail generation - */ - async uploadFiles( - uploadedFiles: File[], - useIndexedDB: boolean - ): Promise { - const newFiles: FileWithUrl[] = []; - - for (const file of uploadedFiles) { - if (useIndexedDB) { - try { - console.log('Storing file in IndexedDB:', file.name); - - // Generate thumbnail only during upload - const thumbnail = await generateThumbnailForFile(file); - - const fileId = createFileId(); // Generate UUID - const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); - console.log('File stored with ID:', storedFile.id); - - // 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 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 with UUID - const fileId = createFileId(); - const enhancedFile: FileWithUrl = Object.assign(file, { - id: fileId, - url: URL.createObjectURL(file), - storedInIndexedDB: false - }); - newFiles.push(enhancedFile); - } - } - - return newFiles; - }, - - /** - * Remove a file from storage - */ - async removeFile(file: FileWithUrl): Promise { - // Clean up blob URL - if (file.url && !file.url.startsWith('indexeddb:')) { - URL.revokeObjectURL(file.url); - } - - // Remove from IndexedDB if stored there - if (file.storedInIndexedDB && file.id) { - try { - await fileStorage.deleteFile(file.id); - } catch (error) { - console.error('Failed to delete file from IndexedDB:', error); - } - } - }, - - /** - * Clear all files from storage - */ - async clearAllFiles(files: FileWithUrl[]): Promise { - // Clean up all blob URLs - cleanupFileUrls(files); - - // Clear IndexedDB - try { - await fileStorage.clearAll(); - } catch (error) { - console.error('Failed to clear IndexedDB:', error); - } - }, - - /** - * Create blob URL for file viewing - */ - async createBlobUrlForFile(file: FileWithUrl): Promise { - // For large files, use IndexedDB direct access to avoid memory issues - const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB - if (file.size > FILE_SIZE_LIMIT) { - console.warn(`File ${file.name} is too large for blob URL. Use direct IndexedDB access.`); - return `indexeddb:${file.id}`; - } - - // For all files, avoid persistent blob URLs - if (file.storedInIndexedDB && file.id) { - const storedFile = await fileStorage.getFile(file.id); - if (storedFile) { - return fileStorage.createBlobUrl(storedFile); - } - } - - // 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); - }, - - /** - * Check for IndexedDB purge - */ - async checkForPurge(currentFiles: FileWithUrl[]): Promise { - if (currentFiles.length === 0) return false; - - try { - // fileStorage.init() no longer needed - using centralized IndexedDB manager - const storedFiles = await fileStorage.getAllFileMetadata(); - return storedFiles.length === 0; // Purge detected if no files in storage but UI shows files - } catch (error) { - console.error('Error checking for purge:', error); - return true; // Assume purged if can't access IndexedDB - } - }, - - /** - * Update storage stats incrementally (re-export utility for convenience) - */ - updateStorageStatsIncremental -}; \ No newline at end of file diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index 4ece16836..462490fdf 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -1,8 +1,12 @@ /** - * Enhanced file types for IndexedDB storage with UUID system - * Extends File interface for compatibility with existing utilities + * File types for the new architecture + * FileContext uses pure File objects with separate ID tracking */ +/** + * @deprecated Use pure File objects with FileContext for ID management + * This interface exists for backward compatibility only + */ export interface FileWithUrl extends File { id: string; // Required UUID from FileContext url?: string; // Blob URL for display @@ -11,7 +15,8 @@ export interface FileWithUrl extends File { } /** - * Metadata-only version for efficient recent files loading + * File metadata for efficient operations without loading full file data + * Used by IndexedDBContext and FileContext for lazy file loading */ export interface FileMetadata { id: string; @@ -20,6 +25,7 @@ export interface FileMetadata { size: number; lastModified: number; thumbnail?: string; + /** @deprecated Legacy compatibility - will be removed */ storedInIndexedDB?: boolean; } diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 1f1e1e509..918d5ad17 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -46,11 +46,12 @@ export interface FileRecord { size: number; type: string; lastModified: number; - quickKey: string; // Fast deduplication key: name|size|lastModified + quickKey?: string; // Fast deduplication key: name|size|lastModified thumbnailUrl?: string; blobUrl?: string; - createdAt: number; + createdAt?: number; processedFile?: ProcessedFileMetadata; + isPinned?: boolean; // Note: File object stored in provider ref, not in state } @@ -218,9 +219,9 @@ export interface FileContextActions { addFiles: (files: File[]) => Promise; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise; - removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void; + removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; updateFileRecord: (id: FileId, updates: Partial) => void; - clearAllFiles: () => void; + clearAllFiles: () => Promise; // File pinning pinFile: (file: File) => void; @@ -247,6 +248,9 @@ export interface FileContextActions { trackPdfDocument: (key: string, pdfDoc: any) => void; scheduleCleanup: (fileId: string, delay?: number) => void; cleanupFile: (fileId: string) => void; + + // Persistence operations + loadFromPersistence: () => Promise; } // File selectors (separate from actions to avoid re-renders) diff --git a/frontend/src/types/tool.ts b/frontend/src/types/tool.ts index faa283953..463b6a63e 100644 --- a/frontend/src/types/tool.ts +++ b/frontend/src/types/tool.ts @@ -38,11 +38,6 @@ export interface ToolConfiguration { supportedFormats?: string[]; } -export interface ToolConfiguration { - maxFiles: number; - supportedFormats?: string[]; -} - export interface Tool { id: string; name: string; diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index 994149c07..b7e3a429c 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,35 +1,4 @@ -import { FileWithUrl } from "../types/file"; -import { StoredFile, fileStorage } from "../services/fileStorage"; - -/** - * @deprecated File objects no longer have mutated ID properties. - * The new system maintains pure File objects and tracks IDs separately in FileContext. - * Use FileContext selectors to get file IDs instead. - */ -export function getFileId(file: File): string | null { - const legacyId = (file as File & { id?: string }).id; - if (legacyId) { - console.warn('DEPRECATED: getFileId() found legacy mutated File object. Use FileContext selectors instead.'); - } - return legacyId || null; -} - -/** - * Get file ID for a File object using FileContext state (new system) - * @param file File object to find ID for - * @param fileState Current FileContext state - * @returns File ID or null if not found - */ -export function getFileIdFromContext(file: File, fileIds: string[], getFile: (id: string) => File | undefined): string | null { - // Find the file ID by comparing File objects - for (const id of fileIds) { - const contextFile = getFile(id); - if (contextFile === file) { - return id; - } - } - return null; -} +// Pure utility functions for file operations /** * Consolidated file size formatting utility @@ -60,102 +29,7 @@ export function getFileSize(file: File | { size: number }): string { return formatFileSize(file.size); } -/** - * Create enhanced file object from stored file metadata - * This eliminates the repeated pattern in FileManager - */ -export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?: string): FileWithUrl { - const enhancedFile: FileWithUrl = { - id: storedFile.id, - storedInIndexedDB: true, - url: undefined, // Don't create blob URL immediately to save memory - thumbnail: thumbnail || storedFile.thumbnail, - // File metadata - name: storedFile.name, - size: storedFile.size, - type: storedFile.type, - lastModified: storedFile.lastModified, - webkitRelativePath: '', - // Lazy-loading File interface methods - arrayBuffer: async () => { - const data = await fileStorage.getFileData(storedFile.id); - if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`); - return data; - }, - bytes: async () => { - return new Uint8Array(); - }, - slice: (start?: number, end?: number, contentType?: string) => { - // Return a promise-based slice that loads from IndexedDB - return new Blob([], { type: contentType || storedFile.type }); - }, - stream: () => { - throw new Error('Stream not implemented for IndexedDB files'); - }, - text: async () => { - const data = await fileStorage.getFileData(storedFile.id); - if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`); - return new TextDecoder().decode(data); - }, - } as FileWithUrl; - return enhancedFile; -} - -/** - * Load files from IndexedDB and convert to enhanced file objects - */ -export async function loadFilesFromIndexedDB(): Promise { - try { - // fileStorage.init() no longer needed - using centralized IndexedDB manager - const storedFiles = await fileStorage.getAllFileMetadata(); - - if (storedFiles.length === 0) { - return []; - } - - const restoredFiles: FileWithUrl[] = storedFiles - .filter(storedFile => { - // Filter out corrupted entries - return storedFile && - storedFile.name && - typeof storedFile.size === 'number'; - }) - .map(storedFile => { - try { - return createEnhancedFileFromStored(storedFile as any); - } catch (error) { - console.error('Failed to restore file:', storedFile?.name || 'unknown', error); - return null; - } - }) - .filter((file): file is FileWithUrl => file !== null); - - return restoredFiles; - } catch (error) { - console.error('Failed to load files from IndexedDB:', error); - return []; - } -} - -/** - * Clean up blob URLs from file objects - */ -export function cleanupFileUrls(files: FileWithUrl[]): void { - files.forEach(file => { - if (file.url && !file.url.startsWith('indexeddb:')) { - URL.revokeObjectURL(file.url); - } - }); -} - -/** - * Check if file should use blob URL or IndexedDB direct access - */ -export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean { - const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB - return file.size > FILE_SIZE_LIMIT; -} /** * Detects and normalizes file extension from filename @@ -177,29 +51,3 @@ export function detectFileExtension(filename: string): string { return extension; } - -/** - * Gets the filename without extension - * @param filename - The filename to process - * @returns Filename without extension - */ -export function getFilenameWithoutExtension(filename: string): string { - if (!filename || typeof filename !== 'string') return ''; - - const parts = filename.split('.'); - if (parts.length <= 1) return filename; - - // Return all parts except the last one (extension) - return parts.slice(0, -1).join('.'); -} - -/** - * Creates a new filename with a different extension - * @param filename - Original filename - * @param newExtension - New extension (without dot) - * @returns New filename with the specified extension - */ -export function changeFileExtension(filename: string, newExtension: string): string { - const nameWithoutExt = getFilenameWithoutExtension(filename); - return `${nameWithoutExt}.${newExtension}`; -}