Indexxedb streamlining and bugfixing

This commit is contained in:
Reece Browne 2025-08-19 21:29:37 +01:00
parent 511bdee7db
commit 0f40f0187b
13 changed files with 466 additions and 475 deletions

View File

@ -154,6 +154,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
<FileManagerProvider
recentFiles={recentFiles}
onFilesSelected={handleFilesSelected}
onNewFilesSelect={handleNewFileUpload}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}

View File

@ -25,21 +25,26 @@ import {
// Import modular components
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
import { createFileSelectors } from './file/fileSelectors';
import { createFileSelectors, buildQuickKeySetFromMetadata } from './file/fileSelectors';
import { addFiles, consumeFiles, createFileActions } from './file/fileActions';
import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
const DEBUG = process.env.NODE_ENV === 'development';
// Provider component
export function FileContextProvider({
// Inner provider component that has access to IndexedDB
function FileContextInner({
children,
enableUrlSync = true,
enablePersistence = true
}: FileContextProviderProps) {
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
// IndexedDB context for persistence
const indexedDB = enablePersistence ? useIndexedDB() : null;
// File ref map - stores File objects outside React state
const filesRef = useRef<Map<FileId, File>>(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<File[]> => {
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<File[]> => {
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<File[]> => {
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<FileRecord>) =>
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 (
<IndexedDBProvider>
<FileContextInner
enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence}
>
{children}
</FileContextInner>
</IndexedDBProvider>
);
} else {
return (
<FileContextInner
enableUrlSync={enableUrlSync}
enablePersistence={enablePersistence}
>
{children}
</FileContextInner>
);
}
}
// Export all hooks from the fileHooks module
export {
useFileState,

View File

@ -35,7 +35,8 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(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<FileManagerProviderProps> = ({
children,
recentFiles,
onFilesSelected,
onNewFilesSelect,
onClose,
isFileSupported,
isOpen,
@ -127,22 +129,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
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<FileManagerProviderProps> = ({
}
}
event.target.value = '';
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
// Cleanup blob URLs when component unmounts
useEffect(() => {

View File

@ -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<FileMetadata>;
loadFile: (fileId: FileId) => Promise<File | null>;
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
deleteFile: (fileId: FileId) => Promise<void>;
// Batch operations
loadAllMetadata: () => Promise<FileMetadata[]>;
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>;
// Utilities
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
}
const IndexedDBContext = createContext<IndexedDBContextValue | null>(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<FileId, { file: File; lastAccessed: number }>());
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<FileMetadata> => {
// 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<File | null> => {
// 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<FileMetadata | null> => {
// 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<void> => {
// Remove from cache
fileCache.current.delete(fileId);
// Remove from IndexedDB
await fileStorage.deleteFile(fileId);
}, []);
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
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<void> => {
// 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<void> => {
// 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 (
<IndexedDBContext.Provider value={value}>
{children}
</IndexedDBContext.Provider>
);
}
export function useIndexedDB() {
const context = useContext(IndexedDBContext);
if (!context) {
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
}
return context;
}

View File

@ -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<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>
): Promise<File[]> {
dispatch: React.Dispatch<FileContextAction>,
indexedDBMetadata?: Array<{ name: string; size: number; lastModified: number }>
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
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;
}

View File

@ -93,7 +93,22 @@ export function createFileSelectors(
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
const quickKeys = new Set<string>();
Object.values(fileRecords).forEach(record => {
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<string> {
const quickKeys = new Set<string>();
metadata.forEach(meta => {
// Format: name|size|lastModified (same as createQuickKey)
const quickKey = `${meta.name}|${meta.size}|${meta.lastModified}`;
quickKeys.add(quickKey);
});
return quickKeys;
}

View File

@ -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<File> => {
// 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');
}
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;
if (!indexedDB) {
throw new Error('IndexedDB context not available');
}
throw new Error('File not found in storage');
}, []);
// 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: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
}, [indexedDB]);
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
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);
// Convert file to ArrayBuffer for StoredFile interface compatibility
const arrayBuffer = await file.arrayBuffer();
// NO FILE MUTATION - Return StoredFile, FileContext manages mapping
return storedFile;
// 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,

View File

@ -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<string | null>(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 };
}

View File

@ -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<StorageStats | null> {
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<FileWithUrl[]> {
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<FileWithUrl[]> {
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<FileWithUrl[]> {
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<void> {
// 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<void> {
// 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<string> {
// 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<boolean> {
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
};

View File

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

View File

@ -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<File[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
clearAllFiles: () => void;
clearAllFiles: () => Promise<void>;
// 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<void>;
}
// File selectors (separate from actions to avoid re-renders)

View File

@ -38,11 +38,6 @@ export interface ToolConfiguration {
supportedFormats?: string[];
}
export interface ToolConfiguration {
maxFiles: number;
supportedFormats?: string[];
}
export interface Tool {
id: string;
name: string;

View File

@ -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<FileWithUrl[]> {
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}`;
}