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
|
||||
recentFiles={recentFiles}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onNewFilesSelect={handleNewFileUpload}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
|
@ -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,
|
||||
|
@ -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(() => {
|
||||
|
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 { 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;
|
||||
}
|
||||
|
@ -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 => {
|
||||
quickKeys.add(record.quickKey);
|
||||
if (record.quickKey) {
|
||||
quickKeys.add(record.quickKey);
|
||||
}
|
||||
});
|
||||
return quickKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for building quickKey sets from IndexedDB metadata
|
||||
*/
|
||||
export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; size: number; lastModified: number }>): Set<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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -38,11 +38,6 @@ export interface ToolConfiguration {
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
export interface ToolConfiguration {
|
||||
maxFiles: number;
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user