diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx index b1b5f0d24..6f943db08 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -72,12 +72,19 @@ const CompactFileDetails: React.FC = ({ {currentFile ? getFileSize(currentFile) : ''} {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} + {currentFile && ` • v${currentFile.versionNumber || 0}`} {hasMultipleFiles && ( {currentFileIndex + 1} of {selectedFiles.length} )} + {/* Compact tool chain for mobile */} + {currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && ( + + {currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')} + + )} {/* Navigation arrows for multiple files */} diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/components/fileManager/FileActions.tsx index 7bc8d27bc..46c38ad59 100644 --- a/frontend/src/components/fileManager/FileActions.tsx +++ b/frontend/src/components/fileManager/FileActions.tsx @@ -1,15 +1,25 @@ 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 DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; +import HistoryIcon from "@mui/icons-material/History"; import { useTranslation } from "react-i18next"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; const FileActions: React.FC = () => { const { t } = useTranslation(); - const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = - useFileManagerContext(); + const { + recentFiles, + selectedFileIds, + filteredFiles, + showAllVersions, + fileGroups, + onSelectAll, + onDeleteSelected, + onDownloadSelected, + onToggleVersions + } = useFileManagerContext(); const handleSelectAll = () => { onSelectAll(); @@ -34,6 +44,9 @@ const FileActions: React.FC = () => { const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; 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 (
{ position: "relative", }} > - {/* Left: Select All */} -
+ {/* Left: Select All and Version Toggle */} + @@ -63,7 +76,30 @@ const FileActions: React.FC = () => { -
+ + {/* Version Toggle - only show if there are versioned files */} + {hasVersionedFiles && ( + + + + + {showAllVersions ? t("fileManager.allVersions", "All") : t("fileManager.latestOnly", "Latest")} + + + + + )} + {/* Center: Selected count */}
= ({ } return ( - + {/* Section 1: Thumbnail Preview */} - + = ({ const { t } = useTranslation(); return ( - + {t('fileManager.details', 'File Details')} @@ -54,10 +54,28 @@ const FileInfoCard: React.FC = ({ {t('fileManager.fileVersion', 'Version')} - - {currentFile ? '1.0' : ''} - + {currentFile && + + v{currentFile ? (currentFile.versionNumber || 0) : ''} + } + + + {/* Tool Chain Display - Compact */} + {currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && ( + <> + + + + {currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')} + + + + )} diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index b04f9bc41..7c3a11285 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -3,9 +3,12 @@ import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@m import MoreVertIcon from '@mui/icons-material/MoreVert'; import DeleteIcon from '@mui/icons-material/Delete'; 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 { getFileSize, getFileDate } from '../../utils/fileUtils'; import { FileMetadata } from '../../types/file'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; interface FileListItemProps { file: FileMetadata; @@ -30,10 +33,17 @@ const FileListItem: React.FC = ({ const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); + const { fileGroups, onRestoreVersion } = useFileManagerContext(); // Keep item in hovered state if menu is open 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 ( <> = ({ DRAFT )} + {hasVersionHistory && ( + + v{currentVersion} + + )} - {getFileSize(file)} • {getFileDate(file)} + + {getFileSize(file)} • {getFileDate(file)} + {hasVersionHistory && ( + • {fileVersions.length} versions + )} + {/* Three dots menu - fades in/out on hover */} @@ -117,6 +137,42 @@ const FileListItem: React.FC = ({ {t('fileManager.download', 'Download')} )} + + {/* Version History Menu */} + {hasVersionHistory && ( + <> + + {t('fileManager.versions', 'Version History')} + {fileVersions.map((version, index) => ( + Current : + + } + onClick={(e) => { + e.stopPropagation(); + if (version.id !== file.id) { + onRestoreVersion(version); + } + }} + disabled={version.id === file.id} + > + + + v{version.versionNumber || 0} + + + {new Date(version.lastModified).toLocaleDateString()} + + + + ))} + + + )} + } onClick={(e) => { diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index abbdfcfbd..be0694f0f 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -3,6 +3,7 @@ import { FileMetadata } from '../types/file'; import { StoredFile, fileStorage } from '../services/fileStorage'; import { downloadFiles } from '../utils/downloadUtils'; import { FileId } from '../types/file'; +import { getLatestVersions, groupFilesByOriginal, getVersionHistory } from '../utils/fileHistoryUtils'; // Type for the context value - now contains everything directly interface FileManagerContextValue { @@ -14,6 +15,8 @@ interface FileManagerContextValue { filteredFiles: FileMetadata[]; fileInputRef: React.RefObject; selectedFilesSet: Set; + showAllVersions: boolean; + fileGroups: Map; // Handlers onSourceChange: (source: 'recent' | 'local' | 'drive') => void; @@ -28,6 +31,9 @@ interface FileManagerContextValue { onDeleteSelected: () => void; onDownloadSelected: () => void; onDownloadSingle: (file: FileMetadata) => void; + onToggleVersions: () => void; + onRestoreVersion: (file: FileMetadata) => void; + onNewFilesSelect: (files: File[]) => void; // External props recentFiles: FileMetadata[]; @@ -68,6 +74,7 @@ export const FileManagerProvider: React.FC = ({ const [selectedFileIds, setSelectedFileIds] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [lastClickedIndex, setLastClickedIndex] = useState(null); + const [showAllVersions, setShowAllVersions] = useState(false); const fileInputRef = useRef(null); // Track blob URLs for cleanup @@ -76,11 +83,44 @@ export const FileManagerProvider: React.FC = ({ // Computed values (with null safety) const selectedFilesSet = new Set(selectedFileIds); - const selectedFiles = selectedFileIds.length === 0 ? [] : - (recentFiles || []).filter(file => selectedFilesSet.has(file.id)); + // Group files by original file ID for version management + const fileGroups = useMemo(() => { + if (!recentFiles || recentFiles.length === 0) return new Map(); + + // Convert FileMetadata to FileRecord-like objects for grouping utility + const recordsForGrouping = recentFiles.map(file => ({ + ...file, + originalFileId: file.originalFileId, + versionNumber: file.versionNumber || 0 + })); + + return groupFilesByOriginal(recordsForGrouping); + }, [recentFiles]); - const filteredFiles = !searchTerm ? recentFiles || [] : - (recentFiles || []).filter(file => + // 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()) ); @@ -243,6 +283,42 @@ export const FileManagerProvider: React.FC = ({ } }, []); + 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 useEffect(() => { @@ -274,6 +350,8 @@ export const FileManagerProvider: React.FC = ({ filteredFiles, fileInputRef, selectedFilesSet, + showAllVersions, + fileGroups, // Handlers onSourceChange: handleSourceChange, @@ -288,6 +366,9 @@ export const FileManagerProvider: React.FC = ({ onDeleteSelected: handleDeleteSelected, onDownloadSelected: handleDownloadSelected, onDownloadSingle: handleDownloadSingle, + onToggleVersions: handleToggleVersions, + onRestoreVersion: handleRestoreVersion, + onNewFilesSelect, // External props recentFiles, @@ -300,6 +381,8 @@ export const FileManagerProvider: React.FC = ({ selectedFiles, filteredFiles, fileInputRef, + showAllVersions, + fileGroups, handleSourceChange, handleLocalFileClick, handleFileSelect, @@ -311,6 +394,9 @@ export const FileManagerProvider: React.FC = ({ handleSelectAll, handleDeleteSelected, handleDownloadSelected, + handleToggleVersions, + handleRestoreVersion, + onNewFilesSelect, recentFiles, isFileSupported, modalHeight, diff --git a/frontend/src/contexts/IndexedDBContext.tsx b/frontend/src/contexts/IndexedDBContext.tsx index dfd2ac5f2..544ddd41f 100644 --- a/frontend/src/contexts/IndexedDBContext.tsx +++ b/frontend/src/contexts/IndexedDBContext.tsx @@ -10,6 +10,7 @@ import { fileStorage, StoredFile } from '../services/fileStorage'; import { FileId } from '../types/file'; import { FileMetadata } from '../types/file'; import { generateThumbnailForFile } from '../utils/thumbnailUtils'; +import { createFileMetadataWithHistory } from '../utils/fileHistoryUtils'; interface IndexedDBContextValue { // Core CRUD operations @@ -67,15 +68,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); evictLRUEntries(); - // Return metadata - return { - id: fileId, - name: file.name, - type: file.type, - size: file.size, - lastModified: file.lastModified, - thumbnail - }; + // Extract history metadata for PDFs and return enhanced metadata + const metadata = await createFileMetadataWithHistory(file, fileId, thumbnail); + + + return metadata; }, []); const loadFile = useCallback(async (fileId: FileId): Promise => { @@ -145,14 +142,46 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { const loadAllMetadata = useCallback(async (): Promise => { const metadata = await fileStorage.getAllFileMetadata(); - return metadata.map(m => ({ - id: m.id, - name: m.name, - type: m.type, - size: m.size, - lastModified: m.lastModified, - thumbnail: m.thumbnail + // 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, + name: m.name, + type: m.type, + size: m.size, + lastModified: m.lastModified, + 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 => { diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index e55108553..f577f1ef8 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -15,6 +15,7 @@ import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; import { FileLifecycleManager } from './lifecycle'; import { fileProcessingService } from '../../services/fileProcessingService'; import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors'; +import { extractFileHistory } from '../../utils/fileHistoryUtils'; 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`); } + // 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); fileRecords.push(record); 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`); } + // 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); fileRecords.push(record); addedFiles.push({ file, id: fileId, thumbnail }); diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index d8d35176d..49f2fbb02 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -10,6 +10,7 @@ import { createOperation } from '../../../utils/toolOperationTracker'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { FileId } from '../../../types/file'; import { FileRecord } from '../../../types/fileContext'; +import { prepareFilesWithHistory } from '../../../utils/fileHistoryUtils'; // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; @@ -170,6 +171,20 @@ export const useToolOperation = ( actions.resetResults(); 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 + ); + try { let processedFiles: File[]; @@ -184,7 +199,7 @@ export const useToolOperation = ( }; processedFiles = await processFiles( params, - validFiles, + filesWithHistory, apiCallsConfig, actions.setProgress, actions.setStatus @@ -194,7 +209,7 @@ export const useToolOperation = ( case ToolType.multiFile: // Multi-file processing - single API call with all 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 response = await axios.post(endpoint, formData, { responseType: 'blob' }); @@ -202,11 +217,11 @@ export const useToolOperation = ( // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs if (config.responseHandler) { // 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' || (response.headers && response.headers['content-type'] === 'application/pdf')) { // 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' }); processedFiles = [singleFile]; } else { @@ -222,7 +237,7 @@ export const useToolOperation = ( case ToolType.custom: actions.setStatus('Processing files...'); - processedFiles = await config.customProcessor(params, validFiles); + processedFiles = await config.customProcessor(params, filesWithHistory); break; } diff --git a/frontend/src/services/pdfMetadataService.ts b/frontend/src/services/pdfMetadataService.ts new file mode 100644 index 000000000..f5c821dc2 --- /dev/null +++ b/frontend/src/services/pdfMetadataService.ts @@ -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; +} + +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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(); \ No newline at end of file diff --git a/frontend/src/types/file.ts b/frontend/src/types/file.ts index c6b038e81..22d98ad12 100644 --- a/frontend/src/types/file.ts +++ b/frontend/src/types/file.ts @@ -6,6 +6,27 @@ declare const tag: unique symbol; export type FileId = string & { readonly [tag]: 'FileId' }; +/** + * Tool operation metadata for history tracking + */ +export interface ToolOperation { + toolName: string; + timestamp: number; + parameters?: Record; +} + +/** + * 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 * Used by IndexedDBContext and FileContext for lazy file loading @@ -18,6 +39,14 @@ export interface FileMetadata { lastModified: number; thumbnail?: string; 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 { diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 9210f9ce9..a9f1f261a 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -54,6 +54,17 @@ export interface FileRecord { processedFile?: ProcessedFileMetadata; insertAfterPageId?: string; // Page ID after which this file should be inserted 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; + }>; + // Note: File object stored in provider ref, not in state } diff --git a/frontend/src/utils/fileHistoryUtils.ts b/frontend/src/utils/fileHistoryUtils.ts new file mode 100644 index 000000000..bc5742ce2 --- /dev/null +++ b/frontend/src/utils/fileHistoryUtils.ts @@ -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 { + // 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 +): Promise { + // Only process PDF files + if (!file.type.includes('pdf')) { + return file; + } + + try { + const arrayBuffer = await file.arrayBuffer(); + + // Create tool operation record + const toolOperation: ToolOperation = { + toolName, + timestamp: Date.now(), + parameters + }; + + let modifiedBytes: ArrayBuffer; + + // Extract version info directly from the PDF metadata to ensure accuracy + const existingHistoryMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer); + + let newVersionNumber: number; + let originalFileId: string; + let parentFileId: string; + let parentToolChain: ToolOperation[]; + + if (existingHistoryMetadata) { + // File already has embedded history - increment version + const history = existingHistoryMetadata.stirlingHistory; + newVersionNumber = history.versionNumber + 1; + originalFileId = history.originalFileId; + parentFileId = 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 +): Promise { + 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 { + const groups = new Map(); + + 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 { + 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; +} \ No newline at end of file