mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Indexxedb streamlining and bugfixing
This commit is contained in:
parent
511bdee7db
commit
0f40f0187b
@ -154,6 +154,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
<FileManagerProvider
|
<FileManagerProvider
|
||||||
recentFiles={recentFiles}
|
recentFiles={recentFiles}
|
||||||
onFilesSelected={handleFilesSelected}
|
onFilesSelected={handleFilesSelected}
|
||||||
|
onNewFilesSelect={handleNewFileUpload}
|
||||||
onClose={closeFilesModal}
|
onClose={closeFilesModal}
|
||||||
isFileSupported={isFileSupported}
|
isFileSupported={isFileSupported}
|
||||||
isOpen={isFilesModalOpen}
|
isOpen={isFilesModalOpen}
|
||||||
|
@ -25,21 +25,26 @@ import {
|
|||||||
|
|
||||||
// Import modular components
|
// Import modular components
|
||||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
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 { addFiles, consumeFiles, createFileActions } from './file/fileActions';
|
||||||
import { FileLifecycleManager } from './file/lifecycle';
|
import { FileLifecycleManager } from './file/lifecycle';
|
||||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||||
|
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
// Provider component
|
|
||||||
export function FileContextProvider({
|
// Inner provider component that has access to IndexedDB
|
||||||
|
function FileContextInner({
|
||||||
children,
|
children,
|
||||||
enableUrlSync = true,
|
enableUrlSync = true,
|
||||||
enablePersistence = true
|
enablePersistence = true
|
||||||
}: FileContextProviderProps) {
|
}: FileContextProviderProps) {
|
||||||
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
|
||||||
|
|
||||||
|
// IndexedDB context for persistence
|
||||||
|
const indexedDB = enablePersistence ? useIndexedDB() : null;
|
||||||
|
|
||||||
// File ref map - stores File objects outside React state
|
// File ref map - stores File objects outside React state
|
||||||
const filesRef = useRef<Map<FileId, File>>(new Map());
|
const filesRef = useRef<Map<FileId, File>>(new Map());
|
||||||
|
|
||||||
@ -67,17 +72,43 @@ export function FileContextProvider({
|
|||||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
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[]> => {
|
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[]> => {
|
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[]> => {
|
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
|
// Action creators
|
||||||
@ -124,14 +155,34 @@ export function FileContextProvider({
|
|||||||
addFiles: addRawFiles,
|
addFiles: addRawFiles,
|
||||||
addProcessedFiles,
|
addProcessedFiles,
|
||||||
addStoredFiles,
|
addStoredFiles,
|
||||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) =>
|
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||||
lifecycleManager.removeFiles(fileIds, stateRef),
|
// 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>) =>
|
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) =>
|
||||||
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
lifecycleManager.updateFileRecord(fileId, updates, stateRef),
|
||||||
clearAllFiles: () => {
|
clearAllFiles: async () => {
|
||||||
lifecycleManager.cleanupAllFiles();
|
lifecycleManager.cleanupAllFiles();
|
||||||
filesRef.current.clear();
|
filesRef.current.clear();
|
||||||
dispatch({ type: 'RESET_CONTEXT' });
|
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
|
// Pinned files functionality with File object wrappers
|
||||||
pinFile: pinFileWrapper,
|
pinFile: pinFileWrapper,
|
||||||
@ -142,7 +193,46 @@ export function FileContextProvider({
|
|||||||
trackPdfDocument: lifecycleManager.trackPdfDocument,
|
trackPdfDocument: lifecycleManager.trackPdfDocument,
|
||||||
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
|
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||||
scheduleCleanup: (fileId: string, delay?: number) =>
|
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,
|
baseActions,
|
||||||
addRawFiles,
|
addRawFiles,
|
||||||
@ -152,7 +242,9 @@ export function FileContextProvider({
|
|||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
consumeFilesWrapper,
|
consumeFilesWrapper,
|
||||||
pinFileWrapper,
|
pinFileWrapper,
|
||||||
unpinFileWrapper
|
unpinFileWrapper,
|
||||||
|
indexedDB,
|
||||||
|
enablePersistence
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Split context values to minimize re-renders
|
// Split context values to minimize re-renders
|
||||||
@ -166,6 +258,13 @@ export function FileContextProvider({
|
|||||||
dispatch
|
dispatch
|
||||||
}), [actions]);
|
}), [actions]);
|
||||||
|
|
||||||
|
// Load files from persistence on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (enablePersistence && indexedDB) {
|
||||||
|
actions.loadFromPersistence();
|
||||||
|
}
|
||||||
|
}, [enablePersistence, indexedDB]); // Only run once on mount
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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 all hooks from the fileHooks module
|
||||||
export {
|
export {
|
||||||
useFileState,
|
useFileState,
|
||||||
|
@ -35,7 +35,8 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
|||||||
interface FileManagerProviderProps {
|
interface FileManagerProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
recentFiles: FileMetadata[];
|
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;
|
onClose: () => void;
|
||||||
isFileSupported: (fileName: string) => boolean;
|
isFileSupported: (fileName: string) => boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -49,6 +50,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
children,
|
children,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
|
onNewFilesSelect,
|
||||||
onClose,
|
onClose,
|
||||||
isFileSupported,
|
isFileSupported,
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -127,22 +129,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
const files = Array.from(event.target.files || []);
|
const files = Array.from(event.target.files || []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Create FileMetadata objects - FileContext will handle storage and ID assignment
|
// For local file uploads, pass File objects directly to FileContext
|
||||||
const fileMetadatas = files.map(file => {
|
onNewFilesSelect(files);
|
||||||
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);
|
|
||||||
await refreshRecentFiles();
|
await refreshRecentFiles();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -150,7 +138,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||||
|
|
||||||
// Cleanup blob URLs when component unmounts
|
// Cleanup blob URLs when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
206
frontend/src/contexts/IndexedDBContext.tsx
Normal file
206
frontend/src/contexts/IndexedDBContext.tsx
Normal 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;
|
||||||
|
}
|
@ -14,7 +14,7 @@ import {
|
|||||||
import { FileMetadata } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||||
import { buildQuickKeySet } from './fileSelectors';
|
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === 'development';
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
@ -59,14 +59,21 @@ export async function addFiles(
|
|||||||
options: AddFileOptions,
|
options: AddFileOptions,
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
dispatch: React.Dispatch<FileContextAction>
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
): Promise<File[]> {
|
indexedDBMetadata?: Array<{ name: string; size: number; lastModified: number }>
|
||||||
|
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||||
const fileRecords: FileRecord[] = [];
|
const fileRecords: FileRecord[] = [];
|
||||||
const addedFiles: File[] = [];
|
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
||||||
|
|
||||||
// Build quickKey lookup from existing files for deduplication
|
// Build quickKey lookup from existing files for deduplication
|
||||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
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) {
|
switch (kind) {
|
||||||
case 'raw': {
|
case 'raw': {
|
||||||
const { files = [] } = options;
|
const { files = [] } = options;
|
||||||
@ -112,7 +119,7 @@ export async function addFiles(
|
|||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
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)
|
// Start background processing for validation only (we already have thumbnail and page count)
|
||||||
fileProcessingService.processFile(file, fileId).then(result => {
|
fileProcessingService.processFile(file, fileId).then(result => {
|
||||||
@ -159,7 +166,7 @@ export async function addFiles(
|
|||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push(file);
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -197,7 +204,7 @@ export async function addFiles(
|
|||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
fileRecords.push(record);
|
fileRecords.push(record);
|
||||||
addedFiles.push(file);
|
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -93,7 +93,22 @@ export function createFileSelectors(
|
|||||||
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
||||||
const quickKeys = new Set<string>();
|
const quickKeys = new Set<string>();
|
||||||
Object.values(fileRecords).forEach(record => {
|
Object.values(fileRecords).forEach(record => {
|
||||||
|
if (record.quickKey) {
|
||||||
quickKeys.add(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;
|
return quickKeys;
|
||||||
}
|
}
|
||||||
|
@ -1,83 +1,91 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||||
import { FileWithUrl, FileMetadata } from '../types/file';
|
import { FileWithUrl, FileMetadata } from '../types/file';
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const indexedDB = useIndexedDB();
|
||||||
|
|
||||||
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
|
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
|
||||||
// Always use ID - no fallback to names to prevent identity drift
|
if (!indexedDB) {
|
||||||
if (!fileMetadata.id) {
|
throw new Error('IndexedDB context not available');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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[]> => {
|
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (!indexedDB) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Get metadata only (no file data) for performance
|
// 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));
|
const sortedFiles = storedFileMetadata.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||||
|
|
||||||
// Convert StoredFile metadata to FileMetadata format
|
// Already in correct FileMetadata format
|
||||||
return sortedFiles.map(stored => ({
|
return sortedFiles;
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load recent files:', error);
|
console.error('Failed to load recent files:', error);
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => {
|
const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => {
|
||||||
const file = files[index];
|
const file = files[index];
|
||||||
if (!file.id) {
|
if (!file.id) {
|
||||||
throw new Error('File ID is required for removal');
|
throw new Error('File ID is required for removal');
|
||||||
}
|
}
|
||||||
|
if (!indexedDB) {
|
||||||
|
throw new Error('IndexedDB context not available');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fileStorage.deleteFile(file.id);
|
await indexedDB.deleteFile(file.id);
|
||||||
setFiles(files.filter((_, i) => i !== index));
|
setFiles(files.filter((_, i) => i !== index));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove file:', error);
|
console.error('Failed to remove file:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const storeFile = useCallback(async (file: File, fileId: string) => {
|
const storeFile = useCallback(async (file: File, fileId: string) => {
|
||||||
|
if (!indexedDB) {
|
||||||
|
throw new Error('IndexedDB context not available');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Generate thumbnail for the file
|
// Store file with provided UUID from FileContext (thumbnail generated internally)
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
const metadata = await indexedDB.saveFile(file, fileId);
|
||||||
|
|
||||||
// Store file with provided UUID from FileContext
|
// Convert file to ArrayBuffer for StoredFile interface compatibility
|
||||||
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
// NO FILE MUTATION - Return StoredFile, FileContext manages mapping
|
// Return StoredFile format for compatibility with old API
|
||||||
return storedFile;
|
return {
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
data: arrayBuffer,
|
||||||
|
thumbnail: metadata.thumbnail
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to store file:', error);
|
console.error('Failed to store file:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const createFileSelectionHandlers = useCallback((
|
const createFileSelectionHandlers = useCallback((
|
||||||
selectedFiles: string[],
|
selectedFiles: string[],
|
||||||
@ -134,12 +142,18 @@ export const useFileManager = () => {
|
|||||||
}, [convertToFile]);
|
}, [convertToFile]);
|
||||||
|
|
||||||
const touchFile = useCallback(async (id: string) => {
|
const touchFile = useCallback(async (id: string) => {
|
||||||
|
if (!indexedDB) {
|
||||||
|
console.warn('IndexedDB context not available for touch operation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to touch file:', error);
|
console.error('Failed to touch file:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FileMetadata } from "../types/file";
|
import { FileMetadata } from "../types/file";
|
||||||
import { fileStorage } from "../services/fileStorage";
|
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,6 +28,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
} {
|
} {
|
||||||
const [thumb, setThumb] = useState<string | null>(null);
|
const [thumb, setThumb] = useState<string | null>(null);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const indexedDB = useIndexedDB();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -44,38 +45,21 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second priority: generate thumbnail for any file type
|
// Second priority: generate thumbnail for files under 100MB
|
||||||
if (file.size < 100 * 1024 * 1024 && !generating) {
|
if (file.size < 100 * 1024 * 1024 && !generating) {
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
let fileObject: File;
|
let fileObject: File;
|
||||||
|
|
||||||
// Handle IndexedDB files vs regular File objects
|
// Try to load file from IndexedDB using new context
|
||||||
if (file.storedInIndexedDB && file.id) {
|
if (file.id && indexedDB) {
|
||||||
// For IndexedDB files, recreate File object from stored data
|
const loadedFile = await indexedDB.loadFile(file.id);
|
||||||
const storedFile = await fileStorage.getFile(file.id);
|
if (!loadedFile) {
|
||||||
if (!storedFile) {
|
|
||||||
throw new Error('File not found in IndexedDB');
|
throw new Error('File not found in IndexedDB');
|
||||||
}
|
}
|
||||||
fileObject = new File([storedFile.data], storedFile.name, {
|
fileObject = loadedFile;
|
||||||
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
|
|
||||||
});
|
|
||||||
} else {
|
} 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
|
// Use the universal thumbnail generator
|
||||||
@ -92,14 +76,14 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
|||||||
if (!cancelled) setGenerating(false);
|
if (!cancelled) setGenerating(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Large files - generate placeholder
|
// Large files - no thumbnail
|
||||||
setThumb(null);
|
setThumb(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadThumbnail();
|
loadThumbnail();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [file, file?.thumbnail, file?.id]);
|
}, [file, file?.thumbnail, file?.id, indexedDB, generating]);
|
||||||
|
|
||||||
return { thumbnail: thumb, isGenerating: generating };
|
return { thumbnail: thumb, isGenerating: generating };
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
};
|
|
@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Enhanced file types for IndexedDB storage with UUID system
|
* File types for the new architecture
|
||||||
* Extends File interface for compatibility with existing utilities
|
* 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 {
|
export interface FileWithUrl extends File {
|
||||||
id: string; // Required UUID from FileContext
|
id: string; // Required UUID from FileContext
|
||||||
url?: string; // Blob URL for display
|
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 {
|
export interface FileMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
@ -20,6 +25,7 @@ export interface FileMetadata {
|
|||||||
size: number;
|
size: number;
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
|
/** @deprecated Legacy compatibility - will be removed */
|
||||||
storedInIndexedDB?: boolean;
|
storedInIndexedDB?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,11 +46,12 @@ export interface FileRecord {
|
|||||||
size: number;
|
size: number;
|
||||||
type: string;
|
type: string;
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
quickKey: string; // Fast deduplication key: name|size|lastModified
|
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
blobUrl?: string;
|
blobUrl?: string;
|
||||||
createdAt: number;
|
createdAt?: number;
|
||||||
processedFile?: ProcessedFileMetadata;
|
processedFile?: ProcessedFileMetadata;
|
||||||
|
isPinned?: boolean;
|
||||||
// Note: File object stored in provider ref, not in state
|
// Note: File object stored in provider ref, not in state
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,9 +219,9 @@ export interface FileContextActions {
|
|||||||
addFiles: (files: File[]) => Promise<File[]>;
|
addFiles: (files: File[]) => Promise<File[]>;
|
||||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
|
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
|
||||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => 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;
|
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
||||||
clearAllFiles: () => void;
|
clearAllFiles: () => Promise<void>;
|
||||||
|
|
||||||
// File pinning
|
// File pinning
|
||||||
pinFile: (file: File) => void;
|
pinFile: (file: File) => void;
|
||||||
@ -247,6 +248,9 @@ export interface FileContextActions {
|
|||||||
trackPdfDocument: (key: string, pdfDoc: any) => void;
|
trackPdfDocument: (key: string, pdfDoc: any) => void;
|
||||||
scheduleCleanup: (fileId: string, delay?: number) => void;
|
scheduleCleanup: (fileId: string, delay?: number) => void;
|
||||||
cleanupFile: (fileId: string) => void;
|
cleanupFile: (fileId: string) => void;
|
||||||
|
|
||||||
|
// Persistence operations
|
||||||
|
loadFromPersistence: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// File selectors (separate from actions to avoid re-renders)
|
// File selectors (separate from actions to avoid re-renders)
|
||||||
|
@ -38,11 +38,6 @@ export interface ToolConfiguration {
|
|||||||
supportedFormats?: string[];
|
supportedFormats?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolConfiguration {
|
|
||||||
maxFiles: number;
|
|
||||||
supportedFormats?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tool {
|
export interface Tool {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,35 +1,4 @@
|
|||||||
import { FileWithUrl } from "../types/file";
|
// Pure utility functions for file operations
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consolidated file size formatting utility
|
* Consolidated file size formatting utility
|
||||||
@ -60,102 +29,7 @@ export function getFileSize(file: File | { size: number }): string {
|
|||||||
return formatFileSize(file.size);
|
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
|
* Detects and normalizes file extension from filename
|
||||||
@ -177,29 +51,3 @@ export function detectFileExtension(filename: string): string {
|
|||||||
|
|
||||||
return extension;
|
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}`;
|
|
||||||
}
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user