Stirling-PDF/frontend/src/contexts/IndexedDBContext.tsx
James Brunton bd13f6bf57
Enable ESLint no-unused-vars rule (#4367)
# Description of Changes
Enable ESLint [no-unused-vars
rule](https://typescript-eslint.io/rules/no-unused-vars/)
2025-09-05 11:16:17 +00:00

208 lines
6.3 KiB
TypeScript

/**
* 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 } from '../services/fileStorage';
import { FileId } from '../types/file';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
interface IndexedDBContextValue {
// Core CRUD operations
saveFile: (file: File, fileId: FileId, existingThumbnail?: 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 }>;
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
}
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 if none provided
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB
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
const storedFile = await fileStorage.getFile(fileId);
if (!storedFile) 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();
}, []);
const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise<boolean> => {
return await fileStorage.updateThumbnail(fileId, thumbnail);
}, []);
const value: IndexedDBContextValue = {
saveFile,
loadFile,
loadMetadata,
deleteFile,
loadAllMetadata,
deleteMultiple,
clearAll,
getStorageStats,
updateThumbnail
};
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;
}