diff --git a/devGuide/FILE_HISTORY_SPECIFICATION.md b/devGuide/FILE_HISTORY_SPECIFICATION.md new file mode 100644 index 000000000..d624d28fb --- /dev/null +++ b/devGuide/FILE_HISTORY_SPECIFICATION.md @@ -0,0 +1,319 @@ +# Stirling PDF File History Specification + +## Overview + +Stirling PDF implements a client-side file history system using IndexedDB storage. File metadata, including version history and tool chains, are stored as `StirlingFileStub` objects that travel alongside the actual file data. This enables comprehensive version tracking, tool history, and file lineage management without modifying PDF content. + +## Storage Architecture + +### IndexedDB-Based Storage +File history is stored in the browser's IndexedDB using the `fileStorage` service, providing: +- **Persistent storage**: Survives browser sessions and page reloads +- **Large capacity**: Supports files up to 100GB+ with full metadata +- **Fast queries**: Optimized for file browsing and history lookups +- **Type safety**: Structured TypeScript interfaces + +### Core Data Structures + +```typescript +interface StirlingFileStub extends BaseFileMetadata { + id: FileId; // Unique file identifier (UUID) + quickKey: string; // Deduplication key: name|size|lastModified + thumbnailUrl?: string; // Generated thumbnail blob URL + processedFile?: ProcessedFileMetadata; // PDF page data and processing results + + // File Metadata + name: string; + size: number; + type: string; + lastModified: number; + createdAt: number; + + // Version Control + isLeaf: boolean; // True if this is the latest version + versionNumber?: number; // Version number (1, 2, 3, etc.) + originalFileId?: string; // UUID of the root file in version chain + parentFileId?: string; // UUID of immediate parent file + + // Tool History + toolHistory?: ToolOperation[]; // Complete sequence of applied tools +} + +interface ToolOperation { + toolName: string; // Tool identifier (e.g., 'compress', 'sanitize') + timestamp: number; // When the tool was applied +} + +interface StoredStirlingFileRecord extends StirlingFileStub { + data: ArrayBuffer; // Actual file content + fileId: FileId; // Duplicate for indexing +} +``` + +## Version Management System + +### Version Progression +- **v1**: Original uploaded file (first version) +- **v2**: First tool applied to original +- **v3**: Second tool applied (inherits from v2) +- **v4**: Third tool applied (inherits from v3) +- **etc.** + +### Leaf Node System +Only the latest version of each file family is marked as `isLeaf: true`: +- **Leaf files**: Show in default file list, available for tool processing +- **History files**: Hidden by default, accessible via history expansion + +### File Relationships +``` +document.pdf (v1, isLeaf: false) + ↓ compress +document.pdf (v2, isLeaf: false) + ↓ sanitize +document.pdf (v3, isLeaf: true) ← Current active version +``` + +## Implementation Architecture + +### 1. FileStorage Service (`fileStorage.ts`) + +**Core Methods:** +```typescript +// Store file with complete metadata +async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): Promise + +// Load file with metadata +async getStirlingFile(id: FileId): Promise +async getStirlingFileStub(id: FileId): Promise + +// Query operations +async getLeafStirlingFileStubs(): Promise +async getAllStirlingFileStubs(): Promise + +// Version management +async markFileAsProcessed(fileId: FileId): Promise // Set isLeaf = false +async markFileAsLeaf(fileId: FileId): Promise // Set isLeaf = true +``` + +### 2. File Context Integration + +**FileContext** manages runtime state with `StirlingFileStub[]` in memory: +```typescript +interface FileContextState { + files: { + ids: FileId[]; + byId: Record; + }; +} +``` + +**Key Operations:** +- `addFiles()`: Stores new files with initial metadata +- `addStirlingFileStubs()`: Loads existing files from storage with preserved metadata +- `consumeFiles()`: Processes files through tools, creating new versions + +### 3. Tool Operation Integration + +**Tool Processing Flow:** +1. **Input**: User selects files (marked as `isLeaf: true`) +2. **Processing**: Backend processes files and returns results +3. **History Creation**: New `StirlingFileStub` created with: + - Incremented version number + - Updated tool history + - Parent file reference +4. **Storage**: Both parent (marked `isLeaf: false`) and child (marked `isLeaf: true`) stored +5. **UI Update**: FileContext updated with new file state + +**Child Stub Creation:** +```typescript +export function createChildStub( + parentStub: StirlingFileStub, + operation: { toolName: string; timestamp: number }, + resultingFile: File, + thumbnail?: string +): StirlingFileStub { + return { + id: createFileId(), + name: resultingFile.name, + size: resultingFile.size, + type: resultingFile.type, + lastModified: resultingFile.lastModified, + quickKey: createQuickKey(resultingFile), + createdAt: Date.now(), + isLeaf: true, + + // Version Control + versionNumber: (parentStub.versionNumber || 1) + 1, + originalFileId: parentStub.originalFileId || parentStub.id, + parentFileId: parentStub.id, + + // Tool History + toolHistory: [...(parentStub.toolHistory || []), operation], + thumbnailUrl: thumbnail + }; +} +``` + +## UI Integration + +### File Manager History Display + +**FileManager** (`FileManager.tsx`) provides: +- **Default View**: Shows only leaf files (`isLeaf: true`) +- **History Expansion**: Click to show all versions of a file family +- **History Groups**: Nested display using `FileHistoryGroup.tsx` + +**FileListItem** (`FileListItem.tsx`) displays: +- **Version Badges**: v1, v2, v3 indicators +- **Tool Chain**: Complete processing history in tooltips +- **History Actions**: "Show/Hide History" toggle, "Restore" for history files + +### FileManagerContext Integration + +**File Selection Flow:** +```typescript +// Recent files (from storage) +onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void +// Calls: actions.addStirlingFileStubs(stirlingFileStubs, options) + +// New uploads +onFileUpload: (files: File[]) => void +// Calls: actions.addFiles(files, options) +``` + +**History Management:** +```typescript +// Toggle history visibility +const { expandedFileIds, onToggleExpansion } = useFileManagerContext(); + +// Restore history file to current +const handleAddToRecents = (file: StirlingFileStub) => { + fileStorage.markFileAsLeaf(file.id); // Make this version current +}; +``` + +## Data Flow + +### New File Upload +``` +1. User uploads files → addFiles() +2. Generate thumbnails and page count +3. Create StirlingFileStub with isLeaf: true, versionNumber: 1 +4. Store both StirlingFile + StirlingFileStub in IndexedDB +5. Dispatch to FileContext state +``` + +### Tool Processing +``` +1. User selects tool + files → useToolOperation() +2. API processes files → returns processed File objects +3. createChildStub() for each result: + - Parent marked isLeaf: false + - Child created with isLeaf: true, incremented version +4. Store all files with updated metadata +5. Update FileContext with new state +``` + +### File Loading (Recent Files) +``` +1. User selects from FileManager → onRecentFileSelect() +2. addStirlingFileStubs() with preserved metadata +3. Load actual StirlingFile data from storage +4. Files appear in workbench with complete history intact +``` + +## Performance Optimizations + +### Metadata Regeneration +When loading files from storage, missing `processedFile` data is regenerated: +```typescript +// In addStirlingFileStubs() +const needsProcessing = !record.processedFile || + !record.processedFile.pages || + record.processedFile.pages.length === 0; + +if (needsProcessing) { + const result = await generateThumbnailWithMetadata(stirlingFile); + record.processedFile = createProcessedFile(result.pageCount, result.thumbnail); +} +``` + +### Memory Management +- **Blob URL Tracking**: Automatic cleanup of thumbnail URLs +- **Lazy Loading**: Files loaded from storage only when needed +- **LRU Caching**: File objects cached in memory with size limits + +## File Deduplication + +### QuickKey System +Files are deduplicated using `quickKey` format: +```typescript +const quickKey = `${file.name}|${file.size}|${file.lastModified}`; +``` + +This prevents duplicate uploads while allowing different versions of the same logical file. + +## Error Handling + +### Graceful Degradation +- **Storage Failures**: Files continue to work without persistence +- **Metadata Issues**: Missing metadata regenerated on demand +- **Version Conflicts**: Automatic version number resolution + +### Recovery Scenarios +- **Corrupted Storage**: Automatic cleanup and re-initialization +- **Missing Files**: Stubs cleaned up automatically +- **Version Mismatches**: Automatic version chain reconstruction + +## Developer Guidelines + +### Adding File History to New Components + +1. **Use FileContext Actions**: +```typescript +const { actions } = useFileActions(); +await actions.addFiles(files); // For new uploads +await actions.addStirlingFileStubs(stubs); // For existing files +``` + +2. **Preserve Metadata When Processing**: +```typescript +const childStub = createChildStub(parentStub, { + toolName: 'compress', + timestamp: Date.now() +}, processedFile, thumbnail); +``` + +3. **Handle Storage Operations**: +```typescript +await fileStorage.storeStirlingFile(stirlingFile, stirlingFileStub); +const stub = await fileStorage.getStirlingFileStub(fileId); +``` + +### Testing File History + +1. **Upload files**: Should show v1, marked as leaf +2. **Apply tool**: Should create v2, mark v1 as non-leaf +3. **Check FileManager**: History should show both versions +4. **Restore old version**: Should mark old version as leaf +5. **Check storage**: Both versions should persist in IndexedDB + +## Future Enhancements + +### Potential Improvements +- **Branch History**: Support for parallel processing branches +- **History Export**: Export complete version history as JSON +- **Conflict Resolution**: Handle concurrent modifications +- **Cloud Sync**: Sync history across devices +- **Compression**: Compress historical file data + +### API Extensions +- **Batch Operations**: Process multiple version chains simultaneously +- **Search Integration**: Search within tool history and file metadata +- **Analytics**: Track usage patterns and tool effectiveness + +--- + +**Last Updated**: January 2025 +**Implementation**: Stirling PDF Frontend v2 +**Storage Version**: IndexedDB with fileStorage service \ No newline at end of file diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 08a2e25d1..ccc811781 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -684,6 +684,13 @@ }, "splitPages": "Enter pages to split on:", "submit": "Split", + "steps": { + "chooseMethod": "Choose Method", + "settings": "Settings" + }, + "settings": { + "selectMethodFirst": "Please select a split method first" + }, "error": { "failed": "An error occurred while splitting the PDF." }, @@ -692,12 +699,45 @@ "placeholder": "Select how to split the PDF" }, "methods": { - "byPages": "Split at Page Numbers", - "bySections": "Split by Sections", - "bySize": "Split by File Size", - "byPageCount": "Split by Page Count", - "byDocCount": "Split by Document Count", - "byChapters": "Split by Chapters" + "prefix": { + "splitAt": "Split at", + "splitBy": "Split by" + }, + "byPages": { + "name": "Page Numbers", + "desc": "Extract specific pages (1,3,5-10)", + "tooltip": "Enter page numbers separated by commas or ranges with hyphens" + }, + "bySections": { + "name": "Sections", + "desc": "Divide pages into grid sections", + "tooltip": "Split each page into horizontal and vertical sections" + }, + "bySize": { + "name": "File Size", + "desc": "Limit maximum file size", + "tooltip": "Specify maximum file size (e.g. 10MB, 500KB)" + }, + "byPageCount": { + "name": "Page Count", + "desc": "Fixed pages per file", + "tooltip": "Enter the number of pages for each split file" + }, + "byDocCount": { + "name": "Document Count", + "desc": "Create specific number of files", + "tooltip": "Enter how many files you want to create" + }, + "byChapters": { + "name": "Chapters", + "desc": "Split at bookmark boundaries", + "tooltip": "Uses PDF bookmarks to determine split points" + }, + "byPageDivider": { + "name": "Page Divider", + "desc": "Auto-split with divider sheets", + "tooltip": "Use QR code divider sheets between documents when scanning" + } }, "value": { "fileSize": { @@ -2432,6 +2472,13 @@ "storageLow": "Storage is running low. Consider removing old files.", "supportMessage": "Powered by browser database storage for unlimited capacity", "noFileSelected": "No files selected", + "showHistory": "Show History", + "hideHistory": "Hide History", + "fileHistory": "File History", + "loadingHistory": "Loading History...", + "lastModified": "Last Modified", + "toolChain": "Tools Applied", + "restore": "Restore", "searchFiles": "Search files...", "recent": "Recent", "localFiles": "Local Files", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index 63ca5c5ec..e75b95e28 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Modal } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { FileMetadata } from '../types/file'; +import { StirlingFileStub } from '../types/fileContext'; import { useFileManager } from '../hooks/useFileManager'; import { useFilesModalContext } from '../contexts/FilesModalContext'; import { Tool } from '../types/tool'; @@ -15,12 +15,12 @@ interface FileManagerProps { } const FileManager: React.FC = ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext(); - const [recentFiles, setRecentFiles] = useState([]); + const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext(); + const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); - const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager(); + const { loadRecentFiles, handleRemoveFile } = useFileManager(); // File management handlers const isFileSupported = useCallback((fileName: string) => { @@ -34,33 +34,26 @@ const FileManager: React.FC = ({ selectedTool }) => { setRecentFiles(files); }, [loadRecentFiles]); - const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { + const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => { try { - // Use stored files flow that preserves original IDs - const filesWithMetadata = await Promise.all( - files.map(async (metadata) => ({ - file: await convertToFile(metadata), - originalId: metadata.id, - metadata - })) - ); - onStoredFilesSelect(filesWithMetadata); + // Use StirlingFileStubs directly - preserves all metadata! + onRecentFileSelect(files); } catch (error) { console.error('Failed to process selected files:', error); } - }, [convertToFile, onStoredFilesSelect]); + }, [onRecentFileSelect]); const handleNewFileUpload = useCallback(async (files: File[]) => { if (files.length > 0) { try { // Files will get IDs assigned through onFilesSelect -> FileContext addFiles - onFilesSelect(files); + onFileUpload(files); await refreshRecentFiles(); } catch (error) { console.error('Failed to process dropped files:', error); } } - }, [onFilesSelect, refreshRecentFiles]); + }, [onFileUpload, refreshRecentFiles]); const handleRemoveFileByIndex = useCallback(async (index: number) => { await handleRemoveFile(index, recentFiles, setRecentFiles); @@ -85,7 +78,7 @@ const FileManager: React.FC = ({ selectedTool }) => { // Cleanup any blob URLs when component unmounts useEffect(() => { return () => { - // FileMetadata doesn't have blob URLs, so no cleanup needed + // StoredFileMetadata doesn't have blob URLs, so no cleanup needed // Blob URLs are managed by FileContext and tool operations console.log('FileManager unmounting - FileContext handles blob URL cleanup'); }; @@ -146,7 +139,7 @@ const FileManager: React.FC = ({ selectedTool }) => { > { - const file = selectors.getFile(record.id); - if (!file) return null; - - return { - id: record.id, - name: file.name, - pageCount: record.processedFile?.totalPages || 1, - thumbnail: record.thumbnailUrl || '', - size: file.size, - file: file - }; - }, [selectors]); - - // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { setError(null); @@ -404,13 +388,10 @@ const FileEditor = ({ }} > {activeStirlingFileStubs.map((record, index) => { - const fileItem = recordToFileItem(record); - if (!fileItem) return null; - return ( ); })} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index 7e7370785..2a927d012 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -8,22 +8,17 @@ import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { StirlingFileStub } from '../../types/fileContext'; import styles from './FileEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; import { FileId } from '../../types/file'; +import ToolChain from '../shared/ToolChain'; + -interface FileItem { - id: FileId; - name: string; - pageCount: number; - thumbnail: string | null; - size: number; - modifiedAt?: number | string | Date; -} interface FileEditorThumbnailProps { - file: FileItem; + file: StirlingFileStub; index: number; totalFiles: number; selectedFiles: FileId[]; @@ -64,6 +59,8 @@ const FileEditorThumbnail = ({ }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; + const pageCount = file.processedFile?.totalPages || 0; + const downloadSelectedFile = useCallback(() => { // Prefer parent-provided handler if available if (typeof onDownloadFile === 'function') { @@ -109,22 +106,21 @@ const FileEditorThumbnail = ({ const pageLabel = useMemo( () => - file.pageCount > 0 - ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` + pageCount > 0 + ? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}` : '', - [file.pageCount] + [pageCount] ); const dateLabel = useMemo(() => { - const d = - file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback + const d = new Date(file.lastModified); if (Number.isNaN(d.getTime())) return ''; return new Intl.DateTimeFormat(undefined, { month: 'short', day: '2-digit', year: 'numeric', }).format(d); - }, [file.modifiedAt]); + }, [file.lastModified]); // ---- Drag & drop wiring ---- const fileElementRef = useCallback((element: HTMLDivElement | null) => { @@ -350,7 +346,8 @@ const FileEditorThumbnail = ({ lineClamp={3} title={`${extUpper || 'FILE'} • ${prettySize}`} > - {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} + {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} + {`v${file.versionNumber} - `} {dateLabel} {extUpper ? ` - ${extUpper} file` : ''} {pageLabel ? ` - ${pageLabel}` : ''} @@ -360,9 +357,9 @@ const FileEditorThumbnail = ({ {/* Preview area */}
- {file.thumbnail && ( + {file.thumbnailUrl && ( {file.name} + + {/* Tool chain display at bottom */} + {file.toolHistory && ( +
+ +
+ )}
); diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx index b1b5f0d24..fac16bb20 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; import { getFileSize } from '../../utils/fileUtils'; -import { FileMetadata } from '../../types/file'; +import { StirlingFileStub } from '../../types/fileContext'; interface CompactFileDetailsProps { - currentFile: FileMetadata | null; + currentFile: StirlingFileStub | null; thumbnail: string | null; - selectedFiles: FileMetadata[]; + selectedFiles: StirlingFileStub[]; currentFileIndex: number; numberOfFiles: number; isAnimating: boolean; @@ -72,12 +72,19 @@ const CompactFileDetails: React.FC = ({ {currentFile ? getFileSize(currentFile) : ''} {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} + {currentFile && ` • v${currentFile.versionNumber || 1}`} {hasMultipleFiles && ( {currentFileIndex + 1} of {selectedFiles.length} )} + {/* Compact tool chain for mobile */} + {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( + + {currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')} + + )} {/* Navigation arrows for multiple files */} diff --git a/frontend/src/components/fileManager/FileHistoryGroup.tsx b/frontend/src/components/fileManager/FileHistoryGroup.tsx new file mode 100644 index 000000000..16bc02aa1 --- /dev/null +++ b/frontend/src/components/fileManager/FileHistoryGroup.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Box, Text, Collapse, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { StirlingFileStub } from '../../types/fileContext'; +import FileListItem from './FileListItem'; + +interface FileHistoryGroupProps { + leafFile: StirlingFileStub; + historyFiles: StirlingFileStub[]; + isExpanded: boolean; + onDownloadSingle: (file: StirlingFileStub) => void; + onFileDoubleClick: (file: StirlingFileStub) => void; + onHistoryFileRemove: (file: StirlingFileStub) => void; + isFileSupported: (fileName: string) => boolean; +} + +const FileHistoryGroup: React.FC = ({ + leafFile, + historyFiles, + isExpanded, + onDownloadSingle, + onFileDoubleClick, + onHistoryFileRemove, + isFileSupported, +}) => { + const { t } = useTranslation(); + + // Sort history files by version number (oldest first, excluding the current leaf file) + const sortedHistory = historyFiles + .filter(file => file.id !== leafFile.id) // Exclude the leaf file itself + .sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1)); + + if (!isExpanded || sortedHistory.length === 0) { + return null; + } + + return ( + + + + + {t('fileManager.fileHistory', 'File History')} ({sortedHistory.length}) + + + + + {sortedHistory.map((historyFile, _index) => ( + {}} // No selection for history files + onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file + onDownload={() => onDownloadSingle(historyFile)} + onDoubleClick={() => onFileDoubleClick(historyFile)} + isHistoryFile={true} // This enables "Add to Recents" in menu + isLatestVersion={false} // History files are never latest + // onAddToRecents is accessed from context by FileListItem + /> + ))} + + + + ); +}; + +export default FileHistoryGroup; diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx index 68c2a491d..f53a2f6ee 100644 --- a/frontend/src/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/components/fileManager/FileInfoCard.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; -import { FileMetadata } from '../../types/file'; +import { StirlingFileStub } from '../../types/fileContext'; +import ToolChain from '../shared/ToolChain'; interface FileInfoCardProps { - currentFile: FileMetadata | null; + currentFile: StirlingFileStub | null; modalHeight: string; } @@ -53,11 +54,36 @@ const FileInfoCard: React.FC = ({ - {t('fileManager.fileVersion', 'Version')} + {t('fileManager.lastModified', 'Last Modified')} - {currentFile ? '1.0' : ''} + {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''} + + + + {t('fileManager.fileVersion', 'Version')} + {currentFile && + + v{currentFile ? (currentFile.versionNumber || 1) : ''} + } + + + + {/* Tool Chain Display */} + {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( + <> + + + {t('fileManager.toolChain', 'Tools Applied')} + + + + )} diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx index bb376765b..842d0bf0e 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/components/fileManager/FileListArea.tsx @@ -4,6 +4,7 @@ import CloudIcon from '@mui/icons-material/Cloud'; import HistoryIcon from '@mui/icons-material/History'; import { useTranslation } from 'react-i18next'; import FileListItem from './FileListItem'; +import FileHistoryGroup from './FileHistoryGroup'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; interface FileListAreaProps { @@ -20,8 +21,11 @@ const FileListArea: React.FC = ({ recentFiles, filteredFiles, selectedFilesSet, + expandedFileIds, + loadedHistoryFiles, onFileSelect, onFileRemove, + onHistoryFileRemove, onFileDoubleClick, onDownloadSingle, isFileSupported, @@ -50,18 +54,37 @@ const FileListArea: React.FC = ({ ) : ( - filteredFiles.map((file, index) => ( - onFileSelect(file, index, shiftKey)} - onRemove={() => onFileRemove(index)} - onDownload={() => onDownloadSingle(file)} - onDoubleClick={() => onFileDoubleClick(file)} - /> - )) + filteredFiles.map((file, index) => { + // All files in filteredFiles are now leaf files only + const historyFiles = loadedHistoryFiles.get(file.id) || []; + const isExpanded = expandedFileIds.has(file.id); + + return ( + + onFileSelect(file, index, shiftKey)} + onRemove={() => onFileRemove(index)} + onDownload={() => onDownloadSingle(file)} + onDoubleClick={() => onFileDoubleClick(file)} + isHistoryFile={false} // All files here are leaf files + isLatestVersion={true} // All files here are the latest versions + /> + + + + ); + }) )} diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index b04f9bc41..7296fa956 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -3,12 +3,16 @@ 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 { FileId, StirlingFileStub } from '../../types/fileContext'; +import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import ToolChain from '../shared/ToolChain'; interface FileListItemProps { - file: FileMetadata; + file: StirlingFileStub; isSelected: boolean; isSupported: boolean; onSelect: (shiftKey?: boolean) => void; @@ -16,6 +20,8 @@ interface FileListItemProps { onDownload?: () => void; onDoubleClick?: () => void; isLast?: boolean; + isHistoryFile?: boolean; // Whether this is a history file (indented) + isLatestVersion?: boolean; // Whether this is the latest version (shows chevron) } const FileListItem: React.FC = ({ @@ -25,60 +31,89 @@ const FileListItem: React.FC = ({ onSelect, onRemove, onDownload, - onDoubleClick + onDoubleClick, + isHistoryFile = false, + isLatestVersion = false }) => { const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); + const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext(); // Keep item in hovered state if menu is open const shouldShowHovered = isHovered || isMenuOpen; + // Get version information for this file + const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId; + const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+) + const currentVersion = file.versionNumber || 1; // Display original files as v1 + const isExpanded = expandedFileIds.has(leafFileId); + return ( <> onSelect(e.shiftKey)} + onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - - {}} // Handled by parent onClick - size="sm" - pl="sm" - pr="xs" - styles={{ - input: { - cursor: 'pointer' - } - }} - /> - + {!isHistoryFile && ( + + {/* Checkbox for regular files only */} + {}} // Handled by parent onClick + size="sm" + pl="sm" + pr="xs" + styles={{ + input: { + cursor: 'pointer' + } + }} + /> + + )} {file.name} - {file.isDraft && ( - - DRAFT - + + v{currentVersion} + + + + + + {getFileSize(file)} • {getFileDate(file)} + + + {/* Tool chain for processed files */} + {file.toolHistory && file.toolHistory.length > 0 && ( + )} - {getFileSize(file)} • {getFileDate(file)} {/* Three dots menu - fades in/out on hover */} @@ -117,6 +152,46 @@ const FileListItem: React.FC = ({ {t('fileManager.download', 'Download')} )} + + {/* Show/Hide History option for latest version files */} + {isLatestVersion && hasVersionHistory && ( + <> + + } + onClick={(e) => { + e.stopPropagation(); + onToggleExpansion(leafFileId); + }} + > + { + (isExpanded ? + t('fileManager.hideHistory', 'Hide History') : + t('fileManager.showHistory', 'Show History') + ) + } + + + + )} + + {/* Restore option for history files */} + {isHistoryFile && ( + <> + } + onClick={(e) => { + e.stopPropagation(); + onAddToRecents(file); + }} + > + {t('fileManager.restore', 'Restore')} + + + + )} + } onClick={(e) => { diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index d3fad72c4..bfa33bc03 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -42,7 +42,7 @@ export default function Workbench() { // Get tool registry to look up selected tool const { toolRegistry } = useToolManagement(); const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null; - const { addToActiveFiles } = useFileHandler(); + const { addFiles } = useFileHandler(); const handlePreviewClose = () => { setPreviewFile(null); @@ -81,7 +81,7 @@ export default function Workbench() { setCurrentView("pageEditor"); }, onMergeFiles: (filesToMerge) => { - filesToMerge.forEach(addToActiveFiles); + addFiles(filesToMerge); setCurrentView("viewer"); } })} diff --git a/frontend/src/components/shared/CardSelector.tsx b/frontend/src/components/shared/CardSelector.tsx new file mode 100644 index 000000000..a8e4a2725 --- /dev/null +++ b/frontend/src/components/shared/CardSelector.tsx @@ -0,0 +1,99 @@ +import { Stack, Card, Text, Flex } from '@mantine/core'; +import { Tooltip } from './Tooltip'; +import { useTranslation } from 'react-i18next'; + +export interface CardOption { + value: T; + prefixKey: string; + nameKey: string; + tooltipKey?: string; + tooltipContent?: any[]; +} + +export interface CardSelectorProps> { + options: K[]; + onSelect: (value: T) => void; + disabled?: boolean; + getTooltipContent?: (option: K) => any[]; +} + +const CardSelector = >({ + options, + onSelect, + disabled = false, + getTooltipContent +}: CardSelectorProps) => { + const { t } = useTranslation(); + + const handleOptionClick = (value: T) => { + if (!disabled) { + onSelect(value); + } + }; + + const getTooltips = (option: K) => { + if (getTooltipContent) { + return getTooltipContent(option); + } + return []; + }; + + return ( + + {options.map((option) => ( + + { + if (!disabled) { + e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-3)'; + e.currentTarget.style.transform = 'translateY(-1px)'; + e.currentTarget.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)'; + } + }} + onMouseLeave={(e) => { + if (!disabled) { + e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-2)'; + e.currentTarget.style.transform = 'translateY(0px)'; + e.currentTarget.style.boxShadow = 'none'; + } + }} + onClick={() => handleOptionClick(option.value)} + > + + + {t(option.prefixKey, "Prefix")} + + + {t(option.nameKey, "Option Name")} + + + + + ))} + + ); +}; + +export default CardSelector; diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx index 6c63af42e..173cfa404 100644 --- a/frontend/src/components/shared/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -12,7 +12,7 @@ import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; interface FileCardProps { file: File; - record?: StirlingFileStub; + fileStub?: StirlingFileStub; onRemove: () => void; onDoubleClick?: () => void; onView?: () => void; @@ -22,12 +22,11 @@ interface FileCardProps { isSupported?: boolean; // Whether the file format is supported by the current tool } -const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { +const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { const { t } = useTranslation(); // Use record thumbnail if available, otherwise fall back to IndexedDB lookup - const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null; - const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata); - const thumb = record?.thumbnailUrl || indexedDBThumb; + const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub); + const thumb = fileStub?.thumbnailUrl || indexedDBThumb; const [isHovered, setIsHovered] = useState(false); return ( @@ -177,7 +176,7 @@ const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSel {getFileDate(file)} - {record?.id && ( + {fileStub?.id && ( onRemove(originalIdx) : () => {}} onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined} onView={onView && supported ? () => onView(item) : undefined} diff --git a/frontend/src/components/shared/FilePreview.tsx b/frontend/src/components/shared/FilePreview.tsx index 06c55ee2d..eb2917029 100644 --- a/frontend/src/components/shared/FilePreview.tsx +++ b/frontend/src/components/shared/FilePreview.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Center } from '@mantine/core'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import { FileMetadata } from '../../types/file'; +import { StirlingFileStub } from '../../types/fileContext'; import DocumentThumbnail from './filePreview/DocumentThumbnail'; import DocumentStack from './filePreview/DocumentStack'; import HoverOverlay from './filePreview/HoverOverlay'; @@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows'; export interface FilePreviewProps { // Core file data - file: File | FileMetadata | null; + file: File | StirlingFileStub | null; thumbnail?: string | null; // Optional features @@ -22,7 +22,7 @@ export interface FilePreviewProps { isAnimating?: boolean; // Event handlers - onFileClick?: (file: File | FileMetadata | null) => void; + onFileClick?: (file: File | StirlingFileStub | null) => void; onPrevious?: () => void; onNext?: () => void; } diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 0d3c3bee4..a3ea42fab 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -7,7 +7,7 @@ import { useFileHandler } from '../../hooks/useFileHandler'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; const LandingPage = () => { - const { addMultipleFiles } = useFileHandler(); + const { addFiles } = useFileHandler(); const fileInputRef = React.useRef(null); const { colorScheme } = useMantineColorScheme(); const { t } = useTranslation(); @@ -15,7 +15,7 @@ const LandingPage = () => { const [isUploadHover, setIsUploadHover] = React.useState(false); const handleFileDrop = async (files: File[]) => { - await addMultipleFiles(files); + await addFiles(files); }; const handleOpenFilesModal = () => { @@ -29,7 +29,7 @@ const LandingPage = () => { const handleFileSelect = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); if (files.length > 0) { - await addMultipleFiles(files); + await addFiles(files); } // Reset the input so the same file can be selected again event.target.value = ''; diff --git a/frontend/src/components/shared/ToolChain.tsx b/frontend/src/components/shared/ToolChain.tsx new file mode 100644 index 000000000..5149bd776 --- /dev/null +++ b/frontend/src/components/shared/ToolChain.tsx @@ -0,0 +1,153 @@ +/** + * Reusable ToolChain component with smart truncation and tooltip expansion + * Used across FileListItem, FileDetails, and FileThumbnail for consistent display + */ + +import React from 'react'; +import { Text, Tooltip, Badge, Group } from '@mantine/core'; +import { ToolOperation } from '../../types/file'; + +interface ToolChainProps { + toolChain: ToolOperation[]; + maxWidth?: string; + displayStyle?: 'text' | 'badges' | 'compact'; + size?: 'xs' | 'sm' | 'md'; + color?: string; +} + +const ToolChain: React.FC = ({ + toolChain, + maxWidth = '100%', + displayStyle = 'text', + size = 'xs', + color = 'var(--mantine-color-blue-7)' +}) => { + if (!toolChain || toolChain.length === 0) return null; + + const toolNames = toolChain.map(tool => tool.toolName); + + // Create full tool chain for tooltip + const fullChainDisplay = displayStyle === 'badges' ? ( + + {toolChain.map((tool, index) => ( + + + {tool.toolName} + + {index < toolChain.length - 1 && ( + + )} + + ))} + + ) : ( + {toolNames.join(' → ')} + ); + + // Create truncated display based on available space + const getTruncatedDisplay = () => { + if (toolNames.length <= 2) { + // Show all tools if 2 or fewer + return { text: toolNames.join(' → '), isTruncated: false }; + } else { + // Show first tool ... last tool for longer chains + return { + text: `${toolNames[0]} → +${toolNames.length-2} → ${toolNames[toolNames.length - 1]}`, + isTruncated: true + }; + } + }; + + const { text: truncatedText, isTruncated } = getTruncatedDisplay(); + + // Compact style for very small spaces + if (displayStyle === 'compact') { + const compactText = toolNames.length === 1 ? toolNames[0] : `${toolNames.length} tools`; + const isCompactTruncated = toolNames.length > 1; + + const compactElement = ( + + {compactText} + + ); + + return isCompactTruncated ? ( + + {compactElement} + + ) : compactElement; + } + + // Badge style for file details + if (displayStyle === 'badges') { + const isBadgesTruncated = toolChain.length > 3; + + const badgesElement = ( +
+ + {toolChain.slice(0, 3).map((tool, index) => ( + + + {tool.toolName} + + {index < Math.min(toolChain.length - 1, 2) && ( + + )} + + ))} + {toolChain.length > 3 && ( + <> + ... + + {toolChain[toolChain.length - 1].toolName} + + + )} + +
+ ); + + return isBadgesTruncated ? ( + + {badgesElement} + + ) : badgesElement; + } + + // Text style (default) for file list items + const textElement = ( + + {truncatedText} + + ); + + return isTruncated ? ( + + {textElement} + + ) : textElement; +}; + +export default ToolChain; diff --git a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx index 56991e9d7..4f87e7f3e 100644 --- a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; -import { FileMetadata } from '../../../types/file'; +import { StirlingFileStub } from '../../../types/fileContext'; export interface DocumentThumbnailProps { - file: File | FileMetadata | null; + file: File | StirlingFileStub | null; thumbnail?: string | null; style?: React.CSSProperties; onClick?: () => void; diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index b989e29b8..f63cfe593 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -18,7 +18,7 @@ const FileStatusIndicator = ({ minFiles = 1, }: FileStatusIndicatorProps) => { const { t } = useTranslation(); - const { openFilesModal, onFilesSelect } = useFilesModalContext(); + const { openFilesModal, onFileUpload } = useFilesModalContext(); const { files: stirlingFileStubs } = useAllFiles(); const { loadRecentFiles } = useFileManager(); const [hasRecentFiles, setHasRecentFiles] = useState(null); @@ -45,7 +45,7 @@ const FileStatusIndicator = ({ input.onchange = (event) => { const files = Array.from((event.target as HTMLInputElement).files || []); if (files.length > 0) { - onFilesSelect(files); + onFileUpload(files); } }; input.click(); diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx index 98a612c41..dc7c02480 100644 --- a/frontend/src/components/tools/split/SplitSettings.tsx +++ b/frontend/src/components/tools/split/SplitSettings.tsx @@ -1,6 +1,7 @@ -import { Stack, TextInput, Select, Checkbox } from '@mantine/core'; +import { Stack, TextInput, Checkbox, Anchor, Text } from '@mantine/core'; +import LocalIcon from '../../shared/LocalIcon'; import { useTranslation } from 'react-i18next'; -import { isSplitMethod, SPLIT_METHODS } from '../../../constants/splitConstants'; +import { SPLIT_METHODS } from '../../../constants/splitConstants'; import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters'; export interface SplitSettingsProps { @@ -113,32 +114,48 @@ const SplitSettings = ({ ); + const renderByPageDividerForm = () => ( + + + + {t("autoSplitPDF.dividerDownload2", "Download 'Auto Splitter Divider (with instructions).pdf'")} + + + onParameterChange('duplexMode', e.currentTarget.checked)} + disabled={disabled} + /> + + ); + + // Don't render anything if no method is selected + if (!parameters.method) { + return ( + + + {t("split.settings.selectMethodFirst", "Please select a split method first")} + + + ); + } + return ( - {/* Method Selector */} -