2025-08-21 17:30:26 +01:00
|
|
|
/**
|
|
|
|
* IndexedDBContext - Clean persistence layer for file storage
|
|
|
|
* Integrates with FileContext to provide transparent file persistence
|
|
|
|
*/
|
|
|
|
|
|
|
|
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
2025-09-05 12:16:17 +01:00
|
|
|
import { fileStorage } from '../services/fileStorage';
|
2025-08-28 10:56:07 +01:00
|
|
|
import { FileId } from '../types/file';
|
2025-09-16 15:08:11 +01:00
|
|
|
import { StirlingFileStub, createStirlingFile, createQuickKey } from '../types/fileContext';
|
2025-08-21 17:30:26 +01:00
|
|
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const DEBUG = process.env.NODE_ENV === 'development';
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
interface IndexedDBContextValue {
|
|
|
|
// Core CRUD operations
|
2025-09-16 15:08:11 +01:00
|
|
|
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StirlingFileStub>;
|
2025-08-21 17:30:26 +01:00
|
|
|
loadFile: (fileId: FileId) => Promise<File | null>;
|
2025-09-16 15:08:11 +01:00
|
|
|
loadMetadata: (fileId: FileId) => Promise<StirlingFileStub | null>;
|
2025-08-21 17:30:26 +01:00
|
|
|
deleteFile: (fileId: FileId) => Promise<void>;
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Batch operations
|
2025-09-16 15:08:11 +01:00
|
|
|
loadAllMetadata: () => Promise<StirlingFileStub[]>;
|
|
|
|
loadLeafMetadata: () => Promise<StirlingFileStub[]>; // Only leaf files for recent files list
|
2025-08-21 17:30:26 +01:00
|
|
|
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
|
|
|
clearAll: () => Promise<void>;
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Utilities
|
|
|
|
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
|
|
|
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
2025-09-16 15:08:11 +01:00
|
|
|
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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`);
|
|
|
|
}, []);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StirlingFileStub> => {
|
2025-08-21 17:30:26 +01:00
|
|
|
// Use existing thumbnail or generate new one if none provided
|
|
|
|
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
|
|
|
|
const stirlingFile = createStirlingFile(file, fileId);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Create minimal stub for storage
|
|
|
|
const stub: StirlingFileStub = {
|
2025-08-21 17:30:26 +01:00
|
|
|
id: fileId,
|
|
|
|
name: file.name,
|
|
|
|
size: file.size,
|
2025-09-16 15:08:11 +01:00
|
|
|
type: file.type,
|
2025-08-21 17:30:26 +01:00
|
|
|
lastModified: file.lastModified,
|
2025-09-16 15:08:11 +01:00
|
|
|
quickKey: createQuickKey(file),
|
|
|
|
thumbnailUrl: thumbnail,
|
|
|
|
isLeaf: true,
|
|
|
|
createdAt: Date.now(),
|
|
|
|
versionNumber: 1,
|
|
|
|
originalFileId: fileId,
|
|
|
|
toolHistory: []
|
2025-08-21 17:30:26 +01:00
|
|
|
};
|
2025-09-16 15:08:11 +01:00
|
|
|
|
|
|
|
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
|
|
|
const storedFile = await fileStorage.getStirlingFileStub(fileId);
|
|
|
|
|
|
|
|
// Cache the file object for immediate reuse
|
|
|
|
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
|
|
|
evictLRUEntries();
|
|
|
|
|
|
|
|
// Return StirlingFileStub from the stored file (no conversion needed)
|
|
|
|
if (!storedFile) {
|
|
|
|
throw new Error(`Failed to retrieve stored file after saving: ${file.name}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
return storedFile;
|
2025-08-21 17:30:26 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
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
|
2025-09-16 15:08:11 +01:00
|
|
|
const storedFile = await fileStorage.getStirlingFile(fileId);
|
2025-08-21 17:30:26 +01:00
|
|
|
if (!storedFile) return null;
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// StirlingFile is already a File object, no reconstruction needed
|
|
|
|
const file = storedFile;
|
2025-08-21 17:30:26 +01:00
|
|
|
|
|
|
|
// Cache for future use with LRU eviction
|
|
|
|
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
|
|
|
evictLRUEntries();
|
|
|
|
|
|
|
|
return file;
|
|
|
|
}, [evictLRUEntries]);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const loadMetadata = useCallback(async (fileId: FileId): Promise<StirlingFileStub | null> => {
|
|
|
|
// Load stub directly from storage service
|
|
|
|
return await fileStorage.getStirlingFileStub(fileId);
|
2025-08-21 17:30:26 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
|
|
|
// Remove from cache
|
|
|
|
fileCache.current.delete(fileId);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Remove from IndexedDB
|
2025-09-16 15:08:11 +01:00
|
|
|
await fileStorage.deleteStirlingFile(fileId);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const loadLeafMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
|
|
|
const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files
|
|
|
|
|
|
|
|
// All files are already StirlingFileStub objects, no processing needed
|
|
|
|
return metadata;
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
}, []);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const loadAllMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
|
|
|
const metadata = await fileStorage.getAllStirlingFileStubs();
|
|
|
|
|
|
|
|
// All files are already StirlingFileStub objects, no processing needed
|
|
|
|
return metadata;
|
2025-08-21 17:30:26 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
|
|
|
// Remove from cache
|
|
|
|
fileIds.forEach(id => fileCache.current.delete(id));
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Remove from IndexedDB in parallel
|
2025-09-16 15:08:11 +01:00
|
|
|
await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id)));
|
2025-08-21 17:30:26 +01:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
const clearAll = useCallback(async (): Promise<void> => {
|
|
|
|
// Clear cache
|
|
|
|
fileCache.current.clear();
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Clear IndexedDB
|
|
|
|
await fileStorage.clearAll();
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const getStorageStats = useCallback(async () => {
|
|
|
|
return await fileStorage.getStorageStats();
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise<boolean> => {
|
|
|
|
return await fileStorage.updateThumbnail(fileId, thumbnail);
|
|
|
|
}, []);
|
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const markFileAsProcessed = useCallback(async (fileId: FileId): Promise<boolean> => {
|
|
|
|
return await fileStorage.markFileAsProcessed(fileId);
|
|
|
|
}, []);
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
const value: IndexedDBContextValue = {
|
|
|
|
saveFile,
|
|
|
|
loadFile,
|
|
|
|
loadMetadata,
|
|
|
|
deleteFile,
|
|
|
|
loadAllMetadata,
|
2025-09-16 15:08:11 +01:00
|
|
|
loadLeafMetadata,
|
2025-08-21 17:30:26 +01:00
|
|
|
deleteMultiple,
|
|
|
|
clearAll,
|
|
|
|
getStorageStats,
|
2025-09-16 15:08:11 +01:00
|
|
|
updateThumbnail,
|
|
|
|
markFileAsProcessed
|
2025-08-21 17:30:26 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
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;
|
2025-08-28 10:56:07 +01:00
|
|
|
}
|