feat: Enhance file handling and processing capabilities

- Implement thumbnail caching in PageThumbnail component to improve performance.
- Update ConvertSettings to map selected files to their corresponding IDs in FileContext.
- Refactor FileContext to support quick deduplication using a new quickKey mechanism.
- Introduce addStoredFiles action to handle files with preserved IDs for better session management.
- Enhance FilesModalContext to support selection of stored files with metadata.
- Update useFileHandler to include logic for adding stored files.
- Modify useFileManager to support selection of stored files while maintaining backward compatibility.
- Improve file processing service with cancellation capabilities for ongoing operations.
- Centralize IndexedDB management with a new IndexedDBManager to streamline database interactions.
- Refactor file storage service to utilize the centralized IndexedDB manager for better database handling.
- Remove deprecated content hash logic and related fields from file types.
This commit is contained in:
Reece Browne 2025-08-14 21:47:02 +01:00
parent 29a4e03784
commit f691e690e4
15 changed files with 594 additions and 417 deletions

View File

@ -16,7 +16,7 @@ interface FileManagerProps {
} }
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => { const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext(); const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]); const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
@ -43,16 +43,19 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
try { try {
const fileObjects = await Promise.all( // NEW: Use stored files flow that preserves original IDs
files.map(async (fileWithUrl) => { const filesWithMetadata = await Promise.all(
return await convertToFile(fileWithUrl); files.map(async (metadata) => ({
}) file: await convertToFile(metadata),
originalId: metadata.id,
metadata
}))
); );
onFilesSelect(fileObjects); onStoredFilesSelect(filesWithMetadata);
} catch (error) { } catch (error) {
console.error('Failed to process selected files:', error); console.error('Failed to process selected files:', error);
} }
}, [convertToFile, onFilesSelect]); }, [convertToFile, onStoredFilesSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => { const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) { if (files.length > 0) {

View File

@ -7,7 +7,7 @@ import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { FileOperation, createStableFileId } from '../../types/fileContext'; import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService'; import { zipFileService } from '../../services/zipFileService';

View File

@ -21,6 +21,7 @@ import { pdfExportService } from "../../services/pdfExportService";
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
import { fileStorage } from "../../services/fileStorage"; import { fileStorage } from "../../services/fileStorage";
import { indexedDBManager, DATABASE_CONFIGS } from "../../services/indexedDBManager";
import './PageEditor.module.css'; import './PageEditor.module.css';
import PageThumbnail from './PageThumbnail'; import PageThumbnail from './PageThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel'; import BulkSelectionPanel from './BulkSelectionPanel';
@ -184,7 +185,7 @@ const PageEditor = ({
totalPages: pages.length, totalPages: pages.length,
destroy: () => {} // Optional cleanup function destroy: () => {} // Optional cleanup function
}; };
}, [filesSignature, activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, getThumbnailFromCache, addThumbnailToCache]); }, [filesSignature, primaryFileId, primaryFileRecord]);
// Display document: Use edited version if exists, otherwise original // Display document: Use edited version if exists, otherwise original
@ -308,23 +309,9 @@ const PageEditor = ({
const pageId = `${primaryFileId}-page-${pageNumber}`; const pageId = `${primaryFileId}-page-${pageNumber}`;
addThumbnailToCache(pageId, thumbnail); addThumbnailToCache(pageId, thumbnail);
// Also update the processedFile so document rebuilds include the thumbnail // Don't update context state - thumbnails stay in cache only
const fileRecord = selectors.getFileRecord(primaryFileId); // This eliminates per-page context rerenders
if (fileRecord?.processedFile?.pages) { // PageThumbnail will find thumbnails via cache polling
const updatedProcessedFile = {
...fileRecord.processedFile,
pages: fileRecord.processedFile.pages.map((page, index) =>
index + 1 === pageNumber
? { ...page, thumbnail }
: page
)
};
actions.updateFileRecord(primaryFileId, { processedFile: updatedProcessedFile });
}
window.dispatchEvent(new CustomEvent('thumbnailReady', {
detail: { pageNumber, thumbnail, pageId }
}));
}); });
}); });
} }
@ -334,7 +321,7 @@ const PageEditor = ({
} catch (error) { } catch (error) {
console.error('PageEditor: Thumbnail generation failed:', error); console.error('PageEditor: Thumbnail generation failed:', error);
} }
}, [mergedPdfDocument, primaryFileId, activeFileIds, generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors, actions]); }, [mergedPdfDocument, primaryFileId, activeFileIds, generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors]);
// Simple useEffect - just generate missing thumbnails when document is ready // Simple useEffect - just generate missing thumbnails when document is ready
useEffect(() => { useEffect(() => {
@ -563,7 +550,7 @@ const PageEditor = ({
return updatedDoc; return updatedDoc;
}, [actions, hasUnsavedDraft]); }, [actions, hasUnsavedDraft]);
// Enhanced draft save with proper IndexedDB handling // Enhanced draft save using centralized IndexedDB manager
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
const draftKey = `draft-${doc.id || 'merged'}`; const draftKey = `draft-${doc.id || 'merged'}`;
const draftData = { const draftData = {
@ -573,173 +560,44 @@ const PageEditor = ({
}; };
try { try {
// Save to 'pdf-drafts' store in IndexedDB // Use centralized IndexedDB manager
const request = indexedDB.open('stirling-pdf-drafts', 1); const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts');
}
};
request.onsuccess = () => {
const db = request.result;
// Check if the object store exists before trying to access it
if (!db.objectStoreNames.contains('drafts')) {
console.warn('drafts object store does not exist, skipping auto-save');
return;
}
const transaction = db.transaction('drafts', 'readwrite'); const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts'); const store = transaction.objectStore('drafts');
store.put(draftData, draftKey);
console.log('Draft auto-saved to IndexedDB');
};
} catch (error) {
console.warn('Failed to auto-save draft:', error);
// Robust IndexedDB initialization with proper error handling
const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
return new Promise<void>((resolve, reject) => {
dbRequest.onerror = () => {
console.warn('Failed to open draft database:', dbRequest.error);
reject(dbRequest.error);
};
dbRequest.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains('drafts')) {
const store = db.createObjectStore('drafts');
console.log('Created drafts object store');
}
};
dbRequest.onsuccess = () => {
const db = dbRequest.result;
// Verify object store exists before attempting transaction
if (!db.objectStoreNames.contains('drafts')) {
console.warn('Drafts object store not found, skipping save');
resolve();
return;
}
try {
const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts');
transaction.onerror = () => {
console.warn('Draft save transaction failed:', transaction.error);
reject(transaction.error);
};
transaction.oncomplete = () => {
console.log('Draft auto-saved successfully');
resolve();
};
const putRequest = store.put(draftData, draftKey); const putRequest = store.put(draftData, draftKey);
putRequest.onsuccess = () => {
console.log('Draft auto-saved to IndexedDB');
};
putRequest.onerror = () => { putRequest.onerror = () => {
console.warn('Failed to put draft data:', putRequest.error); console.warn('Failed to put draft data:', putRequest.error);
reject(putRequest.error);
}; };
} catch (error) { } catch (error) {
console.warn('Transaction creation failed:', error); console.warn('Failed to auto-save draft:', error);
reject(error);
} finally {
db.close();
}
};
});
} }
}, [activeFileIds, selectors]); }, [activeFileIds, selectors]);
// Enhanced draft cleanup with proper IndexedDB handling // Enhanced draft cleanup using centralized IndexedDB manager
const cleanupDraft = useCallback(async () => { const cleanupDraft = useCallback(async () => {
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
try { try {
const request = indexedDB.open('stirling-pdf-drafts', 1); // Use centralized IndexedDB manager
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts');
}
};
request.onsuccess = () => {
const db = request.result;
// Check if the object store exists before trying to access it
if (!db.objectStoreNames.contains('drafts')) {
console.warn('drafts object store does not exist, skipping cleanup');
return;
}
const transaction = db.transaction('drafts', 'readwrite'); const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts'); const store = transaction.objectStore('drafts');
store.delete(draftKey);
};
} catch (error) {
console.warn('Failed to cleanup draft:', error);
const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
return new Promise<void>((resolve, reject) => {
dbRequest.onerror = () => {
console.warn('Failed to open draft database for cleanup:', dbRequest.error);
resolve(); // Don't fail the whole operation if cleanup fails
};
dbRequest.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts');
console.log('Created drafts object store during cleanup fallback');
}
};
dbRequest.onsuccess = () => {
const db = dbRequest.result;
// Check if object store exists before attempting cleanup
if (!db.objectStoreNames.contains('drafts')) {
console.log('No drafts object store found, nothing to cleanup');
resolve();
return;
}
try {
const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts');
transaction.onerror = () => {
console.warn('Draft cleanup transaction failed:', transaction.error);
resolve(); // Don't fail if cleanup fails
};
transaction.oncomplete = () => {
console.log('Draft cleaned up successfully');
resolve();
};
const deleteRequest = store.delete(draftKey); const deleteRequest = store.delete(draftKey);
deleteRequest.onsuccess = () => {
console.log('Draft cleaned up successfully');
};
deleteRequest.onerror = () => { deleteRequest.onerror = () => {
console.warn('Failed to delete draft:', deleteRequest.error); console.warn('Failed to delete draft:', deleteRequest.error);
resolve(); // Don't fail if delete fails
}; };
} catch (error) { } catch (error) {
console.warn('Draft cleanup transaction creation failed:', error); console.warn('Failed to cleanup draft:', error);
resolve(); // Don't fail if cleanup fails
} finally {
db.close();
}
};
});
} }
}, [mergedPdfDocument]); }, [mergedPdfDocument]);
@ -1145,18 +1003,14 @@ const PageEditor = ({
} }
}, [editedDocument, applyChanges, handleExport]); }, [editedDocument, applyChanges, handleExport]);
// Enhanced draft checking with proper IndexedDB handling // Enhanced draft checking using centralized IndexedDB manager
const checkForDrafts = useCallback(async () => { const checkForDrafts = useCallback(async () => {
if (!mergedPdfDocument) return; if (!mergedPdfDocument) return;
try { try {
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
const request = indexedDB.open('stirling-pdf-drafts', 1); // Use centralized IndexedDB manager
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains('drafts')) return;
const transaction = db.transaction('drafts', 'readonly'); const transaction = db.transaction('drafts', 'readonly');
const store = transaction.objectStore('drafts'); const store = transaction.objectStore('drafts');
const getRequest = store.get(draftKey); const getRequest = store.get(draftKey);
@ -1174,7 +1028,11 @@ const PageEditor = ({
} }
} }
}; };
getRequest.onerror = () => {
console.warn('Failed to get draft:', getRequest.error);
}; };
} catch (error) { } catch (error) {
console.warn('Draft check failed:', error); console.warn('Draft check failed:', error);
// Don't throw - draft checking failure shouldn't break the app // Don't throw - draft checking failure shouldn't break the app

View File

@ -11,6 +11,7 @@ import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands'; import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo'; import { Command } from '../../hooks/useUndoRedo';
import { useFileState } from '../../contexts/FileContext'; import { useFileState } from '../../contexts/FileContext';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'; import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
@ -80,6 +81,7 @@ const PageThumbnail = React.memo(({
}: PageThumbnailProps) => { }: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail); const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { getThumbnailFromCache } = useThumbnailGeneration();
// Update thumbnail URL when page prop changes - prevent redundant updates // Update thumbnail URL when page prop changes - prevent redundant updates
useEffect(() => { useEffect(() => {
@ -95,24 +97,19 @@ const PageThumbnail = React.memo(({
return; // Skip if we already have a thumbnail return; // Skip if we already have a thumbnail
} }
const handleThumbnailReady = (event: CustomEvent) => { // Poll for thumbnail in cache (lightweight polling every 500ms)
const { pageNumber, thumbnail, pageId } = event.detail; const pollInterval = setInterval(() => {
// Check if thumbnail is now available in cache
// Guard: check if this component is still mounted and page still exists const cachedThumbnail = getThumbnailFromCache(page.id);
if (pageNumber === page.pageNumber && pageId === page.id) { if (cachedThumbnail) {
// Additional safety: check if the file still exists in FileContext setThumbnailUrl(cachedThumbnail);
const fileId = page.id.split('-page-')[0]; // Extract fileId from pageId clearInterval(pollInterval); // Stop polling once found
const fileExists = selectors.getAllFileIds().includes(fileId);
if (fileExists) {
setThumbnailUrl(thumbnail);
} }
} }, 500);
};
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener); // Cleanup interval
return () => { return () => {
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener); clearInterval(pollInterval);
}; };
}, [page.pageNumber, page.id]); // Remove thumbnailUrl dependency to stabilize effect }, [page.pageNumber, page.id]); // Remove thumbnailUrl dependency to stabilize effect

View File

@ -6,7 +6,6 @@ import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils"; import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { useToolFileSelection } from "../../../contexts/FileContext"; import { useToolFileSelection } from "../../../contexts/FileContext";
import { useFileState } from "../../../contexts/FileContext"; import { useFileState } from "../../../contexts/FileContext";
import { createStableFileId } from "../../../types/fileContext";
import { detectFileExtension } from "../../../utils/fileUtils"; import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown"; import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings"; import ConvertToImageSettings from "./ConvertToImageSettings";
@ -151,7 +150,21 @@ const ConvertSettings = ({
}; };
const updateFileSelection = (files: File[]) => { const updateFileSelection = (files: File[]) => {
setSelectedFiles(files.map(f => createStableFileId(f))); // Map File objects to their actual IDs in FileContext
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
setSelectedFiles(fileIds);
}; };
const handleFromExtensionChange = (value: string) => { const handleFromExtensionChange = (value: string) => {

View File

@ -37,8 +37,9 @@ import {
toFileRecord, toFileRecord,
revokeFileResources, revokeFileResources,
createFileId, createFileId,
computeContentHash createQuickKey
} from '../types/fileContext'; } from '../types/fileContext';
import { FileMetadata } from '../types/file';
// Import real services // Import real services
import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService'; import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
@ -124,7 +125,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
const existingRecord = state.files.byId[id]; const existingRecord = state.files.byId[id];
if (!existingRecord) return state; if (!existingRecord) return state;
// Immutable merge supports all FileRecord fields including contentHash, hashStatus // Immutable merge supports all FileRecord fields
return { return {
...state, ...state,
files: { files: {
@ -374,8 +375,12 @@ export function FileContextProvider({
}); });
blobUrls.current.clear(); blobUrls.current.clear();
// Clear all processing // Clear all processing and cache
enhancedPDFProcessingService.clearAllProcessing(); enhancedPDFProcessingService.clearAll();
// Cancel and clear centralized file processing
fileProcessingService.cancelAllProcessing();
fileProcessingService.clearCache();
// Destroy thumbnails // Destroy thumbnails
thumbnailGenerationService.destroy(); thumbnailGenerationService.destroy();
@ -414,24 +419,40 @@ export function FileContextProvider({
// Action implementations // Action implementations
const addFiles = useCallback(async (files: File[]): Promise<File[]> => { const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
// Generate UUID-based IDs and create records // Three-tier deduplication: UUID (primary key) + quickKey (soft dedupe) + contentHash (hard dedupe)
const fileRecords: FileRecord[] = []; const fileRecords: FileRecord[] = [];
const addedFiles: File[] = []; const addedFiles: File[] = [];
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = new Set<string>();
Object.values(stateRef.current.files.byId).forEach(record => {
existingQuickKeys.add(record.quickKey);
});
for (const file of files) { for (const file of files) {
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) {
console.log(`📄 Skipping duplicate file: ${file.name} (already exists)`);
continue; // Skip duplicate file
}
const fileId = createFileId(); // UUID-based, zero collisions const fileId = createFileId(); // UUID-based, zero collisions
// Store File in ref map // Store File in ref map
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
// Create record with pending hash status // Create record
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
record.hashStatus = 'pending';
// Add to deduplication tracking
existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push(file); addedFiles.push(file);
// Start centralized file processing (async, non-blocking) // Start centralized file processing (async, non-blocking) - SINGLE CALL
fileProcessingService.processFile(file, fileId).then(result => { fileProcessingService.processFile(file, fileId).then(result => {
// Only update if file still exists in context // Only update if file still exists in context
if (filesRef.current.has(fileId)) { if (filesRef.current.has(fileId)) {
@ -448,22 +469,12 @@ export function FileContextProvider({
} }
}); });
console.log(`✅ File processing complete for ${file.name}: ${result.metadata.totalPages} pages`); console.log(`✅ File processing complete for ${file.name}: ${result.metadata.totalPages} pages`);
} else {
console.warn(`❌ File processing failed for ${file.name}:`, result.error);
}
}
}).catch(error => {
console.error(`❌ File processing error for ${file.name}:`, error);
});
// Optional: Persist to IndexedDB if enabled // Optional: Persist to IndexedDB if enabled (reuse the same result)
if (enablePersistence) { if (enablePersistence) {
try { try {
// Use the thumbnail from processing service if available const thumbnail = result.metadata.thumbnailUrl;
fileProcessingService.processFile(file, fileId).then(result => { fileStorage.storeFile(file, fileId, thumbnail).then(() => {
const thumbnail = result.metadata?.thumbnailUrl;
return fileStorage.storeFile(file, fileId, thumbnail);
}).then(() => {
console.log('File persisted to IndexedDB:', fileId); console.log('File persisted to IndexedDB:', fileId);
}).catch(error => { }).catch(error => {
console.warn('Failed to persist file to IndexedDB:', error); console.warn('Failed to persist file to IndexedDB:', error);
@ -472,22 +483,14 @@ export function FileContextProvider({
console.warn('Failed to initiate file persistence:', error); console.warn('Failed to initiate file persistence:', error);
} }
} }
} else {
console.warn(`❌ File processing failed for ${file.name}:`, result.error);
}
}
}).catch(error => {
console.error(`❌ File processing error for ${file.name}:`, error);
});
// Start async content hashing (don't block add operation)
computeContentHash(file).then(contentHash => {
// Only update if file still exists in context
if (filesRef.current.has(fileId)) {
updateFileRecord(fileId, {
contentHash: contentHash || undefined, // Convert null to undefined
hashStatus: contentHash ? 'completed' : 'failed'
});
}
}).catch(() => {
// Hash failed, update status if file still exists
if (filesRef.current.has(fileId)) {
updateFileRecord(fileId, { hashStatus: 'failed' });
}
});
} }
// Only dispatch if we have new files // Only dispatch if we have new files
@ -499,7 +502,79 @@ export function FileContextProvider({
return addedFiles; return addedFiles;
}, [enablePersistence]); // Remove updateFileRecord dependency }, [enablePersistence]); // Remove updateFileRecord dependency
// NEW: Add stored files with preserved IDs to prevent duplicates across sessions
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>): Promise<File[]> => {
const fileRecords: FileRecord[] = [];
const addedFiles: File[] = [];
for (const { file, originalId, metadata } of filesWithMetadata) {
// Skip if file already exists with same ID (exact match)
if (stateRef.current.files.byId[originalId]) {
console.log(`📄 Skipping stored file: ${file.name} (already loaded with same ID)`);
continue;
}
// Store File in ref map with preserved ID
filesRef.current.set(originalId, file);
// Create record with preserved ID and stored metadata
const record: FileRecord = {
id: originalId, // Preserve original UUID from storage
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
quickKey: createQuickKey(file),
thumbnailUrl: metadata.thumbnail,
createdAt: Date.now(),
// Skip processedFile for now - it will be populated by background processing if needed
};
fileRecords.push(record);
addedFiles.push(file);
// Background processing with preserved ID (async, non-blocking)
fileProcessingService.processFile(file, originalId).then(result => {
// Only update if file still exists in context
if (filesRef.current.has(originalId)) {
if (result.success && result.metadata) {
// Update with processed metadata using dispatch directly
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: originalId,
updates: {
processedFile: result.metadata,
// Keep existing thumbnail if available, otherwise use processed one
thumbnailUrl: metadata.thumbnail || result.metadata.thumbnailUrl
}
}
});
console.log(`✅ Stored file processing complete for ${file.name}: ${result.metadata.totalPages} pages`);
} else {
console.warn(`❌ Stored file processing failed for ${file.name}:`, result.error);
}
}
}).catch(error => {
console.error(`❌ Stored file processing error for ${file.name}:`, error);
});
}
// Only dispatch if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
}
console.log(`📁 Added ${fileRecords.length} stored files with preserved IDs`);
return addedFiles;
}, []);
const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => { const removeFiles = useCallback((fileIds: FileId[], deleteFromStorage: boolean = true) => {
// Cancel any ongoing processing for removed files
fileIds.forEach(fileId => {
fileProcessingService.cancelProcessing(fileId);
});
// Clean up Files from ref map first // Clean up Files from ref map first
fileIds.forEach(fileId => { fileIds.forEach(fileId => {
filesRef.current.delete(fileId); filesRef.current.delete(fileId);
@ -560,6 +635,7 @@ export function FileContextProvider({
// Memoized actions to prevent re-renders // Memoized actions to prevent re-renders
const actions = useMemo<FileContextActions>(() => ({ const actions = useMemo<FileContextActions>(() => ({
addFiles, addFiles,
addStoredFiles,
removeFiles, removeFiles,
updateFileRecord, updateFileRecord,
clearAllFiles: () => { clearAllFiles: () => {
@ -582,7 +658,7 @@ export function FileContextProvider({
setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }),
confirmNavigation, confirmNavigation,
cancelNavigation cancelNavigation
}), [addFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); }), [addFiles, addStoredFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]);
// Split context values to minimize re-renders // Split context values to minimize re-renders
const stateValue = useMemo<FileContextStateValue>(() => ({ const stateValue = useMemo<FileContextStateValue>(() => ({
@ -601,6 +677,7 @@ export function FileContextProvider({
...state.ui, ...state.ui,
// Action compatibility layer // Action compatibility layer
addFiles, addFiles,
addStoredFiles,
removeFiles, removeFiles,
updateFileRecord, updateFileRecord,
clearAllFiles: actions.clearAllFiles, clearAllFiles: actions.clearAllFiles,
@ -624,7 +701,7 @@ export function FileContextProvider({
get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render
// Selectors // Selectors
...selectors ...selectors
}), [state, actions, addFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency }), [state, actions, addFiles, addStoredFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler'; import { useFileHandler } from '../hooks/useFileHandler';
import { FileMetadata } from '../types/file';
interface FilesModalContextType { interface FilesModalContextType {
isFilesModalOpen: boolean; isFilesModalOpen: boolean;
@ -7,6 +8,7 @@ interface FilesModalContextType {
closeFilesModal: () => void; closeFilesModal: () => void;
onFileSelect: (file: File) => void; onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void; onFilesSelect: (files: File[]) => void;
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
onModalClose?: () => void; onModalClose?: () => void;
setOnModalClose: (callback: () => void) => void; setOnModalClose: (callback: () => void) => void;
} }
@ -14,7 +16,7 @@ interface FilesModalContextType {
const FilesModalContext = createContext<FilesModalContextType | null>(null); const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addToActiveFiles, addMultipleFiles } = useFileHandler(); const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
@ -37,6 +39,11 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal(); closeFilesModal();
}, [addMultipleFiles, closeFilesModal]); }, [addMultipleFiles, closeFilesModal]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
addStoredFiles(filesWithMetadata);
closeFilesModal();
}, [addStoredFiles, closeFilesModal]);
const setModalCloseCallback = useCallback((callback: () => void) => { const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback); setOnModalClose(() => callback);
}, []); }, []);
@ -47,6 +54,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal, closeFilesModal,
onFileSelect: handleFileSelect, onFileSelect: handleFileSelect,
onFilesSelect: handleFilesSelect, onFilesSelect: handleFilesSelect,
onStoredFilesSelect: handleStoredFilesSelect,
onModalClose, onModalClose,
setOnModalClose: setModalCloseCallback, setOnModalClose: setModalCloseCallback,
}), [ }), [
@ -55,6 +63,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal, closeFilesModal,
handleFileSelect, handleFileSelect,
handleFilesSelect, handleFilesSelect,
handleStoredFilesSelect,
onModalClose, onModalClose,
setModalCloseCallback, setModalCloseCallback,
]); ]);

View File

@ -1,35 +1,38 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useFileState, useFileActions } from '../contexts/FileContext'; import { useFileState, useFileActions } from '../contexts/FileContext';
import { createStableFileId } from '../types/fileContext'; import { FileMetadata } from '../types/file';
export const useFileHandler = () => { export const useFileHandler = () => {
const { state } = useFileState(); const { state } = useFileState(); // Still needed for addStoredFiles
const { actions } = useFileActions(); const { actions } = useFileActions();
const addToActiveFiles = useCallback(async (file: File) => { const addToActiveFiles = useCallback(async (file: File) => {
// Use stable ID function for consistent deduplication // Let FileContext handle deduplication with quickKey logic
const stableId = createStableFileId(file);
const exists = state.files.byId[stableId] !== undefined;
if (!exists) {
await actions.addFiles([file]); await actions.addFiles([file]);
} }, [actions.addFiles]);
}, [state.files.byId, actions.addFiles]);
const addMultipleFiles = useCallback(async (files: File[]) => { const addMultipleFiles = useCallback(async (files: File[]) => {
// Filter out files that already exist using stable IDs // Let FileContext handle deduplication with quickKey logic
const newFiles = files.filter(file => { await actions.addFiles(files);
const stableId = createStableFileId(file); }, [actions.addFiles]);
return state.files.byId[stableId] === undefined;
// NEW: Add stored files preserving their original IDs to prevent session duplicates
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
// Filter out files that already exist with the same ID (exact match)
const newFiles = filesWithMetadata.filter(({ originalId }) => {
return state.files.byId[originalId] === undefined;
}); });
if (newFiles.length > 0) { if (newFiles.length > 0) {
await actions.addFiles(newFiles); await actions.addStoredFiles(newFiles);
} }
}, [state.files.byId, actions.addFiles]);
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
}, [state.files.byId, actions.addStoredFiles]);
return { return {
addToActiveFiles, addToActiveFiles,
addMultipleFiles, addMultipleFiles,
addStoredFiles,
}; };
}; };

View File

@ -95,15 +95,30 @@ export const useFileManager = () => {
setSelectedFiles([]); setSelectedFiles([]);
}; };
const selectMultipleFiles = async (files: FileMetadata[], onFilesSelect: (files: File[]) => void) => { const selectMultipleFiles = async (files: FileMetadata[], onFilesSelect: (files: File[]) => void, onStoredFilesSelect?: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => {
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
try { try {
// Filter by UUID and convert to File objects // Filter by UUID and convert to File objects
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id)); const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
if (onStoredFilesSelect) {
// NEW: Use stored files flow that preserves IDs
const filesWithMetadata = await Promise.all(
selectedFileObjects.map(async (metadata) => ({
file: await convertToFile(metadata),
originalId: metadata.id,
metadata
}))
);
onStoredFilesSelect(filesWithMetadata);
} else {
// LEGACY: Old flow that generates new UUIDs (for backward compatibility)
const filePromises = selectedFileObjects.map(convertToFile); const filePromises = selectedFileObjects.map(convertToFile);
const convertedFiles = await Promise.all(filePromises); const convertedFiles = await Promise.all(filePromises);
onFilesSelect(convertedFiles); // FileContext will assign new UUIDs onFilesSelect(convertedFiles); // FileContext will assign new UUIDs
}
clearSelection(); clearSelection();
} catch (error) { } catch (error) {
console.error('Failed to load selected files:', error); console.error('Failed to load selected files:', error);

View File

@ -25,8 +25,13 @@ export interface FileProcessingResult {
error?: string; error?: string;
} }
interface ProcessingOperation {
promise: Promise<FileProcessingResult>;
abortController: AbortController;
}
class FileProcessingService { class FileProcessingService {
private processingCache = new Map<string, Promise<FileProcessingResult>>(); private processingCache = new Map<string, ProcessingOperation>();
/** /**
* Process a file to extract metadata, page count, and generate thumbnails * Process a file to extract metadata, page count, and generate thumbnails
@ -34,15 +39,24 @@ class FileProcessingService {
*/ */
async processFile(file: File, fileId: string): Promise<FileProcessingResult> { async processFile(file: File, fileId: string): Promise<FileProcessingResult> {
// Check if we're already processing this file // Check if we're already processing this file
const existingPromise = this.processingCache.get(fileId); const existingOperation = this.processingCache.get(fileId);
if (existingPromise) { if (existingOperation) {
console.log(`📁 FileProcessingService: Using cached processing for ${file.name}`); console.log(`📁 FileProcessingService: Using cached processing for ${file.name}`);
return existingPromise; return existingOperation.promise;
} }
// Create abort controller for this operation
const abortController = new AbortController();
// Create processing promise // Create processing promise
const processingPromise = this.performProcessing(file, fileId); const processingPromise = this.performProcessing(file, fileId, abortController);
this.processingCache.set(fileId, processingPromise);
// Store operation with abort controller
const operation: ProcessingOperation = {
promise: processingPromise,
abortController
};
this.processingCache.set(fileId, operation);
// Clean up cache after completion // Clean up cache after completion
processingPromise.finally(() => { processingPromise.finally(() => {
@ -52,18 +66,30 @@ class FileProcessingService {
return processingPromise; return processingPromise;
} }
private async performProcessing(file: File, fileId: string): Promise<FileProcessingResult> { private async performProcessing(file: File, fileId: string, abortController: AbortController): Promise<FileProcessingResult> {
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`); console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
try { try {
// Check for cancellation at start
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
}
let totalPages = 1; let totalPages = 1;
let thumbnailUrl: string | undefined; let thumbnailUrl: string | undefined;
// Handle PDF files // Handle PDF files
if (file.type === 'application/pdf') { if (file.type === 'application/pdf') {
// Read arrayBuffer once and reuse for both PDF.js and fallback
const arrayBuffer = await file.arrayBuffer();
// Check for cancellation after async operation
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
}
// Discover page count using PDF.js (most accurate) // Discover page count using PDF.js (most accurate)
try { try {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await getDocument({ const pdfDoc = await getDocument({
data: arrayBuffer, data: arrayBuffer,
disableAutoFetch: true, disableAutoFetch: true,
@ -75,12 +101,16 @@ class FileProcessingService {
// Clean up immediately // Clean up immediately
pdfDoc.destroy(); pdfDoc.destroy();
// Check for cancellation after PDF.js processing
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
}
} catch (pdfError) { } catch (pdfError) {
console.warn(`📁 FileProcessingService: PDF.js failed for ${file.name}, trying fallback:`, pdfError); console.warn(`📁 FileProcessingService: PDF.js failed for ${file.name}, trying fallback:`, pdfError);
// Fallback to text analysis // Fallback to text analysis (reuse same arrayBuffer)
try { try {
const arrayBuffer = await file.arrayBuffer();
const text = new TextDecoder('latin1').decode(arrayBuffer); const text = new TextDecoder('latin1').decode(arrayBuffer);
const pageMatches = text.match(/\/Type\s*\/Page[^s]/g); const pageMatches = text.match(/\/Type\s*\/Page[^s]/g);
totalPages = pageMatches ? pageMatches.length : 1; totalPages = pageMatches ? pageMatches.length : 1;
@ -96,6 +126,11 @@ class FileProcessingService {
try { try {
thumbnailUrl = await generateThumbnailForFile(file); thumbnailUrl = await generateThumbnailForFile(file);
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`); console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
// Check for cancellation after thumbnail generation
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
}
} catch (thumbError) { } catch (thumbError) {
console.warn(`📁 FileProcessingService: Thumbnail generation failed for ${file.name}:`, thumbError); console.warn(`📁 FileProcessingService: Thumbnail generation failed for ${file.name}:`, thumbError);
} }
@ -145,6 +180,30 @@ class FileProcessingService {
isProcessing(fileId: string): boolean { isProcessing(fileId: string): boolean {
return this.processingCache.has(fileId); return this.processingCache.has(fileId);
} }
/**
* Cancel processing for a specific file
*/
cancelProcessing(fileId: string): boolean {
const operation = this.processingCache.get(fileId);
if (operation) {
operation.abortController.abort();
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
return true;
}
return false;
}
/**
* Cancel all ongoing processing operations
*/
cancelAllProcessing(): void {
this.processingCache.forEach((operation, fileId) => {
operation.abortController.abort();
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
});
console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`);
}
} }
// Export singleton instance // Export singleton instance

View File

@ -1,8 +1,11 @@
/** /**
* IndexedDB File Storage Service * IndexedDB File Storage Service
* Provides high-capacity file storage for PDF processing * Provides high-capacity file storage for PDF processing
* Now uses centralized IndexedDB manager
*/ */
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface StoredFile { export interface StoredFile {
id: string; id: string;
name: string; name: string;
@ -22,69 +25,21 @@ export interface StorageStats {
} }
class FileStorageService { class FileStorageService {
private dbName = 'stirling-pdf-files'; private readonly dbConfig = DATABASE_CONFIGS.FILES;
private dbVersion = 2; // Increment version to force schema update private readonly storeName = 'files';
private storeName = 'files';
private db: IDBDatabase | null = null;
private initPromise: Promise<void> | null = null;
/** /**
* Initialize the IndexedDB database (singleton pattern) * Get database connection using centralized manager
*/ */
async init(): Promise<void> { private async getDatabase(): Promise<IDBDatabase> {
if (this.db) { return indexedDBManager.openDatabase(this.dbConfig);
return Promise.resolve();
}
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
this.initPromise = null;
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
console.log('IndexedDB connection established');
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const oldVersion = (event as any).oldVersion;
console.log('IndexedDB upgrade needed from version', oldVersion, 'to', this.dbVersion);
// Only recreate object store if it doesn't exist or if upgrading from version < 2
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
store.createIndex('lastModified', 'lastModified', { unique: false });
console.log('IndexedDB object store created with keyPath: id');
} else if (oldVersion < 2) {
// Only delete and recreate if upgrading from version 1 to 2
db.deleteObjectStore(this.storeName);
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
store.createIndex('lastModified', 'lastModified', { unique: false });
console.log('IndexedDB object store recreated with keyPath: id (version upgrade)');
}
};
});
return this.initPromise;
} }
/** /**
* Store a file in IndexedDB with external UUID * Store a file in IndexedDB with external UUID
*/ */
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> { async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> {
if (!this.db) await this.init(); const db = await this.getDatabase();
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
@ -100,7 +55,7 @@ class FileStorageService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const transaction = this.db!.transaction([this.storeName], 'readwrite'); const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
// Debug logging // Debug logging
@ -134,10 +89,10 @@ class FileStorageService {
* Retrieve a file from IndexedDB * Retrieve a file from IndexedDB
*/ */
async getFile(id: string): Promise<StoredFile | null> { async getFile(id: string): Promise<StoredFile | null> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.get(id); const request = store.get(id);
@ -150,10 +105,10 @@ class FileStorageService {
* Get all stored files (WARNING: loads all data into memory) * Get all stored files (WARNING: loads all data into memory)
*/ */
async getAllFiles(): Promise<StoredFile[]> { async getAllFiles(): Promise<StoredFile[]> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.getAll(); const request = store.getAll();
@ -175,10 +130,10 @@ class FileStorageService {
* Get metadata of all stored files (without loading data into memory) * Get metadata of all stored files (without loading data into memory)
*/ */
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> { async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.openCursor(); const request = store.openCursor();
const files: Omit<StoredFile, 'data'>[] = []; const files: Omit<StoredFile, 'data'>[] = [];
@ -212,10 +167,10 @@ class FileStorageService {
* Delete a file from IndexedDB * Delete a file from IndexedDB
*/ */
async deleteFile(id: string): Promise<void> { async deleteFile(id: string): Promise<void> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readwrite'); const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.delete(id); const request = store.delete(id);
@ -228,9 +183,9 @@ class FileStorageService {
* Update the lastModified timestamp of a file (for most recently used sorting) * Update the lastModified timestamp of a file (for most recently used sorting)
*/ */
async touchFile(id: string): Promise<boolean> { async touchFile(id: string): Promise<boolean> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readwrite'); const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id); const getRequest = store.get(id);
@ -254,10 +209,10 @@ class FileStorageService {
* Clear all stored files * Clear all stored files
*/ */
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readwrite'); const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.clear(); const request = store.clear();
@ -270,8 +225,6 @@ class FileStorageService {
* Get storage statistics (only our IndexedDB usage) * Get storage statistics (only our IndexedDB usage)
*/ */
async getStorageStats(): Promise<StorageStats> { async getStorageStats(): Promise<StorageStats> {
if (!this.db) await this.init();
let used = 0; let used = 0;
let available = 0; let available = 0;
let quota: number | undefined; let quota: number | undefined;
@ -314,10 +267,10 @@ class FileStorageService {
* Get file count quickly without loading metadata * Get file count quickly without loading metadata
*/ */
async getFileCount(): Promise<number> { async getFileCount(): Promise<number> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.count(); const request = store.count();
@ -364,9 +317,9 @@ class FileStorageService {
// Also check our specific database with different versions // Also check our specific database with different versions
for (let version = 1; version <= 3; version++) { for (let version = 1; version <= 3; version++) {
try { try {
console.log(`Trying to open ${this.dbName} version ${version}...`); console.log(`Trying to open ${this.dbConfig.name} version ${version}...`);
const db = await new Promise<IDBDatabase>((resolve, reject) => { const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbName, version); const request = indexedDB.open(this.dbConfig.name, version);
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
@ -399,10 +352,10 @@ class FileStorageService {
* Debug method to check what's actually in the database * Debug method to check what's actually in the database
*/ */
async debugDatabaseContents(): Promise<void> { async debugDatabaseContents(): Promise<void> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
// First try getAll to see if there's anything // First try getAll to see if there's anything
@ -526,11 +479,11 @@ class FileStorageService {
* Update thumbnail for an existing file * Update thumbnail for an existing file
*/ */
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> { async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
if (!this.db) await this.init(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const transaction = this.db!.transaction([this.storeName], 'readwrite'); const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id); const getRequest = store.get(id);

View File

@ -0,0 +1,227 @@
/**
* Centralized IndexedDB Manager
* Handles all database initialization, schema management, and migrations
* Prevents race conditions and duplicate schema upgrades
*/
export interface DatabaseConfig {
name: string;
version: number;
stores: {
name: string;
keyPath?: string | string[];
autoIncrement?: boolean;
indexes?: {
name: string;
keyPath: string | string[];
unique: boolean;
}[];
}[];
}
class IndexedDBManager {
private static instance: IndexedDBManager;
private databases = new Map<string, IDBDatabase>();
private initPromises = new Map<string, Promise<IDBDatabase>>();
private constructor() {}
static getInstance(): IndexedDBManager {
if (!IndexedDBManager.instance) {
IndexedDBManager.instance = new IndexedDBManager();
}
return IndexedDBManager.instance;
}
/**
* Open or get existing database connection
*/
async openDatabase(config: DatabaseConfig): Promise<IDBDatabase> {
const existingDb = this.databases.get(config.name);
if (existingDb) {
return existingDb;
}
const existingPromise = this.initPromises.get(config.name);
if (existingPromise) {
return existingPromise;
}
const initPromise = this.performDatabaseInit(config);
this.initPromises.set(config.name, initPromise);
try {
const db = await initPromise;
this.databases.set(config.name, db);
return db;
} catch (error) {
this.initPromises.delete(config.name);
throw error;
}
}
private performDatabaseInit(config: DatabaseConfig): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
console.log(`Opening IndexedDB: ${config.name} v${config.version}`);
const request = indexedDB.open(config.name, config.version);
request.onerror = () => {
console.error(`Failed to open ${config.name}:`, request.error);
reject(request.error);
};
request.onsuccess = () => {
const db = request.result;
console.log(`Successfully opened ${config.name}`);
// Set up close handler to clean up our references
db.onclose = () => {
console.log(`Database ${config.name} closed`);
this.databases.delete(config.name);
this.initPromises.delete(config.name);
};
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = request.result;
const oldVersion = event.oldVersion;
console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`);
// Create or update object stores
config.stores.forEach(storeConfig => {
let store: IDBObjectStore;
if (db.objectStoreNames.contains(storeConfig.name)) {
// Store exists - for now, just continue (could add migration logic here)
console.log(`Object store '${storeConfig.name}' already exists`);
return;
}
// Create new object store
const options: IDBObjectStoreParameters = {};
if (storeConfig.keyPath) {
options.keyPath = storeConfig.keyPath;
}
if (storeConfig.autoIncrement) {
options.autoIncrement = storeConfig.autoIncrement;
}
store = db.createObjectStore(storeConfig.name, options);
console.log(`Created object store '${storeConfig.name}'`);
// Create indexes
if (storeConfig.indexes) {
storeConfig.indexes.forEach(indexConfig => {
store.createIndex(
indexConfig.name,
indexConfig.keyPath,
{ unique: indexConfig.unique }
);
console.log(`Created index '${indexConfig.name}' on '${storeConfig.name}'`);
});
}
});
};
});
}
/**
* Get database connection (must be already opened)
*/
getDatabase(name: string): IDBDatabase | null {
return this.databases.get(name) || null;
}
/**
* Close database connection
*/
closeDatabase(name: string): void {
const db = this.databases.get(name);
if (db) {
db.close();
this.databases.delete(name);
this.initPromises.delete(name);
}
}
/**
* Close all database connections
*/
closeAllDatabases(): void {
this.databases.forEach((db, name) => {
console.log(`Closing database: ${name}`);
db.close();
});
this.databases.clear();
this.initPromises.clear();
}
/**
* Delete database completely
*/
async deleteDatabase(name: string): Promise<void> {
// Close connection if open
this.closeDatabase(name);
return new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(name);
deleteRequest.onerror = () => reject(deleteRequest.error);
deleteRequest.onsuccess = () => {
console.log(`Deleted database: ${name}`);
resolve();
};
});
}
/**
* Check if a database exists and what version it is
*/
async getDatabaseVersion(name: string): Promise<number | null> {
return new Promise((resolve) => {
const request = indexedDB.open(name);
request.onsuccess = () => {
const db = request.result;
const version = db.version;
db.close();
resolve(version);
};
request.onerror = () => resolve(null);
request.onupgradeneeded = () => {
// Cancel the upgrade
request.transaction?.abort();
resolve(null);
};
});
}
}
// Pre-defined database configurations
export const DATABASE_CONFIGS = {
FILES: {
name: 'stirling-pdf-files',
version: 2,
stores: [{
name: 'files',
keyPath: 'id',
indexes: [
{ name: 'name', keyPath: 'name', unique: false },
{ name: 'lastModified', keyPath: 'lastModified', unique: false }
]
}]
} as DatabaseConfig,
DRAFTS: {
name: 'stirling-pdf-drafts',
version: 1,
stores: [{
name: 'drafts',
keyPath: 'id'
}]
} as DatabaseConfig
} as const;
export const indexedDBManager = IndexedDBManager.getInstance();

View File

@ -7,8 +7,6 @@ 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
thumbnail?: string; thumbnail?: string;
contentHash?: string; // SHA-256 content hash
hashStatus?: 'pending' | 'completed' | 'failed';
storedInIndexedDB?: boolean; storedInIndexedDB?: boolean;
} }
@ -22,8 +20,6 @@ export interface FileMetadata {
size: number; size: number;
lastModified: number; lastModified: number;
thumbnail?: string; thumbnail?: string;
contentHash?: string;
hashStatus?: 'pending' | 'completed' | 'failed';
storedInIndexedDB?: boolean; storedInIndexedDB?: boolean;
} }

View File

@ -4,6 +4,7 @@
import { ProcessedFile } from './processing'; import { ProcessedFile } from './processing';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
import { FileMetadata } from './file';
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert'; export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert';
@ -16,11 +17,10 @@ export interface FileRecord {
size: number; size: number;
type: string; type: string;
lastModified: number; lastModified: number;
quickKey: string; // Fast deduplication key: name|size|lastModified
thumbnailUrl?: string; thumbnailUrl?: string;
blobUrl?: string; blobUrl?: string;
createdAt: number; createdAt: number;
contentHash?: string; // Optional content hash for deduplication
hashStatus?: 'pending' | 'completed' | 'failed'; // Hash computation status
processedFile?: { processedFile?: {
pages: Array<{ pages: Array<{
thumbnail?: string; thumbnail?: string;
@ -50,53 +50,18 @@ export function createFileId(): FileId {
}); });
} }
// Generate quick deduplication key from file metadata
export function createQuickKey(file: File): string {
// Format: name|size|lastModified for fast duplicate detection
return `${file.name}|${file.size}|${file.lastModified}`;
}
// Legacy support - now just delegates to createFileId // Legacy support - now just delegates to createFileId
export function createStableFileId(file: File): FileId { export function createStableFileId(file: File): FileId {
// Don't mutate File objects - always return new UUID // Don't mutate File objects - always return new UUID
return createFileId(); return createFileId();
} }
// Multi-region content hash for deduplication (head + middle + tail)
export async function computeContentHash(file: File): Promise<string | null> {
try {
const fileSize = file.size;
const chunkSize = 32 * 1024; // 32KB chunks
const chunks: ArrayBuffer[] = [];
// Head chunk (first 32KB)
chunks.push(await file.slice(0, Math.min(chunkSize, fileSize)).arrayBuffer());
// Middle chunk (if file is large enough)
if (fileSize > chunkSize * 2) {
const middleStart = Math.floor(fileSize / 2) - Math.floor(chunkSize / 2);
chunks.push(await file.slice(middleStart, middleStart + chunkSize).arrayBuffer());
}
// Tail chunk (last 32KB, if different from head)
if (fileSize > chunkSize) {
const tailStart = Math.max(chunkSize, fileSize - chunkSize);
chunks.push(await file.slice(tailStart, fileSize).arrayBuffer());
}
// Combine all chunks
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
// Hash the combined chunks
const hashBuffer = await window.crypto.subtle.digest('SHA-256', combined);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (error) {
console.warn('Content hash calculation failed:', error);
return null;
}
}
export function toFileRecord(file: File, id?: FileId): FileRecord { export function toFileRecord(file: File, id?: FileId): FileRecord {
const fileId = id || createStableFileId(file); const fileId = id || createStableFileId(file);
@ -106,6 +71,7 @@ export function toFileRecord(file: File, id?: FileId): FileRecord {
size: file.size, size: file.size,
type: file.type, type: file.type,
lastModified: file.lastModified, lastModified: file.lastModified,
quickKey: createQuickKey(file),
createdAt: Date.now() createdAt: Date.now()
}; };
} }
@ -225,6 +191,7 @@ export type FileContextAction =
export interface FileContextActions { export interface FileContextActions {
// File management - lightweight actions only // File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>; addFiles: (files: File[]) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void; updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
clearAllFiles: () => void; clearAllFiles: () => void;

View File

@ -3,7 +3,7 @@
* Centralizes all PDF operations with proper type safety * Centralizes all PDF operations with proper type safety
*/ */
import { FileId } from './fileRecord'; import { FileId } from './fileContext';
export type OperationId = string; export type OperationId = string;
@ -26,7 +26,7 @@ export interface BaseOperation {
createdAt: number; createdAt: number;
startedAt?: number; startedAt?: number;
completedAt?: number; completedAt?: number;
abortController?: AbortController;1 abortController?: AbortController;
} }
// Split operations // Split operations