diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 877d932efa..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(chmod:*)", - "Bash(mkdir:*)", - "Bash(./gradlew:*)", - "Bash(grep:*)", - "Bash(cat:*)", - "Bash(find:*)", - "Bash(npm test)", - "Bash(npm test:*)", - "Bash(ls:*)", - "Bash(npx tsc:*)", - "Bash(node:*)", - "Bash(npm run dev:*)", - "Bash(sed:*)", - "Bash(npm run typecheck:*)" - ], - "deny": [], - "defaultMode": "acceptEdits" - } -} \ No newline at end of file diff --git a/devGuide/FILE_HISTORY_SPECIFICATION.md b/devGuide/FILE_HISTORY_SPECIFICATION.md new file mode 100644 index 0000000000..d624d28fb1 --- /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 b4b083e6aa..d6a08324f3 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": { @@ -2371,6 +2411,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 63ca5c5ece..e75b95e283 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 }) => { > void; @@ -78,22 +80,6 @@ const FileEditor = ({ // Use activeStirlingFileStubs directly - no conversion needed const localSelectedIds = contextSelectedIds; - // Helper to convert StirlingFileStub to FileThumbnail format - const recordToFileItem = useCallback((record: any) => { - 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); @@ -294,7 +280,6 @@ const FileEditor = ({ const handleDeleteFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; - if (record && file) { // Remove file from context but keep in storage (close, don't delete) const contextFileId = record.id; @@ -306,6 +291,14 @@ const FileEditor = ({ } }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]); + const handleDownloadFile = useCallback((fileId: FileId) => { + const record = activeStirlingFileStubs.find(r => r.id === fileId); + const file = record ? selectors.getFile(record.id) : null; + if (record && file) { + downloadBlob(file, file.name); + } + }, [activeStirlingFileStubs, selectors, setStatus]); + const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); if (record) { @@ -404,13 +397,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 7e7370785e..f28713c73f 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -8,22 +8,18 @@ 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 { formatFileSize } from '../../utils/fileUtils'; +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[]; @@ -33,7 +29,7 @@ interface FileEditorThumbnailProps { onViewFile: (fileId: FileId) => void; onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; - onDownloadFile?: (fileId: FileId) => void; + onDownloadFile: (fileId: FileId) => void; toolMode?: boolean; isSupported?: boolean; } @@ -64,29 +60,8 @@ const FileEditorThumbnail = ({ }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; - const downloadSelectedFile = useCallback(() => { - // Prefer parent-provided handler if available - if (typeof onDownloadFile === 'function') { - onDownloadFile(file.id); - return; - } + const pageCount = file.processedFile?.totalPages || 0; - // Fallback: attempt to download using the File object if provided - const maybeFile = (file as unknown as { file?: File }).file; - if (maybeFile instanceof File) { - const link = document.createElement('a'); - link.href = URL.createObjectURL(maybeFile); - link.download = maybeFile.name || file.name || 'download'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(link.href); - return; - } - - // If we can't find a way to download, surface a status message - onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item'); - }, [file, onDownloadFile, onSetStatus, t]); const handleRef = useRef(null); // ---- Selection ---- @@ -94,12 +69,7 @@ const FileEditorThumbnail = ({ // ---- Meta formatting ---- const prettySize = useMemo(() => { - const bytes = file.size ?? 0; - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + return formatFileSize(file.size); }, [file.size]); const extUpper = useMemo(() => { @@ -109,22 +79,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) => { @@ -309,7 +278,7 @@ const FileEditorThumbnail = ({