From f88c3e25d16e895356e1e5ab477ef9b5d8e6be40 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Wed, 10 Sep 2025 10:03:35 +0100 Subject: [PATCH] 4 types --- .../public/locales/en-GB/translation.json | 1 + frontend/src/components/FileManager.tsx | 30 +- .../fileEditor/FileEditorThumbnail.tsx | 2 +- .../fileManager/CompactFileDetails.tsx | 12 +- .../components/fileManager/FileInfoCard.tsx | 10 +- .../components/fileManager/FileListItem.tsx | 42 +- .../src/components/shared/FilePreview.tsx | 6 +- .../shared/filePreview/DocumentThumbnail.tsx | 4 +- frontend/src/contexts/FileContext.tsx | 43 +- frontend/src/contexts/FileManagerContext.tsx | 421 +++++++++--------- frontend/src/contexts/FilesModalContext.tsx | 10 +- frontend/src/contexts/IndexedDBContext.tsx | 117 +++-- frontend/src/contexts/file/fileActions.ts | 134 ++---- .../hooks/tools/shared/useToolOperation.ts | 63 ++- frontend/src/hooks/useFileHandler.ts | 10 +- frontend/src/hooks/useFileHistory.ts | 27 +- frontend/src/hooks/useFileManager.ts | 33 +- frontend/src/hooks/useIndexedDBThumbnail.ts | 4 +- frontend/src/services/fileStorage.ts | 76 ++-- frontend/src/services/indexedDBManager.ts | 10 +- frontend/src/services/pdfMetadataService.ts | 5 +- frontend/src/types/file.ts | 29 +- frontend/src/types/fileContext.ts | 37 +- frontend/src/utils/downloadUtils.ts | 10 +- frontend/src/utils/fileHistoryUtils.ts | 332 +------------- frontend/src/utils/toolOperationTracker.ts | 3 +- 26 files changed, 604 insertions(+), 867 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 50e377f6d..7792c5c16 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2151,6 +2151,7 @@ "loadingHistory": "Loading History...", "lastModified": "Last Modified", "toolChain": "Tools Applied", + "addToRecents": "Add to Recents", "searchFiles": "Search files...", "recent": "Recent", "localFiles": "Local Files", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 63ca5c5ec..4e19f25b5 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Modal } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { FileMetadata } from '../types/file'; +import { StoredFileMetadata, fileStorage } from '../services/fileStorage'; import { useFileManager } from '../hooks/useFileManager'; import { useFilesModalContext } from '../contexts/FilesModalContext'; import { Tool } from '../types/tool'; @@ -16,11 +16,11 @@ interface FileManagerProps { const FileManager: React.FC = ({ selectedTool }) => { const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext(); - const [recentFiles, setRecentFiles] = useState([]); + const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); - const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager(); + const { loadRecentFiles, handleRemoveFile } = useFileManager(); // File management handlers const isFileSupported = useCallback((fileName: string) => { @@ -34,21 +34,24 @@ const FileManager: React.FC = ({ selectedTool }) => { setRecentFiles(files); }, [loadRecentFiles]); - const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { + const handleFilesSelected = useCallback(async (files: StoredFileMetadata[]) => { try { // Use stored files flow that preserves original IDs - const filesWithMetadata = await Promise.all( - files.map(async (metadata) => ({ - file: await convertToFile(metadata), - originalId: metadata.id, - metadata - })) + // Load full StoredFile objects for selected files + const storedFiles = await Promise.all( + files.map(async (metadata) => { + const storedFile = await fileStorage.getFile(metadata.id); + if (!storedFile) { + throw new Error(`File not found in storage: ${metadata.name}`); + } + return storedFile; + }) ); - onStoredFilesSelect(filesWithMetadata); + onStoredFilesSelect(storedFiles); } catch (error) { console.error('Failed to process selected files:', error); } - }, [convertToFile, onStoredFilesSelect]); + }, [onStoredFilesSelect]); const handleNewFileUpload = useCallback(async (files: File[]) => { if (files.length > 0) { @@ -85,7 +88,7 @@ const FileManager: React.FC = ({ selectedTool }) => { // Cleanup any blob URLs when component unmounts useEffect(() => { return () => { - // FileMetadata doesn't have blob URLs, so no cleanup needed + // StoredFileMetadata doesn't have blob URLs, so no cleanup needed // Blob URLs are managed by FileContext and tool operations console.log('FileManager unmounting - FileContext handles blob URL cleanup'); }; @@ -148,6 +151,7 @@ const FileManager: React.FC = ({ selectedTool }) => { recentFiles={recentFiles} onFilesSelected={handleFilesSelected} onNewFilesSelect={handleNewFileUpload} + onStoredFilesSelect={onStoredFilesSelect} onClose={closeFilesModal} isFileSupported={isFileSupported} isOpen={isFilesModalOpen} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index 4e6561b4d..bf4042d1b 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -69,7 +69,7 @@ const FileEditorThumbnail = ({ const fileRecord = selectors.getStirlingFileStub(file.id); const toolHistory = fileRecord?.toolHistory || []; const hasToolHistory = toolHistory.length > 0; - const versionNumber = fileRecord?.versionNumber || 0; + const versionNumber = fileRecord?.versionNumber || 1; const downloadSelectedFile = useCallback(() => { diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx index 6f943db08..87b0a3f59 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; import { getFileSize } from '../../utils/fileUtils'; -import { FileMetadata } from '../../types/file'; +import { StoredFileMetadata } from '../../services/fileStorage'; interface CompactFileDetailsProps { - currentFile: FileMetadata | null; + currentFile: StoredFileMetadata | null; thumbnail: string | null; - selectedFiles: FileMetadata[]; + selectedFiles: StoredFileMetadata[]; currentFileIndex: number; numberOfFiles: number; isAnimating: boolean; @@ -72,7 +72,7 @@ const CompactFileDetails: React.FC = ({ {currentFile ? getFileSize(currentFile) : ''} {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} - {currentFile && ` • v${currentFile.versionNumber || 0}`} + {currentFile && ` • v${currentFile.versionNumber || 1}`} {hasMultipleFiles && ( @@ -80,9 +80,9 @@ const CompactFileDetails: React.FC = ({ )} {/* Compact tool chain for mobile */} - {currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && ( + {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( - {currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')} + {currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')} )} diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx index 8ff398e96..1ac88efa2 100644 --- a/frontend/src/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; -import { FileMetadata } from '../../types/file'; +import { StoredFileMetadata } from '../../services/fileStorage'; import ToolChain from '../shared/ToolChain'; interface FileInfoCardProps { - currentFile: FileMetadata | null; + currentFile: StoredFileMetadata | null; modalHeight: string; } @@ -114,19 +114,19 @@ const FileInfoCard: React.FC = ({ {t('fileManager.fileVersion', 'Version')} {currentFile && - v{currentFile ? (currentFile.versionNumber || 0) : ''} + v{currentFile ? (currentFile.versionNumber || 1) : ''} } {/* Tool Chain Display */} - {currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && ( + {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( <> {t('fileManager.toolChain', 'Tools Applied')} void; @@ -38,21 +38,16 @@ const FileListItem: React.FC = ({ const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); - const { fileGroups, expandedFileIds, onToggleExpansion, onAddToRecents, isLoadingHistory, getHistoryError } = useFileManagerContext(); + const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext(); // Keep item in hovered state if menu is open const shouldShowHovered = isHovered || isMenuOpen; // Get version information for this file const leafFileId = isLatestVersion ? file.id : (file.originalFileId || file.id); - const lineagePath = fileGroups.get(leafFileId) || []; - const hasVersionHistory = (file.versionNumber || 0) > 0; // Show history for any processed file (v1+) - const currentVersion = file.versionNumber || 0; // Display original files as v0 + const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+) + const currentVersion = file.versionNumber || 1; // Display original files as v1 const isExpanded = expandedFileIds.has(leafFileId); - - // Get loading state for this file's history - const isLoadingFileHistory = isLoadingHistory(file.id); - const historyError = getHistoryError(file.id); return ( <> @@ -95,24 +90,20 @@ const FileListItem: React.FC = ({ {file.name} - {isLoadingFileHistory && } - 0 ? "blue" : "gray"}> - v{currentVersion} - + 1 ? "blue" : "gray"}> + v{currentVersion} + {getFileSize(file)} • {getFileDate(file)} - {hasVersionHistory && ( - • has history - )} {/* Tool chain for processed files */} - {file.historyInfo?.toolChain && file.historyInfo.toolChain.length > 0 && ( + {file.toolHistory && file.toolHistory.length > 0 && ( = ({ <> : } onClick={(e) => { e.stopPropagation(); onToggleExpansion(leafFileId); }} - disabled={isLoadingFileHistory} > - {isLoadingFileHistory ? - t('fileManager.loadingHistory', 'Loading History...') : + { (isExpanded ? t('fileManager.hideHistory', 'Hide History') : t('fileManager.showHistory', 'Show History') ) } - {historyError && ( - - {t('fileManager.historyError', 'Error loading history')} - - )} )} diff --git a/frontend/src/components/shared/FilePreview.tsx b/frontend/src/components/shared/FilePreview.tsx index 06c55ee2d..7c170e573 100644 --- a/frontend/src/components/shared/FilePreview.tsx +++ b/frontend/src/components/shared/FilePreview.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Center } from '@mantine/core'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import { FileMetadata } from '../../types/file'; +import { StoredFileMetadata } from '../../services/fileStorage'; import DocumentThumbnail from './filePreview/DocumentThumbnail'; import DocumentStack from './filePreview/DocumentStack'; import HoverOverlay from './filePreview/HoverOverlay'; @@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows'; export interface FilePreviewProps { // Core file data - file: File | FileMetadata | null; + file: File | StoredFileMetadata | null; thumbnail?: string | null; // Optional features @@ -22,7 +22,7 @@ export interface FilePreviewProps { isAnimating?: boolean; // Event handlers - onFileClick?: (file: File | FileMetadata | null) => void; + onFileClick?: (file: File | StoredFileMetadata | null) => void; onPrevious?: () => void; onNext?: () => void; } diff --git a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx index 56991e9d7..2648bd4b8 100644 --- a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; -import { FileMetadata } from '../../../types/file'; +import { StoredFileMetadata } from '../../../services/fileStorage'; export interface DocumentThumbnailProps { - file: File | FileMetadata | null; + file: File | StoredFileMetadata | null; thumbnail?: string | null; style?: React.CSSProperties; onClick?: () => void; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 3c75b2080..8d0e7fa32 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -32,6 +32,7 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions import { FileLifecycleManager } from './file/lifecycle'; import { FileStateContext, FileActionsContext } from './file/contexts'; import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext'; +import { StoredFile, StoredFileMetadata } from '../services/fileStorage'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -88,11 +89,33 @@ function FileContextInner({ selectFiles(addedFilesWithIds); } - // Persist to IndexedDB if enabled + // Persist to IndexedDB if enabled and update StirlingFileStub with version info if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => { try { - await indexedDB.saveFile(file, id, thumbnail); + const metadata = await indexedDB.saveFile(file, id, thumbnail); + + // Update StirlingFileStub with version information from IndexedDB + if (metadata.versionNumber || metadata.originalFileId) { + dispatch({ + type: 'UPDATE_FILE_RECORD', + payload: { + id, + updates: { + versionNumber: metadata.versionNumber, + originalFileId: metadata.originalFileId, + parentFileId: metadata.parentFileId, + toolHistory: metadata.toolHistory + } + } + }); + + if (DEBUG) console.log(`📄 FileContext: Updated raw file ${file.name} with IndexedDB history data:`, { + versionNumber: metadata.versionNumber, + originalFileId: metadata.originalFileId, + toolChainLength: metadata.toolHistory?.length || 0 + }); + } } catch (error) { console.error('Failed to persist file to IndexedDB:', file.name, error); } @@ -107,7 +130,20 @@ function FileContextInner({ return result.map(({ file, id }) => createStirlingFile(file, id)); }, []); - const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise => { + const addStoredFiles = useCallback(async (storedFiles: StoredFile[], options?: { selectFiles?: boolean }): Promise => { + // Convert StoredFile[] to the format expected by addFiles + const filesWithMetadata = storedFiles.map(storedFile => ({ + file: new File([storedFile.data], storedFile.name, { + type: storedFile.type, + lastModified: storedFile.lastModified + }), + originalId: storedFile.id, + metadata: { + ...storedFile, + data: undefined // Remove data field for metadata + } as StoredFileMetadata + })); + const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); // Auto-select the newly added files if requested @@ -118,6 +154,7 @@ function FileContextInner({ return result.map(({ file, id }) => createStirlingFile(file, id)); }, []); + // Action creators const baseActions = useMemo(() => createFileActions(dispatch), []); diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 9f062acd4..434192ccc 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -1,10 +1,9 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { FileMetadata } from '../types/file'; +import { StoredFileMetadata, StoredFile } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage'; import { downloadFiles } from '../utils/downloadUtils'; import { FileId } from '../types/file'; -import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils'; -import { useMultiFileHistory } from '../hooks/useFileHistory'; +import { groupFilesByOriginal } from '../utils/fileHistoryUtils'; // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -12,36 +11,32 @@ interface FileManagerContextValue { activeSource: 'recent' | 'local' | 'drive'; selectedFileIds: FileId[]; searchTerm: string; - selectedFiles: FileMetadata[]; - filteredFiles: FileMetadata[]; + selectedFiles: StoredFileMetadata[]; + filteredFiles: StoredFileMetadata[]; fileInputRef: React.RefObject; selectedFilesSet: Set; expandedFileIds: Set; - fileGroups: Map; - - // History loading state - isLoadingHistory: (fileId: FileId) => boolean; - getHistoryError: (fileId: FileId) => string | null; + fileGroups: Map; // Handlers onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onLocalFileClick: () => void; - onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void; + onFileSelect: (file: StoredFileMetadata, index: number, shiftKey?: boolean) => void; onFileRemove: (index: number) => void; - onFileDoubleClick: (file: FileMetadata) => void; + onFileDoubleClick: (file: StoredFileMetadata) => void; onOpenFiles: () => void; onSearchChange: (value: string) => void; onFileInputChange: (event: React.ChangeEvent) => void; onSelectAll: () => void; onDeleteSelected: () => void; onDownloadSelected: () => void; - onDownloadSingle: (file: FileMetadata) => void; + onDownloadSingle: (file: StoredFileMetadata) => void; onToggleExpansion: (fileId: string) => void; - onAddToRecents: (file: FileMetadata) => void; + onAddToRecents: (file: StoredFileMetadata) => void; onNewFilesSelect: (files: File[]) => void; // External props - recentFiles: FileMetadata[]; + recentFiles: StoredFileMetadata[]; isFileSupported: (fileName: string) => boolean; modalHeight: string; } @@ -52,9 +47,10 @@ const FileManagerContext = createContext(null); // Provider component props interface FileManagerProviderProps { children: React.ReactNode; - recentFiles: FileMetadata[]; - onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files + recentFiles: StoredFileMetadata[]; + onFilesSelected: (files: StoredFileMetadata[]) => void; // For selecting stored files onNewFilesSelect: (files: File[]) => void; // For uploading new local files + onStoredFilesSelect: (storedFiles: StoredFile[]) => void; // For adding stored files directly onClose: () => void; isFileSupported: (fileName: string) => boolean; isOpen: boolean; @@ -68,6 +64,7 @@ export const FileManagerProvider: React.FC = ({ recentFiles, onFilesSelected, onNewFilesSelect, + onStoredFilesSelect: onStoredFilesSelect, onClose, isFileSupported, isOpen, @@ -80,19 +77,12 @@ export const FileManagerProvider: React.FC = ({ const [searchTerm, setSearchTerm] = useState(''); const [lastClickedIndex, setLastClickedIndex] = useState(null); const [expandedFileIds, setExpandedFileIds] = useState>(new Set()); - const [loadedHistoryFiles, setLoadedHistoryFiles] = useState>(new Map()); // Cache for loaded history + const [loadedHistoryFiles, setLoadedHistoryFiles] = useState>(new Map()); // Cache for loaded history const fileInputRef = useRef(null); // Track blob URLs for cleanup const createdBlobUrls = useRef>(new Set()); - // History loading hook - const { - loadFileHistory, - getHistory, - isLoadingHistory, - getError: getHistoryError - } = useMultiFileHistory(); // Computed values (with null safety) const selectedFilesSet = new Set(selectedFileIds); @@ -100,37 +90,37 @@ export const FileManagerProvider: React.FC = ({ // Group files by original file ID for version management const fileGroups = useMemo(() => { if (!recentFiles || recentFiles.length === 0) return new Map(); - - // Convert FileMetadata to FileRecord-like objects for grouping utility + + // Convert StoredFileMetadata to FileRecord-like objects for grouping utility const recordsForGrouping = recentFiles.map(file => ({ ...file, originalFileId: file.originalFileId, - versionNumber: file.versionNumber || 0 + versionNumber: file.versionNumber || 1 })); - + return groupFilesByOriginal(recordsForGrouping); }, [recentFiles]); - // Get files to display with expansion logic + // Get files to display with expansion logic const displayFiles = useMemo(() => { if (!recentFiles || recentFiles.length === 0) return []; - + const expandedFiles = []; - + // Since we now only load leaf files, iterate through recent files directly for (const leafFile of recentFiles) { // Add the leaf file (main file shown in list) expandedFiles.push(leafFile); - + // If expanded, add the loaded history files if (expandedFileIds.has(leafFile.id)) { const historyFiles = loadedHistoryFiles.get(leafFile.id) || []; // Sort history files by version number (oldest first) - const sortedHistory = historyFiles.sort((a, b) => (a.versionNumber || 0) - (b.versionNumber || 0)); + const sortedHistory = historyFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1)); expandedFiles.push(...sortedHistory); } } - + return expandedFiles; }, [recentFiles, expandedFileIds, loadedHistoryFiles]); @@ -155,7 +145,7 @@ export const FileManagerProvider: React.FC = ({ fileInputRef.current?.click(); }, []); - const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => { + const handleFileSelect = useCallback((file: StoredFileMetadata, currentIndex: number, shiftKey?: boolean) => { const fileId = file.id; if (!fileId) return; @@ -196,49 +186,124 @@ export const FileManagerProvider: React.FC = ({ } }, [filteredFiles, lastClickedIndex]); + // Helper function to safely determine which files can be deleted + const getSafeFilesToDelete = useCallback(( + leafFileIds: string[], + allStoredMetadata: Omit[] + ): string[] => { + const fileMap = new Map(allStoredMetadata.map(f => [f.id as string, f])); + const filesToDelete = new Set(); + const filesToPreserve = new Set(); + + // First, identify all files in the lineages of the leaf files being deleted + for (const leafFileId of leafFileIds) { + 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 = allStoredMetadata.filter(file => + (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 allStoredMetadata) { + 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 && !leafFileIds.includes(file.id)) { + // Find all files in this preserved lineage + const preservedChainFiles = allStoredMetadata.filter(chainFile => + (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 + const safeToDelete = Array.from(filesToDelete).filter(fileId => !filesToPreserve.has(fileId)); + + console.log('Deletion analysis:', { + candidatesForDeletion: Array.from(filesToDelete), + mustPreserve: Array.from(filesToPreserve), + safeToDelete + }); + + return safeToDelete; + }, []); + const handleFileRemove = useCallback(async (index: number) => { const fileToRemove = filteredFiles[index]; if (fileToRemove) { const deletedFileId = fileToRemove.id; - + + // Get all stored files to analyze lineages + const allStoredMetadata = await fileStorage.getAllFileMetadata(); + + // Get safe files to delete (respecting shared lineages) + const filesToDelete = getSafeFilesToDelete([deletedFileId as string], allStoredMetadata); + + console.log(`Safely deleting files for ${fileToRemove.name}:`, filesToDelete); + // Clear from selection immediately - setSelectedFileIds(prev => prev.filter(id => id !== deletedFileId)); - + setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id))); + // Clear from expanded state to prevent ghost entries setExpandedFileIds(prev => { const newExpanded = new Set(prev); - newExpanded.delete(deletedFileId); + filesToDelete.forEach(id => newExpanded.delete(id)); return newExpanded; }); - - // Clear from history cache - need to remove this file from any cached history + + // Clear from history cache - remove all files in the chain setLoadedHistoryFiles(prev => { const newCache = new Map(prev); - - // If the deleted file was a main file with cached history, remove its cache - newCache.delete(deletedFileId); - - // Also remove the deleted file from any other file's history cache + + // 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 => histFile.id !== deletedFileId); + const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id as string)); if (filteredHistory.length !== historyFiles.length) { - // The deleted file was in this history, update the cache newCache.set(mainFileId, filteredHistory); } } - + return newCache; }); - // Call the parent's deletion logic + // Delete safe files from IndexedDB + try { + for (const fileId of filesToDelete) { + await fileStorage.deleteFile(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 await onFileRemove(index); - + // Refresh to ensure consistent state await refreshRecentFiles(); } - }, [filteredFiles, onFileRemove, refreshRecentFiles]); + }, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]); - const handleFileDoubleClick = useCallback((file: FileMetadata) => { + const handleFileDoubleClick = useCallback((file: StoredFileMetadata) => { if (isFileSupported(file.name)) { onFilesSelected([file]); onClose(); @@ -288,59 +353,45 @@ export const FileManagerProvider: React.FC = ({ if (selectedFileIds.length === 0) return; try { - // Use the same logic as individual file deletion for consistency - // Delete each selected file individually using the same cache update logic - const allFilesToDelete = filteredFiles.filter(file => - selectedFileIds.includes(file.id) - ); - - // Deduplicate by file ID since shared files can appear multiple times in the display - const uniqueFilesToDelete = allFilesToDelete.reduce((unique: typeof allFilesToDelete, file) => { - if (!unique.some(f => f.id === file.id)) { - unique.push(file); - } - return unique; - }, []); - - const filesToDelete = uniqueFilesToDelete; - const deletedFileIds = new Set(filesToDelete.map(f => f.id)); + // Get all stored files to analyze lineages + const allStoredMetadata = await fileStorage.getAllFileMetadata(); + + // Get safe files to delete (respecting shared lineages) + const filesToDelete = getSafeFilesToDelete(selectedFileIds, allStoredMetadata); + + console.log(`Bulk safely deleting files and their history chains:`, filesToDelete); // Update history cache synchronously setLoadedHistoryFiles(prev => { const newCache = new Map(prev); - - for (const fileToDelete of filesToDelete) { - // If the deleted file was a main file with cached history, remove its cache - newCache.delete(fileToDelete.id); - - // Also remove the deleted file from any other file's history cache - for (const [mainFileId, historyFiles] of newCache.entries()) { - const filteredHistory = historyFiles.filter(histFile => histFile.id !== fileToDelete.id); - if (filteredHistory.length !== historyFiles.length) { - // The deleted file was in this history, update the cache - newCache.set(mainFileId, filteredHistory); - } + + // 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 as string)); + if (filteredHistory.length !== historyFiles.length) { + newCache.set(mainFileId, filteredHistory); } } - + return newCache; }); // Also clear any expanded state for deleted files to prevent ghost entries setExpandedFileIds(prev => { const newExpanded = new Set(prev); - for (const deletedId of deletedFileIds) { - newExpanded.delete(deletedId); - } + filesToDelete.forEach(id => newExpanded.delete(id)); return newExpanded; }); // Clear selection immediately to prevent ghost selections - setSelectedFileIds(prev => prev.filter(id => !deletedFileIds.has(id))); + setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id))); - // Delete files from IndexedDB - for (const file of filesToDelete) { - await fileStorage.deleteFile(file.id); + // Delete safe files from IndexedDB + for (const fileId of filesToDelete) { + await fileStorage.deleteFile(fileId as FileId); } // Refresh the file list to get updated data @@ -348,7 +399,7 @@ export const FileManagerProvider: React.FC = ({ } catch (error) { console.error('Failed to delete selected files:', error); } - }, [selectedFileIds, filteredFiles, refreshRecentFiles]); + }, [selectedFileIds, filteredFiles, refreshRecentFiles, getSafeFilesToDelete]); const handleDownloadSelected = useCallback(async () => { @@ -369,7 +420,7 @@ export const FileManagerProvider: React.FC = ({ } }, [selectedFileIds, filteredFiles]); - const handleDownloadSingle = useCallback(async (file: FileMetadata) => { + const handleDownloadSingle = useCallback(async (file: StoredFileMetadata) => { try { await downloadFiles([file]); } catch (error) { @@ -379,7 +430,7 @@ export const FileManagerProvider: React.FC = ({ const handleToggleExpansion = useCallback(async (fileId: string) => { const isCurrentlyExpanded = expandedFileIds.has(fileId); - + // Update expansion state setExpandedFileIds(prev => { const newSet = new Set(prev); @@ -394,107 +445,55 @@ export const FileManagerProvider: React.FC = ({ // Load complete history chain if expanding if (!isCurrentlyExpanded) { const currentFileMetadata = recentFiles.find(f => f.id === fileId); - if (currentFileMetadata && (currentFileMetadata.versionNumber || 0) > 0) { + if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) { try { - // Load the current file to get its full history - const storedFile = await fileStorage.getFile(fileId as FileId); - if (storedFile) { - const file = new File([storedFile.data], storedFile.name, { - type: storedFile.type, - lastModified: storedFile.lastModified - }); - - // Get the complete history metadata (this will give us original/parent IDs) - const historyData = await loadFileHistory(file, fileId as FileId); - - if (historyData?.originalFileId) { - // Load complete history chain by traversing parent relationships - const historyFiles: FileMetadata[] = []; - - // Get all stored files for chain traversal - const allStoredMetadata = await fileStorage.getAllFileMetadata(); - const fileMap = new Map(allStoredMetadata.map(f => [f.id, f])); - - // Build complete chain by following parent relationships backwards - const visitedIds = new Set([fileId]); // Don't include the current file - const toProcess = [historyData]; // Start with current file's history data - - while (toProcess.length > 0) { - const currentHistoryData = toProcess.shift()!; - - // Add original file if we haven't seen it - if (currentHistoryData.originalFileId && !visitedIds.has(currentHistoryData.originalFileId)) { - visitedIds.add(currentHistoryData.originalFileId); - const originalMeta = fileMap.get(currentHistoryData.originalFileId as FileId); - if (originalMeta) { - try { - const origStoredFile = await fileStorage.getFile(originalMeta.id); - if (origStoredFile) { - const origFile = new File([origStoredFile.data], origStoredFile.name, { - type: origStoredFile.type, - lastModified: origStoredFile.lastModified - }); - const origMetadata = await createFileMetadataWithHistory(origFile, originalMeta.id, originalMeta.thumbnail); - historyFiles.push(origMetadata); - } - } catch (error) { - console.warn(`Failed to load original file ${originalMeta.id}:`, error); - } - } - } - - // Add parent file if we haven't seen it - if (currentHistoryData.parentFileId && !visitedIds.has(currentHistoryData.parentFileId)) { - visitedIds.add(currentHistoryData.parentFileId); - const parentMeta = fileMap.get(currentHistoryData.parentFileId); - if (parentMeta) { - try { - const parentStoredFile = await fileStorage.getFile(parentMeta.id); - if (parentStoredFile) { - const parentFile = new File([parentStoredFile.data], parentStoredFile.name, { - type: parentStoredFile.type, - lastModified: parentStoredFile.lastModified - }); - const parentMetadata = await createFileMetadataWithHistory(parentFile, parentMeta.id, parentMeta.thumbnail); - historyFiles.push(parentMetadata); - - // Load parent's history to continue the chain - const parentHistoryData = await loadFileHistory(parentFile, parentMeta.id); - if (parentHistoryData) { - toProcess.push(parentHistoryData); - } - } - } catch (error) { - console.warn(`Failed to load parent file ${parentMeta.id}:`, error); - } - } - } - } - - // Also find any files that have the current file as their original (siblings/alternatives) - for (const [metaId, meta] of fileMap) { - if (!visitedIds.has(metaId) && (meta as any).originalFileId === historyData.originalFileId) { - visitedIds.add(metaId); - try { - const siblingStoredFile = await fileStorage.getFile(meta.id); - if (siblingStoredFile) { - const siblingFile = new File([siblingStoredFile.data], siblingStoredFile.name, { - type: siblingStoredFile.type, - lastModified: siblingStoredFile.lastModified - }); - const siblingMetadata = await createFileMetadataWithHistory(siblingFile, meta.id, meta.thumbnail); - historyFiles.push(siblingMetadata); - } - } catch (error) { - console.warn(`Failed to load sibling file ${meta.id}:`, error); - } - } - } - - // Cache the loaded history files - setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles))); - } + // Get all stored file metadata for chain traversal + const allStoredMetadata = await fileStorage.getAllFileMetadata(); + const fileMap = new Map(allStoredMetadata.map(f => [f.id, f])); + + // Get the current file's IndexedDB data + const currentStoredFile = fileMap.get(fileId as FileId); + if (!currentStoredFile) { + console.warn(`No stored file found for ${fileId}`); + return; } + + // Build complete history chain using IndexedDB metadata + const historyFiles: StoredFileMetadata[] = []; + + // Find the original file + const originalFileId = currentStoredFile.originalFileId || currentStoredFile.id; + + // Collect all files in this history chain + const chainFiles = Array.from(fileMap.values()).filter(file => + (file.originalFileId || file.id) === originalFileId && file.id !== fileId + ); + + // Sort by version number (oldest first for history display) + chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1)); + + // Convert stored files to StoredFileMetadata format with proper history info + for (const storedFile of chainFiles) { + // Load the actual file to extract PDF metadata if available + const historyMetadata: StoredFileMetadata = { + id: storedFile.id, + name: storedFile.name, + type: storedFile.type, + size: storedFile.size, + lastModified: storedFile.lastModified, + thumbnail: storedFile.thumbnail, + versionNumber: storedFile.versionNumber, + isLeaf: storedFile.isLeaf, + // Use IndexedDB data directly - it's more reliable than re-parsing PDF + originalFileId: storedFile.originalFileId, + parentFileId: storedFile.parentFileId, + toolHistory: storedFile.toolHistory + }; + historyFiles.push(historyMetadata); + } + + // 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); } @@ -507,30 +506,26 @@ export const FileManagerProvider: React.FC = ({ return newMap; }); } - }, [expandedFileIds, recentFiles, loadFileHistory]); + }, [expandedFileIds, recentFiles]); - const handleAddToRecents = useCallback(async (file: FileMetadata) => { + const handleAddToRecents = useCallback(async (file: StoredFileMetadata) => { try { - console.log('Promoting to recents:', file.name, 'version:', file.versionNumber); - - // Load the file from storage and create a copy with new ID and timestamp + console.log('Adding to recents:', file.name, 'version:', file.versionNumber); + + // Load file from storage and use addStoredFiles pattern const storedFile = await fileStorage.getFile(file.id); - if (storedFile) { - // Create new file with current timestamp to appear at top - const promotedFile = new File([storedFile.data], file.name, { - type: file.type, - lastModified: Date.now() // Current timestamp makes it appear at top - }); - - // Add as new file through the normal flow (creates new ID) - onNewFilesSelect([promotedFile]); - - console.log('Successfully promoted to recents:', file.name, 'v' + file.versionNumber); + if (!storedFile) { + throw new Error(`File not found in storage: ${file.name}`); } + + // Use direct StoredFile approach - much more efficient + onStoredFilesSelect([storedFile]); + + console.log('Successfully added to recents:', file.name, 'v' + file.versionNumber); } catch (error) { - console.error('Failed to promote to recents:', error); + console.error('Failed to add to recents:', error); } - }, [onNewFilesSelect]); + }, [onStoredFilesSelect]); // Cleanup blob URLs when component unmounts useEffect(() => { @@ -565,10 +560,6 @@ export const FileManagerProvider: React.FC = ({ expandedFileIds, fileGroups, - // History loading state - isLoadingHistory, - getHistoryError, - // Handlers onSourceChange: handleSourceChange, onLocalFileClick: handleLocalFileClick, @@ -599,8 +590,6 @@ export const FileManagerProvider: React.FC = ({ fileInputRef, expandedFileIds, fileGroups, - isLoadingHistory, - getHistoryError, handleSourceChange, handleLocalFileClick, handleFileSelect, diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index 6f0aa1d33..985a07a56 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; import { useFileHandler } from '../hooks/useFileHandler'; -import { FileMetadata } from '../types/file'; +import { StoredFileMetadata, StoredFile } from '../services/fileStorage'; import { FileId } from '../types/file'; interface FilesModalContextType { @@ -9,7 +9,7 @@ interface FilesModalContextType { closeFilesModal: () => void; onFileSelect: (file: File) => void; onFilesSelect: (files: File[]) => void; - onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void; + onStoredFilesSelect: (storedFiles: StoredFile[]) => void; onModalClose?: () => void; setOnModalClose: (callback: () => void) => void; } @@ -58,14 +58,14 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch closeFilesModal(); }, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]); - const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => { + const handleStoredFilesSelect = useCallback((storedFiles: StoredFile[]) => { if (customHandler) { // Use custom handler for special cases (like page insertion) - const files = filesWithMetadata.map(item => item.file); + const files = storedFiles.map(storedFile => new File([storedFile.data], storedFile.name, { type: storedFile.type, lastModified: storedFile.lastModified })); customHandler(files, insertAfterPage); } else { // Use normal file handling - addStoredFiles(filesWithMetadata); + addStoredFiles(storedFiles); } closeFilesModal(); }, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]); diff --git a/frontend/src/contexts/IndexedDBContext.tsx b/frontend/src/contexts/IndexedDBContext.tsx index c5b8ad472..efd181200 100644 --- a/frontend/src/contexts/IndexedDBContext.tsx +++ b/frontend/src/contexts/IndexedDBContext.tsx @@ -8,20 +8,19 @@ 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 { StoredFileMetadata } from '../services/fileStorage'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; -import { createFileMetadataWithHistory } from '../utils/fileHistoryUtils'; interface IndexedDBContextValue { // Core CRUD operations - saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise; + saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise; loadFile: (fileId: FileId) => Promise; - loadMetadata: (fileId: FileId) => Promise; + loadMetadata: (fileId: FileId) => Promise; deleteFile: (fileId: FileId) => Promise; // Batch operations - loadAllMetadata: () => Promise; - loadLeafMetadata: () => Promise; // Only leaf files for recent files list + loadAllMetadata: () => Promise; + loadLeafMetadata: () => Promise; // Only leaf files for recent files list deleteMultiple: (fileIds: FileId[]) => Promise; clearAll: () => Promise; @@ -59,20 +58,49 @@ 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 => { + const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise => { // 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); + // Extract history data if attached to the file by tool operations + const historyData = (file as any).__historyData as { + versionNumber: number; + originalFileId: string; + parentFileId: FileId | undefined; + toolHistory: Array<{ toolName: string; timestamp: number; }>; + } | undefined; + + if (historyData) { + console.log('🏛️ INDEXEDDB CONTEXT - Found history data on file:', { + fileName: file.name, + versionNumber: historyData.versionNumber, + originalFileId: historyData.originalFileId, + parentFileId: historyData.parentFileId, + toolChainLength: historyData.toolHistory.length + }); + } + + // Store in IndexedDB with history data + const storedFile = await fileStorage.storeFile(file, fileId, thumbnail, true, historyData); // Cache the file object for immediate reuse fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); evictLRUEntries(); - // Extract history metadata for PDFs and return enhanced metadata - const metadata = await createFileMetadataWithHistory(file, fileId, thumbnail); - + // Return metadata with history information from the stored file + const metadata: StoredFileMetadata = { + id: fileId, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + thumbnail, + isLeaf: true, + versionNumber: storedFile.versionNumber, + originalFileId: storedFile.originalFileId, + parentFileId: storedFile.parentFileId || undefined, + toolHistory: storedFile.toolHistory + }; return metadata; }, []); @@ -103,7 +131,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { return file; }, [evictLRUEntries]); - const loadMetadata = useCallback(async (fileId: FileId): Promise => { + const loadMetadata = useCallback(async (fileId: FileId): Promise => { // Try to get from cache first (no IndexedDB hit) const cached = fileCache.current.get(fileId); if (cached) { @@ -141,7 +169,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { await fileStorage.deleteFile(fileId); }, []); - const loadLeafMetadata = useCallback(async (): Promise => { + const loadLeafMetadata = useCallback(async (): Promise => { const metadata = await fileStorage.getLeafFileMetadata(); // Only get leaf files // Separate PDF and non-PDF files for different processing @@ -149,7 +177,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf')); // Process non-PDF files immediately (no history extraction needed) - const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({ + const nonPdfMetadata: StoredFileMetadata[] = nonPdfFiles.map(m => ({ id: m.id, name: m.name, type: m.type, @@ -161,24 +189,35 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { // Process PDF files with controlled concurrency to avoid memory issues const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory - const pdfMetadata: FileMetadata[] = []; + const pdfMetadata: StoredFileMetadata[] = []; for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) { const batch = pdfFiles.slice(i, i + BATCH_SIZE); const batchResults = await Promise.all(batch.map(async (m) => { try { - // For PDF files, load and extract basic history for display only + // For PDF files, use history data from IndexedDB instead of extracting from PDF const storedFile = await fileStorage.getFile(m.id); - if (storedFile?.data) { - const file = new File([storedFile.data], m.name, { + if (storedFile) { + return { + id: m.id, + name: m.name, type: m.type, - lastModified: m.lastModified - }); - return await createFileMetadataWithHistory(file, m.id, m.thumbnail); + size: m.size, + lastModified: m.lastModified, + thumbnail: m.thumbnail, + isLeaf: m.isLeaf, + versionNumber: storedFile.versionNumber, + historyInfo: { + originalFileId: storedFile.originalFileId, + parentFileId: storedFile.parentFileId || undefined, + versionNumber: storedFile.versionNumber, + toolChain: storedFile.toolHistory + } + }; } } catch (error) { - if (DEBUG) console.warn('🗂️ Failed to extract basic metadata from leaf file:', m.name, error); + if (DEBUG) console.warn('🗂️ Failed to load stored file data for leaf file:', m.name, error); } // Fallback to basic metadata without history @@ -199,7 +238,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { return [...nonPdfMetadata, ...pdfMetadata]; }, []); - const loadAllMetadata = useCallback(async (): Promise => { + const loadAllMetadata = useCallback(async (): Promise => { const metadata = await fileStorage.getAllFileMetadata(); // Separate PDF and non-PDF files for different processing @@ -207,7 +246,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf')); // Process non-PDF files immediately (no history extraction needed) - const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({ + const nonPdfMetadata: StoredFileMetadata[] = nonPdfFiles.map(m => ({ id: m.id, name: m.name, type: m.type, @@ -218,27 +257,37 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { // Process PDF files with controlled concurrency to avoid memory issues const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory - const pdfMetadata: FileMetadata[] = []; + const pdfMetadata: StoredFileMetadata[] = []; for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) { const batch = pdfFiles.slice(i, i + BATCH_SIZE); const batchResults = await Promise.all(batch.map(async (m) => { try { - // For PDF files, load and extract history with timeout + // For PDF files, use history data from IndexedDB instead of extracting from PDF const storedFile = await fileStorage.getFile(m.id); - if (storedFile?.data) { - const file = new File([storedFile.data], m.name, { + if (storedFile) { + return { + id: m.id, + name: m.name, type: m.type, - lastModified: m.lastModified - }); - return await createFileMetadataWithHistory(file, m.id, m.thumbnail); + size: m.size, + lastModified: m.lastModified, + thumbnail: m.thumbnail, + versionNumber: storedFile.versionNumber, + historyInfo: { + originalFileId: storedFile.originalFileId, + parentFileId: storedFile.parentFileId || undefined, + versionNumber: storedFile.versionNumber, + toolChain: storedFile.toolHistory + } + }; } } catch (error) { - if (DEBUG) console.warn('🗂️ Failed to extract history from stored file:', m.name, error); + if (DEBUG) console.warn('🗂️ Failed to load stored file data for metadata:', m.name, error); } - // Fallback to basic metadata if history extraction fails + // Fallback to basic metadata if history loading fails return { id: m.id, name: m.name, diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 1d7a909af..4923895ef 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -10,11 +10,11 @@ import { createFileId, createQuickKey } from '../../types/fileContext'; -import { FileId, FileMetadata } from '../../types/file'; +import { FileId } from '../../types/file'; +import { StoredFileMetadata } from '../../services/fileStorage'; import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; import { FileLifecycleManager } from './lifecycle'; import { buildQuickKeySet } from './fileSelectors'; -import { extractBasicFileMetadata } from '../../utils/fileHistoryUtils'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -82,7 +82,7 @@ interface AddFileOptions { filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>; // For 'stored' files - filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; + filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: StoredFileMetadata }>; // Insertion position insertAfterPageId?: string; @@ -183,24 +183,7 @@ export async function addFiles( if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); } - // Extract basic metadata (version number and tool chain) for display - extractBasicFileMetadata(file, record).then(updatedRecord => { - if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) { - // Basic metadata found, dispatch update to trigger re-render - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { - versionNumber: updatedRecord.versionNumber, - toolHistory: updatedRecord.toolHistory - } - } - }); - } - }).catch(error => { - if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error); - }); + // History metadata is now managed in IndexedDB, not in PDF metadata existingQuickKeys.add(quickKey); stirlingFileStubs.push(record); @@ -244,24 +227,7 @@ export async function addFiles( if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`); } - // Extract basic metadata (version number and tool chain) for display - extractBasicFileMetadata(file, record).then(updatedRecord => { - if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) { - // Basic metadata found, dispatch update to trigger re-render - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { - versionNumber: updatedRecord.versionNumber, - toolHistory: updatedRecord.toolHistory - } - } - }); - } - }).catch(error => { - if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error); - }); + // History metadata is now managed in IndexedDB, not in PDF metadata existingQuickKeys.add(quickKey); stirlingFileStubs.push(record); @@ -338,24 +304,20 @@ export async function addFiles( if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`); } - // Extract basic metadata (version number and tool chain) for display - extractBasicFileMetadata(file, record).then(updatedRecord => { - if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) { - // Basic metadata found, dispatch update to trigger re-render - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { - versionNumber: updatedRecord.versionNumber, - toolHistory: updatedRecord.toolHistory - } - } - }); - } - }).catch(error => { - if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error); - }); + // Use history data from IndexedDB instead of extracting from PDF metadata + if (metadata.versionNumber || metadata.toolHistory) { + record.versionNumber = metadata.versionNumber; + record.originalFileId = metadata.originalFileId; + record.parentFileId = metadata.parentFileId; + record.toolHistory = metadata.toolHistory; + + if (DEBUG) console.log(`📄 addFiles(stored): Applied IndexedDB history data to ${file.name}:`, { + versionNumber: record.versionNumber, + originalFileId: record.originalFileId, + parentFileId: record.parentFileId, + toolHistoryLength: record.toolHistory?.length || 0 + }); + } existingQuickKeys.add(quickKey); stirlingFileStubs.push(record); @@ -413,43 +375,13 @@ async function processFilesIntoRecords( record.processedFile = createProcessedFile(pageCount, thumbnail); } - // Extract basic metadata synchronously during consumeFiles for immediate display - if (file.type.includes('pdf')) { - try { - const updatedRecord = await extractBasicFileMetadata(file, record); - - if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) { - // Update the record directly with basic metadata - Object.assign(record, { - versionNumber: updatedRecord.versionNumber, - toolHistory: updatedRecord.toolHistory - }); - } - } catch (error) { - if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error); - } - } + // History metadata is now managed in IndexedDB, not in PDF metadata 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 } -): Promise { - 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 @@ -480,11 +412,31 @@ export async function consumeFiles( }) ); - // Save output files to IndexedDB - await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB); + // Save output files to IndexedDB and update the StirlingFileStub records with version info + await Promise.all(outputStirlingFileStubs.map(async ({ file, fileId, record }) => { + try { + const metadata = await indexedDB.saveFile(file, fileId, record.thumbnailUrl); + + // Update the record directly with version information from IndexedDB + if (metadata.versionNumber || metadata.historyInfo) { + record.versionNumber = metadata.versionNumber; + record.originalFileId = metadata.historyInfo?.originalFileId; + record.parentFileId = metadata.historyInfo?.parentFileId; + record.toolHistory = metadata.historyInfo?.toolChain; + + if (DEBUG) console.log(`📄 Updated output record for ${file.name} with IndexedDB history data:`, { + versionNumber: metadata.versionNumber, + originalFileId: metadata.historyInfo?.originalFileId, + toolChainLength: metadata.historyInfo?.toolChain?.length || 0 + }); + } + } catch (error) { + console.error('Failed to persist output file to IndexedDB:', file.name, error); + } + })); } - // Dispatch the consume action + // Dispatch the consume action with updated records dispatch({ type: 'CONSUME_FILES', payload: { diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 20ca92ef8..60c6aba2d 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -8,7 +8,6 @@ import { useToolResources } from './useToolResources'; import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; -import { prepareStirlingFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils'; // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; @@ -129,7 +128,7 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); - const { addFiles, consumeFiles, undoConsumeFiles, selectors, findFileId } = useFileContext(); + const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); @@ -166,24 +165,13 @@ export const useToolOperation = ( cleanupBlobUrls(); // Prepare files with history metadata injection (for PDFs) - actions.setStatus('Preparing files...'); - const getFileStubById = (fileId: FileId) => { - return selectors.getStirlingFileStub(fileId); - }; - - const filesWithHistory = await prepareStirlingFilesWithHistory( - validFiles, - getFileStubById, - config.operationType, - params as Record - ); + actions.setStatus('Processing files...'); try { let processedFiles: File[]; - // Convert StirlingFiles with history to regular Files for API processing - // The history is already injected into the File data, we just need to extract the File objects - const filesForAPI = extractFiles(filesWithHistory); + // Use original files directly (no PDF metadata injection - history stored in IndexedDB) + const filesForAPI = extractFiles(validFiles); switch (config.toolType) { case ToolType.singleFile: { @@ -242,8 +230,6 @@ export const useToolOperation = ( if (processedFiles.length > 0) { actions.setFiles(processedFiles); - // Verify metadata preservation for backend quality tracking - await verifyToolMetadataPreservation(validFiles, processedFiles, config.operationType); // Generate thumbnails and download URL concurrently actions.setGeneratingThumbnails(true); @@ -272,7 +258,46 @@ export const useToolOperation = ( } } - const outputFileIds = await consumeFiles(inputFileIds, processedFiles); + // Prepare output files with history data before saving + const processedFilesWithHistory = processedFiles.map(file => { + // Find the corresponding input file for history chain + const inputStub = inputStirlingFileStubs.find(stub => + inputFileIds.includes(stub.id) + ) || inputStirlingFileStubs[0]; // Fallback to first input if not found + + // Create new tool operation + const newToolOperation = { + toolName: config.operationType, + timestamp: Date.now() + }; + + // Build complete tool chain + const existingToolChain = inputStub?.toolHistory || []; + const toolHistory = [...existingToolChain, newToolOperation]; + + // Calculate version number + const versionNumber = inputStub?.versionNumber ? inputStub.versionNumber + 1 : 1; + + // Attach history data to file + (file as any).__historyData = { + versionNumber, + originalFileId: inputStub?.originalFileId || inputStub?.id, + parentFileId: inputStub?.id || null, + toolHistory + }; + + console.log('🏛️ FILE HISTORY - Prepared file with history:', { + fileName: file.name, + versionNumber, + originalFileId: inputStub?.originalFileId || inputStub?.id, + parentFileId: inputStub?.id, + toolChainLength: toolHistory.length + }); + + return file; + }); + + const outputFileIds = await consumeFiles(inputFileIds, processedFilesWithHistory); // Store operation data for undo (only store what we need to avoid memory bloat) lastOperationRef.current = { diff --git a/frontend/src/hooks/useFileHandler.ts b/frontend/src/hooks/useFileHandler.ts index 8c2a4e2a1..5e4118f56 100644 --- a/frontend/src/hooks/useFileHandler.ts +++ b/frontend/src/hooks/useFileHandler.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { useFileState, useFileActions } from '../contexts/FileContext'; -import { FileMetadata } from '../types/file'; +import { StoredFileMetadata, StoredFile } from '../services/fileStorage'; import { FileId } from '../types/file'; export const useFileHandler = () => { @@ -18,17 +18,17 @@ export const useFileHandler = () => { }, [actions.addFiles]); // Add stored files preserving their original IDs to prevent session duplicates - const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => { + const addStoredFiles = useCallback(async (storedFiles: StoredFile[]) => { // Filter out files that already exist with the same ID (exact match) - const newFiles = filesWithMetadata.filter(({ originalId }) => { - return state.files.byId[originalId] === undefined; + const newFiles = storedFiles.filter(({ id }) => { + return state.files.byId[id] === undefined; }); if (newFiles.length > 0) { await actions.addStoredFiles(newFiles, { selectFiles: true }); } - console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`); + console.log(`📁 Added ${newFiles.length} stored files (${storedFiles.length - newFiles.length} skipped as duplicates)`); }, [state.files.byId, actions.addStoredFiles]); return { diff --git a/frontend/src/hooks/useFileHistory.ts b/frontend/src/hooks/useFileHistory.ts index 881d9e6f5..0a1f5c1cc 100644 --- a/frontend/src/hooks/useFileHistory.ts +++ b/frontend/src/hooks/useFileHistory.ts @@ -6,7 +6,7 @@ import { useState, useCallback } from 'react'; import { FileId } from '../types/file'; import { StirlingFileStub } from '../types/fileContext'; -import { loadFileHistoryOnDemand } from '../utils/fileHistoryUtils'; +// loadFileHistoryOnDemand removed - history now comes from IndexedDB directly interface FileHistoryState { originalFileId?: string; @@ -33,16 +33,17 @@ export function useFileHistory(): UseFileHistoryResult { const [error, setError] = useState(null); const loadHistory = useCallback(async ( - file: File, - fileId: FileId, - updateFileStub?: (id: FileId, updates: Partial) => void + _file: File, + _fileId: FileId, + _updateFileStub?: (id: FileId, updates: Partial) => void ) => { setIsLoading(true); setError(null); try { - const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub); - setHistoryData(history); + // History is now loaded from IndexedDB, not PDF metadata + // This function is deprecated + throw new Error('loadFileHistoryOnDemand is deprecated - use IndexedDB history directly'); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load file history'; setError(errorMessage); @@ -76,9 +77,9 @@ export function useMultiFileHistory() { const [errors, setErrors] = useState>(new Map()); const loadFileHistory = useCallback(async ( - file: File, + _file: File, fileId: FileId, - updateFileStub?: (id: FileId, updates: Partial) => void + _updateFileStub?: (id: FileId, updates: Partial) => void ) => { // Don't reload if already loaded or currently loading if (historyCache.has(fileId) || loadingFiles.has(fileId)) { @@ -93,13 +94,9 @@ export function useMultiFileHistory() { }); try { - const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub); - - if (history) { - setHistoryCache(prev => new Map(prev).set(fileId, history)); - } - - return history; + // History is now loaded from IndexedDB, not PDF metadata + // This function is deprecated + throw new Error('loadFileHistoryOnDemand is deprecated - use IndexedDB history directly'); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to load file history'; setErrors(prev => new Map(prev).set(fileId, errorMessage)); diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts index c892cc63d..4771eedcb 100644 --- a/frontend/src/hooks/useFileManager.ts +++ b/frontend/src/hooks/useFileManager.ts @@ -1,13 +1,13 @@ import { useState, useCallback } from 'react'; import { useIndexedDB } from '../contexts/IndexedDBContext'; -import { FileMetadata } from '../types/file'; +import { StoredFileMetadata, StoredFile, fileStorage } from '../services/fileStorage'; import { FileId } from '../types/fileContext'; export const useFileManager = () => { const [loading, setLoading] = useState(false); const indexedDB = useIndexedDB(); - const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise => { + const convertToFile = useCallback(async (fileMetadata: StoredFileMetadata): Promise => { if (!indexedDB) { throw new Error('IndexedDB context not available'); } @@ -22,7 +22,7 @@ export const useFileManager = () => { throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`); }, [indexedDB]); - const loadRecentFiles = useCallback(async (): Promise => { + const loadRecentFiles = useCallback(async (): Promise => { setLoading(true); try { if (!indexedDB) { @@ -45,7 +45,7 @@ export const useFileManager = () => { } }, [indexedDB]); - const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => { + const handleRemoveFile = useCallback(async (index: number, files: StoredFileMetadata[], setFiles: (files: StoredFileMetadata[]) => void) => { const file = files[index]; if (!file.id) { throw new Error('File ID is required for removal'); @@ -105,23 +105,24 @@ export const useFileManager = () => { setSelectedFiles([]); }; - const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void) => { + const selectMultipleFiles = async (files: StoredFileMetadata[], onStoredFilesSelect: (storedFiles: StoredFile[]) => void) => { if (selectedFiles.length === 0) return; try { - // Filter by UUID and convert to File objects + // Filter by UUID and load full StoredFile objects directly const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id)); - - // Use stored files flow that preserves IDs - const filesWithMetadata = await Promise.all( - selectedFileObjects.map(async (metadata) => ({ - file: await convertToFile(metadata), - originalId: metadata.id, - metadata - })) + + const storedFiles = await Promise.all( + selectedFileObjects.map(async (metadata) => { + const storedFile = await fileStorage.getFile(metadata.id); + if (!storedFile) { + throw new Error(`File not found in storage: ${metadata.name}`); + } + return storedFile; + }) ); - onStoredFilesSelect(filesWithMetadata); - + + onStoredFilesSelect(storedFiles); clearSelection(); } catch (error) { console.error('Failed to load selected files:', error); diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts index cd497561b..a3ffd4741 100644 --- a/frontend/src/hooks/useIndexedDBThumbnail.ts +++ b/frontend/src/hooks/useIndexedDBThumbnail.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { FileMetadata } from "../types/file"; +import { StoredFileMetadata } from "../services/fileStorage"; import { useIndexedDB } from "../contexts/IndexedDBContext"; import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { FileId } from "../types/fileContext"; @@ -9,7 +9,7 @@ import { FileId } from "../types/fileContext"; * Hook for IndexedDB-aware thumbnail loading * Handles thumbnail generation for files not in IndexedDB */ -export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): { +export function useIndexedDBThumbnail(file: StoredFileMetadata | undefined | null): { thumbnail: string | null; isGenerating: boolean } { diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts index 14d75d508..55b32341d 100644 --- a/frontend/src/services/fileStorage.ts +++ b/frontend/src/services/fileStorage.ts @@ -4,21 +4,21 @@ * Now uses centralized IndexedDB manager */ -import { FileId } from '../types/file'; +import { FileId, BaseFileMetadata } from '../types/file'; import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager'; -export interface StoredFile { - id: FileId; - name: string; - type: string; - size: number; - lastModified: number; +export interface StoredFile extends BaseFileMetadata { data: ArrayBuffer; thumbnail?: string; url?: string; // For compatibility with existing components - isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet) } +/** + * Lightweight metadata version of StoredFile (without ArrayBuffer data) + * Used for efficient file browsing in FileManager without loading file data + */ +export type StoredFileMetadata = Omit; + export interface StorageStats { used: number; available: number; @@ -40,7 +40,21 @@ class FileStorageService { /** * Store a file in IndexedDB with external UUID */ - async storeFile(file: File, fileId: FileId, thumbnail?: string, isLeaf: boolean = true): Promise { + async storeFile( + file: File, + fileId: FileId, + thumbnail?: string, + isLeaf: boolean = true, + historyData?: { + versionNumber: number; + originalFileId: string; + parentFileId: FileId | undefined; + toolHistory: Array<{ + toolName: string; + timestamp: number; + }>; + } + ): Promise { const db = await this.getDatabase(); const arrayBuffer = await file.arrayBuffer(); @@ -53,7 +67,13 @@ class FileStorageService { lastModified: file.lastModified, data: arrayBuffer, thumbnail, - isLeaf + isLeaf, + + // History data - use provided data or defaults for original files + versionNumber: historyData?.versionNumber ?? 1, + originalFileId: historyData?.originalFileId ?? fileId, + parentFileId: historyData?.parentFileId ?? undefined, + toolHistory: historyData?.toolHistory ?? [] }; return new Promise((resolve, reject) => { @@ -131,14 +151,14 @@ class FileStorageService { /** * Get metadata of all stored files (without loading data into memory) */ - async getAllFileMetadata(): Promise[]> { + async getAllFileMetadata(): Promise { const db = await this.getDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.openCursor(); - const files: Omit[] = []; + const files: StoredFileMetadata[] = []; request.onerror = () => reject(request.error); request.onsuccess = (event) => { @@ -153,7 +173,11 @@ class FileStorageService { type: storedFile.type, size: storedFile.size, lastModified: storedFile.lastModified, - thumbnail: storedFile.thumbnail + thumbnail: storedFile.thumbnail, + versionNumber: storedFile.versionNumber || 1, + originalFileId: storedFile.originalFileId || storedFile.id, + parentFileId: storedFile.parentFileId || undefined, + toolHistory: storedFile.toolHistory || [] }); } cursor.continue(); @@ -270,14 +294,14 @@ class FileStorageService { /** * Get metadata of only leaf files (without loading data into memory) */ - async getLeafFileMetadata(): Promise[]> { + async getLeafFileMetadata(): Promise { const db = await this.getDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.openCursor(); - const files: Omit[] = []; + const files: StoredFileMetadata[] = []; request.onerror = () => reject(request.error); request.onsuccess = (event) => { @@ -293,12 +317,15 @@ class FileStorageService { size: storedFile.size, lastModified: storedFile.lastModified, thumbnail: storedFile.thumbnail, - isLeaf: storedFile.isLeaf + isLeaf: storedFile.isLeaf, + versionNumber: storedFile.versionNumber || 1, + originalFileId: storedFile.originalFileId || storedFile.id, + parentFileId: storedFile.parentFileId || undefined, + toolHistory: storedFile.toolHistory || [] }); } cursor.continue(); } else { - console.log('📄 LEAF FLAG DEBUG - Found leaf files:', files.map(f => ({ id: f.id, name: f.name, isLeaf: f.isLeaf }))); resolve(files); } }; @@ -534,21 +561,6 @@ class FileStorageService { return file; } - /** - * Convert StoredFile to the format expected by FileContext.addStoredFiles() - * This is the recommended way to load stored files into FileContext - */ - createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: FileId; metadata: { thumbnail?: string } } { - const file = this.createFileFromStored(storedFile); - - return { - file, - originalId: storedFile.id, - metadata: { - thumbnail: storedFile.thumbnail - } - }; - } /** * Create blob URL for stored file diff --git a/frontend/src/services/indexedDBManager.ts b/frontend/src/services/indexedDBManager.ts index 9251998a3..8b9be040f 100644 --- a/frontend/src/services/indexedDBManager.ts +++ b/frontend/src/services/indexedDBManager.ts @@ -201,13 +201,16 @@ class IndexedDBManager { export const DATABASE_CONFIGS = { FILES: { name: 'stirling-pdf-files', - version: 2, + version: 3, stores: [{ name: 'files', keyPath: 'id', indexes: [ { name: 'name', keyPath: 'name', unique: false }, - { name: 'lastModified', keyPath: 'lastModified', unique: false } + { name: 'lastModified', keyPath: 'lastModified', unique: false }, + { name: 'originalFileId', keyPath: 'originalFileId', unique: false }, + { name: 'parentFileId', keyPath: 'parentFileId', unique: false }, + { name: 'versionNumber', keyPath: 'versionNumber', unique: false } ] }] } as DatabaseConfig, @@ -219,7 +222,8 @@ export const DATABASE_CONFIGS = { name: 'drafts', keyPath: 'id' }] - } as DatabaseConfig + } as DatabaseConfig, + } as const; export const indexedDBManager = IndexedDBManager.getInstance(); diff --git a/frontend/src/services/pdfMetadataService.ts b/frontend/src/services/pdfMetadataService.ts index 9cf407db8..46ba32c52 100644 --- a/frontend/src/services/pdfMetadataService.ts +++ b/frontend/src/services/pdfMetadataService.ts @@ -7,18 +7,17 @@ */ import { PDFDocument } from 'pdf-lib'; -import { FileId } from '../types/file'; import { ContentCache, type CacheConfig } from '../utils/ContentCache'; const DEBUG = process.env.NODE_ENV === 'development'; /** * Tool operation metadata for history tracking + * Note: Parameters removed for security - sensitive data like passwords should not be stored */ export interface ToolOperation { toolName: string; timestamp: number; - parameters?: Record; } /** @@ -182,7 +181,7 @@ export class PDFMetadataService { latestVersionNumber = parsed.stirlingHistory.versionNumber; historyJson = json; } - } catch (error) { + } catch { // Silent fallback for corrupted history } } diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index b85f45b9d..1b018a02a 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -8,11 +8,11 @@ export type FileId = string & { readonly [tag]: 'FileId' }; /** * Tool operation metadata for history tracking + * Note: Parameters removed for security - sensitive data like passwords should not be stored in history */ export interface ToolOperation { toolName: string; timestamp: number; - parameters?: Record; } /** @@ -21,31 +21,32 @@ export interface ToolOperation { */ export interface FileHistoryInfo { originalFileId: string; - parentFileId?: string; + parentFileId?: FileId; versionNumber: number; toolChain: ToolOperation[]; } /** - * File metadata for efficient operations without loading full file data - * Used by IndexedDBContext and FileContext for lazy file loading + * Base file metadata shared between storage and runtime layers + * Contains all common file properties and history tracking */ -export interface FileMetadata { +export interface BaseFileMetadata { id: FileId; name: string; type: string; size: number; lastModified: number; - thumbnail?: string; - isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet) - - // File history tracking (extracted from PDF metadata) - historyInfo?: FileHistoryInfo; - - // Quick access version information + createdAt?: number; // When file was added to system + + // File history tracking + isLeaf?: boolean; // True if this file hasn't been processed yet originalFileId?: string; // Root file ID for grouping versions versionNumber?: number; // Version number in chain parentFileId?: FileId; // Immediate parent file ID + toolHistory?: Array<{ + toolName: string; + timestamp: number; + }>; // Tool chain for history tracking // Standard PDF document metadata pdfMetadata?: { @@ -59,6 +60,10 @@ export interface FileMetadata { }; } +// FileMetadata has been replaced with StoredFileMetadata from '../services/fileStorage' +// This ensures clear type relationships and eliminates duplication + + export interface StorageConfig { useIndexedDB: boolean; maxFileSize: number; // Maximum size per file in bytes diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 2277f612c..f79aee243 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -3,7 +3,8 @@ */ import { PageOperation } from './pageEditor'; -import { FileId, FileMetadata } from './file'; +import { FileId, BaseFileMetadata } from './file'; +import { StoredFileMetadata, StoredFile } from '../services/fileStorage'; // Re-export FileId for convenience export type { FileId }; @@ -51,30 +52,20 @@ export interface ProcessedFileMetadata { * separately in refs for memory efficiency. Supports multi-tool workflows * where files persist across tool operations. */ -export interface StirlingFileStub { - id: FileId; // UUID primary key for collision-free operations - name: string; // Display name for UI - size: number; // File size for progress indicators - type: string; // MIME type for format validation - lastModified: number; // Original timestamp for deduplication +/** + * StirlingFileStub - Runtime UI metadata for files in the active workbench session + * + * Contains UI display data and processing state. Actual File objects stored + * separately in refs for memory efficiency. Supports multi-tool workflows + * where files persist across tool operations. + */ +export interface StirlingFileStub extends BaseFileMetadata { quickKey?: string; // Fast deduplication key: name|size|lastModified thumbnailUrl?: string; // Generated thumbnail blob URL for visual display blobUrl?: string; // File access blob URL for downloads/processing - createdAt?: number; // When added to workbench for sorting processedFile?: ProcessedFileMetadata; // PDF page data and processing results insertAfterPageId?: string; // Page ID after which this file should be inserted isPinned?: boolean; // Protected from tool consumption (replace/remove) - isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet) - - // File history tracking (from PDF metadata) - originalFileId?: string; // Root file ID for grouping versions - versionNumber?: number; // Version number in chain - parentFileId?: FileId; // Immediate parent file ID - toolHistory?: Array<{ - toolName: string; - timestamp: number; - parameters?: Record; - }>; // Note: File object stored in provider ref, not in state } @@ -117,6 +108,11 @@ export function isStirlingFile(file: File): file is StirlingFile { // Create a StirlingFile from a regular File object export function createStirlingFile(file: File, id?: FileId): StirlingFile { + // Check if file is already a StirlingFile to avoid property redefinition + if (isStirlingFile(file)) { + return file; // Already has fileId and quickKey properties + } + const fileId = id || createFileId(); const quickKey = createQuickKey(file); @@ -220,7 +216,6 @@ export interface FileOperation { metadata?: { originalFileName?: string; outputFileNames?: string[]; - parameters?: Record; fileSize?: number; pageCount?: number; error?: string; @@ -298,7 +293,7 @@ export interface FileContextActions { // File management - lightweight actions only addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; - addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise; + addStoredFiles: (storedFiles: StoredFile[], options?: { selectFiles?: boolean }) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; updateStirlingFileStub: (id: FileId, updates: Partial) => void; reorderFiles: (orderedFileIds: FileId[]) => void; diff --git a/frontend/src/utils/downloadUtils.ts b/frontend/src/utils/downloadUtils.ts index 404e10925..48d1588b9 100644 --- a/frontend/src/utils/downloadUtils.ts +++ b/frontend/src/utils/downloadUtils.ts @@ -1,4 +1,4 @@ -import { FileMetadata } from '../types/file'; +import { StoredFileMetadata } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage'; import { zipFileService } from '../services/zipFileService'; @@ -26,7 +26,7 @@ export function downloadBlob(blob: Blob, filename: string): void { * @param file - The file object with storage information * @throws Error if file cannot be retrieved from storage */ -export async function downloadFileFromStorage(file: FileMetadata): Promise { +export async function downloadFileFromStorage(file: StoredFileMetadata): Promise { const lookupKey = file.id; const storedFile = await fileStorage.getFile(lookupKey); @@ -42,7 +42,7 @@ export async function downloadFileFromStorage(file: FileMetadata): Promise * Downloads multiple files as individual downloads * @param files - Array of files to download */ -export async function downloadMultipleFiles(files: FileMetadata[]): Promise { +export async function downloadMultipleFiles(files: StoredFileMetadata[]): Promise { for (const file of files) { await downloadFileFromStorage(file); } @@ -53,7 +53,7 @@ export async function downloadMultipleFiles(files: FileMetadata[]): Promise { +export async function downloadFilesAsZip(files: StoredFileMetadata[], zipFilename?: string): Promise { if (files.length === 0) { throw new Error('No files provided for ZIP download'); } @@ -94,7 +94,7 @@ export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: st * @param options - Download options */ export async function downloadFiles( - files: FileMetadata[], + files: StoredFileMetadata[], options: { forceZip?: boolean; zipFilename?: string; diff --git a/frontend/src/utils/fileHistoryUtils.ts b/frontend/src/utils/fileHistoryUtils.ts index fbea24bfb..334b7bba6 100644 --- a/frontend/src/utils/fileHistoryUtils.ts +++ b/frontend/src/utils/fileHistoryUtils.ts @@ -1,206 +1,17 @@ /** * File History Utilities * - * Helper functions for integrating PDF metadata service with FileContext operations. - * Handles extraction of history from files and preparation for metadata injection. + * Helper functions for IndexedDB-based file history management. + * Handles file history operations and lineage tracking. */ - -import { pdfMetadataService, type ToolOperation } from '../services/pdfMetadataService'; import { StirlingFileStub } from '../types/fileContext'; -import { FileId, FileMetadata } from '../types/file'; -import { createFileId } from '../types/fileContext'; +import { FileId } from '../types/file'; +import { StoredFileMetadata } from '../services/fileStorage'; -const DEBUG = process.env.NODE_ENV === 'development'; -/** - * Extract history information from a PDF file and update StirlingFileStub - */ -export async function extractFileHistory( - file: File, - record: StirlingFileStub -): Promise { - // Only process PDF files - if (!file.type.includes('pdf')) { - return record; - } - try { - const arrayBuffer = await file.arrayBuffer(); - const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer); - if (historyMetadata) { - const history = historyMetadata.stirlingHistory; - // Update record with history information - return { - ...record, - originalFileId: history.originalFileId, - versionNumber: history.versionNumber, - parentFileId: history.parentFileId as FileId | undefined, - toolHistory: history.toolChain - }; - } - } catch (error) { - if (DEBUG) console.warn('📄 Failed to extract file history:', file.name, error); - } - - return record; -} - -/** - * Inject history metadata into a PDF file for tool operations - */ -export async function injectHistoryForTool( - file: File, - sourceStirlingFileStub: StirlingFileStub, - toolName: string, - parameters?: Record -): Promise { - // Only process PDF files - if (!file.type.includes('pdf')) { - return file; - } - - try { - const arrayBuffer = await file.arrayBuffer(); - - // Create tool operation record - const toolOperation: ToolOperation = { - toolName, - timestamp: Date.now(), - parameters - }; - - let modifiedBytes: ArrayBuffer; - - // Extract version info directly from the PDF metadata to ensure accuracy - const existingHistoryMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer); - - let newVersionNumber: number; - let originalFileId: string; - let parentFileId: string; - let parentToolChain: ToolOperation[]; - - if (existingHistoryMetadata) { - // File already has embedded history - increment version - const history = existingHistoryMetadata.stirlingHistory; - newVersionNumber = history.versionNumber + 1; - originalFileId = history.originalFileId; - parentFileId = sourceStirlingFileStub.id; // This file becomes the parent - parentToolChain = history.toolChain || []; - - } else if (sourceStirlingFileStub.originalFileId && sourceStirlingFileStub.versionNumber) { - // File record has history but PDF doesn't (shouldn't happen, but fallback) - newVersionNumber = sourceStirlingFileStub.versionNumber + 1; - originalFileId = sourceStirlingFileStub.originalFileId; - parentFileId = sourceStirlingFileStub.id; - parentToolChain = sourceStirlingFileStub.toolHistory || []; - } else { - // File has no history - this becomes version 1 - newVersionNumber = 1; - originalFileId = sourceStirlingFileStub.id; // Use source file ID as original - parentFileId = sourceStirlingFileStub.id; // Parent is the source file - parentToolChain = []; // No previous tools - } - - // Create new tool chain with the new operation - const newToolChain = [...parentToolChain, toolOperation]; - - modifiedBytes = await pdfMetadataService.injectHistoryMetadata( - arrayBuffer, - originalFileId, - parentFileId, - newToolChain, - newVersionNumber - ); - - // Create new file with updated metadata - return new File([modifiedBytes], file.name, { type: file.type }); - } catch (error) { - if (DEBUG) console.warn('📄 Failed to inject history for tool operation:', error); - return file; // Return original file if injection fails - } -} - -/** - * Prepare StirlingFiles with history-injected PDFs for tool operations - * Preserves fileId and all StirlingFile metadata while injecting history - */ -export async function prepareStirlingFilesWithHistory( - stirlingFiles: import('../types/fileContext').StirlingFile[], - getStirlingFileStub: (fileId: import('../types/file').FileId) => StirlingFileStub | undefined, - toolName: string, - parameters?: Record -): Promise { - const processedFiles: import('../types/fileContext').StirlingFile[] = []; - - for (const stirlingFile of stirlingFiles) { - const fileStub = getStirlingFileStub(stirlingFile.fileId); - - if (!fileStub) { - // If no stub found, keep original file - processedFiles.push(stirlingFile); - continue; - } - - // Inject history into the file data - const fileWithHistory = await injectHistoryForTool(stirlingFile, fileStub, toolName, parameters); - - // Create new StirlingFile with the updated file data but preserve fileId and quickKey - const updatedStirlingFile = new File([fileWithHistory], fileWithHistory.name, { - type: fileWithHistory.type, - lastModified: fileWithHistory.lastModified - }) as import('../types/fileContext').StirlingFile; - - // Preserve the original fileId and quickKey - Object.defineProperty(updatedStirlingFile, 'fileId', { - value: stirlingFile.fileId, - writable: false, - enumerable: true, - configurable: false - }); - - Object.defineProperty(updatedStirlingFile, 'quickKey', { - value: stirlingFile.quickKey, - writable: false, - enumerable: true, - configurable: false - }); - - processedFiles.push(updatedStirlingFile); - } - - return processedFiles; -} - -/** - * Verify that processed files preserved metadata from originals - * Logs warnings for tools that strip standard PDF metadata - */ -export async function verifyToolMetadataPreservation( - originalFiles: File[], - processedFiles: File[], - toolName: string -): Promise { - if (originalFiles.length === 0 || processedFiles.length === 0) return; - - try { - // For single-file tools, compare the original with the processed file - if (originalFiles.length === 1 && processedFiles.length === 1) { - const originalBytes = await originalFiles[0].arrayBuffer(); - const processedBytes = await processedFiles[0].arrayBuffer(); - - await pdfMetadataService.verifyMetadataPreservation( - originalBytes, - processedBytes, - toolName - ); - } - // For multi-file tools, we could add more complex verification later - } catch (error) { - if (DEBUG) console.warn(`📄 Failed to verify metadata preservation for ${toolName}:`, error); - } -} /** * Group files by processing branches - each branch ends in a leaf file @@ -350,7 +161,7 @@ export async function getRecentLeafFiles(): Promise[]> { +export async function getRecentLeafFileMetadata(): Promise { try { const { fileStorage } = await import('../services/fileStorage'); return await fileStorage.getLeafFileMetadata(); @@ -360,106 +171,18 @@ export async function getRecentLeafFileMetadata(): Promise { - // Only process PDF files - if (!file.type.includes('pdf')) { - return fileStub; - } - try { - const arrayBuffer = await file.arrayBuffer(); - const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer); - - if (historyMetadata) { - const history = historyMetadata.stirlingHistory; - - // Update fileStub with essential metadata only (no parent/original relationships) - return { - ...fileStub, - versionNumber: history.versionNumber, - toolHistory: history.toolChain - }; - } - } catch (error) { - if (DEBUG) console.warn('📄 Failed to extract basic metadata:', file.name, error); - } - - return fileStub; -} /** - * Load file history on-demand for a specific file - * This replaces the automatic history extraction during file loading - */ -export async function loadFileHistoryOnDemand( - file: File, - fileId: FileId, - updateFileStub?: (id: FileId, updates: Partial) => void -): Promise<{ - originalFileId?: string; - versionNumber?: number; - parentFileId?: FileId; - toolHistory?: Array<{ - toolName: string; - timestamp: number; - parameters?: Record; - }>; -} | null> { - // Only process PDF files - if (!file.type.includes('pdf')) { - return null; - } - - try { - const baseFileStub: StirlingFileStub = { - id: fileId, - name: file.name, - size: file.size, - type: file.type, - lastModified: file.lastModified - }; - - const updatedFileStub = await extractFileHistory(file, baseFileStub); - - if (updatedFileStub !== baseFileStub && (updatedFileStub.originalFileId || updatedFileStub.versionNumber)) { - const historyData = { - originalFileId: updatedFileStub.originalFileId, - versionNumber: updatedFileStub.versionNumber, - parentFileId: updatedFileStub.parentFileId, - toolHistory: updatedFileStub.toolHistory - }; - - // Update the file stub if update function is provided - if (updateFileStub) { - updateFileStub(fileId, historyData); - } - - return historyData; - } - - return null; - } catch (error) { - console.warn(`Failed to load history for ${file.name}:`, error); - return null; - } -} - -/** - * Create metadata for storing files with history information + * Create basic metadata for storing files + * History information is managed separately in IndexedDB */ export async function createFileMetadataWithHistory( file: File, fileId: FileId, thumbnail?: string -): Promise { - const baseMetadata: FileMetadata = { +): Promise { + return { id: fileId, name: file.name, type: file.type, @@ -468,41 +191,4 @@ export async function createFileMetadataWithHistory( thumbnail, isLeaf: true // New files are leaf nodes by default }; - - // Extract metadata for PDF files - if (file.type.includes('pdf')) { - try { - const arrayBuffer = await file.arrayBuffer(); - const [historyMetadata, standardMetadata] = await Promise.all([ - pdfMetadataService.extractHistoryMetadata(arrayBuffer), - pdfMetadataService.extractStandardMetadata(arrayBuffer) - ]); - - const result = { ...baseMetadata }; - - // Add standard PDF metadata if available - if (standardMetadata) { - result.pdfMetadata = standardMetadata; - } - - // Add history metadata if available (basic version for display) - if (historyMetadata) { - const history = historyMetadata.stirlingHistory; - // Only add basic metadata needed for display, not full history relationships - result.versionNumber = history.versionNumber; - result.historyInfo = { - originalFileId: history.originalFileId, - parentFileId: history.parentFileId, - versionNumber: history.versionNumber, - toolChain: history.toolChain - }; - } - - return result; - } catch (error) { - if (DEBUG) console.warn('📄 Failed to extract metadata:', file.name, error); - } - } - - return baseMetadata; } diff --git a/frontend/src/utils/toolOperationTracker.ts b/frontend/src/utils/toolOperationTracker.ts index 9ea7ea3df..b4feb1c8c 100644 --- a/frontend/src/utils/toolOperationTracker.ts +++ b/frontend/src/utils/toolOperationTracker.ts @@ -6,7 +6,7 @@ import { FileOperation } from '../types/fileContext'; */ export const createOperation = ( operationType: string, - params: TParams, + _params: TParams, selectedFiles: File[] ): { operation: FileOperation; operationId: string; fileId: FileId } => { const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -20,7 +20,6 @@ export const createOperation = ( status: 'pending', metadata: { originalFileName: selectedFiles[0]?.name, - parameters: params, fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0) } } as any /* FIX ME*/;