mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-24 04:26:14 +00:00
Compare commits
9 Commits
47a16e71cf
...
65e3141760
Author | SHA1 | Date | |
---|---|---|---|
![]() |
65e3141760 | ||
![]() |
4e5789e8f4 | ||
![]() |
3b3a2df392 | ||
![]() |
055d3acc82 | ||
![]() |
aaa0eb4e0f | ||
![]() |
dc9acdd7cc | ||
![]() |
3cea686acd | ||
![]() |
f88c3e25d1 | ||
![]() |
f8bdeabe35 |
@ -2,275 +2,318 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Stirling PDF implements a comprehensive file history tracking system that embeds metadata directly into PDF documents using the PDF keywords field. This system tracks tool operations, version progression, and file lineage through the processing pipeline.
|
||||
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.
|
||||
|
||||
## PDF Metadata Format
|
||||
## Storage Architecture
|
||||
|
||||
### Storage Mechanism
|
||||
File history is stored in the PDF **Keywords** field as a JSON string with the prefix `stirling-history:`.
|
||||
### 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
|
||||
|
||||
### Metadata Structure
|
||||
### Core Data Structures
|
||||
|
||||
```typescript
|
||||
interface PDFHistoryMetadata {
|
||||
stirlingHistory: {
|
||||
originalFileId: string; // UUID of the root file in the version chain
|
||||
parentFileId?: string; // UUID of the immediate parent file
|
||||
versionNumber: number; // Version number (1, 2, 3, etc.)
|
||||
toolChain: ToolOperation[]; // Array of applied tool operations
|
||||
formatVersion: '1.0'; // Metadata format version
|
||||
};
|
||||
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
|
||||
parameters?: Record<string, any>; // Tool-specific parameters (optional)
|
||||
}
|
||||
|
||||
interface StoredStirlingFileRecord extends StirlingFileStub {
|
||||
data: ArrayBuffer; // Actual file content
|
||||
fileId: FileId; // Duplicate for indexing
|
||||
}
|
||||
```
|
||||
|
||||
### Standard PDF Metadata Fields Used
|
||||
The system uses industry-standard PDF document information fields:
|
||||
- **Creator**: Set to "Stirling-PDF" (identifies the application)
|
||||
- **Producer**: Set to "Stirling-PDF" (identifies the PDF library/processor)
|
||||
- **Title, Author, Subject, CreationDate**: Automatically preserved by pdf-lib during processing
|
||||
- **Keywords**: Enhanced with Stirling history data while preserving user keywords
|
||||
|
||||
**Date Handling Strategy**:
|
||||
- **PDF CreationDate**: Preserved automatically (document creation date)
|
||||
- **File.lastModified**: Source of truth for "when file was last changed" (original upload time or tool processing time)
|
||||
- **No duplication**: Single timestamp approach using File.lastModified for all UI displays
|
||||
|
||||
### Example PDF Document Information
|
||||
```
|
||||
PDF Document Info:
|
||||
Title: "User Document Title" (preserved from original)
|
||||
Author: "Document Author" (preserved from original)
|
||||
Creator: "Stirling-PDF"
|
||||
Producer: "Stirling-PDF"
|
||||
CreationDate: "2025-01-01T10:30:00Z" (preserved from original)
|
||||
Keywords: ["user-keyword", "stirling-history:{\"stirlingHistory\":{\"originalFileId\":\"abc123\",\"versionNumber\":2,\"toolChain\":[{\"toolName\":\"compress\",\"timestamp\":1756825614618},{\"toolName\":\"sanitize\",\"timestamp\":1756825631545}],\"formatVersion\":\"1.0\"}}"]
|
||||
|
||||
File System:
|
||||
lastModified: 1756825631545 (tool processing time - source of truth for "when file was last changed")
|
||||
```
|
||||
|
||||
## Version Numbering System
|
||||
## Version Management System
|
||||
|
||||
### Version Progression
|
||||
- **v0**: Original uploaded file (no Stirling PDF processing)
|
||||
- **v1**: First tool applied to original file
|
||||
- **v2**: Second tool applied (inherits from v1)
|
||||
- **v3**: Third tool applied (inherits from v2)
|
||||
- **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.**
|
||||
|
||||
### Version Relationships
|
||||
### 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 (v0)
|
||||
document.pdf (v1, isLeaf: false)
|
||||
↓ compress
|
||||
document.pdf (v1: compress)
|
||||
document.pdf (v2, isLeaf: false)
|
||||
↓ sanitize
|
||||
document.pdf (v2: compress → sanitize)
|
||||
↓ ocr
|
||||
document.pdf (v3: compress → sanitize → ocr)
|
||||
document.pdf (v3, isLeaf: true) ← Current active version
|
||||
```
|
||||
|
||||
## File Lineage Tracking
|
||||
|
||||
### Original File ID
|
||||
The `originalFileId` remains constant throughout the entire version chain, enabling grouping of all versions of the same logical document.
|
||||
|
||||
### Parent-Child Relationships
|
||||
Each processed file references its immediate parent via `parentFileId`, creating a complete audit trail.
|
||||
|
||||
### Tool Chain
|
||||
The `toolChain` array maintains the complete sequence of tool operations applied to reach the current version.
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### Frontend Components
|
||||
### 1. FileStorage Service (`fileStorage.ts`)
|
||||
|
||||
#### 1. PDF Metadata Service (`pdfMetadataService.ts`)
|
||||
- **PDF-lib Integration**: Uses pdf-lib for metadata injection/extraction
|
||||
- **Caching**: ContentCache with 10-minute TTL for performance
|
||||
- **Encryption Support**: Handles encrypted PDFs with `ignoreEncryption: true`
|
||||
|
||||
**Key Methods:**
|
||||
**Core Methods:**
|
||||
```typescript
|
||||
// Inject metadata into PDF
|
||||
injectHistoryMetadata(pdfBytes: ArrayBuffer, originalFileId: string, parentFileId?: string, toolChain: ToolOperation[], versionNumber: number): Promise<ArrayBuffer>
|
||||
// Store file with complete metadata
|
||||
async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): Promise<void>
|
||||
|
||||
// Extract metadata from PDF
|
||||
extractHistoryMetadata(pdfBytes: ArrayBuffer): Promise<PDFHistoryMetadata | null>
|
||||
// Load file with metadata
|
||||
async getStirlingFile(id: FileId): Promise<StirlingFile | null>
|
||||
async getStirlingFileStub(id: FileId): Promise<StirlingFileStub | null>
|
||||
|
||||
// Create new version with incremented number
|
||||
createNewVersion(pdfBytes: ArrayBuffer, parentFileId: string, toolOperation: ToolOperation): Promise<ArrayBuffer>
|
||||
// Query operations
|
||||
async getLeafStirlingFileStubs(): Promise<StirlingFileStub[]>
|
||||
async getAllStirlingFileStubs(): Promise<StirlingFileStub[]>
|
||||
|
||||
// Version management
|
||||
async markFileAsProcessed(fileId: FileId): Promise<boolean> // Set isLeaf = false
|
||||
async markFileAsLeaf(fileId: FileId): Promise<boolean> // Set isLeaf = true
|
||||
```
|
||||
|
||||
#### 2. File History Utilities (`fileHistoryUtils.ts`)
|
||||
- **FileContext Integration**: Links PDF metadata with React state management
|
||||
- **Version Management**: Handles version grouping and latest version filtering
|
||||
- **Tool Integration**: Prepares files for tool processing with history injection
|
||||
### 2. File Context Integration
|
||||
|
||||
**Key Functions:**
|
||||
**FileContext** manages runtime state with `StirlingFileStub[]` in memory:
|
||||
```typescript
|
||||
// Extract history from File and update FileRecord
|
||||
extractFileHistory(file: File, record: FileRecord): Promise<FileRecord>
|
||||
|
||||
// Inject history before tool processing
|
||||
injectHistoryForTool(file: File, sourceFileRecord: FileRecord, toolName: string, parameters?): Promise<File>
|
||||
|
||||
// Group files by original ID for version management
|
||||
groupFilesByOriginal(fileRecords: FileRecord[]): Map<string, FileRecord[]>
|
||||
|
||||
// Get only latest version of each file group
|
||||
getLatestVersions(fileRecords: FileRecord[]): FileRecord[]
|
||||
interface FileContextState {
|
||||
files: {
|
||||
ids: FileId[];
|
||||
byId: Record<FileId, StirlingFileStub>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Tool Operation Integration (`useToolOperation.ts`)
|
||||
- **Automatic Injection**: All tool operations automatically inject history metadata
|
||||
- **Version Progression**: Reads current version from PDF and increments appropriately
|
||||
- **Universal Support**: Works with single-file, multi-file, and custom tool patterns
|
||||
**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
|
||||
|
||||
### Data Flow
|
||||
### 3. Tool Operation Integration
|
||||
|
||||
```
|
||||
1. User uploads PDF → No history (v0)
|
||||
2. Tool processing begins → prepareFilesWithHistory() injects current state
|
||||
3. Backend processes PDF → Returns processed file with embedded history
|
||||
4. FileContext adds result → extractFileHistory() reads embedded metadata
|
||||
5. UI displays file → Shows version badges and tool chain
|
||||
**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
|
||||
- **Version Toggle**: Switch between "Latest Only" and "All Versions" views
|
||||
- **Version Badges**: v0, v1, v2 indicators on file items
|
||||
- **History Dropdown**: Version timeline with restore functionality
|
||||
- **Tool Chain Display**: Complete processing history in file details panel
|
||||
### File Manager History Display
|
||||
|
||||
### Active Files Workbench
|
||||
- **Version Metadata**: Version number in file metadata line (e.g., "PDF file - 3 Pages - v2")
|
||||
- **Tool Chain Overlay**: Bottom overlay showing tool sequence (e.g., "compress → sanitize")
|
||||
- **Real-time Updates**: Immediate display after tool processing
|
||||
**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`
|
||||
|
||||
## Storage and Persistence
|
||||
**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
|
||||
|
||||
### PDF Metadata
|
||||
- **Embedded in PDF**: History travels with the document across downloads/uploads
|
||||
- **Keywords Field**: Uses standard PDF metadata field for maximum compatibility
|
||||
- **Multiple Keywords**: System handles multiple history entries and extracts latest version
|
||||
### FileManagerContext Integration
|
||||
|
||||
### IndexedDB Storage
|
||||
- **Client-side Persistence**: FileMetadata includes extracted history information
|
||||
- **Lazy Loading**: History extracted when files are accessed from storage
|
||||
- **Batch Processing**: Large collections processed in batches of 5 to prevent memory issues
|
||||
|
||||
### Memory Management
|
||||
- **ContentCache**: 10-minute TTL, 50-file capacity for metadata extraction results
|
||||
- **Cleanup**: Automatic cache eviction and expired entry removal
|
||||
- **Large File Support**: No artificial size limits (supports 100GB+ PDFs)
|
||||
|
||||
## Tool Configuration
|
||||
|
||||
### Filename Preservation
|
||||
Most tools preserve the original filename to maintain file identity:
|
||||
|
||||
**No Prefix (Filename Preserved):**
|
||||
- compress, repair, sanitize, addPassword, removePassword, changePermissions, removeCertificateSign, unlockPdfForms, ocr, addWatermark
|
||||
|
||||
**With Prefix (Different Content):**
|
||||
- split (`split_` - creates multiple files)
|
||||
- convert (`converted_` - changes file format)
|
||||
|
||||
### Configuration Pattern
|
||||
**File Selection Flow:**
|
||||
```typescript
|
||||
export const toolOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
operationType: 'toolName',
|
||||
endpoint: '/api/v1/category/tool-endpoint',
|
||||
filePrefix: '', // Empty for filename preservation
|
||||
buildFormData: buildToolFormData,
|
||||
defaultParameters
|
||||
// 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
|
||||
};
|
||||
```
|
||||
|
||||
### Metadata Preservation Strategy
|
||||
The system uses a **minimal touch approach** for PDF metadata:
|
||||
## Data Flow
|
||||
|
||||
```typescript
|
||||
// Only modify necessary fields, let pdf-lib preserve everything else
|
||||
pdfDoc.setCreator('Stirling-PDF');
|
||||
pdfDoc.setProducer('Stirling-PDF');
|
||||
pdfDoc.setKeywords([...existingKeywords, historyKeyword]);
|
||||
|
||||
// File.lastModified = Date.now() for processed files (source of truth)
|
||||
// PDF internal dates (CreationDate, etc.) preserved automatically by pdf-lib
|
||||
### 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
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Automatic Preservation**: pdf-lib preserves Title, Author, Subject, CreationDate without explicit re-setting
|
||||
- **No Duplication**: File.lastModified is single source of truth for "when file changed"
|
||||
- **Simpler Code**: Minimal metadata operations reduce complexity and bugs
|
||||
- **Better Performance**: Fewer PDF reads/writes during processing
|
||||
### 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
|
||||
```
|
||||
|
||||
## Error Handling and Resilience
|
||||
### 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
|
||||
- **Extraction Failures**: Files display normally without history if metadata extraction fails
|
||||
- **Encrypted PDFs**: System handles encrypted documents with `ignoreEncryption` option
|
||||
- **Corrupted Metadata**: Invalid history metadata is silently ignored with fallback to basic file info
|
||||
- **Storage Failures**: Files continue to work without persistence
|
||||
- **Metadata Issues**: Missing metadata regenerated on demand
|
||||
- **Version Conflicts**: Automatic version number resolution
|
||||
|
||||
### Performance Considerations
|
||||
- **Caching**: Metadata extraction results are cached to avoid re-parsing
|
||||
- **Batch Processing**: Large file collections processed in controlled batches
|
||||
- **Async Extraction**: History extraction doesn't block file operations
|
||||
### Recovery Scenarios
|
||||
- **Corrupted Storage**: Automatic cleanup and re-initialization
|
||||
- **Missing Files**: Stubs cleaned up automatically
|
||||
- **Version Mismatches**: Automatic version chain reconstruction
|
||||
|
||||
## Developer Guidelines
|
||||
|
||||
### Adding History to New Tools
|
||||
1. **Set `filePrefix: ''`** in tool configuration to preserve filenames
|
||||
2. **Use existing patterns**: Tool operations automatically inherit history injection
|
||||
3. **Custom processors**: Must handle history injection manually if using custom response handlers
|
||||
### 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 a PDF**: Should show no version (v0), original File.lastModified preserved
|
||||
2. **Apply any tool**: Should show v1 with tool name, File.lastModified updated to processing time
|
||||
3. **Apply another tool**: Should show v2 with tool chain sequence
|
||||
4. **Check file manager**: Version toggle, history dropdown, standard PDF metadata should all work
|
||||
5. **Check workbench**: Tool chain overlay should appear on thumbnails
|
||||
|
||||
### Backend Tool Monitoring
|
||||
The system automatically logs metadata preservation:
|
||||
- **Success**: `✅ METADATA PRESERVED: Tool 'ocr' correctly preserved all PDF metadata`
|
||||
- **Issues**: `⚠️ METADATA LOSS: Tool 'compress' did not preserve PDF metadata: CreationDate modified, Author stripped`
|
||||
|
||||
This helps identify which backend tools need to be updated to preserve standard PDF metadata fields.
|
||||
|
||||
### Debugging
|
||||
Enable development mode logging to see:
|
||||
- History injection: `📄 Injected PDF history metadata`
|
||||
- History extraction: `📄 History extraction completed`
|
||||
- Version progression: Version number increments and tool chain updates
|
||||
- Metadata issues: Warnings for tools that strip PDF metadata
|
||||
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
|
||||
|
||||
### Possible Extensions
|
||||
- **Branching**: Support for parallel processing branches from same source
|
||||
- **Diff Tracking**: Track specific changes made by each tool
|
||||
- **User Attribution**: Add user information to tool operations
|
||||
- **Timestamp Precision**: Enhanced timestamp tracking for audit trails
|
||||
- **Export Options**: Export complete processing history as JSON/XML
|
||||
### 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
|
||||
|
||||
### Compatibility
|
||||
- **PDF Standard Compliance**: Uses standard PDF Keywords field for broad compatibility
|
||||
- **Backwards Compatibility**: PDFs without history metadata work normally
|
||||
- **Future Versions**: Format version field enables future metadata schema evolution
|
||||
### 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
|
||||
**Format Version**: 1.0
|
||||
**Implementation**: Stirling PDF Frontend v2
|
||||
**Storage Version**: IndexedDB with fileStorage service
|
@ -2146,6 +2146,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": "FileHistory",
|
||||
"loadingHistory": "Loading History...",
|
||||
"lastModified": "Last Modified",
|
||||
"toolChain": "Tools Applied",
|
||||
"restore": "Restore",
|
||||
"searchFiles": "Search files...",
|
||||
"recent": "Recent",
|
||||
"localFiles": "Local Files",
|
||||
|
@ -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<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
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<FileManagerProps> = ({ 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<FileManagerProps> = ({ 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<FileManagerProps> = ({ selectedTool }) => {
|
||||
>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onRecentFilesSelected={handleRecentFilesSelected}
|
||||
onNewFilesSelect={handleNewFileUpload}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
|
@ -69,7 +69,7 @@ const FileEditorThumbnail = ({
|
||||
const fileRecord = selectors.getStirlingFileStub(file.id);
|
||||
const toolHistory = fileRecord?.toolHistory || [];
|
||||
const hasToolHistory = toolHistory.length > 0;
|
||||
const versionNumber = fileRecord?.versionNumber || 0;
|
||||
const versionNumber = fileRecord?.versionNumber || 1;
|
||||
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
|
@ -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,7 +72,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ''}
|
||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||
{currentFile && ` • v${currentFile.versionNumber || 0}`}
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
{hasMultipleFiles && (
|
||||
<Text size="xs" c="blue">
|
||||
@ -80,9 +80,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
</Text>
|
||||
)}
|
||||
{/* Compact tool chain for mobile */}
|
||||
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && (
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.historyInfo.toolChain.map(tool => tool.toolName).join(' → ')}
|
||||
{currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
68
frontend/src/components/fileManager/FileHistoryGroup.tsx
Normal file
68
frontend/src/components/fileManager/FileHistoryGroup.tsx
Normal file
@ -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;
|
||||
onFileRemove: (index: number) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
leafFile,
|
||||
historyFiles,
|
||||
isExpanded,
|
||||
onDownloadSingle,
|
||||
onFileDoubleClick,
|
||||
onFileRemove,
|
||||
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 (
|
||||
<Collapse in={isExpanded}>
|
||||
<Box ml="md" mt="xs" mb="sm">
|
||||
<Group align="center" mb="sm">
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
{t('fileManager.fileHistory', 'File History')} ({sortedHistory.length})
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box ml="md">
|
||||
{sortedHistory.map((historyFile, index) => (
|
||||
<FileListItem
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
isSelected={false} // History files are not selectable
|
||||
isSupported={isFileSupported(historyFile.name)}
|
||||
onSelect={() => {}} // No selection for history files
|
||||
onRemove={() => onFileRemove(index)} // Pass through remove handler
|
||||
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
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileHistoryGroup;
|
@ -2,11 +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;
|
||||
}
|
||||
|
||||
@ -114,19 +114,19 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||
{currentFile &&
|
||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
|
||||
v{currentFile ? (currentFile.versionNumber || 0) : ''}
|
||||
v{currentFile ? (currentFile.versionNumber || 1) : ''}
|
||||
</Badge>}
|
||||
|
||||
</Group>
|
||||
|
||||
{/* Tool Chain Display */}
|
||||
{currentFile?.historyInfo?.toolChain && currentFile.historyInfo.toolChain.length > 0 && (
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box py="xs">
|
||||
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
|
||||
<ToolChain
|
||||
toolChain={currentFile.historyInfo.toolChain}
|
||||
toolChain={currentFile.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
maxWidth={'180px'}
|
||||
|
@ -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,8 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFilesSet,
|
||||
fileGroups,
|
||||
expandedFileIds,
|
||||
loadedHistoryFiles,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
@ -53,14 +54,13 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
// Determine if this is a history file based on whether it's in the recent files or loaded as history
|
||||
const isLeafFile = recentFiles.some(rf => rf.id === file.id);
|
||||
const isHistoryFile = !isLeafFile; // If not in recent files, it's a loaded history file
|
||||
const isLatestVersion = isLeafFile; // Only leaf files (from recent files) are latest versions
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
const isExpanded = expandedFileIds.has(file.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={file.id}>
|
||||
<FileListItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
@ -68,9 +68,20 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
isHistoryFile={isHistoryFile}
|
||||
isLatestVersion={isLatestVersion}
|
||||
isHistoryFile={false} // All files here are leaf files
|
||||
isLatestVersion={true} // All files here are the latest versions
|
||||
/>
|
||||
|
||||
<FileHistoryGroup
|
||||
leafFile={file}
|
||||
historyFiles={historyFiles}
|
||||
isExpanded={isExpanded}
|
||||
onDownloadSingle={onDownloadSingle}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
onFileRemove={onFileRemove}
|
||||
isFileSupported={isFileSupported}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Button, Loader } from '@mantine/core';
|
||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Loader } from '@mantine/core';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
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 { 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;
|
||||
@ -38,29 +39,26 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { fileGroups, expandedFileIds, onToggleExpansion, onAddToRecents, isLoadingHistory, getHistoryError } = useFileManagerContext();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
// Get version information for this file
|
||||
const leafFileId = isLatestVersion ? file.id : (file.originalFileId || file.id);
|
||||
const lineagePath = fileGroups.get(leafFileId) || [];
|
||||
const hasVersionHistory = (file.versionNumber || 0) > 0; // Show history for any processed file (v1+)
|
||||
const currentVersion = file.versionNumber || 0; // Display original files as v0
|
||||
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);
|
||||
|
||||
// Get loading state for this file's history
|
||||
const isLoadingFileHistory = isLoadingHistory(file.id);
|
||||
const historyError = getHistoryError(file.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-gray-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
cursor: isHistoryFile ? 'default' : 'pointer',
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-gray-1)'
|
||||
: (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: 'background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
@ -70,14 +68,15 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
paddingLeft: isHistoryFile ? '2rem' : '0.75rem', // Indent history files
|
||||
borderLeft: isHistoryFile ? '3px solid var(--mantine-color-blue-4)' : 'none' // Visual indicator for history
|
||||
}}
|
||||
onClick={(e) => onSelect(e.shiftKey)}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{!isHistoryFile && (
|
||||
<Box>
|
||||
{/* Checkbox for all files */}
|
||||
{/* Checkbox for regular files only */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
@ -91,12 +90,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
{isLoadingFileHistory && <Loader size={14} />}
|
||||
<Badge size="xs" variant="light" color={currentVersion > 0 ? "blue" : "gray"}>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
@ -104,15 +103,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{getFileSize(file)} • {getFileDate(file)}
|
||||
{hasVersionHistory && (
|
||||
<Text span c="dimmed"> • has history</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Tool chain for processed files */}
|
||||
{file.historyInfo?.toolChain && file.historyInfo.toolChain.length > 0 && (
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.historyInfo.toolChain}
|
||||
toolChain={file.toolHistory}
|
||||
maxWidth={'150px'}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
@ -163,44 +159,35 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
isLoadingFileHistory ?
|
||||
<Loader size={16} /> :
|
||||
<HistoryIcon style={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpansion(leafFileId);
|
||||
}}
|
||||
disabled={isLoadingFileHistory}
|
||||
>
|
||||
{isLoadingFileHistory ?
|
||||
t('fileManager.loadingHistory', 'Loading History...') :
|
||||
{
|
||||
(isExpanded ?
|
||||
t('fileManager.hideHistory', 'Hide History') :
|
||||
t('fileManager.showHistory', 'Show History')
|
||||
)
|
||||
}
|
||||
</Menu.Item>
|
||||
{historyError && (
|
||||
<Menu.Item disabled c="red" style={{ fontSize: '12px' }}>
|
||||
{t('fileManager.historyError', 'Error loading history')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add to Recents option for history files */}
|
||||
{/* Restore option for history files */}
|
||||
{isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<AddIcon style={{ fontSize: 16 }} />}
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToRecents(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.addToRecents', 'Add to Recents')}
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
|
@ -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");
|
||||
}
|
||||
})}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 = '';
|
||||
|
@ -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;
|
||||
|
@ -17,7 +17,7 @@ const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const { openFilesModal, onFileUpload } = useFilesModalContext();
|
||||
const { files: stirlingFileStubs } = useAllFiles();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
@ -44,7 +44,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();
|
||||
|
@ -372,11 +372,12 @@ const Viewer = ({
|
||||
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||||
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
|
||||
|
||||
// Get data directly from IndexedDB
|
||||
const arrayBuffer = await fileStorage.getFileData(fileId);
|
||||
if (!arrayBuffer) {
|
||||
// Get file directly from IndexedDB
|
||||
const file = await fileStorage.getStirlingFile(fileId);
|
||||
if (!file) {
|
||||
throw new Error('File not found in IndexedDB - may have been purged by browser');
|
||||
}
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Store reference for cleanup
|
||||
currentArrayBufferRef.current = arrayBuffer;
|
||||
|
@ -22,13 +22,12 @@ import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
||||
import { createFileSelectors } from './file/fileSelectors';
|
||||
import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { FileLifecycleManager } from './file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||
@ -73,58 +72,44 @@ function FileContextInner({
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []);
|
||||
|
||||
const selectFiles = (addedFilesWithIds: AddedFile[]) => {
|
||||
const selectFiles = (stirlingFiles: StirlingFile[]) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const newFileIds = addedFilesWithIds.map(({ id }) => id);
|
||||
const newFileIds = stirlingFiles.map(stirlingFile => stirlingFile.fileId);
|
||||
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
|
||||
}
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
const stirlingFiles = await addFiles({ files, ...options }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && addedFilesWithIds.length > 0) {
|
||||
selectFiles(addedFilesWithIds);
|
||||
if (options?.selectFiles && stirlingFiles.length > 0) {
|
||||
selectFiles(stirlingFiles);
|
||||
}
|
||||
|
||||
// Persist to IndexedDB if enabled
|
||||
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
|
||||
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, id, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
return stirlingFiles;
|
||||
}, [enablePersistence]);
|
||||
|
||||
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, [indexedDB, enablePersistence]);
|
||||
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
|
||||
const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && result.length > 0) {
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
|
||||
// Action creators
|
||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
|
||||
}, []);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
@ -143,8 +128,7 @@ function FileContextInner({
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
...baseActions,
|
||||
addFiles: addRawFiles,
|
||||
addProcessedFiles,
|
||||
addStoredFiles,
|
||||
addStirlingFileStubs: addStirlingFileStubsAction,
|
||||
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||
// Remove from memory and cleanup resources
|
||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||
@ -199,8 +183,7 @@ function FileContextInner({
|
||||
}), [
|
||||
baseActions,
|
||||
addRawFiles,
|
||||
addProcessedFiles,
|
||||
addStoredFiles,
|
||||
addStirlingFileStubsAction,
|
||||
lifecycleManager,
|
||||
setHasUnsavedChanges,
|
||||
consumeFilesWrapper,
|
||||
|
@ -1,10 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
import { FileId } from '../types/file';
|
||||
import { getLatestVersions, groupFilesByOriginal, getVersionHistory, createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
|
||||
import { useMultiFileHistory } from '../hooks/useFileHistory';
|
||||
import { groupFilesByOriginal } from '../utils/fileHistoryUtils';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
@ -12,36 +11,33 @@ interface FileManagerContextValue {
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: FileId[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileMetadata[];
|
||||
filteredFiles: FileMetadata[];
|
||||
selectedFiles: StirlingFileStub[];
|
||||
filteredFiles: StirlingFileStub[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
expandedFileIds: Set<string>;
|
||||
fileGroups: Map<string, FileMetadata[]>;
|
||||
|
||||
// History loading state
|
||||
isLoadingHistory: (fileId: FileId) => boolean;
|
||||
getHistoryError: (fileId: FileId) => string | null;
|
||||
fileGroups: Map<string, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
|
||||
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileMetadata) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: FileMetadata) => void;
|
||||
onToggleExpansion: (fileId: string) => void;
|
||||
onAddToRecents: (file: FileMetadata) => void;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onToggleExpansion: (fileId: FileId) => void;
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileMetadata[];
|
||||
recentFiles: StirlingFileStub[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
@ -52,8 +48,8 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: FileMetadata[];
|
||||
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files
|
||||
recentFiles: StirlingFileStub[];
|
||||
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
|
||||
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
||||
onClose: () => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
@ -66,7 +62,7 @@ interface FileManagerProviderProps {
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onRecentFilesSelected,
|
||||
onNewFilesSelect,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
@ -80,19 +76,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [expandedFileIds, setExpandedFileIds] = useState<Set<string>>(new Set());
|
||||
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, FileMetadata[]>>(new Map()); // Cache for loaded history
|
||||
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
// History loading hook
|
||||
const {
|
||||
loadFileHistory,
|
||||
getHistory,
|
||||
isLoadingHistory,
|
||||
getError: getHistoryError
|
||||
} = useMultiFileHistory();
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
@ -101,11 +90,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const fileGroups = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return new Map();
|
||||
|
||||
// Convert FileMetadata to FileRecord-like objects for grouping utility
|
||||
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
|
||||
const recordsForGrouping = recentFiles.map(file => ({
|
||||
...file,
|
||||
originalFileId: file.originalFileId,
|
||||
versionNumber: file.versionNumber || 0
|
||||
versionNumber: file.versionNumber || 1
|
||||
}));
|
||||
|
||||
return groupFilesByOriginal(recordsForGrouping);
|
||||
@ -115,24 +104,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return [];
|
||||
|
||||
const expandedFiles = [];
|
||||
|
||||
// Since we now only load leaf files, iterate through recent files directly
|
||||
for (const leafFile of recentFiles) {
|
||||
// Add the leaf file (main file shown in list)
|
||||
expandedFiles.push(leafFile);
|
||||
|
||||
// If expanded, add the loaded history files
|
||||
if (expandedFileIds.has(leafFile.id)) {
|
||||
const historyFiles = loadedHistoryFiles.get(leafFile.id) || [];
|
||||
// Sort history files by version number (oldest first)
|
||||
const sortedHistory = historyFiles.sort((a, b) => (a.versionNumber || 0) - (b.versionNumber || 0));
|
||||
expandedFiles.push(...sortedHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedFiles;
|
||||
}, [recentFiles, expandedFileIds, loadedHistoryFiles]);
|
||||
// Only return leaf files - history files will be handled by separate components
|
||||
return recentFiles;
|
||||
}, [recentFiles]);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
||||
@ -155,7 +129,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
@ -196,33 +170,99 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [filteredFiles, lastClickedIndex]);
|
||||
|
||||
// Helper function to safely determine which files can be deleted
|
||||
const getSafeFilesToDelete = useCallback((
|
||||
leafFileIds: string[],
|
||||
allStoredStubs: StirlingFileStub[]
|
||||
): string[] => {
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id as string, f]));
|
||||
const filesToDelete = new Set<string>();
|
||||
const filesToPreserve = new Set<string>();
|
||||
|
||||
// First, identify all files in the lineages of the leaf files being deleted
|
||||
for (const leafFileId of leafFileIds) {
|
||||
const currentFile = fileMap.get(leafFileId);
|
||||
if (!currentFile) continue;
|
||||
|
||||
// Always include the leaf file itself for deletion
|
||||
filesToDelete.add(leafFileId);
|
||||
|
||||
// If this is a processed file with history, trace back through its lineage
|
||||
if (currentFile.versionNumber && currentFile.versionNumber > 1) {
|
||||
const originalFileId = currentFile.originalFileId || currentFile.id;
|
||||
|
||||
// Find all files in this history chain
|
||||
const chainFiles = allStoredStubs.filter((file: StirlingFileStub) =>
|
||||
(file.originalFileId || file.id) === originalFileId
|
||||
);
|
||||
|
||||
// Add all files in this lineage as candidates for deletion
|
||||
chainFiles.forEach(file => filesToDelete.add(file.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Now identify files that must be preserved because they're referenced by OTHER lineages
|
||||
for (const file of allStoredStubs) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
|
||||
if (file.isLeaf !== false && !leafFileIds.includes(file.id)) {
|
||||
// Find all files in this preserved lineage
|
||||
const preservedChainFiles = allStoredStubs.filter((chainFile: StirlingFileStub) =>
|
||||
(chainFile.originalFileId || chainFile.id) === fileOriginalId
|
||||
);
|
||||
|
||||
// Mark all files in this preserved lineage as must-preserve
|
||||
preservedChainFiles.forEach(chainFile => filesToPreserve.add(chainFile.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Final list: files to delete minus files that must be preserved
|
||||
const safeToDelete = Array.from(filesToDelete).filter(fileId => !filesToPreserve.has(fileId));
|
||||
|
||||
console.log('Deletion analysis:', {
|
||||
candidatesForDeletion: Array.from(filesToDelete),
|
||||
mustPreserve: Array.from(filesToPreserve),
|
||||
safeToDelete
|
||||
});
|
||||
|
||||
return safeToDelete;
|
||||
}, []);
|
||||
|
||||
const handleFileRemove = useCallback(async (index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
if (fileToRemove) {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Get all stored files to analyze lineages
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// Get safe files to delete (respecting shared lineages)
|
||||
const filesToDelete = getSafeFilesToDelete([deletedFileId as string], allStoredStubs);
|
||||
|
||||
console.log(`Safely deleting files for ${fileToRemove.name}:`, filesToDelete);
|
||||
|
||||
// Clear from selection immediately
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== deletedFileId));
|
||||
setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id)));
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
newExpanded.delete(deletedFileId);
|
||||
filesToDelete.forEach(id => newExpanded.delete(id));
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - need to remove this file from any cached history
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// If the deleted file was a main file with cached history, remove its cache
|
||||
newCache.delete(deletedFileId);
|
||||
// Remove cache entries for all deleted files
|
||||
filesToDelete.forEach(id => newCache.delete(id as FileId));
|
||||
|
||||
// Also remove the deleted file from any other file's history cache
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => histFile.id !== deletedFileId);
|
||||
const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id as string));
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
// The deleted file was in this history, update the cache
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
@ -230,27 +270,36 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Call the parent's deletion logic
|
||||
await onFileRemove(index);
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
for (const fileId of filesToDelete) {
|
||||
await fileStorage.deleteStirlingFile(fileId as FileId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Call the parent's deletion logic for the main file only
|
||||
onFileRemove(index);
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}
|
||||
}, [filteredFiles, onFileRemove, refreshRecentFiles]);
|
||||
}, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||
const handleFileDoubleClick = useCallback((file: StirlingFileStub) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onFilesSelected([file]);
|
||||
onRecentFilesSelected([file]);
|
||||
onClose();
|
||||
}
|
||||
}, [isFileSupported, onFilesSelected, onClose]);
|
||||
}, [isFileSupported, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
onFilesSelected(selectedFiles);
|
||||
onRecentFilesSelected(selectedFiles);
|
||||
onClose();
|
||||
}
|
||||
}, [selectedFiles, onFilesSelected, onClose]);
|
||||
}, [selectedFiles, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
@ -288,40 +337,28 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Use the same logic as individual file deletion for consistency
|
||||
// Delete each selected file individually using the same cache update logic
|
||||
const allFilesToDelete = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
// Get all stored files to analyze lineages
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// Deduplicate by file ID since shared files can appear multiple times in the display
|
||||
const uniqueFilesToDelete = allFilesToDelete.reduce((unique: typeof allFilesToDelete, file) => {
|
||||
if (!unique.some(f => f.id === file.id)) {
|
||||
unique.push(file);
|
||||
}
|
||||
return unique;
|
||||
}, []);
|
||||
// Get safe files to delete (respecting shared lineages)
|
||||
const filesToDelete = getSafeFilesToDelete(selectedFileIds, allStoredStubs);
|
||||
|
||||
const filesToDelete = uniqueFilesToDelete;
|
||||
const deletedFileIds = new Set(filesToDelete.map(f => f.id));
|
||||
console.log(`Bulk safely deleting files and their history chains:`, filesToDelete);
|
||||
|
||||
// Update history cache synchronously
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
for (const fileToDelete of filesToDelete) {
|
||||
// If the deleted file was a main file with cached history, remove its cache
|
||||
newCache.delete(fileToDelete.id);
|
||||
// Remove cache entries for all deleted files
|
||||
filesToDelete.forEach(id => newCache.delete(id as FileId));
|
||||
|
||||
// Also remove the deleted file from any other file's history cache
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => histFile.id !== fileToDelete.id);
|
||||
const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id as string));
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
// The deleted file was in this history, update the cache
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
@ -329,18 +366,16 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
// Also clear any expanded state for deleted files to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
for (const deletedId of deletedFileIds) {
|
||||
newExpanded.delete(deletedId);
|
||||
}
|
||||
filesToDelete.forEach(id => newExpanded.delete(id));
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear selection immediately to prevent ghost selections
|
||||
setSelectedFileIds(prev => prev.filter(id => !deletedFileIds.has(id)));
|
||||
setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id)));
|
||||
|
||||
// Delete files from IndexedDB
|
||||
for (const file of filesToDelete) {
|
||||
await fileStorage.deleteFile(file.id);
|
||||
// Delete safe files from IndexedDB
|
||||
for (const fileId of filesToDelete) {
|
||||
await fileStorage.deleteStirlingFile(fileId as FileId);
|
||||
}
|
||||
|
||||
// Refresh the file list to get updated data
|
||||
@ -348,7 +383,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
} catch (error) {
|
||||
console.error('Failed to delete selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
|
||||
}, [selectedFileIds, filteredFiles, refreshRecentFiles, getSafeFilesToDelete]);
|
||||
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
@ -369,7 +404,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles]);
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
|
||||
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
@ -377,7 +412,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleExpansion = useCallback(async (fileId: string) => {
|
||||
const handleToggleExpansion = useCallback(async (fileId: FileId) => {
|
||||
const isCurrentlyExpanded = expandedFileIds.has(fileId);
|
||||
|
||||
// Update expansion state
|
||||
@ -394,107 +429,52 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
// Load complete history chain if expanding
|
||||
if (!isCurrentlyExpanded) {
|
||||
const currentFileMetadata = recentFiles.find(f => f.id === fileId);
|
||||
if (currentFileMetadata && (currentFileMetadata.versionNumber || 0) > 0) {
|
||||
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
|
||||
try {
|
||||
// Load the current file to get its full history
|
||||
const storedFile = await fileStorage.getFile(fileId as FileId);
|
||||
if (storedFile) {
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
// Get all stored file metadata for chain traversal
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
|
||||
// Get the complete history metadata (this will give us original/parent IDs)
|
||||
const historyData = await loadFileHistory(file, fileId as FileId);
|
||||
|
||||
if (historyData?.originalFileId) {
|
||||
// Load complete history chain by traversing parent relationships
|
||||
const historyFiles: FileMetadata[] = [];
|
||||
|
||||
// Get all stored files for chain traversal
|
||||
const allStoredMetadata = await fileStorage.getAllFileMetadata();
|
||||
const fileMap = new Map(allStoredMetadata.map(f => [f.id, f]));
|
||||
|
||||
// Build complete chain by following parent relationships backwards
|
||||
const visitedIds = new Set([fileId]); // Don't include the current file
|
||||
const toProcess = [historyData]; // Start with current file's history data
|
||||
|
||||
while (toProcess.length > 0) {
|
||||
const currentHistoryData = toProcess.shift()!;
|
||||
|
||||
// Add original file if we haven't seen it
|
||||
if (currentHistoryData.originalFileId && !visitedIds.has(currentHistoryData.originalFileId)) {
|
||||
visitedIds.add(currentHistoryData.originalFileId);
|
||||
const originalMeta = fileMap.get(currentHistoryData.originalFileId as FileId);
|
||||
if (originalMeta) {
|
||||
try {
|
||||
const origStoredFile = await fileStorage.getFile(originalMeta.id);
|
||||
if (origStoredFile) {
|
||||
const origFile = new File([origStoredFile.data], origStoredFile.name, {
|
||||
type: origStoredFile.type,
|
||||
lastModified: origStoredFile.lastModified
|
||||
});
|
||||
const origMetadata = await createFileMetadataWithHistory(origFile, originalMeta.id, originalMeta.thumbnail);
|
||||
historyFiles.push(origMetadata);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load original file ${originalMeta.id}:`, error);
|
||||
// Get the current file's IndexedDB data
|
||||
const currentStoredStub = fileMap.get(fileId as FileId);
|
||||
if (!currentStoredStub) {
|
||||
console.warn(`No stored file found for ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build complete history chain using IndexedDB metadata
|
||||
const historyFiles: StirlingFileStub[] = [];
|
||||
|
||||
// Find the original file
|
||||
|
||||
// Collect only files in this specific branch (ancestors of current file)
|
||||
const chainFiles: StirlingFileStub[] = [];
|
||||
const allFiles = Array.from(fileMap.values());
|
||||
|
||||
// Build a map for fast parent lookups
|
||||
const fileIdMap = new Map<FileId, StirlingFileStub>();
|
||||
allFiles.forEach(f => fileIdMap.set(f.id, f));
|
||||
|
||||
// Trace back from current file through parent chain
|
||||
let currentFile = fileIdMap.get(fileId);
|
||||
while (currentFile?.parentFileId) {
|
||||
const parentFile = fileIdMap.get(currentFile.parentFileId);
|
||||
if (parentFile) {
|
||||
chainFiles.push(parentFile);
|
||||
currentFile = parentFile;
|
||||
} else {
|
||||
break; // Parent not found, stop tracing
|
||||
}
|
||||
}
|
||||
|
||||
// Add parent file if we haven't seen it
|
||||
if (currentHistoryData.parentFileId && !visitedIds.has(currentHistoryData.parentFileId)) {
|
||||
visitedIds.add(currentHistoryData.parentFileId);
|
||||
const parentMeta = fileMap.get(currentHistoryData.parentFileId);
|
||||
if (parentMeta) {
|
||||
try {
|
||||
const parentStoredFile = await fileStorage.getFile(parentMeta.id);
|
||||
if (parentStoredFile) {
|
||||
const parentFile = new File([parentStoredFile.data], parentStoredFile.name, {
|
||||
type: parentStoredFile.type,
|
||||
lastModified: parentStoredFile.lastModified
|
||||
});
|
||||
const parentMetadata = await createFileMetadataWithHistory(parentFile, parentMeta.id, parentMeta.thumbnail);
|
||||
historyFiles.push(parentMetadata);
|
||||
// Sort by version number (oldest first for history display)
|
||||
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
|
||||
|
||||
// Load parent's history to continue the chain
|
||||
const parentHistoryData = await loadFileHistory(parentFile, parentMeta.id);
|
||||
if (parentHistoryData) {
|
||||
toProcess.push(parentHistoryData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load parent file ${parentMeta.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also find any files that have the current file as their original (siblings/alternatives)
|
||||
for (const [metaId, meta] of fileMap) {
|
||||
if (!visitedIds.has(metaId) && (meta as any).originalFileId === historyData.originalFileId) {
|
||||
visitedIds.add(metaId);
|
||||
try {
|
||||
const siblingStoredFile = await fileStorage.getFile(meta.id);
|
||||
if (siblingStoredFile) {
|
||||
const siblingFile = new File([siblingStoredFile.data], siblingStoredFile.name, {
|
||||
type: siblingStoredFile.type,
|
||||
lastModified: siblingStoredFile.lastModified
|
||||
});
|
||||
const siblingMetadata = await createFileMetadataWithHistory(siblingFile, meta.id, meta.thumbnail);
|
||||
historyFiles.push(siblingMetadata);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load sibling file ${meta.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// StirlingFileStubs already have all the data we need - no conversion required!
|
||||
historyFiles.push(...chainFiles);
|
||||
|
||||
// Cache the loaded history files
|
||||
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load history chain for file ${fileId}:`, error);
|
||||
}
|
||||
@ -507,30 +487,19 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [expandedFileIds, recentFiles, loadFileHistory]);
|
||||
}, [expandedFileIds, recentFiles]);
|
||||
|
||||
const handleAddToRecents = useCallback(async (file: FileMetadata) => {
|
||||
const handleAddToRecents = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
console.log('Promoting to recents:', file.name, 'version:', file.versionNumber);
|
||||
// Mark the file as a leaf node so it appears in recent files
|
||||
await fileStorage.markFileAsLeaf(file.id);
|
||||
|
||||
// Load the file from storage and create a copy with new ID and timestamp
|
||||
const storedFile = await fileStorage.getFile(file.id);
|
||||
if (storedFile) {
|
||||
// Create new file with current timestamp to appear at top
|
||||
const promotedFile = new File([storedFile.data], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now() // Current timestamp makes it appear at top
|
||||
});
|
||||
|
||||
// Add as new file through the normal flow (creates new ID)
|
||||
onNewFilesSelect([promotedFile]);
|
||||
|
||||
console.log('Successfully promoted to recents:', file.name, 'v' + file.versionNumber);
|
||||
}
|
||||
// Refresh the recent files list to show updated state
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to promote to recents:', error);
|
||||
console.error('Failed to add to recents:', error);
|
||||
}
|
||||
}, [onNewFilesSelect]);
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
@ -564,10 +533,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
|
||||
// History loading state
|
||||
isLoadingHistory,
|
||||
getHistoryError,
|
||||
loadedHistoryFiles,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
@ -599,8 +565,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
isLoadingHistory,
|
||||
getHistoryError,
|
||||
loadedHistoryFiles,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
|
@ -1,15 +1,15 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { FileId } from '../types/file';
|
||||
import { useFileActions } from './FileContext';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void;
|
||||
onFileUpload: (files: File[]) => void;
|
||||
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
@ -17,7 +17,8 @@ interface FilesModalContextType {
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
const { actions } = useFileActions();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||
@ -36,39 +37,45 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler([file], insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addToActiveFiles(file);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
const handleFileUpload = useCallback((files: File[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler(files, insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addMultipleFiles(files);
|
||||
addFiles(files);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
|
||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
const files = filesWithMetadata.map(item => item.file);
|
||||
customHandler(files, insertAfterPage);
|
||||
// Load the actual files from storage for custom handler
|
||||
try {
|
||||
const loadedFiles: File[] = [];
|
||||
for (const stub of stirlingFileStubs) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (stirlingFile) {
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
customHandler(loadedFiles, insertAfterPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load files for custom handler:', error);
|
||||
}
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addStoredFiles(filesWithMetadata);
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
||||
if (actions.addStirlingFileStubs) {
|
||||
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
|
||||
} else {
|
||||
console.error('addStirlingFileStubs action not available');
|
||||
}
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
@ -78,18 +85,16 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onStoredFilesSelect: handleStoredFilesSelect,
|
||||
onFileUpload: handleFileUpload,
|
||||
onRecentFileSelect: handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileSelect,
|
||||
handleFilesSelect,
|
||||
handleStoredFilesSelect,
|
||||
handleFileUpload,
|
||||
handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
@ -4,24 +4,23 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StirlingFileStub, createStirlingFile } from '../types/fileContext';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { createFileMetadataWithHistory } from '../utils/fileHistoryUtils';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StirlingFileStub>;
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<StirlingFileStub | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||
loadLeafMetadata: () => Promise<FileMetadata[]>; // Only leaf files for recent files list
|
||||
loadAllMetadata: () => Promise<StirlingFileStub[]>;
|
||||
loadLeafMetadata: () => Promise<StirlingFileStub[]>; // Only leaf files for recent files list
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
@ -59,22 +58,42 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StirlingFileStub> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
|
||||
// Create minimal stub for storage
|
||||
const stub: StirlingFileStub = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
quickKey: `${file.name}|${file.size}|${file.lastModified}`,
|
||||
thumbnailUrl: thumbnail,
|
||||
isLeaf: true,
|
||||
createdAt: Date.now(),
|
||||
versionNumber: 1,
|
||||
originalFileId: fileId,
|
||||
toolHistory: []
|
||||
};
|
||||
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
const storedFile = await fileStorage.getStirlingFileStub(fileId);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
// Extract history metadata for PDFs and return enhanced metadata
|
||||
const metadata = await createFileMetadataWithHistory(file, fileId, thumbnail);
|
||||
// Return StirlingFileStub from the stored file (no conversion needed)
|
||||
if (!storedFile) {
|
||||
throw new Error(`Failed to retrieve stored file after saving: ${file.name}`);
|
||||
}
|
||||
|
||||
|
||||
return metadata;
|
||||
return storedFile;
|
||||
}, []);
|
||||
|
||||
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||
@ -87,14 +106,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
}
|
||||
|
||||
// Load from IndexedDB
|
||||
const storedFile = await fileStorage.getFile(fileId);
|
||||
const storedFile = await fileStorage.getStirlingFile(fileId);
|
||||
if (!storedFile) return null;
|
||||
|
||||
// Reconstruct File object
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
// StirlingFile is already a File object, no reconstruction needed
|
||||
const file = storedFile;
|
||||
|
||||
// Cache for future use with LRU eviction
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
@ -103,34 +119,9 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
return file;
|
||||
}, [evictLRUEntries]);
|
||||
|
||||
const loadMetadata = useCallback(async (fileId: FileId): Promise<FileMetadata | null> => {
|
||||
// Try to get from cache first (no IndexedDB hit)
|
||||
const cached = fileCache.current.get(fileId);
|
||||
if (cached) {
|
||||
const file = cached.file;
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
}
|
||||
|
||||
// Load metadata from IndexedDB (efficient - no data field)
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const fileMetadata = metadata.find(m => m.id === fileId);
|
||||
|
||||
if (!fileMetadata) return null;
|
||||
|
||||
return {
|
||||
id: fileMetadata.id,
|
||||
name: fileMetadata.name,
|
||||
type: fileMetadata.type,
|
||||
size: fileMetadata.size,
|
||||
lastModified: fileMetadata.lastModified,
|
||||
thumbnail: fileMetadata.thumbnail
|
||||
};
|
||||
const loadMetadata = useCallback(async (fileId: FileId): Promise<StirlingFileStub | null> => {
|
||||
// Load stub directly from storage service
|
||||
return await fileStorage.getStirlingFileStub(fileId);
|
||||
}, []);
|
||||
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
@ -138,121 +129,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteFile(fileId);
|
||||
await fileStorage.deleteStirlingFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadLeafMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getLeafFileMetadata(); // Only get leaf files
|
||||
const loadLeafMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files
|
||||
|
||||
// Separate PDF and non-PDF files for different processing
|
||||
const pdfFiles = metadata.filter(m => m.type.includes('pdf'));
|
||||
const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
|
||||
// Process non-PDF files immediately (no history extraction needed)
|
||||
const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail,
|
||||
isLeaf: m.isLeaf
|
||||
}));
|
||||
|
||||
// Process PDF files with controlled concurrency to avoid memory issues
|
||||
const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory
|
||||
const pdfMetadata: FileMetadata[] = [];
|
||||
|
||||
for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
|
||||
const batch = pdfFiles.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const batchResults = await Promise.all(batch.map(async (m) => {
|
||||
try {
|
||||
// For PDF files, load and extract basic history for display only
|
||||
const storedFile = await fileStorage.getFile(m.id);
|
||||
if (storedFile?.data) {
|
||||
const file = new File([storedFile.data], m.name, {
|
||||
type: m.type,
|
||||
lastModified: m.lastModified
|
||||
});
|
||||
return await createFileMetadataWithHistory(file, m.id, m.thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn('🗂️ Failed to extract basic metadata from leaf file:', m.name, error);
|
||||
}
|
||||
|
||||
// Fallback to basic metadata without history
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail,
|
||||
isLeaf: m.isLeaf
|
||||
};
|
||||
}));
|
||||
|
||||
pdfMetadata.push(...batchResults);
|
||||
}
|
||||
|
||||
return [...nonPdfMetadata, ...pdfMetadata];
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const loadAllMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// Separate PDF and non-PDF files for different processing
|
||||
const pdfFiles = metadata.filter(m => m.type.includes('pdf'));
|
||||
const nonPdfFiles = metadata.filter(m => !m.type.includes('pdf'));
|
||||
|
||||
// Process non-PDF files immediately (no history extraction needed)
|
||||
const nonPdfMetadata: FileMetadata[] = nonPdfFiles.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail
|
||||
}));
|
||||
|
||||
// Process PDF files with controlled concurrency to avoid memory issues
|
||||
const BATCH_SIZE = 5; // Process 5 PDFs at a time to avoid overwhelming memory
|
||||
const pdfMetadata: FileMetadata[] = [];
|
||||
|
||||
for (let i = 0; i < pdfFiles.length; i += BATCH_SIZE) {
|
||||
const batch = pdfFiles.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const batchResults = await Promise.all(batch.map(async (m) => {
|
||||
try {
|
||||
// For PDF files, load and extract history with timeout
|
||||
const storedFile = await fileStorage.getFile(m.id);
|
||||
if (storedFile?.data) {
|
||||
const file = new File([storedFile.data], m.name, {
|
||||
type: m.type,
|
||||
lastModified: m.lastModified
|
||||
});
|
||||
return await createFileMetadataWithHistory(file, m.id, m.thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn('🗂️ 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
|
||||
};
|
||||
}));
|
||||
|
||||
pdfMetadata.push(...batchResults);
|
||||
}
|
||||
|
||||
return [...nonPdfMetadata, ...pdfMetadata];
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||
@ -260,7 +152,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
fileIds.forEach(id => fileCache.current.delete(id));
|
||||
|
||||
// Remove from IndexedDB in parallel
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id)));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
|
@ -8,14 +8,15 @@ import {
|
||||
FileContextState,
|
||||
toStirlingFileStub,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
createQuickKey,
|
||||
createStirlingFile,
|
||||
} from '../../types/fileContext';
|
||||
import { FileId, FileMetadata } from '../../types/file';
|
||||
import { FileId } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { buildQuickKeySet } from './fileSelectors';
|
||||
import { extractBasicFileMetadata } from '../../utils/fileHistoryUtils';
|
||||
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
@ -70,54 +71,89 @@ export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* File addition types
|
||||
* Create a child StirlingFileStub from a parent stub with proper history management.
|
||||
* Used when a tool processes an existing file to create a new version with incremented history.
|
||||
*
|
||||
* @param parentStub - The parent StirlingFileStub to create a child from
|
||||
* @param operation - Tool operation information (toolName, timestamp)
|
||||
* @returns New child StirlingFileStub with proper version history
|
||||
*/
|
||||
type AddFileKind = 'raw' | 'processed' | 'stored';
|
||||
export function createChildStub(
|
||||
parentStub: StirlingFileStub,
|
||||
operation: { toolName: string; timestamp: number },
|
||||
resultingFile: File,
|
||||
thumbnail?: string
|
||||
): StirlingFileStub {
|
||||
const newFileId = createFileId();
|
||||
|
||||
// Build new tool history by appending to parent's history
|
||||
const parentToolHistory = parentStub.toolHistory || [];
|
||||
const newToolHistory = [...parentToolHistory, operation];
|
||||
|
||||
// Calculate new version number
|
||||
const newVersionNumber = (parentStub.versionNumber || 1) + 1;
|
||||
|
||||
// Determine original file ID (root of the version chain)
|
||||
const originalFileId = parentStub.originalFileId || parentStub.id;
|
||||
|
||||
// Update the child stub's name to match the processed file
|
||||
return {
|
||||
// Copy all parent metadata
|
||||
...parentStub,
|
||||
|
||||
// Update identity and version info
|
||||
id: newFileId,
|
||||
versionNumber: newVersionNumber,
|
||||
parentFileId: parentStub.id,
|
||||
originalFileId: originalFileId,
|
||||
toolHistory: newToolHistory,
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true, // New child is the current leaf node
|
||||
name: resultingFile.name,
|
||||
size: resultingFile.size,
|
||||
type: resultingFile.type,
|
||||
lastModified: resultingFile.lastModified,
|
||||
thumbnailUrl: thumbnail
|
||||
|
||||
// Preserve thumbnails and processing metadata from parent
|
||||
// These will be updated if the child has new thumbnails, but fallback to parent
|
||||
};
|
||||
}
|
||||
|
||||
interface AddFileOptions {
|
||||
// For 'raw' files
|
||||
files?: File[];
|
||||
|
||||
// For 'processed' files
|
||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||
|
||||
// For 'stored' files
|
||||
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||
|
||||
// Insertion position
|
||||
insertAfterPageId?: string;
|
||||
}
|
||||
|
||||
export interface AddedFile {
|
||||
file: File;
|
||||
id: FileId;
|
||||
thumbnail?: string;
|
||||
// Auto-selection after adding
|
||||
selectFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||
* Unified file addition helper - replaces addFiles
|
||||
*/
|
||||
export async function addFiles(
|
||||
kind: AddFileKind,
|
||||
options: AddFileOptions,
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager
|
||||
): Promise<AddedFile[]> {
|
||||
lifecycleManager: FileLifecycleManager,
|
||||
enablePersistence: boolean = false
|
||||
): Promise<StirlingFile[]> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||
|
||||
switch (kind) {
|
||||
case 'raw': {
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
@ -163,9 +199,8 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
// Create record with immediate thumbnail and page metadata
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
const record = toStirlingFileStub(file, fileId, thumbnail);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
@ -183,296 +218,81 @@ export async function addFiles(
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
// Extract basic metadata (version number and tool chain) for display
|
||||
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||
// Basic metadata found, dispatch update to trigger re-render
|
||||
dispatch({
|
||||
type: 'UPDATE_FILE_RECORD',
|
||||
payload: {
|
||||
id: fileId,
|
||||
updates: {
|
||||
versionNumber: updatedRecord.versionNumber,
|
||||
toolHistory: updatedRecord.toolHistory
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(error => {
|
||||
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
|
||||
});
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
|
||||
// Create StirlingFile directly
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
stirlingFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
case 'processed': {
|
||||
const { filesWithThumbnails = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||
|
||||
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create processedFile with provided metadata
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
// Extract basic metadata (version number and tool chain) for display
|
||||
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||
// Basic metadata found, dispatch update to trigger re-render
|
||||
dispatch({
|
||||
type: 'UPDATE_FILE_RECORD',
|
||||
payload: {
|
||||
id: fileId,
|
||||
updates: {
|
||||
versionNumber: updatedRecord.versionNumber,
|
||||
toolHistory: updatedRecord.toolHistory
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(error => {
|
||||
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
|
||||
});
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stored': {
|
||||
const { filesWithMetadata = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||
|
||||
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
// Try to preserve original ID, but generate new if it conflicts
|
||||
let fileId = originalId;
|
||||
if (filesRef.current.has(originalId)) {
|
||||
fileId = createFileId();
|
||||
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||
}
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
// Persist to storage if enabled using fileStorage service
|
||||
if (enablePersistence && stirlingFiles.length > 0) {
|
||||
await Promise.all(stirlingFiles.map(async (stirlingFile, index) => {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||
// Get corresponding stub with all metadata
|
||||
const fileStub = stirlingFileStubs[index];
|
||||
|
||||
// Get page count from PDF
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
pageCount = pdf.numPages;
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
// Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly
|
||||
await fileStorage.storeStirlingFile(stirlingFile, fileStub);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||
if (DEBUG) console.log(`📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, fileStub);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||
}
|
||||
|
||||
// Restore metadata from storage
|
||||
if (metadata.thumbnail) {
|
||||
record.thumbnailUrl = metadata.thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (metadata.thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create processedFile metadata with correct page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
// Extract basic metadata (version number and tool chain) for display
|
||||
extractBasicFileMetadata(file, record).then(updatedRecord => {
|
||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||
// Basic metadata found, dispatch update to trigger re-render
|
||||
dispatch({
|
||||
type: 'UPDATE_FILE_RECORD',
|
||||
payload: {
|
||||
id: fileId,
|
||||
updates: {
|
||||
versionNumber: updatedRecord.versionNumber,
|
||||
toolHistory: updatedRecord.toolHistory
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(error => {
|
||||
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
|
||||
});
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
}
|
||||
break;
|
||||
console.error('Failed to persist file to storage:', stirlingFile.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
return stirlingFiles;
|
||||
} finally {
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to process files into records with thumbnails and metadata
|
||||
*/
|
||||
async function processFilesIntoRecords(
|
||||
files: File[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
}
|
||||
|
||||
// Extract basic metadata synchronously during consumeFiles for immediate display
|
||||
if (file.type.includes('pdf')) {
|
||||
try {
|
||||
const updatedRecord = await extractBasicFileMetadata(file, record);
|
||||
|
||||
if (updatedRecord !== record && (updatedRecord.versionNumber || updatedRecord.toolHistory)) {
|
||||
// Update the record directly with basic metadata
|
||||
Object.assign(record, {
|
||||
versionNumber: updatedRecord.versionNumber,
|
||||
toolHistory: updatedRecord.toolHistory
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to extract basic metadata for ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { record, file, fileId, thumbnail };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to persist files to IndexedDB
|
||||
*/
|
||||
async function persistFilesToIndexedDB(
|
||||
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||
): Promise<void> {
|
||||
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
* Now accepts pre-created StirlingFiles and StirlingFileStubs to preserve all metadata
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
outputStirlingFiles: StirlingFile[],
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; markFileAsProcessed: (fileId: FileId) => Promise<boolean> } | null
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<FileId[]> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputStirlingFiles.length} output files with pre-created stubs`);
|
||||
|
||||
// Process output files with thumbnails and metadata
|
||||
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
// Validate that we have matching files and stubs
|
||||
if (outputStirlingFiles.length !== outputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between output files (${outputStirlingFiles.length}) and stubs (${outputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Mark input files as processed in IndexedDB (no longer leaf nodes) and save output files
|
||||
if (indexedDB) {
|
||||
// Mark input files as processed (isLeaf = false)
|
||||
// Store StirlingFiles in filesRef using their existing IDs (no ID generation needed)
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
if (stirlingFile.fileId !== stub.id) {
|
||||
console.warn(`📄 consumeFiles: ID mismatch between StirlingFile (${stirlingFile.fileId}) and stub (${stub.id})`);
|
||||
}
|
||||
|
||||
filesRef.current.set(stirlingFile.fileId, stirlingFile);
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Stored StirlingFile ${stirlingFile.name} with ID ${stirlingFile.fileId}`);
|
||||
}
|
||||
|
||||
// Mark input files as processed in storage (no longer leaf nodes)
|
||||
await Promise.all(
|
||||
inputFileIds.map(async (fileId) => {
|
||||
try {
|
||||
await indexedDB.markFileAsProcessed(fileId);
|
||||
await fileStorage.markFileAsProcessed(fileId);
|
||||
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
|
||||
@ -480,22 +300,39 @@ export async function consumeFiles(
|
||||
})
|
||||
);
|
||||
|
||||
// Save output files to IndexedDB
|
||||
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||
// Save output files directly to fileStorage with complete metadata
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
try {
|
||||
// Use fileStorage directly with complete metadata from stub
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
|
||||
if (DEBUG) console.log(`📄 Saved StirlingFile ${stirlingFile.name} directly to storage with complete metadata:`, {
|
||||
fileId: stirlingFile.fileId,
|
||||
versionNumber: stub.versionNumber,
|
||||
originalFileId: stub.originalFileId,
|
||||
parentFileId: stub.parentFileId,
|
||||
toolChainLength: stub.toolHistory?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist output file to fileStorage:', stirlingFile.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the consume action
|
||||
// Dispatch the consume action with pre-created stubs (no processing needed)
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
|
||||
outputStirlingFileStubs: outputStirlingFileStubs
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||
return outputStirlingFileStubs.map(stub => stub.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -606,6 +443,96 @@ export async function undoConsumeFiles(
|
||||
/**
|
||||
* Action factory functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add files using existing StirlingFileStubs from storage - preserves all metadata
|
||||
* Use this when loading files that already exist in storage (FileManager, etc.)
|
||||
* StirlingFileStubs come with proper thumbnails, history, processing state
|
||||
*/
|
||||
export async function addStirlingFileStubs(
|
||||
stirlingFileStubs: StirlingFileStub[],
|
||||
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
_lifecycleManager: FileLifecycleManager
|
||||
): Promise<StirlingFile[]> {
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
|
||||
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
const validStubs: StirlingFileStub[] = [];
|
||||
const loadedFiles: StirlingFile[] = [];
|
||||
|
||||
for (const stub of stirlingFileStubs) {
|
||||
// Check for duplicates using quickKey
|
||||
if (existingQuickKeys.has(stub.quickKey || '')) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate StirlingFileStub: ${stub.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the actual StirlingFile from storage
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (!stirlingFile) {
|
||||
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${stub.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the loaded file in filesRef
|
||||
filesRef.current.set(stub.id, stirlingFile);
|
||||
|
||||
// Use the original stub (preserves thumbnails, history, metadata!)
|
||||
const record = { ...stub };
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Check if processedFile data needs regeneration for proper Page Editor support
|
||||
if (stirlingFile.type.startsWith('application/pdf')) {
|
||||
const needsProcessing = !record.processedFile ||
|
||||
!record.processedFile.pages ||
|
||||
record.processedFile.pages.length === 0 ||
|
||||
record.processedFile.totalPages !== record.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
|
||||
try {
|
||||
// Generate basic processedFile structure with page count
|
||||
const result = await generateThumbnailWithMetadata(stirlingFile);
|
||||
record.processedFile = createProcessedFile(result.pageCount, result.thumbnail);
|
||||
record.thumbnailUrl = result.thumbnail; // Update thumbnail if needed
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${result.pageCount} pages`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}:`, error);
|
||||
// Ensure we have at least basic structure
|
||||
if (!record.processedFile) {
|
||||
record.processedFile = createProcessedFile(1); // Fallback to 1 page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingQuickKeys.add(stub.quickKey || '');
|
||||
validStubs.push(record);
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (validStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
} finally {
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
|
||||
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
||||
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
||||
|
@ -6,9 +6,9 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, toStirlingFileStub } from '../../../types/fileContext';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { prepareStirlingFilesWithHistory, verifyToolMetadataPreservation } from '../../../utils/fileHistoryUtils';
|
||||
import { createChildStub } from '../../../contexts/file/fileActions';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ProcessingProgress, ResponseHandler };
|
||||
@ -129,7 +129,7 @@ export const useToolOperation = <TParams>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors, findFileId } = useFileContext();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
@ -166,24 +166,13 @@ export const useToolOperation = <TParams>(
|
||||
cleanupBlobUrls();
|
||||
|
||||
// Prepare files with history metadata injection (for PDFs)
|
||||
actions.setStatus('Preparing files...');
|
||||
const getFileStubById = (fileId: FileId) => {
|
||||
return selectors.getStirlingFileStub(fileId);
|
||||
};
|
||||
|
||||
const filesWithHistory = await prepareStirlingFilesWithHistory(
|
||||
validFiles,
|
||||
getFileStubById,
|
||||
config.operationType,
|
||||
params as Record<string, any>
|
||||
);
|
||||
actions.setStatus('Processing files...');
|
||||
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
|
||||
// Convert StirlingFiles with history to regular Files for API processing
|
||||
// The history is already injected into the File data, we just need to extract the File objects
|
||||
const filesForAPI = extractFiles(filesWithHistory);
|
||||
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
||||
const filesForAPI = extractFiles(validFiles);
|
||||
|
||||
switch (config.toolType) {
|
||||
case ToolType.singleFile: {
|
||||
@ -242,8 +231,6 @@ export const useToolOperation = <TParams>(
|
||||
if (processedFiles.length > 0) {
|
||||
actions.setFiles(processedFiles);
|
||||
|
||||
// Verify metadata preservation for backend quality tracking
|
||||
await verifyToolMetadataPreservation(validFiles, processedFiles, config.operationType);
|
||||
|
||||
// Generate thumbnails and download URL concurrently
|
||||
actions.setGeneratingThumbnails(true);
|
||||
@ -272,7 +259,25 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
}
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
|
||||
// Create new tool operation
|
||||
const newToolOperation = {
|
||||
toolName: config.operationType,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
console.log("tool complete inputs ")
|
||||
const outputStirlingFileStubs = processedFiles.length != inputStirlingFileStubs.length
|
||||
? processedFiles.map((file, index) => toStirlingFileStub(file, undefined, thumbnails[index]))
|
||||
: processedFiles.map((resultingFile, index) =>
|
||||
createChildStub(inputStirlingFileStubs[index], newToolOperation, resultingFile, thumbnails[index])
|
||||
);
|
||||
|
||||
// Create StirlingFile objects from processed files and child stubs
|
||||
const outputStirlingFiles = processedFiles.map((file, index) => {
|
||||
const childStub = outputStirlingFileStubs[index];
|
||||
return createStirlingFile(file, childStub.id);
|
||||
});
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
|
@ -1,39 +1,17 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFileState, useFileActions } from '../contexts/FileContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { FileId } from '../types/file';
|
||||
import { useFileActions } from '../contexts/FileContext';
|
||||
|
||||
export const useFileHandler = () => {
|
||||
const { state } = useFileState(); // Still needed for addStoredFiles
|
||||
const { actions } = useFileActions();
|
||||
|
||||
const addToActiveFiles = useCallback(async (file: File) => {
|
||||
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => {
|
||||
// Merge default options with passed options - passed options take precedence
|
||||
const mergedOptions = { selectFiles: true, ...options };
|
||||
// Let FileContext handle deduplication with quickKey logic
|
||||
await actions.addFiles([file], { selectFiles: true });
|
||||
await actions.addFiles(files, mergedOptions);
|
||||
}, [actions.addFiles]);
|
||||
|
||||
const addMultipleFiles = useCallback(async (files: File[]) => {
|
||||
// Let FileContext handle deduplication with quickKey logic
|
||||
await actions.addFiles(files, { selectFiles: true });
|
||||
}, [actions.addFiles]);
|
||||
|
||||
// Add stored files preserving their original IDs to prevent session duplicates
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
|
||||
// Filter out files that already exist with the same ID (exact match)
|
||||
const newFiles = filesWithMetadata.filter(({ originalId }) => {
|
||||
return state.files.byId[originalId] === undefined;
|
||||
});
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
await actions.addStoredFiles(newFiles, { selectFiles: true });
|
||||
}
|
||||
|
||||
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
|
||||
}, [state.files.byId, actions.addStoredFiles]);
|
||||
|
||||
return {
|
||||
addToActiveFiles,
|
||||
addMultipleFiles,
|
||||
addStoredFiles,
|
||||
addFiles,
|
||||
};
|
||||
};
|
||||
|
@ -6,7 +6,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { FileId } from '../types/file';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { loadFileHistoryOnDemand } from '../utils/fileHistoryUtils';
|
||||
// loadFileHistoryOnDemand removed - history now comes from IndexedDB directly
|
||||
|
||||
interface FileHistoryState {
|
||||
originalFileId?: string;
|
||||
@ -33,16 +33,17 @@ export function useFileHistory(): UseFileHistoryResult {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = useCallback(async (
|
||||
file: File,
|
||||
fileId: FileId,
|
||||
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
|
||||
_file: File,
|
||||
_fileId: FileId,
|
||||
_updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub);
|
||||
setHistoryData(history);
|
||||
// History is now loaded from IndexedDB, not PDF metadata
|
||||
// This function is deprecated
|
||||
throw new Error('loadFileHistoryOnDemand is deprecated - use IndexedDB history directly');
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load file history';
|
||||
setError(errorMessage);
|
||||
@ -76,9 +77,9 @@ export function useMultiFileHistory() {
|
||||
const [errors, setErrors] = useState<Map<FileId, string>>(new Map());
|
||||
|
||||
const loadFileHistory = useCallback(async (
|
||||
file: File,
|
||||
_file: File,
|
||||
fileId: FileId,
|
||||
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
|
||||
_updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
|
||||
) => {
|
||||
// Don't reload if already loaded or currently loading
|
||||
if (historyCache.has(fileId) || loadingFiles.has(fileId)) {
|
||||
@ -93,13 +94,9 @@ export function useMultiFileHistory() {
|
||||
});
|
||||
|
||||
try {
|
||||
const history = await loadFileHistoryOnDemand(file, fileId, updateFileStub);
|
||||
|
||||
if (history) {
|
||||
setHistoryCache(prev => new Map(prev).set(fileId, history));
|
||||
}
|
||||
|
||||
return history;
|
||||
// History is now loaded from IndexedDB, not PDF metadata
|
||||
// This function is deprecated
|
||||
throw new Error('loadFileHistoryOnDemand is deprecated - use IndexedDB history directly');
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load file history';
|
||||
setErrors(prev => new Map(prev).set(fileId, errorMessage));
|
||||
|
@ -1,28 +1,29 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { StirlingFileStub, StirlingFile } from '../types/fileContext';
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
|
||||
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
|
||||
const convertToFile = useCallback(async (fileStub: StirlingFileStub): Promise<File> => {
|
||||
if (!indexedDB) {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
|
||||
// Regular file loading
|
||||
if (fileMetadata.id) {
|
||||
const file = await indexedDB.loadFile(fileMetadata.id);
|
||||
if (fileStub.id) {
|
||||
const file = await indexedDB.loadFile(fileStub.id);
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
throw new Error(`File not found in storage: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
|
||||
throw new Error(`File not found in storage: ${fileStub.name} (ID: ${fileStub.id})`);
|
||||
}, [indexedDB]);
|
||||
|
||||
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const loadRecentFiles = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!indexedDB) {
|
||||
@ -30,11 +31,10 @@ export const useFileManager = () => {
|
||||
}
|
||||
|
||||
// Load only leaf files metadata (processed files that haven't been used as input for other tools)
|
||||
const storedFileMetadata = await indexedDB.loadLeafMetadata();
|
||||
const stirlingFileStubs = await fileStorage.getLeafStirlingFileStubs();
|
||||
|
||||
// For now, only regular files - drafts will be handled separately in the future
|
||||
const allFiles = storedFileMetadata;
|
||||
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
const sortedFiles = stirlingFileStubs.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
@ -45,7 +45,7 @@ export const useFileManager = () => {
|
||||
}
|
||||
}, [indexedDB]);
|
||||
|
||||
const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => {
|
||||
const handleRemoveFile = useCallback(async (index: number, files: StirlingFileStub[], setFiles: (files: StirlingFileStub[]) => void) => {
|
||||
const file = files[index];
|
||||
if (!file.id) {
|
||||
throw new Error('File ID is required for removal');
|
||||
@ -70,10 +70,10 @@ export const useFileManager = () => {
|
||||
// Store file with provided UUID from FileContext (thumbnail generated internally)
|
||||
const metadata = await indexedDB.saveFile(file, fileId);
|
||||
|
||||
// Convert file to ArrayBuffer for StoredFile interface compatibility
|
||||
// Convert file to ArrayBuffer for storage compatibility
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Return StoredFile format for compatibility with old API
|
||||
// This method is deprecated - use FileStorage directly instead
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
@ -81,7 +81,7 @@ export const useFileManager = () => {
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
data: arrayBuffer,
|
||||
thumbnail: metadata.thumbnail
|
||||
thumbnail: metadata.thumbnailUrl
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to store file:', error);
|
||||
@ -105,23 +105,24 @@ export const useFileManager = () => {
|
||||
setSelectedFiles([]);
|
||||
};
|
||||
|
||||
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void) => {
|
||||
const selectMultipleFiles = async (files: StirlingFileStub[], onStirlingFilesSelect: (stirlingFiles: StirlingFile[]) => void) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
// Filter by UUID and convert to File objects
|
||||
// Filter by UUID and load full StirlingFile objects directly
|
||||
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
|
||||
|
||||
// Use stored files flow that preserves IDs
|
||||
const filesWithMetadata = await Promise.all(
|
||||
selectedFileObjects.map(async (metadata) => ({
|
||||
file: await convertToFile(metadata),
|
||||
originalId: metadata.id,
|
||||
metadata
|
||||
}))
|
||||
const stirlingFiles = await Promise.all(
|
||||
selectedFileObjects.map(async (stub) => {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (!stirlingFile) {
|
||||
throw new Error(`File not found in storage: ${stub.name}`);
|
||||
}
|
||||
return stirlingFile;
|
||||
})
|
||||
);
|
||||
onStoredFilesSelect(filesWithMetadata);
|
||||
|
||||
onStirlingFilesSelect(stirlingFiles);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
console.error('Failed to load selected files:', error);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FileMetadata } from "../types/file";
|
||||
import { StirlingFileStub } from "../types/fileContext";
|
||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
import { FileId } from "../types/fileContext";
|
||||
@ -9,7 +9,7 @@ import { FileId } from "../types/fileContext";
|
||||
* Hook for IndexedDB-aware thumbnail loading
|
||||
* Handles thumbnail generation for files not in IndexedDB
|
||||
*/
|
||||
export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
export function useIndexedDBThumbnail(file: StirlingFileStub | undefined | null): {
|
||||
thumbnail: string | null;
|
||||
isGenerating: boolean
|
||||
} {
|
||||
@ -27,8 +27,8 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
}
|
||||
|
||||
// First priority: use stored thumbnail
|
||||
if (file.thumbnail) {
|
||||
setThumb(file.thumbnail);
|
||||
if (file.thumbnailUrl) {
|
||||
setThumb(file.thumbnailUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -77,7 +77,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
|
||||
loadThumbnail();
|
||||
return () => { cancelled = true; };
|
||||
}, [file, file?.thumbnail, file?.id, indexedDB, generating]);
|
||||
}, [file, file?.thumbnailUrl, file?.id, indexedDB, generating]);
|
||||
|
||||
return { thumbnail: thumb, isGenerating: generating };
|
||||
}
|
||||
|
@ -1,22 +1,23 @@
|
||||
/**
|
||||
* IndexedDB File Storage Service
|
||||
* Provides high-capacity file storage for PDF processing
|
||||
* Now uses centralized IndexedDB manager
|
||||
* Stirling File Storage Service
|
||||
* Single-table architecture with typed query methods
|
||||
* Forces correct usage patterns through service API design
|
||||
*/
|
||||
|
||||
import { FileId } from '../types/file';
|
||||
import { FileId, BaseFileMetadata } from '../types/file';
|
||||
import { StirlingFile, StirlingFileStub, createStirlingFile } from '../types/fileContext';
|
||||
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
||||
|
||||
export interface StoredFile {
|
||||
id: FileId;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
/**
|
||||
* Storage record - single source of truth
|
||||
* Contains all data needed for both StirlingFile and StirlingFileStub
|
||||
*/
|
||||
export interface StoredStirlingFileRecord extends BaseFileMetadata {
|
||||
data: ArrayBuffer;
|
||||
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
|
||||
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
|
||||
thumbnail?: string;
|
||||
url?: string; // For compatibility with existing components
|
||||
isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet)
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
@ -38,47 +39,49 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file in IndexedDB with external UUID
|
||||
* Store a StirlingFile with its metadata from StirlingFileStub
|
||||
*/
|
||||
async storeFile(file: File, fileId: FileId, thumbnail?: string, isLeaf: boolean = true): Promise<StoredFile> {
|
||||
async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
const arrayBuffer = await stirlingFile.arrayBuffer();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const storedFile: StoredFile = {
|
||||
id: fileId, // Use provided UUID
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
const record: StoredStirlingFileRecord = {
|
||||
id: stirlingFile.fileId,
|
||||
fileId: stirlingFile.fileId, // Explicit field for clarity
|
||||
quickKey: stirlingFile.quickKey,
|
||||
name: stirlingFile.name,
|
||||
type: stirlingFile.type,
|
||||
size: stirlingFile.size,
|
||||
lastModified: stirlingFile.lastModified,
|
||||
data: arrayBuffer,
|
||||
thumbnail,
|
||||
isLeaf
|
||||
thumbnail: stub.thumbnailUrl,
|
||||
isLeaf: stub.isLeaf ?? true,
|
||||
|
||||
// History data from stub
|
||||
versionNumber: stub.versionNumber ?? 1,
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? []
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Verify store exists before creating transaction
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
throw new Error(`Object store '${this.storeName}' not found. Available stores: ${Array.from(db.objectStoreNames).join(', ')}`);
|
||||
}
|
||||
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
// Debug logging
|
||||
console.log('📄 LEAF FLAG DEBUG - Storing file:', {
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
isLeaf: storedFile.isLeaf,
|
||||
dataSize: storedFile.data.byteLength
|
||||
});
|
||||
|
||||
const request = store.add(storedFile);
|
||||
const request = store.add(record);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('IndexedDB add error:', request.error);
|
||||
console.error('Failed object:', storedFile);
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
console.log('File stored successfully with ID:', storedFile.id);
|
||||
resolve(storedFile);
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Transaction error:', error);
|
||||
@ -88,9 +91,9 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file from IndexedDB
|
||||
* Get StirlingFile with full data - for loading into workbench
|
||||
*/
|
||||
async getFile(id: FileId): Promise<StoredFile | null> {
|
||||
async getStirlingFile(id: FileId): Promise<StirlingFile | null> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -99,76 +102,166 @@ class FileStorageService {
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
});
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (!record) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored files (WARNING: loads all data into memory)
|
||||
*/
|
||||
async getAllFiles(): Promise<StoredFile[]> {
|
||||
const db = await this.getDatabase();
|
||||
// Create File from stored data
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const file = new File([blob], record.name, {
|
||||
type: record.type,
|
||||
lastModified: record.lastModified
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
// Filter out null/corrupted entries
|
||||
const files = request.result.filter(file =>
|
||||
file &&
|
||||
file.data &&
|
||||
file.name &&
|
||||
typeof file.size === 'number'
|
||||
);
|
||||
resolve(files);
|
||||
// Convert to StirlingFile with preserved IDs
|
||||
const stirlingFile = createStirlingFile(file, record.fileId);
|
||||
resolve(stirlingFile);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata of all stored files (without loading data into memory)
|
||||
* Get multiple StirlingFiles - for batch loading
|
||||
*/
|
||||
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
||||
async getStirlingFiles(ids: FileId[]): Promise<StirlingFile[]> {
|
||||
const results = await Promise.all(ids.map(id => this.getStirlingFile(id)));
|
||||
return results.filter((file): file is StirlingFile => file !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get StirlingFileStub (metadata only) - for UI browsing
|
||||
*/
|
||||
async getStirlingFileStub(id: FileId): Promise<StirlingFileStub | null> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (!record) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create StirlingFileStub from metadata (no file data)
|
||||
const stub: StirlingFileStub = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
lastModified: record.lastModified,
|
||||
quickKey: record.quickKey,
|
||||
thumbnailUrl: record.thumbnail,
|
||||
isLeaf: record.isLeaf,
|
||||
versionNumber: record.versionNumber,
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
createdAt: Date.now() // Current session
|
||||
};
|
||||
|
||||
resolve(stub);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all StirlingFileStubs (metadata only) - for FileManager browsing
|
||||
*/
|
||||
async getAllStirlingFileStubs(): Promise<StirlingFileStub[]> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const files: Omit<StoredFile, 'data'>[] = [];
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const storedFile = cursor.value;
|
||||
// Only extract metadata, skip the data field
|
||||
if (storedFile && storedFile.name && typeof storedFile.size === 'number') {
|
||||
files.push({
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
type: storedFile.type,
|
||||
size: storedFile.size,
|
||||
lastModified: storedFile.lastModified,
|
||||
thumbnail: storedFile.thumbnail
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
if (record && record.name && typeof record.size === 'number') {
|
||||
// Extract metadata only - no file data
|
||||
stubs.push({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
lastModified: record.lastModified,
|
||||
quickKey: record.quickKey,
|
||||
thumbnailUrl: record.thumbnail,
|
||||
isLeaf: record.isLeaf,
|
||||
versionNumber: record.versionNumber || 1,
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Metadata loaded efficiently without file data
|
||||
resolve(files);
|
||||
resolve(stubs);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from IndexedDB
|
||||
* Get leaf StirlingFileStubs only - for unprocessed files
|
||||
*/
|
||||
async deleteFile(id: FileId): Promise<void> {
|
||||
async getLeafStirlingFileStubs(): Promise<StirlingFileStub[]> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const leafStubs: StirlingFileStub[] = [];
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
// Only include leaf files (default to true if undefined)
|
||||
if (record && record.name && typeof record.size === 'number' && record.isLeaf !== false) {
|
||||
leafStubs.push({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
lastModified: record.lastModified,
|
||||
quickKey: record.quickKey,
|
||||
thumbnailUrl: record.thumbnail,
|
||||
isLeaf: record.isLeaf,
|
||||
versionNumber: record.versionNumber || 1,
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(leafStubs);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete StirlingFile - single operation, no sync issues
|
||||
*/
|
||||
async deleteStirlingFile(id: FileId): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -182,415 +275,7 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastModified timestamp of a file (for most recently used sorting)
|
||||
*/
|
||||
async touchFile(id: FileId): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const getRequest = store.get(id);
|
||||
getRequest.onsuccess = () => {
|
||||
const file = getRequest.result;
|
||||
if (file) {
|
||||
// Update lastModified to current timestamp
|
||||
file.lastModified = Date.now();
|
||||
const updateRequest = store.put(file);
|
||||
updateRequest.onsuccess = () => resolve(true);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
} else {
|
||||
resolve(false); // File not found
|
||||
}
|
||||
};
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a file as no longer being a leaf (it has been processed)
|
||||
*/
|
||||
async markFileAsProcessed(id: FileId): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const getRequest = store.get(id);
|
||||
getRequest.onsuccess = () => {
|
||||
const file = getRequest.result;
|
||||
if (file) {
|
||||
console.log('📄 LEAF FLAG DEBUG - Marking as processed:', {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
wasLeaf: file.isLeaf,
|
||||
nowLeaf: false
|
||||
});
|
||||
file.isLeaf = false;
|
||||
const updateRequest = store.put(file);
|
||||
updateRequest.onsuccess = () => resolve(true);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
} else {
|
||||
console.warn('📄 LEAF FLAG DEBUG - File not found for processing:', id);
|
||||
resolve(false); // File not found
|
||||
}
|
||||
};
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only leaf files (files that haven't been processed yet)
|
||||
*/
|
||||
async getLeafFiles(): Promise<StoredFile[]> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const leafFiles: StoredFile[] = [];
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const storedFile = cursor.value;
|
||||
if (storedFile && storedFile.isLeaf !== false) { // Default to true if undefined
|
||||
leafFiles.push(storedFile);
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(leafFiles);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata of only leaf files (without loading data into memory)
|
||||
*/
|
||||
async getLeafFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const files: Omit<StoredFile, 'data'>[] = [];
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
const storedFile = cursor.value;
|
||||
// Only include leaf files (default to true if undefined for backward compatibility)
|
||||
if (storedFile && storedFile.name && typeof storedFile.size === 'number' && storedFile.isLeaf !== false) {
|
||||
files.push({
|
||||
id: storedFile.id,
|
||||
name: storedFile.name,
|
||||
type: storedFile.type,
|
||||
size: storedFile.size,
|
||||
lastModified: storedFile.lastModified,
|
||||
thumbnail: storedFile.thumbnail,
|
||||
isLeaf: storedFile.isLeaf
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
console.log('📄 LEAF FLAG DEBUG - Found leaf files:', files.map(f => ({ id: f.id, name: f.name, isLeaf: f.isLeaf })));
|
||||
resolve(files);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored files
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics (only our IndexedDB usage)
|
||||
*/
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
let used = 0;
|
||||
let available = 0;
|
||||
let quota: number | undefined;
|
||||
let fileCount = 0;
|
||||
|
||||
try {
|
||||
// Get browser quota for context
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
quota = estimate.quota;
|
||||
available = estimate.quota || 0;
|
||||
}
|
||||
|
||||
// Calculate our actual IndexedDB usage from file metadata
|
||||
const files = await this.getAllFileMetadata();
|
||||
used = files.reduce((total, file) => total + (file?.size || 0), 0);
|
||||
fileCount = files.length;
|
||||
|
||||
// Adjust available space
|
||||
if (quota) {
|
||||
available = quota - used;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not get storage stats:', error);
|
||||
// If we can't read metadata, database might be purged
|
||||
used = 0;
|
||||
fileCount = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
available,
|
||||
fileCount,
|
||||
quota
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file count quickly without loading metadata
|
||||
*/
|
||||
async getFileCount(): Promise<number> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.count();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all IndexedDB databases to see if files are in another version
|
||||
*/
|
||||
async debugAllDatabases(): Promise<void> {
|
||||
console.log('=== Checking All IndexedDB Databases ===');
|
||||
|
||||
if ('databases' in indexedDB) {
|
||||
try {
|
||||
const databases = await indexedDB.databases();
|
||||
console.log('Found databases:', databases);
|
||||
|
||||
for (const dbInfo of databases) {
|
||||
if (dbInfo.name?.includes('stirling') || dbInfo.name?.includes('pdf')) {
|
||||
console.log(`Checking database: ${dbInfo.name} (version: ${dbInfo.version})`);
|
||||
try {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(dbInfo.name!, dbInfo.version);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
console.log(`Database ${dbInfo.name} object stores:`, Array.from(db.objectStoreNames));
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.error(`Failed to open database ${dbInfo.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to list databases:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('indexedDB.databases() not supported');
|
||||
}
|
||||
|
||||
// Also check our specific database with different versions
|
||||
for (let version = 1; version <= 3; version++) {
|
||||
try {
|
||||
console.log(`Trying to open ${this.dbConfig.name} version ${version}...`);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbConfig.name, version);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onupgradeneeded = () => {
|
||||
// Don't actually upgrade, just check
|
||||
request.transaction?.abort();
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`Version ${version} object stores:`, Array.from(db.objectStoreNames));
|
||||
|
||||
if (db.objectStoreNames.contains('files')) {
|
||||
const transaction = db.transaction(['files'], 'readonly');
|
||||
const store = transaction.objectStore('files');
|
||||
const countRequest = store.count();
|
||||
countRequest.onsuccess = () => {
|
||||
console.log(`Version ${version} files store has ${countRequest.result} entries`);
|
||||
};
|
||||
}
|
||||
|
||||
db.close();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(`Version ${version} not accessible:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to check what's actually in the database
|
||||
*/
|
||||
async debugDatabaseContents(): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
// First try getAll to see if there's anything
|
||||
const getAllRequest = store.getAll();
|
||||
getAllRequest.onsuccess = () => {
|
||||
console.log('=== Raw getAll() result ===');
|
||||
console.log('Raw entries found:', getAllRequest.result.length);
|
||||
getAllRequest.result.forEach((item, index) => {
|
||||
console.log(`Raw entry ${index}:`, {
|
||||
keys: Object.keys(item || {}),
|
||||
id: item?.id,
|
||||
name: item?.name,
|
||||
size: item?.size,
|
||||
type: item?.type,
|
||||
hasData: !!item?.data,
|
||||
dataSize: item?.data?.byteLength,
|
||||
fullObject: item
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Then try cursor
|
||||
const cursorRequest = store.openCursor();
|
||||
console.log('=== IndexedDB Cursor Debug ===');
|
||||
let count = 0;
|
||||
|
||||
cursorRequest.onerror = () => {
|
||||
console.error('Cursor error:', cursorRequest.error);
|
||||
reject(cursorRequest.error);
|
||||
};
|
||||
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest).result;
|
||||
if (cursor) {
|
||||
count++;
|
||||
const value = cursor.value;
|
||||
console.log(`Cursor File ${count}:`, {
|
||||
id: value?.id,
|
||||
name: value?.name,
|
||||
size: value?.size,
|
||||
type: value?.type,
|
||||
hasData: !!value?.data,
|
||||
dataSize: value?.data?.byteLength,
|
||||
hasThumbnail: !!value?.thumbnail,
|
||||
allKeys: Object.keys(value || {})
|
||||
});
|
||||
cursor.continue();
|
||||
} else {
|
||||
console.log(`=== End Cursor Debug - Found ${count} files ===`);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert StoredFile back to pure File object without mutations
|
||||
* Returns a clean File object - use FileContext.addStoredFiles() for proper metadata handling
|
||||
*/
|
||||
createFileFromStored(storedFile: StoredFile): File {
|
||||
if (!storedFile || !storedFile.data) {
|
||||
throw new Error('Invalid stored file: missing data');
|
||||
}
|
||||
|
||||
if (!storedFile.name || typeof storedFile.size !== 'number') {
|
||||
throw new Error('Invalid stored file: missing metadata');
|
||||
}
|
||||
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const file = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
|
||||
// Use FileContext.addStoredFiles() to properly associate with metadata
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert StoredFile to the format expected by FileContext.addStoredFiles()
|
||||
* This is the recommended way to load stored files into FileContext
|
||||
*/
|
||||
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: FileId; metadata: { thumbnail?: string } } {
|
||||
const file = this.createFileFromStored(storedFile);
|
||||
|
||||
return {
|
||||
file,
|
||||
originalId: storedFile.id,
|
||||
metadata: {
|
||||
thumbnail: storedFile.thumbnail
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create blob URL for stored file
|
||||
*/
|
||||
createBlobUrl(storedFile: StoredFile): string {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file data as ArrayBuffer for streaming/chunked processing
|
||||
*/
|
||||
async getFileData(id: FileId): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const storedFile = await this.getFile(id);
|
||||
return storedFile ? storedFile.data : null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get file data for ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary blob URL that gets revoked automatically
|
||||
*/
|
||||
async createTemporaryBlobUrl(id: FileId): Promise<string | null> {
|
||||
const data = await this.getFileData(id);
|
||||
if (!data) return null;
|
||||
|
||||
const blob = new Blob([data], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Auto-revoke after a short delay to free memory
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 10000); // 10 seconds
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update thumbnail for an existing file
|
||||
* Update thumbnail for existing file
|
||||
*/
|
||||
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
@ -602,13 +287,12 @@ class FileStorageService {
|
||||
const getRequest = store.get(id);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const storedFile = getRequest.result;
|
||||
if (storedFile) {
|
||||
storedFile.thumbnail = thumbnail;
|
||||
const updateRequest = store.put(storedFile);
|
||||
const record = getRequest.result as StoredStirlingFileRecord;
|
||||
if (record) {
|
||||
record.thumbnail = thumbnail;
|
||||
const updateRequest = store.put(record);
|
||||
|
||||
updateRequest.onsuccess = () => {
|
||||
console.log('Thumbnail updated for file:', id);
|
||||
resolve(true);
|
||||
};
|
||||
updateRequest.onerror = () => {
|
||||
@ -632,31 +316,161 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage quota is running low
|
||||
* Clear all stored files
|
||||
*/
|
||||
async isStorageLow(): Promise<boolean> {
|
||||
const stats = await this.getStorageStats();
|
||||
if (!stats.quota) return false;
|
||||
async clearAll(): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
const usagePercent = stats.used / stats.quota;
|
||||
return usagePercent > 0.8; // Consider low if over 80% used
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old files if storage is low
|
||||
* Get storage statistics
|
||||
*/
|
||||
async cleanupOldFiles(maxFiles: number = 50): Promise<void> {
|
||||
const files = await this.getAllFileMetadata();
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
let used = 0;
|
||||
let available = 0;
|
||||
let quota: number | undefined;
|
||||
let fileCount = 0;
|
||||
|
||||
if (files.length <= maxFiles) return;
|
||||
try {
|
||||
// Get browser quota for context
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
quota = estimate.quota;
|
||||
available = estimate.quota || 0;
|
||||
}
|
||||
|
||||
// Sort by last modified (oldest first)
|
||||
files.sort((a, b) => a.lastModified - b.lastModified);
|
||||
// Calculate our actual IndexedDB usage from file metadata
|
||||
const stubs = await this.getAllStirlingFileStubs();
|
||||
used = stubs.reduce((total, stub) => total + (stub?.size || 0), 0);
|
||||
fileCount = stubs.length;
|
||||
|
||||
// Delete oldest files
|
||||
const filesToDelete = files.slice(0, files.length - maxFiles);
|
||||
for (const file of filesToDelete) {
|
||||
await this.deleteFile(file.id);
|
||||
// Adjust available space
|
||||
if (quota) {
|
||||
available = quota - used;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not get storage stats:', error);
|
||||
used = 0;
|
||||
fileCount = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
used,
|
||||
available,
|
||||
fileCount,
|
||||
quota
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create blob URL for stored file data
|
||||
*/
|
||||
async createBlobUrl(id: FileId): Promise<string | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (record) {
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
resolve(url);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to create blob URL for ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a file as processed (no longer a leaf file)
|
||||
* Used when a file becomes input to a tool operation
|
||||
*/
|
||||
async markFileAsProcessed(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return false; // File not found
|
||||
}
|
||||
|
||||
// Update the isLeaf flag to false
|
||||
record.isLeaf = false;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to mark file as processed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a file as leaf (opposite of markFileAsProcessed)
|
||||
* Used when promoting a file back to "recent" status
|
||||
*/
|
||||
async markFileAsLeaf(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return false; // File not found
|
||||
}
|
||||
|
||||
// Update the isLeaf flag to true
|
||||
record.isLeaf = true;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to mark file as leaf:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -201,13 +201,16 @@ class IndexedDBManager {
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: 'stirling-pdf-files',
|
||||
version: 2,
|
||||
version: 3,
|
||||
stores: [{
|
||||
name: 'files',
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'name', keyPath: 'name', unique: false },
|
||||
{ name: 'lastModified', keyPath: 'lastModified', unique: false }
|
||||
{ name: 'lastModified', keyPath: 'lastModified', unique: false },
|
||||
{ name: 'originalFileId', keyPath: 'originalFileId', unique: false },
|
||||
{ name: 'parentFileId', keyPath: 'parentFileId', unique: false },
|
||||
{ name: 'versionNumber', keyPath: 'versionNumber', unique: false }
|
||||
]
|
||||
}]
|
||||
} as DatabaseConfig,
|
||||
@ -219,7 +222,8 @@ export const DATABASE_CONFIGS = {
|
||||
name: 'drafts',
|
||||
keyPath: 'id'
|
||||
}]
|
||||
} as DatabaseConfig
|
||||
} as DatabaseConfig,
|
||||
|
||||
} as const;
|
||||
|
||||
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||
|
@ -7,18 +7,17 @@
|
||||
*/
|
||||
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { FileId } from '../types/file';
|
||||
import { ContentCache, type CacheConfig } from '../utils/ContentCache';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Tool operation metadata for history tracking
|
||||
* Note: Parameters removed for security - sensitive data like passwords should not be stored
|
||||
*/
|
||||
export interface ToolOperation {
|
||||
toolName: string;
|
||||
timestamp: number;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -182,7 +181,7 @@ export class PDFMetadataService {
|
||||
latestVersionNumber = parsed.stirlingHistory.versionNumber;
|
||||
historyJson = json;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Silent fallback for corrupted history
|
||||
}
|
||||
}
|
||||
|
@ -8,11 +8,11 @@ export type FileId = string & { readonly [tag]: 'FileId' };
|
||||
|
||||
/**
|
||||
* Tool operation metadata for history tracking
|
||||
* Note: Parameters removed for security - sensitive data like passwords should not be stored in history
|
||||
*/
|
||||
export interface ToolOperation {
|
||||
toolName: string;
|
||||
timestamp: number;
|
||||
parameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -21,31 +21,32 @@ export interface ToolOperation {
|
||||
*/
|
||||
export interface FileHistoryInfo {
|
||||
originalFileId: string;
|
||||
parentFileId?: string;
|
||||
parentFileId?: FileId;
|
||||
versionNumber: number;
|
||||
toolChain: ToolOperation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* File metadata for efficient operations without loading full file data
|
||||
* Used by IndexedDBContext and FileContext for lazy file loading
|
||||
* Base file metadata shared between storage and runtime layers
|
||||
* Contains all common file properties and history tracking
|
||||
*/
|
||||
export interface FileMetadata {
|
||||
export interface BaseFileMetadata {
|
||||
id: FileId;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
thumbnail?: string;
|
||||
isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet)
|
||||
createdAt?: number; // When file was added to system
|
||||
|
||||
// File history tracking (extracted from PDF metadata)
|
||||
historyInfo?: FileHistoryInfo;
|
||||
|
||||
// Quick access version information
|
||||
// File history tracking
|
||||
isLeaf?: boolean; // True if this file hasn't been processed yet
|
||||
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;
|
||||
}>; // Tool chain for history tracking
|
||||
|
||||
// Standard PDF document metadata
|
||||
pdfMetadata?: {
|
||||
@ -59,6 +60,10 @@ export interface FileMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
// FileMetadata has been replaced with StoredFileMetadata from '../services/fileStorage'
|
||||
// This ensures clear type relationships and eliminates duplication
|
||||
|
||||
|
||||
export interface StorageConfig {
|
||||
useIndexedDB: boolean;
|
||||
maxFileSize: number; // Maximum size per file in bytes
|
||||
|
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { PageOperation } from './pageEditor';
|
||||
import { FileId, FileMetadata } from './file';
|
||||
import { FileId, BaseFileMetadata } from './file';
|
||||
|
||||
// Re-export FileId for convenience
|
||||
export type { FileId };
|
||||
@ -51,30 +51,20 @@ export interface ProcessedFileMetadata {
|
||||
* separately in refs for memory efficiency. Supports multi-tool workflows
|
||||
* where files persist across tool operations.
|
||||
*/
|
||||
export interface StirlingFileStub {
|
||||
id: FileId; // UUID primary key for collision-free operations
|
||||
name: string; // Display name for UI
|
||||
size: number; // File size for progress indicators
|
||||
type: string; // MIME type for format validation
|
||||
lastModified: number; // Original timestamp for deduplication
|
||||
/**
|
||||
* StirlingFileStub - Runtime UI metadata for files in the active workbench session
|
||||
*
|
||||
* Contains UI display data and processing state. Actual File objects stored
|
||||
* separately in refs for memory efficiency. Supports multi-tool workflows
|
||||
* where files persist across tool operations.
|
||||
*/
|
||||
export interface StirlingFileStub extends BaseFileMetadata {
|
||||
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||
thumbnailUrl?: string; // Generated thumbnail blob URL for visual display
|
||||
blobUrl?: string; // File access blob URL for downloads/processing
|
||||
createdAt?: number; // When added to workbench for sorting
|
||||
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
||||
isLeaf?: boolean; // True if this file is a leaf node (hasn't been processed yet)
|
||||
|
||||
// File history tracking (from PDF metadata)
|
||||
originalFileId?: string; // Root file ID for grouping versions
|
||||
versionNumber?: number; // Version number in chain
|
||||
parentFileId?: FileId; // Immediate parent file ID
|
||||
toolHistory?: Array<{
|
||||
toolName: string;
|
||||
timestamp: number;
|
||||
parameters?: Record<string, any>;
|
||||
}>;
|
||||
// Note: File object stored in provider ref, not in state
|
||||
}
|
||||
|
||||
@ -117,6 +107,11 @@ export function isStirlingFile(file: File): file is StirlingFile {
|
||||
|
||||
// Create a StirlingFile from a regular File object
|
||||
export function createStirlingFile(file: File, id?: FileId): StirlingFile {
|
||||
// Check if file is already a StirlingFile to avoid property redefinition
|
||||
if (isStirlingFile(file)) {
|
||||
return file; // Already has fileId and quickKey properties
|
||||
}
|
||||
|
||||
const fileId = id || createFileId();
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
@ -163,7 +158,9 @@ export function isFileObject(obj: any): obj is File | StirlingFile {
|
||||
|
||||
export function toStirlingFileStub(
|
||||
file: File,
|
||||
id?: FileId
|
||||
id?: FileId,
|
||||
thumbnail?: string
|
||||
|
||||
): StirlingFileStub {
|
||||
const fileId = id || createFileId();
|
||||
return {
|
||||
@ -174,7 +171,8 @@ export function toStirlingFileStub(
|
||||
lastModified: file.lastModified,
|
||||
quickKey: createQuickKey(file),
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true // New files are leaf nodes by default
|
||||
isLeaf: true, // New files are leaf nodes by default
|
||||
thumbnailUrl: thumbnail
|
||||
};
|
||||
}
|
||||
|
||||
@ -220,7 +218,6 @@ export interface FileOperation {
|
||||
metadata?: {
|
||||
originalFileName?: string;
|
||||
outputFileNames?: string[];
|
||||
parameters?: Record<string, any>;
|
||||
fileSize?: number;
|
||||
pageCount?: number;
|
||||
error?: string;
|
||||
@ -297,8 +294,7 @@ export type FileContextAction =
|
||||
export interface FileContextActions {
|
||||
// File management - lightweight actions only
|
||||
addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<StirlingFile[]>;
|
||||
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>, options?: { selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||
addStirlingFileStubs: (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise<StirlingFile[]>;
|
||||
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
||||
updateStirlingFileStub: (id: FileId, updates: Partial<StirlingFileStub>) => void;
|
||||
reorderFiles: (orderedFileIds: FileId[]) => void;
|
||||
@ -310,7 +306,7 @@ export interface FileContextActions {
|
||||
unpinFile: (file: StirlingFile) => void;
|
||||
|
||||
// File consumption (replace unpinned files with outputs)
|
||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
|
||||
consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise<FileId[]>;
|
||||
undoConsumeFiles: (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]) => Promise<void>;
|
||||
// Selection management
|
||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { zipFileService } from '../services/zipFileService';
|
||||
|
||||
@ -26,23 +26,23 @@ export function downloadBlob(blob: Blob, filename: string): void {
|
||||
* @param file - The file object with storage information
|
||||
* @throws Error if file cannot be retrieved from storage
|
||||
*/
|
||||
export async function downloadFileFromStorage(file: FileMetadata): Promise<void> {
|
||||
export async function downloadFileFromStorage(file: StirlingFileStub): Promise<void> {
|
||||
const lookupKey = file.id;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
if (!storedFile) {
|
||||
if (!stirlingFile) {
|
||||
throw new Error(`File "${file.name}" not found in storage`);
|
||||
}
|
||||
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
downloadBlob(blob, storedFile.name);
|
||||
// StirlingFile is already a File object, just download it
|
||||
downloadBlob(stirlingFile, stirlingFile.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads multiple files as individual downloads
|
||||
* @param files - Array of files to download
|
||||
*/
|
||||
export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void> {
|
||||
export async function downloadMultipleFiles(files: StirlingFileStub[]): Promise<void> {
|
||||
for (const file of files) {
|
||||
await downloadFileFromStorage(file);
|
||||
}
|
||||
@ -53,27 +53,24 @@ export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void
|
||||
* @param files - Array of files to include in ZIP
|
||||
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
|
||||
*/
|
||||
export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: string): Promise<void> {
|
||||
export async function downloadFilesAsZip(files: StirlingFileStub[], zipFilename?: string): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
throw new Error('No files provided for ZIP download');
|
||||
}
|
||||
|
||||
// Convert stored files to File objects
|
||||
const fileObjects: File[] = [];
|
||||
const filesToZip: File[] = [];
|
||||
for (const fileWithUrl of files) {
|
||||
const lookupKey = fileWithUrl.id;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
if (storedFile) {
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
fileObjects.push(file);
|
||||
if (stirlingFile) {
|
||||
// StirlingFile is already a File object!
|
||||
filesToZip.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileObjects.length === 0) {
|
||||
if (filesToZip.length === 0) {
|
||||
throw new Error('No valid files found in storage for ZIP download');
|
||||
}
|
||||
|
||||
@ -82,7 +79,7 @@ export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: st
|
||||
`files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`;
|
||||
|
||||
// Create and download ZIP
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(fileObjects, finalZipFilename);
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(filesToZip, finalZipFilename);
|
||||
downloadBlob(zipFile, finalZipFilename);
|
||||
}
|
||||
|
||||
@ -94,7 +91,7 @@ export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: st
|
||||
* @param options - Download options
|
||||
*/
|
||||
export async function downloadFiles(
|
||||
files: FileMetadata[],
|
||||
files: StirlingFileStub[],
|
||||
options: {
|
||||
forceZip?: boolean;
|
||||
zipFilename?: string;
|
||||
|
@ -1,206 +1,10 @@
|
||||
/**
|
||||
* File History Utilities
|
||||
*
|
||||
* Helper functions for integrating PDF metadata service with FileContext operations.
|
||||
* Handles extraction of history from files and preparation for metadata injection.
|
||||
* Helper functions for IndexedDB-based file history management.
|
||||
* Handles file history operations and lineage tracking.
|
||||
*/
|
||||
|
||||
import { pdfMetadataService, type ToolOperation } from '../services/pdfMetadataService';
|
||||
import { StirlingFileStub } 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 StirlingFileStub
|
||||
*/
|
||||
export async function extractFileHistory(
|
||||
file: File,
|
||||
record: StirlingFileStub
|
||||
): Promise<StirlingFileStub> {
|
||||
// Only process PDF files
|
||||
if (!file.type.includes('pdf')) {
|
||||
return record;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
|
||||
|
||||
if (historyMetadata) {
|
||||
const history = historyMetadata.stirlingHistory;
|
||||
|
||||
// Update record with history information
|
||||
return {
|
||||
...record,
|
||||
originalFileId: history.originalFileId,
|
||||
versionNumber: history.versionNumber,
|
||||
parentFileId: history.parentFileId as FileId | undefined,
|
||||
toolHistory: history.toolChain
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn('📄 Failed to extract file history:', file.name, error);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject history metadata into a PDF file for tool operations
|
||||
*/
|
||||
export async function injectHistoryForTool(
|
||||
file: File,
|
||||
sourceStirlingFileStub: StirlingFileStub,
|
||||
toolName: string,
|
||||
parameters?: Record<string, any>
|
||||
): Promise<File> {
|
||||
// Only process PDF files
|
||||
if (!file.type.includes('pdf')) {
|
||||
return file;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Create tool operation record
|
||||
const toolOperation: ToolOperation = {
|
||||
toolName,
|
||||
timestamp: Date.now(),
|
||||
parameters
|
||||
};
|
||||
|
||||
let modifiedBytes: ArrayBuffer;
|
||||
|
||||
// Extract version info directly from the PDF metadata to ensure accuracy
|
||||
const existingHistoryMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
|
||||
|
||||
let newVersionNumber: number;
|
||||
let originalFileId: string;
|
||||
let parentFileId: string;
|
||||
let parentToolChain: ToolOperation[];
|
||||
|
||||
if (existingHistoryMetadata) {
|
||||
// File already has embedded history - increment version
|
||||
const history = existingHistoryMetadata.stirlingHistory;
|
||||
newVersionNumber = history.versionNumber + 1;
|
||||
originalFileId = history.originalFileId;
|
||||
parentFileId = sourceStirlingFileStub.id; // This file becomes the parent
|
||||
parentToolChain = history.toolChain || [];
|
||||
|
||||
} else if (sourceStirlingFileStub.originalFileId && sourceStirlingFileStub.versionNumber) {
|
||||
// File record has history but PDF doesn't (shouldn't happen, but fallback)
|
||||
newVersionNumber = sourceStirlingFileStub.versionNumber + 1;
|
||||
originalFileId = sourceStirlingFileStub.originalFileId;
|
||||
parentFileId = sourceStirlingFileStub.id;
|
||||
parentToolChain = sourceStirlingFileStub.toolHistory || [];
|
||||
} else {
|
||||
// File has no history - this becomes version 1
|
||||
newVersionNumber = 1;
|
||||
originalFileId = sourceStirlingFileStub.id; // Use source file ID as original
|
||||
parentFileId = sourceStirlingFileStub.id; // Parent is the source file
|
||||
parentToolChain = []; // No previous tools
|
||||
}
|
||||
|
||||
// Create new tool chain with the new operation
|
||||
const newToolChain = [...parentToolChain, toolOperation];
|
||||
|
||||
modifiedBytes = await pdfMetadataService.injectHistoryMetadata(
|
||||
arrayBuffer,
|
||||
originalFileId,
|
||||
parentFileId,
|
||||
newToolChain,
|
||||
newVersionNumber
|
||||
);
|
||||
|
||||
// Create new file with updated metadata
|
||||
return new File([modifiedBytes], file.name, { type: file.type });
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn('📄 Failed to inject history for tool operation:', error);
|
||||
return file; // Return original file if injection fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare StirlingFiles with history-injected PDFs for tool operations
|
||||
* Preserves fileId and all StirlingFile metadata while injecting history
|
||||
*/
|
||||
export async function prepareStirlingFilesWithHistory(
|
||||
stirlingFiles: import('../types/fileContext').StirlingFile[],
|
||||
getStirlingFileStub: (fileId: import('../types/file').FileId) => StirlingFileStub | undefined,
|
||||
toolName: string,
|
||||
parameters?: Record<string, any>
|
||||
): Promise<import('../types/fileContext').StirlingFile[]> {
|
||||
const processedFiles: import('../types/fileContext').StirlingFile[] = [];
|
||||
|
||||
for (const stirlingFile of stirlingFiles) {
|
||||
const fileStub = getStirlingFileStub(stirlingFile.fileId);
|
||||
|
||||
if (!fileStub) {
|
||||
// If no stub found, keep original file
|
||||
processedFiles.push(stirlingFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inject history into the file data
|
||||
const fileWithHistory = await injectHistoryForTool(stirlingFile, fileStub, toolName, parameters);
|
||||
|
||||
// Create new StirlingFile with the updated file data but preserve fileId and quickKey
|
||||
const updatedStirlingFile = new File([fileWithHistory], fileWithHistory.name, {
|
||||
type: fileWithHistory.type,
|
||||
lastModified: fileWithHistory.lastModified
|
||||
}) as import('../types/fileContext').StirlingFile;
|
||||
|
||||
// Preserve the original fileId and quickKey
|
||||
Object.defineProperty(updatedStirlingFile, 'fileId', {
|
||||
value: stirlingFile.fileId,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(updatedStirlingFile, 'quickKey', {
|
||||
value: stirlingFile.quickKey,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
processedFiles.push(updatedStirlingFile);
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that processed files preserved metadata from originals
|
||||
* Logs warnings for tools that strip standard PDF metadata
|
||||
*/
|
||||
export async function verifyToolMetadataPreservation(
|
||||
originalFiles: File[],
|
||||
processedFiles: File[],
|
||||
toolName: string
|
||||
): Promise<void> {
|
||||
if (originalFiles.length === 0 || processedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
// For single-file tools, compare the original with the processed file
|
||||
if (originalFiles.length === 1 && processedFiles.length === 1) {
|
||||
const originalBytes = await originalFiles[0].arrayBuffer();
|
||||
const processedBytes = await processedFiles[0].arrayBuffer();
|
||||
|
||||
await pdfMetadataService.verifyMetadataPreservation(
|
||||
originalBytes,
|
||||
processedBytes,
|
||||
toolName
|
||||
);
|
||||
}
|
||||
// For multi-file tools, we could add more complex verification later
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to verify metadata preservation for ${toolName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group files by processing branches - each branch ends in a leaf file
|
||||
@ -264,49 +68,6 @@ export function groupFilesByOriginal(StirlingFileStubs: StirlingFileStub[]): Map
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version of each file group (optimized version using leaf flags)
|
||||
*/
|
||||
export function getLatestVersions(fileStubs: StirlingFileStub[]): StirlingFileStub[] {
|
||||
// If we have leaf flags, use them for much faster filtering
|
||||
const hasLeafFlags = fileStubs.some(fileStub => fileStub.isLeaf !== undefined);
|
||||
|
||||
if (hasLeafFlags) {
|
||||
// Fast path: just return files marked as leaf nodes
|
||||
return fileStubs.filter(fileStub => fileStub.isLeaf !== false); // Default to true if undefined
|
||||
} else {
|
||||
// Fallback to expensive calculation for backward compatibility
|
||||
const groups = groupFilesByOriginal(fileStubs);
|
||||
const latestVersions: StirlingFileStub[] = [];
|
||||
|
||||
for (const [_, fileStubs] of groups) {
|
||||
if (fileStubs.length > 0) {
|
||||
// First item is the latest version (sorted desc by version number)
|
||||
latestVersions.push(fileStubs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return latestVersions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history for a file
|
||||
*/
|
||||
export function getVersionHistory(
|
||||
targetFileStub: StirlingFileStub,
|
||||
allFileStubs: StirlingFileStub[]
|
||||
): StirlingFileStub[] {
|
||||
const originalId = targetFileStub.originalFileId || targetFileStub.id;
|
||||
|
||||
return allFileStubs
|
||||
.filter(fileStub => {
|
||||
const fileStubOriginalId = fileStub.originalFileId || fileStub.id;
|
||||
return fileStubOriginalId === originalId;
|
||||
})
|
||||
.sort((a, b) => (b.versionNumber || 0) - (a.versionNumber || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file has version history
|
||||
*/
|
||||
@ -314,195 +75,4 @@ export function hasVersionHistory(fileStub: StirlingFileStub): boolean {
|
||||
return !!(fileStub.originalFileId && fileStub.versionNumber && fileStub.versionNumber > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a descriptive name for a file version
|
||||
*/
|
||||
export function generateVersionName(fileStub: StirlingFileStub): string {
|
||||
const baseName = fileStub.name.replace(/\.pdf$/i, '');
|
||||
|
||||
if (!hasVersionHistory(fileStub)) {
|
||||
return fileStub.name;
|
||||
}
|
||||
|
||||
const versionInfo = fileStub.versionNumber ? ` (v${fileStub.versionNumber})` : '';
|
||||
const toolInfo = fileStub.toolHistory && fileStub.toolHistory.length > 0
|
||||
? ` - ${fileStub.toolHistory[fileStub.toolHistory.length - 1].toolName}`
|
||||
: '';
|
||||
|
||||
return `${baseName}${versionInfo}${toolInfo}.pdf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent files efficiently using leaf flags from IndexedDB
|
||||
* This is much faster than loading all files and calculating leaf nodes
|
||||
*/
|
||||
export async function getRecentLeafFiles(): Promise<import('../services/fileStorage').StoredFile[]> {
|
||||
try {
|
||||
const { fileStorage } = await import('../services/fileStorage');
|
||||
return await fileStorage.getLeafFiles();
|
||||
} catch (error) {
|
||||
console.warn('Failed to get recent leaf files from IndexedDB:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent file metadata efficiently using leaf flags from IndexedDB
|
||||
* This is much faster than loading all files and calculating leaf nodes
|
||||
*/
|
||||
export async function getRecentLeafFileMetadata(): Promise<Omit<import('../services/fileStorage').StoredFile, 'data'>[]> {
|
||||
try {
|
||||
const { fileStorage } = await import('../services/fileStorage');
|
||||
return await fileStorage.getLeafFileMetadata();
|
||||
} catch (error) {
|
||||
console.warn('Failed to get recent leaf file metadata from IndexedDB:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract basic file metadata (version number and tool chain) without full history calculation
|
||||
* This is lightweight and used for displaying essential info on file thumbnails
|
||||
*/
|
||||
export async function extractBasicFileMetadata(
|
||||
file: File,
|
||||
fileStub: StirlingFileStub
|
||||
): Promise<StirlingFileStub> {
|
||||
// Only process PDF files
|
||||
if (!file.type.includes('pdf')) {
|
||||
return fileStub;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const historyMetadata = await pdfMetadataService.extractHistoryMetadata(arrayBuffer);
|
||||
|
||||
if (historyMetadata) {
|
||||
const history = historyMetadata.stirlingHistory;
|
||||
|
||||
// Update fileStub with essential metadata only (no parent/original relationships)
|
||||
return {
|
||||
...fileStub,
|
||||
versionNumber: history.versionNumber,
|
||||
toolHistory: history.toolChain
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn('📄 Failed to extract basic metadata:', file.name, error);
|
||||
}
|
||||
|
||||
return fileStub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load file history on-demand for a specific file
|
||||
* This replaces the automatic history extraction during file loading
|
||||
*/
|
||||
export async function loadFileHistoryOnDemand(
|
||||
file: File,
|
||||
fileId: FileId,
|
||||
updateFileStub?: (id: FileId, updates: Partial<StirlingFileStub>) => void
|
||||
): Promise<{
|
||||
originalFileId?: string;
|
||||
versionNumber?: number;
|
||||
parentFileId?: FileId;
|
||||
toolHistory?: Array<{
|
||||
toolName: string;
|
||||
timestamp: number;
|
||||
parameters?: Record<string, any>;
|
||||
}>;
|
||||
} | null> {
|
||||
// Only process PDF files
|
||||
if (!file.type.includes('pdf')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFileStub: StirlingFileStub = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
|
||||
const updatedFileStub = await extractFileHistory(file, baseFileStub);
|
||||
|
||||
if (updatedFileStub !== baseFileStub && (updatedFileStub.originalFileId || updatedFileStub.versionNumber)) {
|
||||
const historyData = {
|
||||
originalFileId: updatedFileStub.originalFileId,
|
||||
versionNumber: updatedFileStub.versionNumber,
|
||||
parentFileId: updatedFileStub.parentFileId,
|
||||
toolHistory: updatedFileStub.toolHistory
|
||||
};
|
||||
|
||||
// Update the file stub if update function is provided
|
||||
if (updateFileStub) {
|
||||
updateFileStub(fileId, historyData);
|
||||
}
|
||||
|
||||
return historyData;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load history for ${file.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata for storing files with history information
|
||||
*/
|
||||
export async function createFileMetadataWithHistory(
|
||||
file: File,
|
||||
fileId: FileId,
|
||||
thumbnail?: string
|
||||
): Promise<FileMetadata> {
|
||||
const baseMetadata: FileMetadata = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
thumbnail,
|
||||
isLeaf: true // New files are leaf nodes by default
|
||||
};
|
||||
|
||||
// Extract metadata for PDF files
|
||||
if (file.type.includes('pdf')) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const [historyMetadata, standardMetadata] = await Promise.all([
|
||||
pdfMetadataService.extractHistoryMetadata(arrayBuffer),
|
||||
pdfMetadataService.extractStandardMetadata(arrayBuffer)
|
||||
]);
|
||||
|
||||
const result = { ...baseMetadata };
|
||||
|
||||
// Add standard PDF metadata if available
|
||||
if (standardMetadata) {
|
||||
result.pdfMetadata = standardMetadata;
|
||||
}
|
||||
|
||||
// Add history metadata if available (basic version for display)
|
||||
if (historyMetadata) {
|
||||
const history = historyMetadata.stirlingHistory;
|
||||
// Only add basic metadata needed for display, not full history relationships
|
||||
result.versionNumber = history.versionNumber;
|
||||
result.historyInfo = {
|
||||
originalFileId: history.originalFileId,
|
||||
parentFileId: history.parentFileId,
|
||||
versionNumber: history.versionNumber,
|
||||
toolChain: history.toolChain
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn('📄 Failed to extract metadata:', file.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
return baseMetadata;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { FileOperation } from '../types/fileContext';
|
||||
*/
|
||||
export const createOperation = <TParams = void>(
|
||||
operationType: string,
|
||||
params: TParams,
|
||||
_params: TParams,
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: FileId } => {
|
||||
const operationId = `${operationType}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
@ -20,7 +20,6 @@ export const createOperation = <TParams = void>(
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: selectedFiles[0]?.name,
|
||||
parameters: params,
|
||||
fileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0)
|
||||
}
|
||||
} as any /* FIX ME*/;
|
||||
|
Loading…
x
Reference in New Issue
Block a user