Basic file history

This commit is contained in:
Connor Yoh 2025-09-02 17:24:26 +01:00
parent 1a3e8e7ecf
commit d4e0fb581f
13 changed files with 978 additions and 38 deletions

View File

@ -72,12 +72,19 @@ 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}`}
</Text> </Text>
{hasMultipleFiles && ( {hasMultipleFiles && (
<Text size="xs" c="blue"> <Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length} {currentFileIndex + 1} of {selectedFiles.length}
</Text> </Text>
)} )}
{/* Compact tool chain for mobile */}
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && (
<Text size="xs" c="dimmed">
{currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')}
</Text>
)}
</Box> </Box>
{/* Navigation arrows for multiple files */} {/* Navigation arrows for multiple files */}

View File

@ -1,15 +1,25 @@
import React from "react"; import React from "react";
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core"; import { Group, Text, ActionIcon, Tooltip, Switch } from "@mantine/core";
import SelectAllIcon from "@mui/icons-material/SelectAll"; import SelectAllIcon from "@mui/icons-material/SelectAll";
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";
import HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "../../contexts/FileManagerContext"; import { useFileManagerContext } from "../../contexts/FileManagerContext";
const FileActions: React.FC = () => { const FileActions: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = const {
useFileManagerContext(); recentFiles,
selectedFileIds,
filteredFiles,
showAllVersions,
fileGroups,
onSelectAll,
onDeleteSelected,
onDownloadSelected,
onToggleVersions
} = useFileManagerContext();
const handleSelectAll = () => { const handleSelectAll = () => {
onSelectAll(); onSelectAll();
@ -35,6 +45,9 @@ const FileActions: React.FC = () => {
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
const hasSelection = selectedFileIds.length > 0; const hasSelection = selectedFileIds.length > 0;
// Check if there are any files with version history
const hasVersionedFiles = Array.from(fileGroups.values()).some(versions => versions.length > 1);
return ( return (
<div <div
style={{ style={{
@ -47,8 +60,8 @@ const FileActions: React.FC = () => {
position: "relative", position: "relative",
}} }}
> >
{/* Left: Select All */} {/* Left: Select All and Version Toggle */}
<div> <Group gap="md">
<Tooltip <Tooltip
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")} label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
> >
@ -63,7 +76,30 @@ const FileActions: React.FC = () => {
<SelectAllIcon style={{ fontSize: "1rem" }} /> <SelectAllIcon style={{ fontSize: "1rem" }} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div>
{/* Version Toggle - only show if there are versioned files */}
{hasVersionedFiles && (
<Tooltip
label={showAllVersions ?
t("fileManager.showLatestOnly", "Show latest versions only") :
t("fileManager.showAllVersions", "Show all versions")
}
>
<Group gap="xs" style={{ cursor: 'pointer' }} onClick={onToggleVersions}>
<HistoryIcon style={{ fontSize: "1rem", color: 'var(--mantine-color-blue-6)' }} />
<Text size="xs" c="dimmed">
{showAllVersions ? t("fileManager.allVersions", "All") : t("fileManager.latestOnly", "Latest")}
</Text>
<Switch
size="xs"
checked={showAllVersions}
onChange={onToggleVersions}
style={{ pointerEvents: 'none' }}
/>
</Group>
</Tooltip>
)}
</Group>
{/* Center: Selected count */} {/* Center: Selected count */}
<div <div

View File

@ -74,9 +74,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({
} }
return ( return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}> <Stack gap="md" h={`calc(${modalHeight} - 2rem)`}>
{/* Section 1: Thumbnail Preview */} {/* Section 1: Thumbnail Preview */}
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}> <Box style={{ width: '100%', height: `calc(${modalHeight} * 0.42 - 1rem)`, textAlign: 'center', padding: 'xs' }}>
<FilePreview <FilePreview
file={currentFile} file={currentFile}
thumbnail={getCurrentThumbnail()} thumbnail={getCurrentThumbnail()}

View File

@ -16,7 +16,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}> <Card withBorder p={0} h={`calc(${modalHeight} * 0.38 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}> <Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Text size="sm" fw={500} ta="center" c="white"> <Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')} {t('fileManager.details', 'File Details')}
@ -54,10 +54,28 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<Group justify="space-between" py="xs"> <Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text> <Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
<Text size="sm" fw={500}> {currentFile &&
{currentFile ? '1.0' : ''} <Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
</Text> v{currentFile ? (currentFile.versionNumber || 0) : ''}
</Badge>}
</Group> </Group>
{/* Tool Chain Display - Compact */}
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && (
<>
<Divider />
<Box py="xs">
<Text size="xs" style={{
color: 'var(--mantine-color-blue-6)',
lineHeight: 1.3,
wordBreak: 'break-word'
}}>
{currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')}
</Text>
</Box>
</>
)}
</Stack> </Stack>
</ScrollArea> </ScrollArea>
</Card> </Card>

View File

@ -3,9 +3,12 @@ import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@m
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';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
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 { FileMetadata } from '../../types/file';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface FileListItemProps { interface FileListItemProps {
file: FileMetadata; file: FileMetadata;
@ -30,10 +33,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, onRestoreVersion } = 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
const originalFileId = file.originalFileId || file.id;
const fileVersions = fileGroups.get(originalFileId) || [];
const hasVersionHistory = fileVersions.length > 1;
const currentVersion = file.versionNumber || 0; // Display original files as v0
return ( return (
<> <>
<Box <Box
@ -77,8 +87,18 @@ const FileListItem: React.FC<FileListItemProps> = ({
DRAFT DRAFT
</Badge> </Badge>
)} )}
{hasVersionHistory && (
<Badge size="xs" variant="light" color="blue">
v{currentVersion}
</Badge>
)}
</Group> </Group>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text> <Text size="xs" c="dimmed">
{getFileSize(file)} {getFileDate(file)}
{hasVersionHistory && (
<Text span c="dimmed"> {fileVersions.length} versions</Text>
)}
</Text>
</Box> </Box>
{/* Three dots menu - fades in/out on hover */} {/* Three dots menu - fades in/out on hover */}
@ -117,6 +137,42 @@ const FileListItem: React.FC<FileListItemProps> = ({
{t('fileManager.download', 'Download')} {t('fileManager.download', 'Download')}
</Menu.Item> </Menu.Item>
)} )}
{/* Version History Menu */}
{hasVersionHistory && (
<>
<Menu.Divider />
<Menu.Label>{t('fileManager.versions', 'Version History')}</Menu.Label>
{fileVersions.map((version, index) => (
<Menu.Item
key={version.id}
leftSection={
version.id === file.id ?
<Badge size="xs" color="blue">Current</Badge> :
<RestoreIcon style={{ fontSize: 16 }} />
}
onClick={(e) => {
e.stopPropagation();
if (version.id !== file.id) {
onRestoreVersion(version);
}
}}
disabled={version.id === file.id}
>
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<Text size="sm">
v{version.versionNumber || 0}
</Text>
<Text size="xs" c="dimmed">
{new Date(version.lastModified).toLocaleDateString()}
</Text>
</Group>
</Menu.Item>
))}
<Menu.Divider />
</>
)}
<Menu.Item <Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />} leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => { onClick={(e) => {

View File

@ -3,6 +3,7 @@ import { FileMetadata } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage'; import { StoredFile, 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 } from '../utils/fileHistoryUtils';
// Type for the context value - now contains everything directly // Type for the context value - now contains everything directly
interface FileManagerContextValue { interface FileManagerContextValue {
@ -14,6 +15,8 @@ interface FileManagerContextValue {
filteredFiles: FileMetadata[]; filteredFiles: FileMetadata[];
fileInputRef: React.RefObject<HTMLInputElement | null>; fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>; selectedFilesSet: Set<string>;
showAllVersions: boolean;
fileGroups: Map<string, FileMetadata[]>;
// Handlers // Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void; onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
@ -28,6 +31,9 @@ interface FileManagerContextValue {
onDeleteSelected: () => void; onDeleteSelected: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onDownloadSingle: (file: FileMetadata) => void; onDownloadSingle: (file: FileMetadata) => void;
onToggleVersions: () => void;
onRestoreVersion: (file: FileMetadata) => void;
onNewFilesSelect: (files: File[]) => void;
// External props // External props
recentFiles: FileMetadata[]; recentFiles: FileMetadata[];
@ -68,6 +74,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]); const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null); const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [showAllVersions, setShowAllVersions] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup // Track blob URLs for cleanup
@ -76,11 +83,44 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
// Computed values (with null safety) // Computed values (with null safety)
const selectedFilesSet = new Set(selectedFileIds); const selectedFilesSet = new Set(selectedFileIds);
const selectedFiles = selectedFileIds.length === 0 ? [] : // Group files by original file ID for version management
(recentFiles || []).filter(file => selectedFilesSet.has(file.id)); const fileGroups = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return new Map();
const filteredFiles = !searchTerm ? recentFiles || [] : // Convert FileMetadata to FileRecord-like objects for grouping utility
(recentFiles || []).filter(file => const recordsForGrouping = recentFiles.map(file => ({
...file,
originalFileId: file.originalFileId,
versionNumber: file.versionNumber || 0
}));
return groupFilesByOriginal(recordsForGrouping);
}, [recentFiles]);
// Get files to display (latest versions only or all versions)
const displayFiles = useMemo(() => {
if (!recentFiles || recentFiles.length === 0) return [];
if (showAllVersions) {
return recentFiles;
} else {
// Show only latest versions
const recordsForFiltering = recentFiles.map(file => ({
...file,
originalFileId: file.originalFileId,
versionNumber: file.versionNumber || 0
}));
const latestVersions = getLatestVersions(recordsForFiltering);
return latestVersions.map(record => recentFiles.find(file => file.id === record.id)!);
}
}, [recentFiles, showAllVersions]);
const selectedFiles = selectedFileIds.length === 0 ? [] :
displayFiles.filter(file => selectedFilesSet.has(file.id));
const filteredFiles = !searchTerm ? displayFiles :
displayFiles.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()) file.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
@ -243,6 +283,42 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
} }
}, []); }, []);
const handleToggleVersions = useCallback(() => {
setShowAllVersions(prev => !prev);
// Clear selection when toggling versions
setSelectedFileIds([]);
setLastClickedIndex(null);
}, []);
const handleRestoreVersion = useCallback(async (file: FileMetadata) => {
try {
console.log('Restoring version:', file.name, 'version:', file.versionNumber);
// 1. Load the file from IndexedDB storage
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
console.error('File not found in storage:', file.id);
return;
}
// 2. Create new File object from stored data
const restoredFile = new File([storedFile.data], file.name, {
type: file.type,
lastModified: file.lastModified
});
// 3. Add the restored file as a new version through the normal file upload flow
// This will trigger the file processing and create a new entry in recent files
onNewFilesSelect([restoredFile]);
// 4. Refresh the recent files list to show the new version
await refreshRecentFiles();
console.log('Successfully restored version:', file.name, 'v' + file.versionNumber);
} catch (error) {
console.error('Failed to restore version:', error);
}
}, [refreshRecentFiles, onNewFilesSelect]);
// Cleanup blob URLs when component unmounts // Cleanup blob URLs when component unmounts
useEffect(() => { useEffect(() => {
@ -274,6 +350,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
filteredFiles, filteredFiles,
fileInputRef, fileInputRef,
selectedFilesSet, selectedFilesSet,
showAllVersions,
fileGroups,
// Handlers // Handlers
onSourceChange: handleSourceChange, onSourceChange: handleSourceChange,
@ -288,6 +366,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onDeleteSelected: handleDeleteSelected, onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected, onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle, onDownloadSingle: handleDownloadSingle,
onToggleVersions: handleToggleVersions,
onRestoreVersion: handleRestoreVersion,
onNewFilesSelect,
// External props // External props
recentFiles, recentFiles,
@ -300,6 +381,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedFiles, selectedFiles,
filteredFiles, filteredFiles,
fileInputRef, fileInputRef,
showAllVersions,
fileGroups,
handleSourceChange, handleSourceChange,
handleLocalFileClick, handleLocalFileClick,
handleFileSelect, handleFileSelect,
@ -311,6 +394,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
handleSelectAll, handleSelectAll,
handleDeleteSelected, handleDeleteSelected,
handleDownloadSelected, handleDownloadSelected,
handleToggleVersions,
handleRestoreVersion,
onNewFilesSelect,
recentFiles, recentFiles,
isFileSupported, isFileSupported,
modalHeight, modalHeight,

View File

@ -10,6 +10,7 @@ import { fileStorage, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
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
@ -67,15 +68,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries(); evictLRUEntries();
// Return metadata // Extract history metadata for PDFs and return enhanced metadata
return { const metadata = await createFileMetadataWithHistory(file, fileId, thumbnail);
id: fileId,
name: file.name,
type: file.type, return metadata;
size: file.size,
lastModified: file.lastModified,
thumbnail
};
}, []); }, []);
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => { const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
@ -145,14 +142,46 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => { const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata(); const metadata = await fileStorage.getAllFileMetadata();
return metadata.map(m => ({ // For each PDF file, extract history metadata
const metadataWithHistory = await Promise.all(metadata.map(async (m) => {
// For non-PDF files, return basic metadata
if (!m.type.includes('pdf')) {
return {
id: m.id, id: m.id,
name: m.name, name: m.name,
type: m.type, type: m.type,
size: m.size, size: m.size,
lastModified: m.lastModified, lastModified: m.lastModified,
thumbnail: m.thumbnail thumbnail: m.thumbnail
};
}
try {
// For PDF files, load and extract history
const storedFile = await fileStorage.getFile(m.id);
if (storedFile?.data) {
const file = new File([storedFile.data], m.name, { type: m.type });
const enhancedMetadata = await createFileMetadataWithHistory(file, m.id, m.thumbnail);
return enhancedMetadata;
}
} catch (error) {
if (DEBUG) console.warn('🗂️ IndexedDB.loadAllMetadata: Failed to extract history from stored file:', m.name, error);
}
// Fallback to basic metadata if history extraction fails
return {
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail
};
})); }));
return metadataWithHistory;
}, []); }, []);
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => { const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {

View File

@ -15,6 +15,7 @@ import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle'; import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService'; import { fileProcessingService } from '../../services/fileProcessingService';
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors'; import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
import { extractFileHistory } from '../../utils/fileHistoryUtils';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -183,6 +184,27 @@ 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 file history from PDF metadata (async)
extractFileHistory(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// History was found, dispatch update to trigger re-render
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: {
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
parentFileId: updatedRecord.parentFileId,
toolHistory: updatedRecord.toolHistory
}
}
});
}
}).catch(error => {
if (DEBUG) console.warn(`📄 addFiles(raw): Failed to extract history for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
@ -225,6 +247,27 @@ 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 file history from PDF metadata (async)
extractFileHistory(file, record).then(updatedRecord => {
if (updatedRecord !== record && (updatedRecord.originalFileId || updatedRecord.versionNumber)) {
// History was found, dispatch update to trigger re-render
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: {
originalFileId: updatedRecord.originalFileId,
versionNumber: updatedRecord.versionNumber,
parentFileId: updatedRecord.parentFileId,
toolHistory: updatedRecord.toolHistory
}
}
});
}
}).catch(error => {
if (DEBUG) console.warn(`📄 addFiles(processed): Failed to extract history for ${file.name}:`, error);
});
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });

View File

@ -10,6 +10,7 @@ import { createOperation } from '../../../utils/toolOperationTracker';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file'; import { FileId } from '../../../types/file';
import { FileRecord } from '../../../types/fileContext'; import { FileRecord } from '../../../types/fileContext';
import { prepareFilesWithHistory } from '../../../utils/fileHistoryUtils';
// Re-export for backwards compatibility // Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler }; export type { ProcessingProgress, ResponseHandler };
@ -170,6 +171,20 @@ export const useToolOperation = <TParams>(
actions.resetResults(); actions.resetResults();
cleanupBlobUrls(); cleanupBlobUrls();
// Prepare files with history metadata injection (for PDFs)
actions.setStatus('Preparing files...');
const getFileRecord = (file: File) => {
const fileId = findFileId(file);
return fileId ? selectors.getFileRecord(fileId) : undefined;
};
const filesWithHistory = await prepareFilesWithHistory(
validFiles,
getFileRecord,
config.operationType,
params as Record<string, any>
);
try { try {
let processedFiles: File[]; let processedFiles: File[];
@ -184,7 +199,7 @@ export const useToolOperation = <TParams>(
}; };
processedFiles = await processFiles( processedFiles = await processFiles(
params, params,
validFiles, filesWithHistory,
apiCallsConfig, apiCallsConfig,
actions.setProgress, actions.setProgress,
actions.setStatus actions.setStatus
@ -194,7 +209,7 @@ export const useToolOperation = <TParams>(
case ToolType.multiFile: case ToolType.multiFile:
// Multi-file processing - single API call with all files // Multi-file processing - single API call with all files
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
const formData = config.buildFormData(params, validFiles); const formData = config.buildFormData(params, filesWithHistory);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await axios.post(endpoint, formData, { responseType: 'blob' }); const response = await axios.post(endpoint, formData, { responseType: 'blob' });
@ -202,11 +217,11 @@ export const useToolOperation = <TParams>(
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) { if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction) // Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles); processedFiles = await config.responseHandler(response.data, filesWithHistory);
} else if (response.data.type === 'application/pdf' || } else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) { (response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename // Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = validFiles[0]?.name || 'document.pdf'; const originalFileName = filesWithHistory[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' }); const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
processedFiles = [singleFile]; processedFiles = [singleFile];
} else { } else {
@ -222,7 +237,7 @@ export const useToolOperation = <TParams>(
case ToolType.custom: case ToolType.custom:
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, validFiles); processedFiles = await config.customProcessor(params, filesWithHistory);
break; break;
} }

View File

@ -0,0 +1,331 @@
/**
* PDF Metadata Service - File History Tracking with pdf-lib
*
* Handles injection and extraction of file history metadata in PDFs using pdf-lib.
* This service embeds file history directly into PDF metadata, making it persistent
* across all tool operations and downloads.
*/
import { PDFDocument } from 'pdf-lib';
import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Tool operation metadata for history tracking
*/
export interface ToolOperation {
toolName: string;
timestamp: number;
parameters?: Record<string, any>;
}
/**
* Complete file history metadata structure
*/
export interface PDFHistoryMetadata {
stirlingHistory: {
originalFileId: string;
parentFileId?: string;
versionNumber: number;
toolChain: ToolOperation[];
createdBy: 'Stirling-PDF';
formatVersion: '1.0';
createdAt: number;
lastModified: number;
};
}
/**
* Service for managing PDF file history metadata
*/
export class PDFMetadataService {
private static readonly HISTORY_KEYWORD = 'stirling-history';
private static readonly FORMAT_VERSION = '1.0';
/**
* Inject file history metadata into a PDF
*/
async injectHistoryMetadata(
pdfBytes: ArrayBuffer,
originalFileId: string,
parentFileId?: string,
toolChain: ToolOperation[] = [],
versionNumber: number = 1
): Promise<ArrayBuffer> {
try {
const pdfDoc = await PDFDocument.load(pdfBytes);
const historyMetadata: PDFHistoryMetadata = {
stirlingHistory: {
originalFileId,
parentFileId,
versionNumber,
toolChain: [...toolChain],
createdBy: 'Stirling-PDF',
formatVersion: PDFMetadataService.FORMAT_VERSION,
createdAt: Date.now(),
lastModified: Date.now()
}
};
// Set basic metadata
pdfDoc.setCreator('Stirling-PDF');
pdfDoc.setProducer('Stirling-PDF');
pdfDoc.setModificationDate(new Date());
// Embed history metadata in keywords field (most compatible)
const historyJson = JSON.stringify(historyMetadata);
const existingKeywords = pdfDoc.getKeywords();
// Handle keywords as array (pdf-lib stores them as array)
let keywordList: string[] = [];
if (Array.isArray(existingKeywords)) {
// Remove any existing history keywords to avoid duplicates
keywordList = existingKeywords.filter(keyword =>
!keyword.startsWith(`${PDFMetadataService.HISTORY_KEYWORD}:`)
);
} else if (existingKeywords) {
// Remove history from single keyword string
const cleanKeyword = this.extractHistoryFromKeywords(existingKeywords, true);
if (cleanKeyword) {
keywordList = [cleanKeyword];
}
}
// Add our new history metadata as a keyword (replacing any previous history)
const historyKeyword = `${PDFMetadataService.HISTORY_KEYWORD}:${historyJson}`;
keywordList.push(historyKeyword);
pdfDoc.setKeywords(keywordList);
if (DEBUG) {
console.log('📄 Injected PDF history metadata:', {
originalFileId,
parentFileId,
versionNumber,
toolCount: toolChain.length
});
}
return await pdfDoc.save();
} catch (error) {
if (DEBUG) console.error('📄 Failed to inject PDF metadata:', error);
// Return original bytes if metadata injection fails
return pdfBytes;
}
}
/**
* Extract file history metadata from a PDF
*/
async extractHistoryMetadata(pdfBytes: ArrayBuffer): Promise<PDFHistoryMetadata | null> {
try {
const pdfDoc = await PDFDocument.load(pdfBytes);
const keywords = pdfDoc.getKeywords();
// Look for history keyword directly in array or convert to string
let historyJson: string | null = null;
if (Array.isArray(keywords)) {
// Search through keywords array for our history keyword - get the LATEST one
const historyKeywords = keywords.filter(keyword =>
keyword.startsWith(`${PDFMetadataService.HISTORY_KEYWORD}:`)
);
if (historyKeywords.length > 0) {
// If multiple history keywords exist, parse all and get the highest version number
let latestVersionNumber = 0;
for (const historyKeyword of historyKeywords) {
try {
const json = historyKeyword.substring(`${PDFMetadataService.HISTORY_KEYWORD}:`.length);
const parsed = JSON.parse(json) as PDFHistoryMetadata;
if (parsed.stirlingHistory.versionNumber > latestVersionNumber) {
latestVersionNumber = parsed.stirlingHistory.versionNumber;
historyJson = json;
}
} catch (error) {
// Silent fallback for corrupted history
}
}
}
} else if (keywords) {
// Fallback to string parsing
historyJson = this.extractHistoryFromKeywords(keywords);
}
if (!historyJson) return null;
const metadata = JSON.parse(historyJson) as PDFHistoryMetadata;
// Validate metadata structure
if (!this.isValidHistoryMetadata(metadata)) {
return null;
}
return metadata;
} catch (error) {
if (DEBUG) console.error('📄 pdfMetadataService.extractHistoryMetadata: Failed to extract:', error);
return null;
}
}
/**
* Add a tool operation to existing PDF history
*/
async addToolOperation(
pdfBytes: ArrayBuffer,
toolOperation: ToolOperation
): Promise<ArrayBuffer> {
try {
// Extract existing history
const existingHistory = await this.extractHistoryMetadata(pdfBytes);
if (!existingHistory) {
if (DEBUG) console.warn('📄 No existing history found, cannot add tool operation');
return pdfBytes;
}
// Add new tool operation
const updatedToolChain = [...existingHistory.stirlingHistory.toolChain, toolOperation];
// Re-inject with updated history
return await this.injectHistoryMetadata(
pdfBytes,
existingHistory.stirlingHistory.originalFileId,
existingHistory.stirlingHistory.parentFileId,
updatedToolChain,
existingHistory.stirlingHistory.versionNumber
);
} catch (error) {
if (DEBUG) console.error('📄 Failed to add tool operation:', error);
return pdfBytes;
}
}
/**
* Create a new version of a PDF with incremented version number
*/
async createNewVersion(
pdfBytes: ArrayBuffer,
parentFileId: string,
toolOperation: ToolOperation
): Promise<ArrayBuffer> {
try {
const parentHistory = await this.extractHistoryMetadata(pdfBytes);
const originalFileId = parentHistory?.stirlingHistory.originalFileId || parentFileId;
const parentToolChain = parentHistory?.stirlingHistory.toolChain || [];
const newVersionNumber = (parentHistory?.stirlingHistory.versionNumber || 0) + 1;
// Create new tool chain with the new operation
const newToolChain = [...parentToolChain, toolOperation];
return await this.injectHistoryMetadata(
pdfBytes,
originalFileId,
parentFileId,
newToolChain,
newVersionNumber
);
} catch (error) {
if (DEBUG) console.error('📄 Failed to create new version:', error);
return pdfBytes;
}
}
/**
* Check if a PDF has Stirling history metadata
*/
async hasStirlingHistory(pdfBytes: ArrayBuffer): Promise<boolean> {
const metadata = await this.extractHistoryMetadata(pdfBytes);
return metadata !== null;
}
/**
* Get version information from PDF
*/
async getVersionInfo(pdfBytes: ArrayBuffer): Promise<{
originalFileId: string;
versionNumber: number;
toolCount: number;
parentFileId?: string;
} | null> {
const metadata = await this.extractHistoryMetadata(pdfBytes);
if (!metadata) return null;
return {
originalFileId: metadata.stirlingHistory.originalFileId,
versionNumber: metadata.stirlingHistory.versionNumber,
toolCount: metadata.stirlingHistory.toolChain.length,
parentFileId: metadata.stirlingHistory.parentFileId
};
}
/**
* Embed history JSON in keywords field with delimiter
*/
private embedHistoryInKeywords(existingKeywords: string, historyJson: string): string {
// Remove any existing history
const cleanKeywords = this.extractHistoryFromKeywords(existingKeywords, true) || existingKeywords;
// Add new history with delimiter
const historyKeyword = `${PDFMetadataService.HISTORY_KEYWORD}:${historyJson}`;
if (cleanKeywords.trim()) {
return `${cleanKeywords.trim()} ${historyKeyword}`;
}
return historyKeyword;
}
/**
* Extract history JSON from keywords field
*/
private extractHistoryFromKeywords(keywords: string, returnRemainder = false): string | null {
const historyPrefix = `${PDFMetadataService.HISTORY_KEYWORD}:`;
const historyIndex = keywords.indexOf(historyPrefix);
if (historyIndex === -1) return null;
const historyStart = historyIndex + historyPrefix.length;
let historyEnd = keywords.length;
// Look for the next keyword (space followed by non-JSON content)
// Simple heuristic: find space followed by word that doesn't look like JSON
const afterHistory = keywords.substring(historyStart);
const nextSpaceIndex = afterHistory.indexOf(' ');
if (nextSpaceIndex > 0) {
const afterSpace = afterHistory.substring(nextSpaceIndex + 1);
if (afterSpace && !afterSpace.trim().startsWith('{')) {
historyEnd = historyStart + nextSpaceIndex;
}
}
if (returnRemainder) {
// Return keywords with history removed
const before = keywords.substring(0, historyIndex);
const after = keywords.substring(historyEnd);
return `${before}${after}`.replace(/\s+/g, ' ').trim();
}
return keywords.substring(historyStart, historyEnd).trim();
}
/**
* Validate metadata structure
*/
private isValidHistoryMetadata(metadata: any): metadata is PDFHistoryMetadata {
return metadata &&
metadata.stirlingHistory &&
typeof metadata.stirlingHistory.originalFileId === 'string' &&
typeof metadata.stirlingHistory.versionNumber === 'number' &&
Array.isArray(metadata.stirlingHistory.toolChain) &&
metadata.stirlingHistory.createdBy === 'Stirling-PDF' &&
metadata.stirlingHistory.formatVersion === PDFMetadataService.FORMAT_VERSION;
}
}
// Export singleton instance
export const pdfMetadataService = new PDFMetadataService();

View File

@ -6,6 +6,27 @@
declare const tag: unique symbol; declare const tag: unique symbol;
export type FileId = string & { readonly [tag]: 'FileId' }; export type FileId = string & { readonly [tag]: 'FileId' };
/**
* Tool operation metadata for history tracking
*/
export interface ToolOperation {
toolName: string;
timestamp: number;
parameters?: Record<string, any>;
}
/**
* File history information extracted from PDF metadata
*/
export interface FileHistoryInfo {
originalFileId: string;
parentFileId?: string;
versionNumber: number;
toolChain: ToolOperation[];
createdAt: number;
lastModified: number;
}
/** /**
* File metadata for efficient operations without loading full file data * File metadata for efficient operations without loading full file data
* Used by IndexedDBContext and FileContext for lazy file loading * Used by IndexedDBContext and FileContext for lazy file loading
@ -18,6 +39,14 @@ export interface FileMetadata {
lastModified: number; lastModified: number;
thumbnail?: string; thumbnail?: string;
isDraft?: boolean; // Marks files as draft versions isDraft?: boolean; // Marks files as draft versions
// File history tracking (extracted from PDF metadata)
historyInfo?: FileHistoryInfo;
// Quick access version information
originalFileId?: string; // Root file ID for grouping versions
versionNumber?: number; // Version number in chain
parentFileId?: FileId; // Immediate parent file ID
} }
export interface StorageConfig { export interface StorageConfig {

View File

@ -54,6 +54,17 @@ export interface FileRecord {
processedFile?: ProcessedFileMetadata; processedFile?: ProcessedFileMetadata;
insertAfterPageId?: string; // Page ID after which this file should be inserted insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean; isPinned?: boolean;
// 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
} }

View File

@ -0,0 +1,279 @@
/**
* File History Utilities
*
* Helper functions for integrating PDF metadata service with FileContext operations.
* Handles extraction of history from files and preparation for metadata injection.
*/
import { pdfMetadataService, type ToolOperation } from '../services/pdfMetadataService';
import { FileRecord } from '../types/fileContext';
import { FileId, FileMetadata } from '../types/file';
import { createFileId } from '../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Extract history information from a PDF file and update FileRecord
*/
export async function extractFileHistory(
file: File,
record: FileRecord
): Promise<FileRecord> {
// 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,
sourceFileRecord: FileRecord,
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 = sourceFileRecord.id; // This file becomes the parent
parentToolChain = history.toolChain || [];
} else if (sourceFileRecord.originalFileId && sourceFileRecord.versionNumber) {
// File record has history but PDF doesn't (shouldn't happen, but fallback)
newVersionNumber = sourceFileRecord.versionNumber + 1;
originalFileId = sourceFileRecord.originalFileId;
parentFileId = sourceFileRecord.id;
parentToolChain = sourceFileRecord.toolHistory || [];
} else {
// File has no history - this becomes version 1
newVersionNumber = 1;
originalFileId = sourceFileRecord.id; // Use source file ID as original
parentFileId = sourceFileRecord.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 FormData with history-injected PDFs for tool operations
*/
export async function prepareFilesWithHistory(
files: File[],
getFileRecord: (file: File) => FileRecord | undefined,
toolName: string,
parameters?: Record<string, any>
): Promise<File[]> {
const processedFiles: File[] = [];
for (const file of files) {
const record = getFileRecord(file);
if (!record) {
processedFiles.push(file);
continue;
}
const fileWithHistory = await injectHistoryForTool(file, record, toolName, parameters);
processedFiles.push(fileWithHistory);
}
return processedFiles;
}
/**
* Group files by their original file ID for version management
*/
export function groupFilesByOriginal(fileRecords: FileRecord[]): Map<string, FileRecord[]> {
const groups = new Map<string, FileRecord[]>();
for (const record of fileRecords) {
const originalId = record.originalFileId || record.id;
if (!groups.has(originalId)) {
groups.set(originalId, []);
}
groups.get(originalId)!.push(record);
}
// Sort each group by version number
for (const [_, records] of groups) {
records.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
}
return groups;
}
/**
* Get the latest version of each file group
*/
export function getLatestVersions(fileRecords: FileRecord[]): FileRecord[] {
const groups = groupFilesByOriginal(fileRecords);
const latestVersions: FileRecord[] = [];
for (const [_, records] of groups) {
if (records.length > 0) {
// First item is the latest version (sorted desc by version number)
latestVersions.push(records[0]);
}
}
return latestVersions;
}
/**
* Get version history for a file
*/
export function getVersionHistory(
targetRecord: FileRecord,
allRecords: FileRecord[]
): FileRecord[] {
const originalId = targetRecord.originalFileId || targetRecord.id;
return allRecords
.filter(record => {
const recordOriginalId = record.originalFileId || record.id;
return recordOriginalId === originalId;
})
.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
}
/**
* Check if a file has version history
*/
export function hasVersionHistory(record: FileRecord): boolean {
return !!(record.originalFileId && record.versionNumber && record.versionNumber > 0);
}
/**
* Generate a descriptive name for a file version
*/
export function generateVersionName(record: FileRecord): string {
const baseName = record.name.replace(/\.pdf$/i, '');
if (!hasVersionHistory(record)) {
return record.name;
}
const versionInfo = record.versionNumber ? ` (v${record.versionNumber})` : '';
const toolInfo = record.toolHistory && record.toolHistory.length > 0
? ` - ${record.toolHistory[record.toolHistory.length - 1].toolName}`
: '';
return `${baseName}${versionInfo}${toolInfo}.pdf`;
}
/**
* Create metadata for storing files with history information
*/
export async function createFileMetadataWithHistory(
file: File,
fileId: FileId,
thumbnail?: string
): Promise<FileMetadata> {
const baseMetadata: FileMetadata = {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
thumbnail
};
// Extract history for PDF files
if (file.type.includes('pdf')) {
try {
const arrayBuffer = await file.arrayBuffer();
const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
if (historyMetadata) {
const history = historyMetadata.stirlingHistory;
return {
...baseMetadata,
originalFileId: history.originalFileId,
versionNumber: history.versionNumber,
parentFileId: history.parentFileId as FileId | undefined,
historyInfo: {
originalFileId: history.originalFileId,
parentFileId: history.parentFileId,
versionNumber: history.versionNumber,
toolChain: history.toolChain,
createdAt: history.createdAt,
lastModified: history.lastModified
}
};
}
} catch (error) {
if (DEBUG) console.warn('📄 Failed to extract history for metadata:', file.name, error);
}
}
return baseMetadata;
}