mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Merge branch 'V2' into feature/v2/embed-pdf
This commit is contained in:
@@ -22,13 +22,12 @@ import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
||||
import { createFileSelectors } from './file/fileSelectors';
|
||||
import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { FileLifecycleManager } from './file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||
@@ -73,58 +72,44 @@ function FileContextInner({
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []);
|
||||
|
||||
const selectFiles = (addedFilesWithIds: AddedFile[]) => {
|
||||
const selectFiles = (stirlingFiles: StirlingFile[]) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const newFileIds = addedFilesWithIds.map(({ id }) => id);
|
||||
const newFileIds = stirlingFiles.map(stirlingFile => stirlingFile.fileId);
|
||||
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
|
||||
}
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
const stirlingFiles = await addFiles({ files, ...options }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && addedFilesWithIds.length > 0) {
|
||||
selectFiles(addedFilesWithIds);
|
||||
if (options?.selectFiles && stirlingFiles.length > 0) {
|
||||
selectFiles(stirlingFiles);
|
||||
}
|
||||
|
||||
// Persist to IndexedDB if enabled
|
||||
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 stirlingFiles;
|
||||
}, [enablePersistence]);
|
||||
|
||||
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, [indexedDB, enablePersistence]);
|
||||
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
|
||||
const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && result.length > 0) {
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
|
||||
// Action creators
|
||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
|
||||
}, []);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
@@ -143,8 +128,7 @@ function FileContextInner({
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
...baseActions,
|
||||
addFiles: addRawFiles,
|
||||
addProcessedFiles,
|
||||
addStoredFiles,
|
||||
addStirlingFileStubs: addStirlingFileStubsAction,
|
||||
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||
// Remove from memory and cleanup resources
|
||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||
@@ -199,8 +183,7 @@ function FileContextInner({
|
||||
}), [
|
||||
baseActions,
|
||||
addRawFiles,
|
||||
addProcessedFiles,
|
||||
addStoredFiles,
|
||||
addStirlingFileStubsAction,
|
||||
lifecycleManager,
|
||||
setHasUnsavedChanges,
|
||||
consumeFilesWrapper,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
import { FileId } from '../types/file';
|
||||
import { groupFilesByOriginal } from '../utils/fileHistoryUtils';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
@@ -10,27 +11,34 @@ interface FileManagerContextValue {
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: FileId[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileMetadata[];
|
||||
filteredFiles: FileMetadata[];
|
||||
selectedFiles: StirlingFileStub[];
|
||||
filteredFiles: StirlingFileStub[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
selectedFilesSet: Set<FileId>;
|
||||
expandedFileIds: Set<FileId>;
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
|
||||
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileMetadata) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: FileMetadata) => void;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onToggleExpansion: (fileId: FileId) => void;
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileMetadata[];
|
||||
recentFiles: StirlingFileStub[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
@@ -41,8 +49,8 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: FileMetadata[];
|
||||
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files
|
||||
recentFiles: StirlingFileStub[];
|
||||
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
|
||||
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
||||
onClose: () => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
@@ -55,7 +63,7 @@ interface FileManagerProviderProps {
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onRecentFilesSelected,
|
||||
onNewFilesSelect,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
@@ -68,19 +76,44 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
|
||||
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
||||
// Group files by original file ID for version management
|
||||
const fileGroups = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return new Map();
|
||||
|
||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||
(recentFiles || []).filter(file =>
|
||||
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
|
||||
const recordsForGrouping = recentFiles.map(file => ({
|
||||
...file,
|
||||
originalFileId: file.originalFileId,
|
||||
versionNumber: file.versionNumber || 1
|
||||
}));
|
||||
|
||||
return groupFilesByOriginal(recordsForGrouping);
|
||||
}, [recentFiles]);
|
||||
|
||||
// Get files to display with expansion logic
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return [];
|
||||
|
||||
// Only return leaf files - history files will be handled by separate components
|
||||
return recentFiles;
|
||||
}, [recentFiles]);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
||||
|
||||
const filteredFiles = !searchTerm ? displayFiles :
|
||||
displayFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -97,7 +130,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
@@ -138,27 +171,214 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [filteredFiles, lastClickedIndex]);
|
||||
|
||||
const handleFileRemove = useCallback((index: number) => {
|
||||
// Helper function to safely determine which files can be deleted
|
||||
const getSafeFilesToDelete = useCallback((
|
||||
fileIds: FileId[],
|
||||
allStoredStubs: StirlingFileStub[]
|
||||
): FileId[] => {
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
const filesToDelete = new Set<FileId>();
|
||||
const filesToPreserve = new Set<FileId>();
|
||||
|
||||
// First, identify all files in the lineages of the leaf files being deleted
|
||||
for (const leafFileId of fileIds) {
|
||||
const currentFile = fileMap.get(leafFileId);
|
||||
if (!currentFile) continue;
|
||||
|
||||
// Always include the leaf file itself for deletion
|
||||
filesToDelete.add(leafFileId);
|
||||
|
||||
// If this is a processed file with history, trace back through its lineage
|
||||
if (currentFile.versionNumber && currentFile.versionNumber > 1) {
|
||||
const originalFileId = currentFile.originalFileId || currentFile.id;
|
||||
|
||||
// Find all files in this history chain
|
||||
const chainFiles = allStoredStubs.filter((file: StirlingFileStub) =>
|
||||
(file.originalFileId || file.id) === originalFileId
|
||||
);
|
||||
|
||||
// Add all files in this lineage as candidates for deletion
|
||||
chainFiles.forEach(file => filesToDelete.add(file.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Now identify files that must be preserved because they're referenced by OTHER lineages
|
||||
for (const file of allStoredStubs) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
|
||||
if (file.isLeaf !== false && !fileIds.includes(file.id)) {
|
||||
// Find all files in this preserved lineage
|
||||
const preservedChainFiles = allStoredStubs.filter((chainFile: StirlingFileStub) =>
|
||||
(chainFile.originalFileId || chainFile.id) === fileOriginalId
|
||||
);
|
||||
|
||||
// Mark all files in this preserved lineage as must-preserve
|
||||
preservedChainFiles.forEach(chainFile => filesToPreserve.add(chainFile.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Final list: files to delete minus files that must be preserved
|
||||
let safeToDelete = Array.from(filesToDelete).filter(fileId => !filesToPreserve.has(fileId));
|
||||
|
||||
// Check for orphaned non-leaf files after main deletion
|
||||
const remainingFiles = allStoredStubs.filter(file => !safeToDelete.includes(file.id));
|
||||
const orphanedNonLeafFiles: FileId[] = [];
|
||||
|
||||
for (const file of remainingFiles) {
|
||||
// Only check non-leaf files (files that have been processed and have children)
|
||||
if (file.isLeaf === false) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// Check if this non-leaf file has any living descendants
|
||||
const hasLivingDescendants = remainingFiles.some(otherFile => {
|
||||
// Check if otherFile is a descendant of this file
|
||||
const otherOriginalId = otherFile.originalFileId || otherFile.id;
|
||||
return (
|
||||
// Direct parent relationship
|
||||
otherFile.parentFileId === file.id ||
|
||||
// Same lineage but different from this file
|
||||
(otherOriginalId === fileOriginalId && otherFile.id !== file.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasLivingDescendants) {
|
||||
orphanedNonLeafFiles.push(file.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orphaned non-leaf files to deletion list
|
||||
safeToDelete = [...safeToDelete, ...orphanedNonLeafFiles];
|
||||
|
||||
return safeToDelete;
|
||||
}, []);
|
||||
|
||||
// Shared internal delete logic
|
||||
const performFileDelete = useCallback(async (fileToRemove: StirlingFileStub, fileIndex: number) => {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Get all stored files to analyze lineages
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// Get safe files to delete (respecting shared lineages)
|
||||
const filesToDelete = getSafeFilesToDelete([deletedFileId], allStoredStubs);
|
||||
|
||||
// Clear from selection immediately
|
||||
setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id)));
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
filesToDelete.forEach(id => newExpanded.delete(id));
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// Remove cache entries for all deleted files
|
||||
filesToDelete.forEach(id => newCache.delete(id as FileId));
|
||||
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id));
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
for (const fileId of filesToDelete) {
|
||||
await fileStorage.deleteStirlingFile(fileId as FileId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Call the parent's deletion logic for the main file only
|
||||
onFileRemove(fileIndex);
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}, [getSafeFilesToDelete, setSelectedFileIds, setExpandedFileIds, setLoadedHistoryFiles, onFileRemove, refreshRecentFiles]);
|
||||
|
||||
const handleFileRemove = useCallback(async (index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
if (fileToRemove) {
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
|
||||
await performFileDelete(fileToRemove, index);
|
||||
}
|
||||
onFileRemove(index);
|
||||
}, [filteredFiles, onFileRemove]);
|
||||
}, [filteredFiles, performFileDelete]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||
// Handle deletion by fileId (more robust than index-based)
|
||||
const handleFileRemoveById = useCallback(async (fileId: FileId) => {
|
||||
// Find the file and its index in filteredFiles
|
||||
const fileIndex = filteredFiles.findIndex(file => file.id === fileId);
|
||||
const fileToRemove = filteredFiles[fileIndex];
|
||||
|
||||
if (fileToRemove && fileIndex !== -1) {
|
||||
await performFileDelete(fileToRemove, fileIndex);
|
||||
}
|
||||
}, [filteredFiles, performFileDelete]);
|
||||
|
||||
// Handle deletion of specific history files (not index-based)
|
||||
const handleHistoryFileRemove = useCallback(async (fileToRemove: StirlingFileStub) => {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
newExpanded.delete(deletedFileId);
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// Remove cache entries for all deleted files
|
||||
newCache.delete(deletedFileId);
|
||||
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => deletedFileId != histFile.id);
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
await fileStorage.deleteStirlingFile(deletedFileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: StirlingFileStub) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onFilesSelected([file]);
|
||||
onRecentFilesSelected([file]);
|
||||
onClose();
|
||||
}
|
||||
}, [isFileSupported, onFilesSelected, onClose]);
|
||||
}, [isFileSupported, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
onFilesSelected(selectedFiles);
|
||||
onRecentFilesSelected(selectedFiles);
|
||||
onClose();
|
||||
}
|
||||
}, [selectedFiles, onFilesSelected, onClose]);
|
||||
}, [selectedFiles, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
@@ -196,25 +416,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Get files to delete based on current filtered view
|
||||
const filesToDelete = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Delete files from storage
|
||||
for (const file of filesToDelete) {
|
||||
await fileStorage.deleteFile(file.id);
|
||||
// Delete each selected file using the proven single delete logic
|
||||
for (const fileId of selectedFileIds) {
|
||||
await handleFileRemoveById(fileId);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
setSelectedFileIds([]);
|
||||
|
||||
// Refresh the file list
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
|
||||
}, [selectedFileIds, handleFileRemoveById]);
|
||||
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
@@ -235,7 +444,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles]);
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
|
||||
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
@@ -243,6 +452,94 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleExpansion = useCallback(async (fileId: FileId) => {
|
||||
const isCurrentlyExpanded = expandedFileIds.has(fileId);
|
||||
|
||||
// Update expansion state
|
||||
setExpandedFileIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(fileId)) {
|
||||
newSet.delete(fileId);
|
||||
} else {
|
||||
newSet.add(fileId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Load complete history chain if expanding
|
||||
if (!isCurrentlyExpanded) {
|
||||
const currentFileMetadata = recentFiles.find(f => f.id === fileId);
|
||||
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
|
||||
try {
|
||||
// Get all stored file metadata for chain traversal
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
|
||||
// Get the current file's IndexedDB data
|
||||
const currentStoredStub = fileMap.get(fileId as FileId);
|
||||
if (!currentStoredStub) {
|
||||
console.warn(`No stored file found for ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build complete history chain using IndexedDB metadata
|
||||
const historyFiles: StirlingFileStub[] = [];
|
||||
|
||||
// Find the original file
|
||||
|
||||
// Collect only files in this specific branch (ancestors of current file)
|
||||
const chainFiles: StirlingFileStub[] = [];
|
||||
const allFiles = Array.from(fileMap.values());
|
||||
|
||||
// Build a map for fast parent lookups
|
||||
const fileIdMap = new Map<FileId, StirlingFileStub>();
|
||||
allFiles.forEach(f => fileIdMap.set(f.id, f));
|
||||
|
||||
// Trace back from current file through parent chain
|
||||
let currentFile = fileIdMap.get(fileId);
|
||||
while (currentFile?.parentFileId) {
|
||||
const parentFile = fileIdMap.get(currentFile.parentFileId);
|
||||
if (parentFile) {
|
||||
chainFiles.push(parentFile);
|
||||
currentFile = parentFile;
|
||||
} else {
|
||||
break; // Parent not found, stop tracing
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by version number (oldest first for history display)
|
||||
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
|
||||
|
||||
// StirlingFileStubs already have all the data we need - no conversion required!
|
||||
historyFiles.push(...chainFiles);
|
||||
|
||||
// Cache the loaded history files
|
||||
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load history chain for file ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear loaded history when collapsing
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(fileId as FileId);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [expandedFileIds, recentFiles]);
|
||||
|
||||
const handleAddToRecents = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
// Mark the file as a leaf node so it appears in recent files
|
||||
await fileStorage.markFileAsLeaf(file.id);
|
||||
|
||||
// Refresh the recent files list to show updated state
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to add to recents:', error);
|
||||
}
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
@@ -274,12 +571,16 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
onLocalFileClick: handleLocalFileClick,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFileRemove: handleFileRemove,
|
||||
onHistoryFileRemove: handleHistoryFileRemove,
|
||||
onFileDoubleClick: handleFileDoubleClick,
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
@@ -288,6 +589,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onDeleteSelected: handleDeleteSelected,
|
||||
onDownloadSelected: handleDownloadSelected,
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
onToggleExpansion: handleToggleExpansion,
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onNewFilesSelect,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
@@ -300,10 +604,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleFileRemoveById,
|
||||
performFileDelete,
|
||||
handleFileDoubleClick,
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
@@ -311,6 +620,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleSelectAll,
|
||||
handleDeleteSelected,
|
||||
handleDownloadSelected,
|
||||
handleToggleExpansion,
|
||||
handleAddToRecents,
|
||||
onNewFilesSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { FileId } from '../types/file';
|
||||
import { useFileActions } from './FileContext';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void;
|
||||
onFileUpload: (files: File[]) => void;
|
||||
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
@@ -17,7 +17,8 @@ interface FilesModalContextType {
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
const { actions } = useFileActions();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||
@@ -36,39 +37,45 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler([file], insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addToActiveFiles(file);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
const handleFileUpload = useCallback((files: File[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler(files, insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addMultipleFiles(files);
|
||||
addFiles(files);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
|
||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
const files = filesWithMetadata.map(item => item.file);
|
||||
customHandler(files, insertAfterPage);
|
||||
// Load the actual files from storage for custom handler
|
||||
try {
|
||||
const loadedFiles: File[] = [];
|
||||
for (const stub of stirlingFileStubs) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (stirlingFile) {
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
customHandler(loadedFiles, insertAfterPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load files for custom handler:', error);
|
||||
}
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addStoredFiles(filesWithMetadata);
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
||||
if (actions.addStirlingFileStubs) {
|
||||
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
|
||||
} else {
|
||||
console.error('addStirlingFileStubs action not available');
|
||||
}
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
@@ -78,18 +85,16 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onStoredFilesSelect: handleStoredFilesSelect,
|
||||
onFileUpload: handleFileUpload,
|
||||
onRecentFileSelect: handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileSelect,
|
||||
handleFilesSelect,
|
||||
handleStoredFilesSelect,
|
||||
handleFileUpload,
|
||||
handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
||||
@@ -4,28 +4,30 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StirlingFileStub, createStirlingFile, createQuickKey } from '../types/fileContext';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StirlingFileStub>;
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<StirlingFileStub | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||
loadAllMetadata: () => Promise<StirlingFileStub[]>;
|
||||
loadLeafMetadata: () => Promise<StirlingFileStub[]>; // Only leaf files for recent files list
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Utilities
|
||||
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
||||
@@ -56,26 +58,42 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StirlingFileStub> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
|
||||
// Create minimal stub for storage
|
||||
const stub: StirlingFileStub = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
quickKey: createQuickKey(file),
|
||||
thumbnailUrl: thumbnail,
|
||||
isLeaf: true,
|
||||
createdAt: Date.now(),
|
||||
versionNumber: 1,
|
||||
originalFileId: fileId,
|
||||
toolHistory: []
|
||||
};
|
||||
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
const storedFile = await fileStorage.getStirlingFileStub(fileId);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
// Return metadata
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
thumbnail
|
||||
};
|
||||
// Return StirlingFileStub from the stored file (no conversion needed)
|
||||
if (!storedFile) {
|
||||
throw new Error(`Failed to retrieve stored file after saving: ${file.name}`);
|
||||
}
|
||||
|
||||
return storedFile;
|
||||
}, []);
|
||||
|
||||
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||
@@ -88,14 +106,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
}
|
||||
|
||||
// Load from IndexedDB
|
||||
const storedFile = await fileStorage.getFile(fileId);
|
||||
const storedFile = await fileStorage.getStirlingFile(fileId);
|
||||
if (!storedFile) return null;
|
||||
|
||||
// Reconstruct File object
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
// StirlingFile is already a File object, no reconstruction needed
|
||||
const file = storedFile;
|
||||
|
||||
// Cache for future use with LRU eviction
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
@@ -104,34 +119,9 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
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 loadMetadata = useCallback(async (fileId: FileId): Promise<StirlingFileStub | null> => {
|
||||
// Load stub directly from storage service
|
||||
return await fileStorage.getStirlingFileStub(fileId);
|
||||
}, []);
|
||||
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
@@ -139,20 +129,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteFile(fileId);
|
||||
await fileStorage.deleteStirlingFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const loadLeafMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files
|
||||
|
||||
return metadata.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail
|
||||
}));
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||
@@ -160,7 +152,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
fileIds.forEach(id => fileCache.current.delete(id));
|
||||
|
||||
// Remove from IndexedDB in parallel
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id)));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
@@ -179,16 +171,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
return await fileStorage.updateThumbnail(fileId, thumbnail);
|
||||
}, []);
|
||||
|
||||
const markFileAsProcessed = useCallback(async (fileId: FileId): Promise<boolean> => {
|
||||
return await fileStorage.markFileAsProcessed(fileId);
|
||||
}, []);
|
||||
|
||||
const value: IndexedDBContextValue = {
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
loadLeafMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail
|
||||
updateThumbnail,
|
||||
markFileAsProcessed
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -125,16 +125,18 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
return state; // File doesn't exist, no-op
|
||||
}
|
||||
|
||||
const updatedRecord = {
|
||||
...existingRecord,
|
||||
...updates
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
byId: {
|
||||
...state.files.byId,
|
||||
[id]: {
|
||||
...existingRecord,
|
||||
...updates
|
||||
}
|
||||
[id]: updatedRecord
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
StirlingFileStub,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
toStirlingFileStub,
|
||||
createNewStirlingFileStub,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
createQuickKey,
|
||||
createStirlingFile,
|
||||
ProcessedFileMetadata,
|
||||
} from '../../types/fileContext';
|
||||
import { FileId, FileMetadata } from '../../types/file';
|
||||
import { FileId } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { buildQuickKeySet } from './fileSelectors';
|
||||
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
@@ -69,345 +72,283 @@ export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* File addition types
|
||||
* Generate fresh ProcessedFileMetadata for a file
|
||||
* Used when tools process files to ensure metadata matches actual file content
|
||||
*/
|
||||
type AddFileKind = 'raw' | 'processed' | 'stored';
|
||||
export async function generateProcessedFileMetadata(file: File): Promise<ProcessedFileMetadata | undefined> {
|
||||
// Only generate metadata for PDF files
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
return createProcessedFile(result.pageCount, result.thumbnail);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child StirlingFileStub from a parent stub with proper history management.
|
||||
* Used when a tool processes an existing file to create a new version with incremented history.
|
||||
*
|
||||
* @param parentStub - The parent StirlingFileStub to create a child from
|
||||
* @param operation - Tool operation information (toolName, timestamp)
|
||||
* @param resultingFile - The processed File object
|
||||
* @param thumbnail - Optional thumbnail for the child
|
||||
* @param processedFileMetadata - Optional fresh metadata for the processed file
|
||||
* @returns New child StirlingFileStub with proper version history
|
||||
*/
|
||||
export function createChildStub(
|
||||
parentStub: StirlingFileStub,
|
||||
operation: { toolName: string; timestamp: number },
|
||||
resultingFile: File,
|
||||
thumbnail?: string,
|
||||
processedFileMetadata?: ProcessedFileMetadata
|
||||
): StirlingFileStub {
|
||||
const newFileId = createFileId();
|
||||
|
||||
// Build new tool history by appending to parent's history
|
||||
const parentToolHistory = parentStub.toolHistory || [];
|
||||
const newToolHistory = [...parentToolHistory, operation];
|
||||
|
||||
// Calculate new version number
|
||||
const newVersionNumber = (parentStub.versionNumber || 1) + 1;
|
||||
|
||||
// Determine original file ID (root of the version chain)
|
||||
const originalFileId = parentStub.originalFileId || parentStub.id;
|
||||
|
||||
// Copy parent metadata but exclude processedFile to prevent stale data
|
||||
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
||||
|
||||
return {
|
||||
// Copy parent metadata (excluding processedFile)
|
||||
...parentMetadata,
|
||||
|
||||
// Update identity and version info
|
||||
id: newFileId,
|
||||
versionNumber: newVersionNumber,
|
||||
parentFileId: parentStub.id,
|
||||
originalFileId: originalFileId,
|
||||
toolHistory: newToolHistory,
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true, // New child is the current leaf node
|
||||
name: resultingFile.name,
|
||||
size: resultingFile.size,
|
||||
type: resultingFile.type,
|
||||
lastModified: resultingFile.lastModified,
|
||||
thumbnailUrl: thumbnail,
|
||||
|
||||
// Set fresh processedFile metadata (no inheritance from parent)
|
||||
processedFile: processedFileMetadata
|
||||
};
|
||||
}
|
||||
|
||||
interface AddFileOptions {
|
||||
// For 'raw' files
|
||||
files?: File[];
|
||||
|
||||
// For 'processed' files
|
||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||
|
||||
// For 'stored' files
|
||||
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||
|
||||
// Insertion position
|
||||
insertAfterPageId?: string;
|
||||
}
|
||||
|
||||
export interface AddedFile {
|
||||
file: File;
|
||||
id: FileId;
|
||||
thumbnail?: string;
|
||||
// Auto-selection after adding
|
||||
selectFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||
* Unified file addition helper - replaces addFiles
|
||||
*/
|
||||
export async function addFiles(
|
||||
kind: AddFileKind,
|
||||
options: AddFileOptions,
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager
|
||||
): Promise<AddedFile[]> {
|
||||
lifecycleManager: FileLifecycleManager,
|
||||
enablePersistence: boolean = false
|
||||
): Promise<StirlingFile[]> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||
|
||||
switch (kind) {
|
||||
case 'raw': {
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
for (const file of files) {
|
||||
const quickKey = createQuickKey(file);
|
||||
for (const file of files) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count immediately
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
// Non-PDF files: simple thumbnail generation, no page count
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create record with immediate thumbnail and page metadata
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create initial processedFile metadata with page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
case 'processed': {
|
||||
const { filesWithThumbnails = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||
const quickKey = createQuickKey(file);
|
||||
// Generate processedFile metadata using centralized function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create processedFile with provided metadata
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
// Extract thumbnail for non-PDF files or use from processedFile for PDFs
|
||||
let thumbnail: string | undefined;
|
||||
if (processedFileMetadata) {
|
||||
// PDF file - use thumbnail from processedFile metadata
|
||||
thumbnail = processedFileMetadata.thumbnailUrl;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${processedFileMetadata.totalPages} pages, thumbnail: SUCCESS`);
|
||||
} else if (!file.type.startsWith('application/pdf')) {
|
||||
// Non-PDF files: simple thumbnail generation, no processedFile metadata
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stored': {
|
||||
const { filesWithMetadata = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||
|
||||
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
// Try to preserve original ID, but generate new if it conflicts
|
||||
let fileId = originalId;
|
||||
if (filesRef.current.has(originalId)) {
|
||||
fileId = createFileId();
|
||||
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||
}
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||
|
||||
// Get page count from PDF
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
pageCount = pdf.numPages;
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||
}
|
||||
|
||||
// Restore metadata from storage
|
||||
if (metadata.thumbnail) {
|
||||
record.thumbnailUrl = metadata.thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (metadata.thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create processedFile metadata with correct page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
// Create new filestub with processedFile metadata
|
||||
const fileStub = createNewStirlingFileStub(file, fileId, thumbnail, processedFileMetadata);
|
||||
if (thumbnail) {
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
fileStub.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(fileStub);
|
||||
|
||||
// Create StirlingFile directly
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
stirlingFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Persist to storage if enabled using fileStorage service
|
||||
if (enablePersistence && stirlingFiles.length > 0) {
|
||||
await Promise.all(stirlingFiles.map(async (stirlingFile, index) => {
|
||||
try {
|
||||
// Get corresponding stub with all metadata
|
||||
const fileStub = stirlingFileStubs[index];
|
||||
|
||||
// Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly
|
||||
await fileStorage.storeStirlingFile(stirlingFile, fileStub);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, fileStub);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to storage:', stirlingFile.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
return stirlingFiles;
|
||||
} finally {
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to process files into records with thumbnails and metadata
|
||||
*/
|
||||
async function processFilesIntoRecords(
|
||||
files: File[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
}
|
||||
|
||||
return { record, file, fileId, thumbnail };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to persist files to IndexedDB
|
||||
*/
|
||||
async function persistFilesToIndexedDB(
|
||||
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||
): Promise<void> {
|
||||
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
* Now accepts pre-created StirlingFiles and StirlingFileStubs to preserve all metadata
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
outputStirlingFiles: StirlingFile[],
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<FileId[]> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputStirlingFiles.length} output files with pre-created stubs`);
|
||||
|
||||
// Process output files with thumbnails and metadata
|
||||
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
|
||||
// Persist output files to IndexedDB if available
|
||||
if (indexedDB) {
|
||||
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||
// Validate that we have matching files and stubs
|
||||
if (outputStirlingFiles.length !== outputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between output files (${outputStirlingFiles.length}) and stubs (${outputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Dispatch the consume action
|
||||
// Store StirlingFiles in filesRef using their existing IDs (no ID generation needed)
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
if (stirlingFile.fileId !== stub.id) {
|
||||
console.warn(`📄 consumeFiles: ID mismatch between StirlingFile (${stirlingFile.fileId}) and stub (${stub.id})`);
|
||||
}
|
||||
|
||||
filesRef.current.set(stirlingFile.fileId, stirlingFile);
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Stored StirlingFile ${stirlingFile.name} with ID ${stirlingFile.fileId}`);
|
||||
}
|
||||
|
||||
// Mark input files as processed in storage (no longer leaf nodes)
|
||||
if(!outputStirlingFileStubs.reduce((areAllV1, stub) => areAllV1 && (stub.versionNumber == 1), true)) {
|
||||
await Promise.all(
|
||||
inputFileIds.map(async (fileId) => {
|
||||
try {
|
||||
await fileStorage.markFileAsProcessed(fileId);
|
||||
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Save output files directly to fileStorage with complete metadata
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
try {
|
||||
// Use fileStorage directly with complete metadata from stub
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
|
||||
if (DEBUG) console.log(`📄 Saved StirlingFile ${stirlingFile.name} directly to storage with complete metadata:`, {
|
||||
fileId: stirlingFile.fileId,
|
||||
versionNumber: stub.versionNumber,
|
||||
originalFileId: stub.originalFileId,
|
||||
parentFileId: stub.parentFileId,
|
||||
toolChainLength: stub.toolHistory?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist output file to fileStorage:', stirlingFile.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the consume action with pre-created stubs (no processing needed)
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
|
||||
outputStirlingFileStubs: outputStirlingFileStubs
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||
return outputStirlingFileStubs.map(stub => stub.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,6 +459,97 @@ export async function undoConsumeFiles(
|
||||
/**
|
||||
* Action factory functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add files using existing StirlingFileStubs from storage - preserves all metadata
|
||||
* Use this when loading files that already exist in storage (FileManager, etc.)
|
||||
* StirlingFileStubs come with proper thumbnails, history, processing state
|
||||
*/
|
||||
export async function addStirlingFileStubs(
|
||||
stirlingFileStubs: StirlingFileStub[],
|
||||
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
_lifecycleManager: FileLifecycleManager
|
||||
): Promise<StirlingFile[]> {
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
|
||||
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
const validStubs: StirlingFileStub[] = [];
|
||||
const loadedFiles: StirlingFile[] = [];
|
||||
|
||||
for (const stub of stirlingFileStubs) {
|
||||
// Check for duplicates using quickKey
|
||||
if (existingQuickKeys.has(stub.quickKey || '')) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate StirlingFileStub: ${stub.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the actual StirlingFile from storage
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (!stirlingFile) {
|
||||
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${stub.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the loaded file in filesRef
|
||||
filesRef.current.set(stub.id, stirlingFile);
|
||||
|
||||
// Use the original stub (preserves thumbnails, history, metadata!)
|
||||
const record = { ...stub };
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Check if processedFile data needs regeneration for proper Page Editor support
|
||||
if (stirlingFile.type.startsWith('application/pdf')) {
|
||||
const needsProcessing = !record.processedFile ||
|
||||
!record.processedFile.pages ||
|
||||
record.processedFile.pages.length === 0 ||
|
||||
record.processedFile.totalPages !== record.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
|
||||
|
||||
// Use centralized metadata generation function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
|
||||
if (processedFileMetadata) {
|
||||
record.processedFile = processedFileMetadata;
|
||||
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
|
||||
} else {
|
||||
// Fallback for files that couldn't be processed
|
||||
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
|
||||
if (!record.processedFile) {
|
||||
record.processedFile = createProcessedFile(1); // Fallback to 1 page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingQuickKeys.add(stub.quickKey || '');
|
||||
validStubs.push(record);
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (validStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
} finally {
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
|
||||
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
||||
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
||||
|
||||
Reference in New Issue
Block a user