This commit is contained in:
Connor Yoh 2025-09-10 10:03:35 +01:00
parent f8bdeabe35
commit f88c3e25d1
26 changed files with 604 additions and 867 deletions

View File

@ -2151,6 +2151,7 @@
"loadingHistory": "Loading History...", "loadingHistory": "Loading History...",
"lastModified": "Last Modified", "lastModified": "Last Modified",
"toolChain": "Tools Applied", "toolChain": "Tools Applied",
"addToRecents": "Add to Recents",
"searchFiles": "Search files...", "searchFiles": "Search files...",
"recent": "Recent", "recent": "Recent",
"localFiles": "Local Files", "localFiles": "Local Files",

View File

@ -1,7 +1,7 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core'; import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { FileMetadata } from '../types/file'; import { StoredFileMetadata, fileStorage } from '../services/fileStorage';
import { useFileManager } from '../hooks/useFileManager'; import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext'; import { useFilesModalContext } from '../contexts/FilesModalContext';
import { Tool } from '../types/tool'; import { Tool } from '../types/tool';
@ -16,11 +16,11 @@ interface FileManagerProps {
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => { const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext(); const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]); const [recentFiles, setRecentFiles] = useState<StoredFileMetadata[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager(); const { loadRecentFiles, handleRemoveFile } = useFileManager();
// File management handlers // File management handlers
const isFileSupported = useCallback((fileName: string) => { const isFileSupported = useCallback((fileName: string) => {
@ -34,21 +34,24 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
setRecentFiles(files); setRecentFiles(files);
}, [loadRecentFiles]); }, [loadRecentFiles]);
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { const handleFilesSelected = useCallback(async (files: StoredFileMetadata[]) => {
try { try {
// Use stored files flow that preserves original IDs // Use stored files flow that preserves original IDs
const filesWithMetadata = await Promise.all( // Load full StoredFile objects for selected files
files.map(async (metadata) => ({ const storedFiles = await Promise.all(
file: await convertToFile(metadata), files.map(async (metadata) => {
originalId: metadata.id, const storedFile = await fileStorage.getFile(metadata.id);
metadata if (!storedFile) {
})) throw new Error(`File not found in storage: ${metadata.name}`);
}
return storedFile;
})
); );
onStoredFilesSelect(filesWithMetadata); onStoredFilesSelect(storedFiles);
} catch (error) { } catch (error) {
console.error('Failed to process selected files:', error); console.error('Failed to process selected files:', error);
} }
}, [convertToFile, onStoredFilesSelect]); }, [onStoredFilesSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => { const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) { if (files.length > 0) {
@ -85,7 +88,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Cleanup any blob URLs when component unmounts // Cleanup any blob URLs when component unmounts
useEffect(() => { useEffect(() => {
return () => { 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 // Blob URLs are managed by FileContext and tool operations
console.log('FileManager unmounting - FileContext handles blob URL cleanup'); console.log('FileManager unmounting - FileContext handles blob URL cleanup');
}; };
@ -148,6 +151,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
recentFiles={recentFiles} recentFiles={recentFiles}
onFilesSelected={handleFilesSelected} onFilesSelected={handleFilesSelected}
onNewFilesSelect={handleNewFileUpload} onNewFilesSelect={handleNewFileUpload}
onStoredFilesSelect={onStoredFilesSelect}
onClose={closeFilesModal} onClose={closeFilesModal}
isFileSupported={isFileSupported} isFileSupported={isFileSupported}
isOpen={isFilesModalOpen} isOpen={isFilesModalOpen}

View File

@ -69,7 +69,7 @@ const FileEditorThumbnail = ({
const fileRecord = selectors.getStirlingFileStub(file.id); const fileRecord = selectors.getStirlingFileStub(file.id);
const toolHistory = fileRecord?.toolHistory || []; const toolHistory = fileRecord?.toolHistory || [];
const hasToolHistory = toolHistory.length > 0; const hasToolHistory = toolHistory.length > 0;
const versionNumber = fileRecord?.versionNumber || 0; const versionNumber = fileRecord?.versionNumber || 1;
const downloadSelectedFile = useCallback(() => { const downloadSelectedFile = useCallback(() => {

View File

@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils'; import { getFileSize } from '../../utils/fileUtils';
import { FileMetadata } from '../../types/file'; import { StoredFileMetadata } from '../../services/fileStorage';
interface CompactFileDetailsProps { interface CompactFileDetailsProps {
currentFile: FileMetadata | null; currentFile: StoredFileMetadata | null;
thumbnail: string | null; thumbnail: string | null;
selectedFiles: FileMetadata[]; selectedFiles: StoredFileMetadata[];
currentFileIndex: number; currentFileIndex: number;
numberOfFiles: number; numberOfFiles: number;
isAnimating: boolean; isAnimating: boolean;
@ -72,7 +72,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''} {currentFile ? getFileSize(currentFile) : ''}
{selectedFiles.length > 1 && `${selectedFiles.length} files`} {selectedFiles.length > 1 && `${selectedFiles.length} files`}
{currentFile && ` • v${currentFile.versionNumber || 0}`} {currentFile && ` • v${currentFile.versionNumber || 1}`}
</Text> </Text>
{hasMultipleFiles && ( {hasMultipleFiles && (
<Text size="xs" c="blue"> <Text size="xs" c="blue">
@ -80,9 +80,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
</Text> </Text>
)} )}
{/* Compact tool chain for mobile */} {/* Compact tool chain for mobile */}
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && ( {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')} {currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')}
</Text> </Text>
)} )}
</Box> </Box>

View File

@ -2,11 +2,11 @@ import React from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
import { FileMetadata } from '../../types/file'; import { StoredFileMetadata } from '../../services/fileStorage';
import ToolChain from '../shared/ToolChain'; import ToolChain from '../shared/ToolChain';
interface FileInfoCardProps { interface FileInfoCardProps {
currentFile: FileMetadata | null; currentFile: StoredFileMetadata | null;
modalHeight: string; modalHeight: string;
} }
@ -114,19 +114,19 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text> <Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
{currentFile && {currentFile &&
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}> <Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
v{currentFile ? (currentFile.versionNumber || 0) : ''} v{currentFile ? (currentFile.versionNumber || 1) : ''}
</Badge>} </Badge>}
</Group> </Group>
{/* Tool Chain Display */} {/* Tool Chain Display */}
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && ( {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<> <>
<Divider /> <Divider />
<Box py="xs"> <Box py="xs">
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text> <Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
<ToolChain <ToolChain
toolChain={currentFile.historyInfo.toolChain} toolChain={currentFile.toolHistory}
displayStyle="badges" displayStyle="badges"
size="xs" size="xs"
maxWidth={'180px'} maxWidth={'180px'}

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Button, Loader } from '@mantine/core'; import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Loader } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
@ -7,12 +7,12 @@ import AddIcon from '@mui/icons-material/Add';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils'; import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileMetadata } from '../../types/file'; import { StoredFileMetadata } from '../../services/fileStorage';
import { useFileManagerContext } from '../../contexts/FileManagerContext'; import { useFileManagerContext } from '../../contexts/FileManagerContext';
import ToolChain from '../shared/ToolChain'; import ToolChain from '../shared/ToolChain';
interface FileListItemProps { interface FileListItemProps {
file: FileMetadata; file: StoredFileMetadata;
isSelected: boolean; isSelected: boolean;
isSupported: boolean; isSupported: boolean;
onSelect: (shiftKey?: boolean) => void; onSelect: (shiftKey?: boolean) => void;
@ -38,22 +38,17 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation(); 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 // Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen; const shouldShowHovered = isHovered || isMenuOpen;
// Get version information for this file // Get version information for this file
const leafFileId = isLatestVersion ? file.id : (file.originalFileId || file.id); const leafFileId = isLatestVersion ? file.id : (file.originalFileId || file.id);
const lineagePath = fileGroups.get(leafFileId) || []; const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
const hasVersionHistory = (file.versionNumber || 0) > 0; // Show history for any processed file (v1+) const currentVersion = file.versionNumber || 1; // Display original files as v1
const currentVersion = file.versionNumber || 0; // Display original files as v0
const isExpanded = expandedFileIds.has(leafFileId); const isExpanded = expandedFileIds.has(leafFileId);
// Get loading state for this file's history
const isLoadingFileHistory = isLoadingHistory(file.id);
const historyError = getHistoryError(file.id);
return ( return (
<> <>
<Box <Box
@ -95,8 +90,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text> <Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
{isLoadingFileHistory && <Loader size={14} />} <Badge size="xs" variant="light" color={currentVersion > 1 ? "blue" : "gray"}>
<Badge size="xs" variant="light" color={currentVersion > 0 ? "blue" : "gray"}>
v{currentVersion} v{currentVersion}
</Badge> </Badge>
@ -104,15 +98,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{getFileSize(file)} {getFileDate(file)} {getFileSize(file)} {getFileDate(file)}
{hasVersionHistory && (
<Text span c="dimmed"> has history</Text>
)}
</Text> </Text>
{/* Tool chain for processed files */} {/* Tool chain for processed files */}
{file.historyInfo?.toolChain && file.historyInfo.toolChain.length > 0 && ( {file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain <ToolChain
toolChain={file.historyInfo.toolChain} toolChain={file.toolHistory}
maxWidth={'150px'} maxWidth={'150px'}
displayStyle="text" displayStyle="text"
size="xs" size="xs"
@ -163,29 +154,20 @@ const FileListItem: React.FC<FileListItemProps> = ({
<> <>
<Menu.Item <Menu.Item
leftSection={ leftSection={
isLoadingFileHistory ?
<Loader size={16} /> :
<HistoryIcon style={{ fontSize: 16 }} /> <HistoryIcon style={{ fontSize: 16 }} />
} }
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleExpansion(leafFileId); onToggleExpansion(leafFileId);
}} }}
disabled={isLoadingFileHistory}
> >
{isLoadingFileHistory ? {
t('fileManager.loadingHistory', 'Loading History...') :
(isExpanded ? (isExpanded ?
t('fileManager.hideHistory', 'Hide History') : t('fileManager.hideHistory', 'Hide History') :
t('fileManager.showHistory', 'Show History') t('fileManager.showHistory', 'Show History')
) )
} }
</Menu.Item> </Menu.Item>
{historyError && (
<Menu.Item disabled c="red" style={{ fontSize: '12px' }}>
{t('fileManager.historyError', 'Error loading history')}
</Menu.Item>
)}
<Menu.Divider /> <Menu.Divider />
</> </>
)} )}

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Center } from '@mantine/core'; import { Box, Center } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { FileMetadata } from '../../types/file'; import { StoredFileMetadata } from '../../services/fileStorage';
import DocumentThumbnail from './filePreview/DocumentThumbnail'; import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack'; import DocumentStack from './filePreview/DocumentStack';
import HoverOverlay from './filePreview/HoverOverlay'; import HoverOverlay from './filePreview/HoverOverlay';
@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
export interface FilePreviewProps { export interface FilePreviewProps {
// Core file data // Core file data
file: File | FileMetadata | null; file: File | StoredFileMetadata | null;
thumbnail?: string | null; thumbnail?: string | null;
// Optional features // Optional features
@ -22,7 +22,7 @@ export interface FilePreviewProps {
isAnimating?: boolean; isAnimating?: boolean;
// Event handlers // Event handlers
onFileClick?: (file: File | FileMetadata | null) => void; onFileClick?: (file: File | StoredFileMetadata | null) => void;
onPrevious?: () => void; onPrevious?: () => void;
onNext?: () => void; onNext?: () => void;
} }

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { Box, Center, Image } from '@mantine/core'; import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FileMetadata } from '../../../types/file'; import { StoredFileMetadata } from '../../../services/fileStorage';
export interface DocumentThumbnailProps { export interface DocumentThumbnailProps {
file: File | FileMetadata | null; file: File | StoredFileMetadata | null;
thumbnail?: string | null; thumbnail?: string | null;
style?: React.CSSProperties; style?: React.CSSProperties;
onClick?: () => void; onClick?: () => void;

View File

@ -32,6 +32,7 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
import { FileLifecycleManager } from './file/lifecycle'; import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts'; import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext'; import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
import { StoredFile, StoredFileMetadata } from '../services/fileStorage';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -88,11 +89,33 @@ function FileContextInner({
selectFiles(addedFilesWithIds); selectFiles(addedFilesWithIds);
} }
// Persist to IndexedDB if enabled // Persist to IndexedDB if enabled and update StirlingFileStub with version info
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => { await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
try { 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) { } catch (error) {
console.error('Failed to persist file to IndexedDB:', file.name, 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)); return result.map(({ file, id }) => createStirlingFile(file, id));
}, []); }, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => { const addStoredFiles = useCallback(async (storedFiles: StoredFile[], options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
// 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); const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested // Auto-select the newly added files if requested
@ -118,6 +154,7 @@ function FileContextInner({
return result.map(({ file, id }) => createStirlingFile(file, id)); return result.map(({ file, id }) => createStirlingFile(file, id));
}, []); }, []);
// Action creators // Action creators
const baseActions = useMemo(() => createFileActions(dispatch), []); const baseActions = useMemo(() => createFileActions(dispatch), []);

View File

@ -1,10 +1,9 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; 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 { fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils'; import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils'; import { groupFilesByOriginal } from '../utils/fileHistoryUtils';
import { useMultiFileHistory } from '../hooks/useFileHistory';
// Type for the context value - now contains everything directly // Type for the context value - now contains everything directly
interface FileManagerContextValue { interface FileManagerContextValue {
@ -12,36 +11,32 @@ interface FileManagerContextValue {
activeSource: 'recent' | 'local' | 'drive'; activeSource: 'recent' | 'local' | 'drive';
selectedFileIds: FileId[]; selectedFileIds: FileId[];
searchTerm: string; searchTerm: string;
selectedFiles: FileMetadata[]; selectedFiles: StoredFileMetadata[];
filteredFiles: FileMetadata[]; filteredFiles: StoredFileMetadata[];
fileInputRef: React.RefObject<HTMLInputElement | null>; fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>; selectedFilesSet: Set<string>;
expandedFileIds: Set<string>; expandedFileIds: Set<string>;
fileGroups: Map<string, FileMetadata[]>; fileGroups: Map<string, StoredFileMetadata[]>;
// History loading state
isLoadingHistory: (fileId: FileId) => boolean;
getHistoryError: (fileId: FileId) => string | null;
// Handlers // Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void; onLocalFileClick: () => void;
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void; onFileSelect: (file: StoredFileMetadata, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void; onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileMetadata) => void; onFileDoubleClick: (file: StoredFileMetadata) => void;
onOpenFiles: () => void; onOpenFiles: () => void;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void; onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectAll: () => void; onSelectAll: () => void;
onDeleteSelected: () => void; onDeleteSelected: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onDownloadSingle: (file: FileMetadata) => void; onDownloadSingle: (file: StoredFileMetadata) => void;
onToggleExpansion: (fileId: string) => void; onToggleExpansion: (fileId: string) => void;
onAddToRecents: (file: FileMetadata) => void; onAddToRecents: (file: StoredFileMetadata) => void;
onNewFilesSelect: (files: File[]) => void; onNewFilesSelect: (files: File[]) => void;
// External props // External props
recentFiles: FileMetadata[]; recentFiles: StoredFileMetadata[];
isFileSupported: (fileName: string) => boolean; isFileSupported: (fileName: string) => boolean;
modalHeight: string; modalHeight: string;
} }
@ -52,9 +47,10 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
// Provider component props // Provider component props
interface FileManagerProviderProps { interface FileManagerProviderProps {
children: React.ReactNode; children: React.ReactNode;
recentFiles: FileMetadata[]; recentFiles: StoredFileMetadata[];
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files onFilesSelected: (files: StoredFileMetadata[]) => void; // For selecting stored files
onNewFilesSelect: (files: File[]) => void; // For uploading new local files onNewFilesSelect: (files: File[]) => void; // For uploading new local files
onStoredFilesSelect: (storedFiles: StoredFile[]) => void; // For adding stored files directly
onClose: () => void; onClose: () => void;
isFileSupported: (fileName: string) => boolean; isFileSupported: (fileName: string) => boolean;
isOpen: boolean; isOpen: boolean;
@ -68,6 +64,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
recentFiles, recentFiles,
onFilesSelected, onFilesSelected,
onNewFilesSelect, onNewFilesSelect,
onStoredFilesSelect: onStoredFilesSelect,
onClose, onClose,
isFileSupported, isFileSupported,
isOpen, isOpen,
@ -80,19 +77,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null); const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [expandedFileIds, setExpandedFileIds] = useState<Set<string>>(new Set()); const [expandedFileIds, setExpandedFileIds] = useState<Set<string>>(new Set());
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, FileMetadata[]>>(new Map()); // Cache for loaded history const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StoredFileMetadata[]>>(new Map()); // Cache for loaded history
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup // Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set()); const createdBlobUrls = useRef<Set<string>>(new Set());
// History loading hook
const {
loadFileHistory,
getHistory,
isLoadingHistory,
getError: getHistoryError
} = useMultiFileHistory();
// Computed values (with null safety) // Computed values (with null safety)
const selectedFilesSet = new Set(selectedFileIds); const selectedFilesSet = new Set(selectedFileIds);
@ -101,11 +91,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const fileGroups = useMemo(() => { const fileGroups = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return new Map(); 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 => ({ const recordsForGrouping = recentFiles.map(file => ({
...file, ...file,
originalFileId: file.originalFileId, originalFileId: file.originalFileId,
versionNumber: file.versionNumber || 0 versionNumber: file.versionNumber || 1
})); }));
return groupFilesByOriginal(recordsForGrouping); return groupFilesByOriginal(recordsForGrouping);
@ -126,7 +116,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (expandedFileIds.has(leafFile.id)) { if (expandedFileIds.has(leafFile.id)) {
const historyFiles = loadedHistoryFiles.get(leafFile.id) || []; const historyFiles = loadedHistoryFiles.get(leafFile.id) || [];
// Sort history files by version number (oldest first) // 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); expandedFiles.push(...sortedHistory);
} }
} }
@ -155,7 +145,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click(); 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; const fileId = file.id;
if (!fileId) return; if (!fileId) return;
@ -196,33 +186,99 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
} }
}, [filteredFiles, lastClickedIndex]); }, [filteredFiles, lastClickedIndex]);
// Helper function to safely determine which files can be deleted
const getSafeFilesToDelete = useCallback((
leafFileIds: string[],
allStoredMetadata: Omit<import('../services/fileStorage').StoredFile, 'data'>[]
): string[] => {
const fileMap = new Map(allStoredMetadata.map(f => [f.id as string, f]));
const filesToDelete = new Set<string>();
const filesToPreserve = new Set<string>();
// 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 handleFileRemove = useCallback(async (index: number) => {
const fileToRemove = filteredFiles[index]; const fileToRemove = filteredFiles[index];
if (fileToRemove) { if (fileToRemove) {
const deletedFileId = fileToRemove.id; 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 // 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 // Clear from expanded state to prevent ghost entries
setExpandedFileIds(prev => { setExpandedFileIds(prev => {
const newExpanded = new Set(prev); const newExpanded = new Set(prev);
newExpanded.delete(deletedFileId); filesToDelete.forEach(id => newExpanded.delete(id));
return newExpanded; 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 => { setLoadedHistoryFiles(prev => {
const newCache = new Map(prev); const newCache = new Map(prev);
// If the deleted file was a main file with cached history, remove its cache // Remove cache entries for all deleted files
newCache.delete(deletedFileId); filesToDelete.forEach(id => newCache.delete(id as FileId));
// Also remove the deleted file from any other file's history cache // Also remove deleted files from any other file's history cache
for (const [mainFileId, historyFiles] of newCache.entries()) { 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) { if (filteredHistory.length !== historyFiles.length) {
// The deleted file was in this history, update the cache
newCache.set(mainFileId, filteredHistory); newCache.set(mainFileId, filteredHistory);
} }
} }
@ -230,15 +286,24 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
return newCache; 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); await onFileRemove(index);
// Refresh to ensure consistent state // Refresh to ensure consistent state
await refreshRecentFiles(); await refreshRecentFiles();
} }
}, [filteredFiles, onFileRemove, refreshRecentFiles]); }, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
const handleFileDoubleClick = useCallback((file: FileMetadata) => { const handleFileDoubleClick = useCallback((file: StoredFileMetadata) => {
if (isFileSupported(file.name)) { if (isFileSupported(file.name)) {
onFilesSelected([file]); onFilesSelected([file]);
onClose(); onClose();
@ -288,40 +353,28 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
if (selectedFileIds.length === 0) return; if (selectedFileIds.length === 0) return;
try { try {
// Use the same logic as individual file deletion for consistency // Get all stored files to analyze lineages
// Delete each selected file individually using the same cache update logic const allStoredMetadata = await fileStorage.getAllFileMetadata();
const allFilesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id)
);
// Deduplicate by file ID since shared files can appear multiple times in the display // Get safe files to delete (respecting shared lineages)
const uniqueFilesToDelete = allFilesToDelete.reduce((unique: typeof allFilesToDelete, file) => { const filesToDelete = getSafeFilesToDelete(selectedFileIds, allStoredMetadata);
if (!unique.some(f => f.id === file.id)) {
unique.push(file);
}
return unique;
}, []);
const filesToDelete = uniqueFilesToDelete; console.log(`Bulk safely deleting files and their history chains:`, filesToDelete);
const deletedFileIds = new Set(filesToDelete.map(f => f.id));
// Update history cache synchronously // Update history cache synchronously
setLoadedHistoryFiles(prev => { setLoadedHistoryFiles(prev => {
const newCache = new Map(prev); const newCache = new Map(prev);
for (const fileToDelete of filesToDelete) { // Remove cache entries for all deleted files
// If the deleted file was a main file with cached history, remove its cache filesToDelete.forEach(id => newCache.delete(id as FileId));
newCache.delete(fileToDelete.id);
// Also remove the deleted file from any other file's history cache // Also remove deleted files from any other file's history cache
for (const [mainFileId, historyFiles] of newCache.entries()) { for (const [mainFileId, historyFiles] of newCache.entries()) {
const filteredHistory = historyFiles.filter(histFile => histFile.id !== fileToDelete.id); const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id as string));
if (filteredHistory.length !== historyFiles.length) { if (filteredHistory.length !== historyFiles.length) {
// The deleted file was in this history, update the cache
newCache.set(mainFileId, filteredHistory); newCache.set(mainFileId, filteredHistory);
} }
} }
}
return newCache; return newCache;
}); });
@ -329,18 +382,16 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Also clear any expanded state for deleted files to prevent ghost entries // Also clear any expanded state for deleted files to prevent ghost entries
setExpandedFileIds(prev => { setExpandedFileIds(prev => {
const newExpanded = new Set(prev); const newExpanded = new Set(prev);
for (const deletedId of deletedFileIds) { filesToDelete.forEach(id => newExpanded.delete(id));
newExpanded.delete(deletedId);
}
return newExpanded; return newExpanded;
}); });
// Clear selection immediately to prevent ghost selections // 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 // Delete safe files from IndexedDB
for (const file of filesToDelete) { for (const fileId of filesToDelete) {
await fileStorage.deleteFile(file.id); await fileStorage.deleteFile(fileId as FileId);
} }
// Refresh the file list to get updated data // Refresh the file list to get updated data
@ -348,7 +399,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
} catch (error) { } catch (error) {
console.error('Failed to delete selected files:', error); console.error('Failed to delete selected files:', error);
} }
}, [selectedFileIds, filteredFiles, refreshRecentFiles]); }, [selectedFileIds, filteredFiles, refreshRecentFiles, getSafeFilesToDelete]);
const handleDownloadSelected = useCallback(async () => { const handleDownloadSelected = useCallback(async () => {
@ -369,7 +420,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
} }
}, [selectedFileIds, filteredFiles]); }, [selectedFileIds, filteredFiles]);
const handleDownloadSingle = useCallback(async (file: FileMetadata) => { const handleDownloadSingle = useCallback(async (file: StoredFileMetadata) => {
try { try {
await downloadFiles([file]); await downloadFiles([file]);
} catch (error) { } catch (error) {
@ -394,107 +445,55 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Load complete history chain if expanding // Load complete history chain if expanding
if (!isCurrentlyExpanded) { if (!isCurrentlyExpanded) {
const currentFileMetadata = recentFiles.find(f => f.id === fileId); const currentFileMetadata = recentFiles.find(f => f.id === fileId);
if (currentFileMetadata && (currentFileMetadata.versionNumber || 0) > 0) { if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
try { try {
// Load the current file to get its full history // Get all stored file metadata for chain traversal
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 allStoredMetadata = await fileStorage.getAllFileMetadata();
const fileMap = new Map(allStoredMetadata.map(f => [f.id, f])); const fileMap = new Map(allStoredMetadata.map(f => [f.id, f]));
// Build complete chain by following parent relationships backwards // Get the current file's IndexedDB data
const visitedIds = new Set([fileId]); // Don't include the current file const currentStoredFile = fileMap.get(fileId as FileId);
const toProcess = [historyData]; // Start with current file's history data if (!currentStoredFile) {
console.warn(`No stored file found for ${fileId}`);
while (toProcess.length > 0) { return;
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 // Build complete history chain using IndexedDB metadata
if (currentHistoryData.parentFileId && !visitedIds.has(currentHistoryData.parentFileId)) { const historyFiles: StoredFileMetadata[] = [];
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 // Find the original file
const parentHistoryData = await loadFileHistory(parentFile, parentMeta.id); const originalFileId = currentStoredFile.originalFileId || currentStoredFile.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) // Collect all files in this history chain
for (const [metaId, meta] of fileMap) { const chainFiles = Array.from(fileMap.values()).filter(file =>
if (!visitedIds.has(metaId) && (meta as any).originalFileId === historyData.originalFileId) { (file.originalFileId || file.id) === originalFileId && file.id !== fileId
visitedIds.add(metaId); );
try {
const siblingStoredFile = await fileStorage.getFile(meta.id); // Sort by version number (oldest first for history display)
if (siblingStoredFile) { chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
const siblingFile = new File([siblingStoredFile.data], siblingStoredFile.name, {
type: siblingStoredFile.type, // Convert stored files to StoredFileMetadata format with proper history info
lastModified: siblingStoredFile.lastModified for (const storedFile of chainFiles) {
}); // Load the actual file to extract PDF metadata if available
const siblingMetadata = await createFileMetadataWithHistory(siblingFile, meta.id, meta.thumbnail); const historyMetadata: StoredFileMetadata = {
historyFiles.push(siblingMetadata); id: storedFile.id,
} name: storedFile.name,
} catch (error) { type: storedFile.type,
console.warn(`Failed to load sibling file ${meta.id}:`, error); 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 // Cache the loaded history files
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles))); setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
}
}
} catch (error) { } catch (error) {
console.warn(`Failed to load history chain for file ${fileId}:`, error); console.warn(`Failed to load history chain for file ${fileId}:`, error);
} }
@ -507,30 +506,26 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
return newMap; return newMap;
}); });
} }
}, [expandedFileIds, recentFiles, loadFileHistory]); }, [expandedFileIds, recentFiles]);
const handleAddToRecents = useCallback(async (file: FileMetadata) => { const handleAddToRecents = useCallback(async (file: StoredFileMetadata) => {
try { try {
console.log('Promoting to recents:', file.name, 'version:', file.versionNumber); console.log('Adding to recents:', file.name, 'version:', file.versionNumber);
// Load the file from storage and create a copy with new ID and timestamp // Load file from storage and use addStoredFiles pattern
const storedFile = await fileStorage.getFile(file.id); const storedFile = await fileStorage.getFile(file.id);
if (storedFile) { if (!storedFile) {
// Create new file with current timestamp to appear at top throw new Error(`File not found in storage: ${file.name}`);
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);
} }
// Use direct StoredFile approach - much more efficient
onStoredFilesSelect([storedFile]);
console.log('Successfully added to recents:', file.name, 'v' + file.versionNumber);
} catch (error) { } 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 // Cleanup blob URLs when component unmounts
useEffect(() => { useEffect(() => {
@ -565,10 +560,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
expandedFileIds, expandedFileIds,
fileGroups, fileGroups,
// History loading state
isLoadingHistory,
getHistoryError,
// Handlers // Handlers
onSourceChange: handleSourceChange, onSourceChange: handleSourceChange,
onLocalFileClick: handleLocalFileClick, onLocalFileClick: handleLocalFileClick,
@ -599,8 +590,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef, fileInputRef,
expandedFileIds, expandedFileIds,
fileGroups, fileGroups,
isLoadingHistory,
getHistoryError,
handleSourceChange, handleSourceChange,
handleLocalFileClick, handleLocalFileClick,
handleFileSelect, handleFileSelect,

View File

@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler'; import { useFileHandler } from '../hooks/useFileHandler';
import { FileMetadata } from '../types/file'; import { StoredFileMetadata, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
interface FilesModalContextType { interface FilesModalContextType {
@ -9,7 +9,7 @@ interface FilesModalContextType {
closeFilesModal: () => void; closeFilesModal: () => void;
onFileSelect: (file: File) => void; onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void; onFilesSelect: (files: File[]) => void;
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void; onStoredFilesSelect: (storedFiles: StoredFile[]) => void;
onModalClose?: () => void; onModalClose?: () => void;
setOnModalClose: (callback: () => void) => void; setOnModalClose: (callback: () => void) => void;
} }
@ -58,14 +58,14 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal(); closeFilesModal();
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]); }, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => { const handleStoredFilesSelect = useCallback((storedFiles: StoredFile[]) => {
if (customHandler) { if (customHandler) {
// Use custom handler for special cases (like page insertion) // 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); customHandler(files, insertAfterPage);
} else { } else {
// Use normal file handling // Use normal file handling
addStoredFiles(filesWithMetadata); addStoredFiles(storedFiles);
} }
closeFilesModal(); closeFilesModal();
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]); }, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);

View File

@ -8,20 +8,19 @@ import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
import { FileMetadata } from '../types/file'; import { StoredFileMetadata } from '../services/fileStorage';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
interface IndexedDBContextValue { interface IndexedDBContextValue {
// Core CRUD operations // Core CRUD operations
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>; saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StoredFileMetadata>;
loadFile: (fileId: FileId) => Promise<File | null>; loadFile: (fileId: FileId) => Promise<File | null>;
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>; loadMetadata: (fileId: FileId) => Promise<StoredFileMetadata | null>;
deleteFile: (fileId: FileId) => Promise<void>; deleteFile: (fileId: FileId) => Promise<void>;
// Batch operations // Batch operations
loadAllMetadata: () => Promise<FileMetadata[]>; loadAllMetadata: () => Promise<StoredFileMetadata[]>;
loadLeafMetadata: () => Promise<FileMetadata[]>; // Only leaf files for recent files list loadLeafMetadata: () => Promise<StoredFileMetadata[]>; // Only leaf files for recent files list
deleteMultiple: (fileIds: FileId[]) => Promise<void>; deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>; clearAll: () => Promise<void>;
@ -59,20 +58,49 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`); if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
}, []); }, []);
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => { const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StoredFileMetadata> => {
// Use existing thumbnail or generate new one if none provided // Use existing thumbnail or generate new one if none provided
const thumbnail = existingThumbnail || await generateThumbnailForFile(file); const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB // Extract history data if attached to the file by tool operations
await fileStorage.storeFile(file, fileId, thumbnail); 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 // Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries(); evictLRUEntries();
// Extract history metadata for PDFs and return enhanced metadata // Return metadata with history information from the stored file
const metadata = await createFileMetadataWithHistory(file, fileId, thumbnail); 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; return metadata;
}, []); }, []);
@ -103,7 +131,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
return file; return file;
}, [evictLRUEntries]); }, [evictLRUEntries]);
const loadMetadata = useCallback(async (fileId: FileId): Promise<FileMetadata | null> => { const loadMetadata = useCallback(async (fileId: FileId): Promise<StoredFileMetadata | null> => {
// Try to get from cache first (no IndexedDB hit) // Try to get from cache first (no IndexedDB hit)
const cached = fileCache.current.get(fileId); const cached = fileCache.current.get(fileId);
if (cached) { if (cached) {
@ -141,7 +169,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
await fileStorage.deleteFile(fileId); await fileStorage.deleteFile(fileId);
}, []); }, []);
const loadLeafMetadata = useCallback(async (): Promise<FileMetadata[]> => { const loadLeafMetadata = useCallback(async (): Promise<StoredFileMetadata[]> => {
const metadata = await fileStorage.getLeafFileMetadata(); // Only get leaf files const metadata = await fileStorage.getLeafFileMetadata(); // Only get leaf files
// Separate PDF and non-PDF files for different processing // 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')); const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
// Process non-PDF files immediately (no history extraction needed) // Process non-PDF files immediately (no history extraction needed)
const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({ const nonPdfMetadata: StoredFileMetadata[] = nonPdfFiles.map(m => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
type: m.type, type: m.type,
@ -161,24 +189,35 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Process PDF files with controlled concurrency to avoid memory issues // 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 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) { for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
const batch = pdfFiles.slice(i, i + BATCH_SIZE); const batch = pdfFiles.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (m) => { const batchResults = await Promise.all(batch.map(async (m) => {
try { 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); const storedFile = await fileStorage.getFile(m.id);
if (storedFile?.data) { if (storedFile) {
const file = new File([storedFile.data], m.name, { return {
id: m.id,
name: m.name,
type: m.type, type: m.type,
lastModified: m.lastModified size: m.size,
}); lastModified: m.lastModified,
return await createFileMetadataWithHistory(file, m.id, m.thumbnail); 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) { } 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 // Fallback to basic metadata without history
@ -199,7 +238,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
return [...nonPdfMetadata, ...pdfMetadata]; return [...nonPdfMetadata, ...pdfMetadata];
}, []); }, []);
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => { const loadAllMetadata = useCallback(async (): Promise<StoredFileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata(); const metadata = await fileStorage.getAllFileMetadata();
// Separate PDF and non-PDF files for different processing // 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')); const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
// Process non-PDF files immediately (no history extraction needed) // Process non-PDF files immediately (no history extraction needed)
const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({ const nonPdfMetadata: StoredFileMetadata[] = nonPdfFiles.map(m => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
type: m.type, type: m.type,
@ -218,27 +257,37 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Process PDF files with controlled concurrency to avoid memory issues // 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 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) { for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
const batch = pdfFiles.slice(i, i + BATCH_SIZE); const batch = pdfFiles.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (m) => { const batchResults = await Promise.all(batch.map(async (m) => {
try { 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); const storedFile = await fileStorage.getFile(m.id);
if (storedFile?.data) { if (storedFile) {
const file = new File([storedFile.data], m.name, { return {
id: m.id,
name: m.name,
type: m.type, type: m.type,
lastModified: m.lastModified size: m.size,
}); lastModified: m.lastModified,
return await createFileMetadataWithHistory(file, m.id, m.thumbnail); thumbnail: m.thumbnail,
versionNumber: storedFile.versionNumber,
historyInfo: {
originalFileId: storedFile.originalFileId,
parentFileId: storedFile.parentFileId || undefined,
versionNumber: storedFile.versionNumber,
toolChain: storedFile.toolHistory
}
};
} }
} catch (error) { } 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 { return {
id: m.id, id: m.id,
name: m.name, name: m.name,

View File

@ -10,11 +10,11 @@ import {
createFileId, createFileId,
createQuickKey createQuickKey
} from '../../types/fileContext'; } 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 { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle'; import { FileLifecycleManager } from './lifecycle';
import { buildQuickKeySet } from './fileSelectors'; import { buildQuickKeySet } from './fileSelectors';
import { extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -82,7 +82,7 @@ interface AddFileOptions {
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>; filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
// For 'stored' files // For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: StoredFileMetadata }>;
// Insertion position // Insertion position
insertAfterPageId?: string; 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`); 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 // History metadata is now managed in IndexedDB, not in PDF metadata
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);
});
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
stirlingFileStubs.push(record); 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`); 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 // History metadata is now managed in IndexedDB, not in PDF metadata
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);
});
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
stirlingFileStubs.push(record); 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`); 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 // Use history data from IndexedDB instead of extracting from PDF metadata
extractBasicFileMetadata(file, record).then(updatedRecord => { if (metadata.versionNumber || metadata.toolHistory) {
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) { record.versionNumber = metadata.versionNumber;
// Basic metadata found, dispatch update to trigger re-render record.originalFileId = metadata.originalFileId;
dispatch({ record.parentFileId = metadata.parentFileId;
type: 'UPDATE_FILE_RECORD', record.toolHistory = metadata.toolHistory;
payload: {
id: fileId, if (DEBUG) console.log(`📄 addFiles(stored): Applied IndexedDB history data to ${file.name}:`, {
updates: { versionNumber: record.versionNumber,
versionNumber: updatedRecord.versionNumber, originalFileId: record.originalFileId,
toolHistory: updatedRecord.toolHistory parentFileId: record.parentFileId,
} toolHistoryLength: record.toolHistory?.length || 0
}
}); });
} }
}).catch(error => {
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
stirlingFileStubs.push(record); stirlingFileStubs.push(record);
@ -413,43 +375,13 @@ async function processFilesIntoRecords(
record.processedFile = createProcessedFile(pageCount, thumbnail); record.processedFile = createProcessedFile(pageCount, thumbnail);
} }
// Extract basic metadata synchronously during consumeFiles for immediate display // History metadata is now managed in IndexedDB, not in PDF metadata
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);
}
}
return { record, file, fileId, thumbnail }; return { record, file, fileId, thumbnail };
}) })
); );
} }
/**
* Helper function to persist files to IndexedDB
*/
async function persistFilesToIndexedDB(
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
): Promise<void> {
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
try {
await indexedDB.saveFile(file, fileId, thumbnail);
} catch (error) {
console.error('Failed to persist file to IndexedDB:', file.name, error);
}
}));
}
/** /**
* Consume files helper - replace unpinned input files with output files * Consume files helper - replace unpinned input files with output files
@ -480,11 +412,31 @@ export async function consumeFiles(
}) })
); );
// Save output files to IndexedDB // Save output files to IndexedDB and update the StirlingFileStub records with version info
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB); 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({ dispatch({
type: 'CONSUME_FILES', type: 'CONSUME_FILES',
payload: { payload: {

View File

@ -8,7 +8,6 @@ import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext'; import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { prepareStirlingFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
// Re-export for backwards compatibility // Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler }; export type { ProcessingProgress, ResponseHandler };
@ -129,7 +128,7 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
const { addFiles, consumeFiles, undoConsumeFiles, selectors, findFileId } = useFileContext(); const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
@ -166,24 +165,13 @@ export const useToolOperation = <TParams>(
cleanupBlobUrls(); cleanupBlobUrls();
// Prepare files with history metadata injection (for PDFs) // Prepare files with history metadata injection (for PDFs)
actions.setStatus('Preparing files...'); actions.setStatus('Processing files...');
const getFileStubById = (fileId: FileId) => {
return selectors.getStirlingFileStub(fileId);
};
const filesWithHistory = await prepareStirlingFilesWithHistory(
validFiles,
getFileStubById,
config.operationType,
params as Record<string, any>
);
try { try {
let processedFiles: File[]; let processedFiles: File[];
// Convert StirlingFiles with history to regular Files for API processing // Use original files directly (no PDF metadata injection - history stored in IndexedDB)
// The history is already injected into the File data, we just need to extract the File objects const filesForAPI = extractFiles(validFiles);
const filesForAPI = extractFiles(filesWithHistory);
switch (config.toolType) { switch (config.toolType) {
case ToolType.singleFile: { case ToolType.singleFile: {
@ -242,8 +230,6 @@ export const useToolOperation = <TParams>(
if (processedFiles.length > 0) { if (processedFiles.length > 0) {
actions.setFiles(processedFiles); actions.setFiles(processedFiles);
// Verify metadata preservation for backend quality tracking
await verifyToolMetadataPreservation(validFiles, processedFiles, config.operationType);
// Generate thumbnails and download URL concurrently // Generate thumbnails and download URL concurrently
actions.setGeneratingThumbnails(true); actions.setGeneratingThumbnails(true);
@ -272,7 +258,46 @@ export const useToolOperation = <TParams>(
} }
} }
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) // Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = { lastOperationRef.current = {

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useFileState, useFileActions } from '../contexts/FileContext'; import { useFileState, useFileActions } from '../contexts/FileContext';
import { FileMetadata } from '../types/file'; import { StoredFileMetadata, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
export const useFileHandler = () => { export const useFileHandler = () => {
@ -18,17 +18,17 @@ export const useFileHandler = () => {
}, [actions.addFiles]); }, [actions.addFiles]);
// Add stored files preserving their original IDs to prevent session duplicates // 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) // Filter out files that already exist with the same ID (exact match)
const newFiles = filesWithMetadata.filter(({ originalId }) => { const newFiles = storedFiles.filter(({ id }) => {
return state.files.byId[originalId] === undefined; return state.files.byId[id] === undefined;
}); });
if (newFiles.length > 0) { if (newFiles.length > 0) {
await actions.addStoredFiles(newFiles, { selectFiles: true }); 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]); }, [state.files.byId, actions.addStoredFiles]);
return { return {

View File

@ -6,7 +6,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
import { StirlingFileStub } from '../types/fileContext'; import { StirlingFileStub } from '../types/fileContext';
import { loadFileHistoryOnDemand } from '../utils/fileHistoryUtils'; // loadFileHistoryOnDemand removed - history now comes from IndexedDB directly
interface FileHistoryState { interface FileHistoryState {
originalFileId?: string; originalFileId?: string;
@ -33,16 +33,17 @@ export function useFileHistory(): UseFileHistoryResult {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async ( const loadHistory = useCallback(async (
file: File, _file: File,
fileId: FileId, _fileId: FileId,
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void _updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
) => { ) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub); // History is now loaded from IndexedDB, not PDF metadata
setHistoryData(history); // This function is deprecated
throw new Error('loadFileHistoryOnDemand is deprecated - use IndexedDB history directly');
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load file history'; const errorMessage = err instanceof Error ? err.message : 'Failed to load file history';
setError(errorMessage); setError(errorMessage);
@ -76,9 +77,9 @@ export function useMultiFileHistory() {
const [errors, setErrors] = useState<Map<FileId, string>>(new Map()); const [errors, setErrors] = useState<Map<FileId, string>>(new Map());
const loadFileHistory = useCallback(async ( const loadFileHistory = useCallback(async (
file: File, _file: File,
fileId: FileId, fileId: FileId,
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void _updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
) => { ) => {
// Don't reload if already loaded or currently loading // Don't reload if already loaded or currently loading
if (historyCache.has(fileId) || loadingFiles.has(fileId)) { if (historyCache.has(fileId) || loadingFiles.has(fileId)) {
@ -93,13 +94,9 @@ export function useMultiFileHistory() {
}); });
try { try {
const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub); // History is now loaded from IndexedDB, not PDF metadata
// This function is deprecated
if (history) { throw new Error('loadFileHistoryOnDemand is deprecated - use IndexedDB history directly');
setHistoryCache(prev => new Map(prev).set(fileId, history));
}
return history;
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load file history'; const errorMessage = err instanceof Error ? err.message : 'Failed to load file history';
setErrors(prev => new Map(prev).set(fileId, errorMessage)); setErrors(prev => new Map(prev).set(fileId, errorMessage));

View File

@ -1,13 +1,13 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext'; import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file'; import { StoredFileMetadata, StoredFile, fileStorage } from '../services/fileStorage';
import { FileId } from '../types/fileContext'; import { FileId } from '../types/fileContext';
export const useFileManager = () => { export const useFileManager = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const indexedDB = useIndexedDB(); const indexedDB = useIndexedDB();
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => { const convertToFile = useCallback(async (fileMetadata: StoredFileMetadata): Promise<File> => {
if (!indexedDB) { if (!indexedDB) {
throw new Error('IndexedDB context not available'); 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})`); throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
}, [indexedDB]); }, [indexedDB]);
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => { const loadRecentFiles = useCallback(async (): Promise<StoredFileMetadata[]> => {
setLoading(true); setLoading(true);
try { try {
if (!indexedDB) { if (!indexedDB) {
@ -45,7 +45,7 @@ export const useFileManager = () => {
} }
}, [indexedDB]); }, [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]; const file = files[index];
if (!file.id) { if (!file.id) {
throw new Error('File ID is required for removal'); throw new Error('File ID is required for removal');
@ -105,23 +105,24 @@ export const useFileManager = () => {
setSelectedFiles([]); 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; if (selectedFiles.length === 0) return;
try { 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)); const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
// Use stored files flow that preserves IDs const storedFiles = await Promise.all(
const filesWithMetadata = await Promise.all( selectedFileObjects.map(async (metadata) => {
selectedFileObjects.map(async (metadata) => ({ const storedFile = await fileStorage.getFile(metadata.id);
file: await convertToFile(metadata), if (!storedFile) {
originalId: metadata.id, throw new Error(`File not found in storage: ${metadata.name}`);
metadata }
})) return storedFile;
})
); );
onStoredFilesSelect(filesWithMetadata);
onStoredFilesSelect(storedFiles);
clearSelection(); clearSelection();
} catch (error) { } catch (error) {
console.error('Failed to load selected files:', error); console.error('Failed to load selected files:', error);

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { FileMetadata } from "../types/file"; import { StoredFileMetadata } from "../services/fileStorage";
import { useIndexedDB } from "../contexts/IndexedDBContext"; import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { FileId } from "../types/fileContext"; import { FileId } from "../types/fileContext";
@ -9,7 +9,7 @@ import { FileId } from "../types/fileContext";
* Hook for IndexedDB-aware thumbnail loading * Hook for IndexedDB-aware thumbnail loading
* Handles thumbnail generation for files not in IndexedDB * 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; thumbnail: string | null;
isGenerating: boolean isGenerating: boolean
} { } {

View File

@ -4,21 +4,21 @@
* Now uses centralized IndexedDB manager * Now uses centralized IndexedDB manager
*/ */
import { FileId } from '../types/file'; import { FileId, BaseFileMetadata } from '../types/file';
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager'; import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface StoredFile { export interface StoredFile extends BaseFileMetadata {
id: FileId;
name: string;
type: string;
size: number;
lastModified: number;
data: ArrayBuffer; data: ArrayBuffer;
thumbnail?: string; thumbnail?: string;
url?: string; // For compatibility with existing components 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<StoredFile, 'data'>;
export interface StorageStats { export interface StorageStats {
used: number; used: number;
available: number; available: number;
@ -40,7 +40,21 @@ class FileStorageService {
/** /**
* Store a file in IndexedDB with external UUID * Store a file in IndexedDB with external UUID
*/ */
async storeFile(file: File, fileId: FileId, thumbnail?: string, isLeaf: boolean = true): Promise<StoredFile> { 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<StoredFile> {
const db = await this.getDatabase(); const db = await this.getDatabase();
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
@ -53,7 +67,13 @@ class FileStorageService {
lastModified: file.lastModified, lastModified: file.lastModified,
data: arrayBuffer, data: arrayBuffer,
thumbnail, 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) => { return new Promise((resolve, reject) => {
@ -131,14 +151,14 @@ class FileStorageService {
/** /**
* Get metadata of all stored files (without loading data into memory) * Get metadata of all stored files (without loading data into memory)
*/ */
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> { async getAllFileMetadata(): Promise<StoredFileMetadata[]> {
const db = await this.getDatabase(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.openCursor(); const request = store.openCursor();
const files: Omit<StoredFile, 'data'>[] = []; const files: StoredFileMetadata[] = [];
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
request.onsuccess = (event) => { request.onsuccess = (event) => {
@ -153,7 +173,11 @@ class FileStorageService {
type: storedFile.type, type: storedFile.type,
size: storedFile.size, size: storedFile.size,
lastModified: storedFile.lastModified, 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(); cursor.continue();
@ -270,14 +294,14 @@ class FileStorageService {
/** /**
* Get metadata of only leaf files (without loading data into memory) * Get metadata of only leaf files (without loading data into memory)
*/ */
async getLeafFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> { async getLeafFileMetadata(): Promise<StoredFileMetadata[]> {
const db = await this.getDatabase(); const db = await this.getDatabase();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly'); const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);
const request = store.openCursor(); const request = store.openCursor();
const files: Omit<StoredFile, 'data'>[] = []; const files: StoredFileMetadata[] = [];
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
request.onsuccess = (event) => { request.onsuccess = (event) => {
@ -293,12 +317,15 @@ class FileStorageService {
size: storedFile.size, size: storedFile.size,
lastModified: storedFile.lastModified, lastModified: storedFile.lastModified,
thumbnail: storedFile.thumbnail, 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(); cursor.continue();
} else { } else {
console.log('📄 LEAF FLAG DEBUG - Found leaf files:', files.map(f => ({ id: f.id, name: f.name, isLeaf: f.isLeaf })));
resolve(files); resolve(files);
} }
}; };
@ -534,21 +561,6 @@ class FileStorageService {
return file; 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 * Create blob URL for stored file

View File

@ -201,13 +201,16 @@ class IndexedDBManager {
export const DATABASE_CONFIGS = { export const DATABASE_CONFIGS = {
FILES: { FILES: {
name: 'stirling-pdf-files', name: 'stirling-pdf-files',
version: 2, version: 3,
stores: [{ stores: [{
name: 'files', name: 'files',
keyPath: 'id', keyPath: 'id',
indexes: [ indexes: [
{ name: 'name', keyPath: 'name', unique: false }, { 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, } as DatabaseConfig,
@ -219,7 +222,8 @@ export const DATABASE_CONFIGS = {
name: 'drafts', name: 'drafts',
keyPath: 'id' keyPath: 'id'
}] }]
} as DatabaseConfig } as DatabaseConfig,
} as const; } as const;
export const indexedDBManager = IndexedDBManager.getInstance(); export const indexedDBManager = IndexedDBManager.getInstance();

View File

@ -7,18 +7,17 @@
*/ */
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { FileId } from '../types/file';
import { ContentCache, type CacheConfig } from '../utils/ContentCache'; import { ContentCache, type CacheConfig } from '../utils/ContentCache';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
/** /**
* Tool operation metadata for history tracking * Tool operation metadata for history tracking
* Note: Parameters removed for security - sensitive data like passwords should not be stored
*/ */
export interface ToolOperation { export interface ToolOperation {
toolName: string; toolName: string;
timestamp: number; timestamp: number;
parameters?: Record<string, any>;
} }
/** /**
@ -182,7 +181,7 @@ export class PDFMetadataService {
latestVersionNumber = parsed.stirlingHistory.versionNumber; latestVersionNumber = parsed.stirlingHistory.versionNumber;
historyJson = json; historyJson = json;
} }
} catch (error) { } catch {
// Silent fallback for corrupted history // Silent fallback for corrupted history
} }
} }

View File

@ -8,11 +8,11 @@ export type FileId = string & { readonly [tag]: 'FileId' };
/** /**
* Tool operation metadata for history tracking * Tool operation metadata for history tracking
* Note: Parameters removed for security - sensitive data like passwords should not be stored in history
*/ */
export interface ToolOperation { export interface ToolOperation {
toolName: string; toolName: string;
timestamp: number; timestamp: number;
parameters?: Record<string, any>;
} }
/** /**
@ -21,31 +21,32 @@ export interface ToolOperation {
*/ */
export interface FileHistoryInfo { export interface FileHistoryInfo {
originalFileId: string; originalFileId: string;
parentFileId?: string; parentFileId?: FileId;
versionNumber: number; versionNumber: number;
toolChain: ToolOperation[]; toolChain: ToolOperation[];
} }
/** /**
* File metadata for efficient operations without loading full file data * Base file metadata shared between storage and runtime layers
* Used by IndexedDBContext and FileContext for lazy file loading * Contains all common file properties and history tracking
*/ */
export interface FileMetadata { export interface BaseFileMetadata {
id: FileId; id: FileId;
name: string; name: string;
type: string; type: string;
size: number; size: number;
lastModified: number; lastModified: number;
thumbnail?: string; createdAt?: number; // When file was added to system
isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet)
// File history tracking (extracted from PDF metadata) // File history tracking
historyInfo?: FileHistoryInfo; isLeaf?: boolean; // True if this file hasn't been processed yet
// Quick access version information
originalFileId?: string; // Root file ID for grouping versions originalFileId?: string; // Root file ID for grouping versions
versionNumber?: number; // Version number in chain versionNumber?: number; // Version number in chain
parentFileId?: FileId; // Immediate parent file ID parentFileId?: FileId; // Immediate parent file ID
toolHistory?: Array<{
toolName: string;
timestamp: number;
}>; // Tool chain for history tracking
// Standard PDF document metadata // Standard PDF document metadata
pdfMetadata?: { 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 { export interface StorageConfig {
useIndexedDB: boolean; useIndexedDB: boolean;
maxFileSize: number; // Maximum size per file in bytes maxFileSize: number; // Maximum size per file in bytes

View File

@ -3,7 +3,8 @@
*/ */
import { PageOperation } from './pageEditor'; 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 // Re-export FileId for convenience
export type { FileId }; export type { FileId };
@ -51,30 +52,20 @@ export interface ProcessedFileMetadata {
* separately in refs for memory efficiency. Supports multi-tool workflows * separately in refs for memory efficiency. Supports multi-tool workflows
* where files persist across tool operations. * where files persist across tool operations.
*/ */
export interface StirlingFileStub { /**
id: FileId; // UUID primary key for collision-free operations * StirlingFileStub - Runtime UI metadata for files in the active workbench session
name: string; // Display name for UI *
size: number; // File size for progress indicators * Contains UI display data and processing state. Actual File objects stored
type: string; // MIME type for format validation * separately in refs for memory efficiency. Supports multi-tool workflows
lastModified: number; // Original timestamp for deduplication * where files persist across tool operations.
*/
export interface StirlingFileStub extends BaseFileMetadata {
quickKey?: string; // Fast deduplication key: name|size|lastModified quickKey?: string; // Fast deduplication key: name|size|lastModified
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
blobUrl?: string; // File access blob URL for downloads/processing 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 processedFile?: ProcessedFileMetadata; // PDF page data and processing results
insertAfterPageId?: string; // Page ID after which this file should be inserted insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean; // Protected from tool consumption (replace/remove) 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<string, any>;
}>;
// Note: File object stored in provider ref, not in state // 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 // Create a StirlingFile from a regular File object
export function createStirlingFile(file: File, id?: FileId): StirlingFile { 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 fileId = id || createFileId();
const quickKey = createQuickKey(file); const quickKey = createQuickKey(file);
@ -220,7 +216,6 @@ export interface FileOperation {
metadata?: { metadata?: {
originalFileName?: string; originalFileName?: string;
outputFileNames?: string[]; outputFileNames?: string[];
parameters?: Record<string, any>;
fileSize?: number; fileSize?: number;
pageCount?: number; pageCount?: number;
error?: string; error?: string;
@ -298,7 +293,7 @@ export interface FileContextActions {
// File management - lightweight actions only // File management - lightweight actions only
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>; addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>; addStoredFiles: (storedFiles: StoredFile[], options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void; updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
reorderFiles: (orderedFileIds: FileId[]) => void; reorderFiles: (orderedFileIds: FileId[]) => void;

View File

@ -1,4 +1,4 @@
import { FileMetadata } from '../types/file'; import { StoredFileMetadata } from '../services/fileStorage';
import { fileStorage } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage';
import { zipFileService } from '../services/zipFileService'; 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 * @param file - The file object with storage information
* @throws Error if file cannot be retrieved from storage * @throws Error if file cannot be retrieved from storage
*/ */
export async function downloadFileFromStorage(file: FileMetadata): Promise<void> { export async function downloadFileFromStorage(file: StoredFileMetadata): Promise<void> {
const lookupKey = file.id; const lookupKey = file.id;
const storedFile = await fileStorage.getFile(lookupKey); const storedFile = await fileStorage.getFile(lookupKey);
@ -42,7 +42,7 @@ export async function downloadFileFromStorage(file: FileMetadata): Promise<void>
* Downloads multiple files as individual downloads * Downloads multiple files as individual downloads
* @param files - Array of files to download * @param files - Array of files to download
*/ */
export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void> { export async function downloadMultipleFiles(files: StoredFileMetadata[]): Promise<void> {
for (const file of files) { for (const file of files) {
await downloadFileFromStorage(file); await downloadFileFromStorage(file);
} }
@ -53,7 +53,7 @@ export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void
* @param files - Array of files to include in ZIP * @param files - Array of files to include in ZIP
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name) * @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
*/ */
export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: string): Promise<void> { export async function downloadFilesAsZip(files: StoredFileMetadata[], zipFilename?: string): Promise<void> {
if (files.length === 0) { if (files.length === 0) {
throw new Error('No files provided for ZIP download'); 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 * @param options - Download options
*/ */
export async function downloadFiles( export async function downloadFiles(
files: FileMetadata[], files: StoredFileMetadata[],
options: { options: {
forceZip?: boolean; forceZip?: boolean;
zipFilename?: string; zipFilename?: string;

View File

@ -1,206 +1,17 @@
/** /**
* File History Utilities * File History Utilities
* *
* Helper functions for integrating PDF metadata service with FileContext operations. * Helper functions for IndexedDB-based file history management.
* Handles extraction of history from files and preparation for metadata injection. * Handles file history operations and lineage tracking.
*/ */
import { pdfMetadataService, type ToolOperation } from '../services/pdfMetadataService';
import { StirlingFileStub } from '../types/fileContext'; import { StirlingFileStub } from '../types/fileContext';
import { FileId, FileMetadata } from '../types/file'; import { FileId } from '../types/file';
import { createFileId } from '../types/fileContext'; 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<StirlingFileStub> {
// 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<string, any>
): Promise<File> {
// 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<string, any>
): Promise<import('../types/fileContext').StirlingFile[]> {
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<void> {
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 * Group files by processing branches - each branch ends in a leaf file
@ -350,7 +161,7 @@ export async function getRecentLeafFiles(): Promise<import('../services/fileStor
* Get recent file metadata efficiently using leaf flags from IndexedDB * Get recent file metadata efficiently using leaf flags from IndexedDB
* This is much faster than loading all files and calculating leaf nodes * This is much faster than loading all files and calculating leaf nodes
*/ */
export async function getRecentLeafFileMetadata(): Promise<Omit<import('../services/fileStorage').StoredFile, 'data'>[]> { export async function getRecentLeafFileMetadata(): Promise<StoredFileMetadata[]> {
try { try {
const { fileStorage } = await import('../services/fileStorage'); const { fileStorage } = await import('../services/fileStorage');
return await fileStorage.getLeafFileMetadata(); return await fileStorage.getLeafFileMetadata();
@ -360,106 +171,18 @@ export async function getRecentLeafFileMetadata(): Promise<Omit<import('../servi
} }
} }
/**
* Extract basic file metadata (version number and tool chain) without full history calculation
* This is lightweight and used for displaying essential info on file thumbnails
*/
export async function extractBasicFileMetadata(
file: File,
fileStub: StirlingFileStub
): Promise<StirlingFileStub> {
// 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 * Create basic metadata for storing files
* This replaces the automatic history extraction during file loading * History information is managed separately in IndexedDB
*/
export async function loadFileHistoryOnDemand(
file: File,
fileId: FileId,
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
): Promise<{
originalFileId?: string;
versionNumber?: number;
parentFileId?: FileId;
toolHistory?: Array<{
toolName: string;
timestamp: number;
parameters?: Record<string, any>;
}>;
} | 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
*/ */
export async function createFileMetadataWithHistory( export async function createFileMetadataWithHistory(
file: File, file: File,
fileId: FileId, fileId: FileId,
thumbnail?: string thumbnail?: string
): Promise<FileMetadata> { ): Promise<StoredFileMetadata> {
const baseMetadata: FileMetadata = { return {
id: fileId, id: fileId,
name: file.name, name: file.name,
type: file.type, type: file.type,
@ -468,41 +191,4 @@ export async function createFileMetadataWithHistory(
thumbnail, thumbnail,
isLeaf: true // New files are leaf nodes by default 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;
} }

View File

@ -6,7 +6,7 @@ import { FileOperation } from '../types/fileContext';
*/ */
export const createOperation = <TParams = void>( export const createOperation = <TParams = void>(
operationType: string, operationType: string,
params: TParams, _params: TParams,
selectedFiles: File[] selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: FileId } => { ): { operation: FileOperation; operationId: string; fileId: FileId } => {
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@ -20,7 +20,6 @@ export const createOperation = <TParams = void>(
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: selectedFiles[0]?.name, originalFileName: selectedFiles[0]?.name,
parameters: params,
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0) fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
} }
} as any /* FIX ME*/; } as any /* FIX ME*/;