2025-08-21 17:30:26 +01:00
|
|
|
/**
|
|
|
|
* File actions - Unified file operations with single addFiles helper
|
|
|
|
*/
|
|
|
|
|
2025-08-28 10:56:07 +01:00
|
|
|
import {
|
2025-09-05 11:33:03 +01:00
|
|
|
StirlingFileStub,
|
2025-08-21 17:30:26 +01:00
|
|
|
FileContextAction,
|
|
|
|
FileContextState,
|
2025-09-16 15:08:11 +01:00
|
|
|
createNewStirlingFileStub,
|
2025-08-21 17:30:26 +01:00
|
|
|
createFileId,
|
2025-09-16 15:08:11 +01:00
|
|
|
createQuickKey,
|
|
|
|
createStirlingFile,
|
|
|
|
ProcessedFileMetadata,
|
2025-08-21 17:30:26 +01:00
|
|
|
} from '../../types/fileContext';
|
2025-09-16 15:08:11 +01:00
|
|
|
import { FileId } from '../../types/file';
|
2025-08-21 17:30:26 +01:00
|
|
|
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
|
|
|
import { FileLifecycleManager } from './lifecycle';
|
2025-09-05 12:16:17 +01:00
|
|
|
import { buildQuickKeySet } from './fileSelectors';
|
2025-09-16 15:08:11 +01:00
|
|
|
import { StirlingFile } from '../../types/fileContext';
|
|
|
|
import { fileStorage } from '../../services/fileStorage';
|
2025-08-21 17:30:26 +01:00
|
|
|
const DEBUG = process.env.NODE_ENV === 'development';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Simple mutex to prevent race conditions in addFiles
|
|
|
|
*/
|
|
|
|
class SimpleMutex {
|
|
|
|
private locked = false;
|
|
|
|
private queue: Array<() => void> = [];
|
|
|
|
|
|
|
|
async lock(): Promise<void> {
|
|
|
|
if (!this.locked) {
|
|
|
|
this.locked = true;
|
|
|
|
return Promise.resolve();
|
|
|
|
}
|
|
|
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
this.queue.push(() => {
|
|
|
|
this.locked = true;
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
unlock(): void {
|
|
|
|
if (this.queue.length > 0) {
|
|
|
|
const next = this.queue.shift()!;
|
|
|
|
next();
|
|
|
|
} else {
|
|
|
|
this.locked = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Global mutex for addFiles operations
|
|
|
|
const addFilesMutex = new SimpleMutex();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper to create ProcessedFile metadata structure
|
|
|
|
*/
|
|
|
|
export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
|
|
|
return {
|
|
|
|
totalPages: pageCount,
|
|
|
|
pages: Array.from({ length: pageCount }, (_, index) => ({
|
|
|
|
pageNumber: index + 1,
|
|
|
|
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
|
|
|
|
rotation: 0,
|
|
|
|
splitBefore: false
|
|
|
|
})),
|
|
|
|
thumbnailUrl: thumbnail,
|
|
|
|
lastProcessed: Date.now()
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-09-16 15:08:11 +01:00
|
|
|
* Generate fresh ProcessedFileMetadata for a file
|
|
|
|
* Used when tools process files to ensure metadata matches actual file content
|
2025-08-21 17:30:26 +01:00
|
|
|
*/
|
2025-09-16 15:08:11 +01:00
|
|
|
export async function generateProcessedFileMetadata(file: File): Promise<ProcessedFileMetadata | undefined> {
|
|
|
|
// Only generate metadata for PDF files
|
|
|
|
if (!file.type.startsWith('application/pdf')) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const result = await generateThumbnailWithMetadata(file);
|
|
|
|
return createProcessedFile(result.pageCount, result.thumbnail);
|
|
|
|
} catch (error) {
|
|
|
|
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
|
|
|
}
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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)
|
|
|
|
* @param resultingFile - The processed File object
|
|
|
|
* @param thumbnail - Optional thumbnail for the child
|
|
|
|
* @param processedFileMetadata - Optional fresh metadata for the processed file
|
|
|
|
* @returns New child StirlingFileStub with proper version history
|
|
|
|
*/
|
|
|
|
export function createChildStub(
|
|
|
|
parentStub: StirlingFileStub,
|
|
|
|
operation: { toolName: string; timestamp: number },
|
|
|
|
resultingFile: File,
|
|
|
|
thumbnail?: string,
|
|
|
|
processedFileMetadata?: ProcessedFileMetadata
|
|
|
|
): 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;
|
|
|
|
|
|
|
|
// Copy parent metadata but exclude processedFile to prevent stale data
|
|
|
|
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
|
|
|
|
|
|
|
return {
|
|
|
|
// Copy parent metadata (excluding processedFile)
|
|
|
|
...parentMetadata,
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
|
|
// Set fresh processedFile metadata (no inheritance from parent)
|
|
|
|
processedFile: processedFileMetadata
|
|
|
|
};
|
|
|
|
}
|
2025-08-21 17:30:26 +01:00
|
|
|
|
|
|
|
interface AddFileOptions {
|
|
|
|
files?: File[];
|
2025-08-28 10:56:07 +01:00
|
|
|
|
|
|
|
// For 'processed' files
|
2025-08-21 17:30:26 +01:00
|
|
|
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-26 15:30:58 +01:00
|
|
|
// Insertion position
|
|
|
|
insertAfterPageId?: string;
|
2025-08-21 17:30:26 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Auto-selection after adding
|
|
|
|
selectFiles?: boolean;
|
2025-08-29 16:39:19 +01:00
|
|
|
}
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
/**
|
2025-09-16 15:08:11 +01:00
|
|
|
* Unified file addition helper - replaces addFiles
|
2025-08-21 17:30:26 +01:00
|
|
|
*/
|
|
|
|
export async function addFiles(
|
|
|
|
options: AddFileOptions,
|
|
|
|
stateRef: React.MutableRefObject<FileContextState>,
|
|
|
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
|
|
|
dispatch: React.Dispatch<FileContextAction>,
|
2025-09-16 15:08:11 +01:00
|
|
|
lifecycleManager: FileLifecycleManager,
|
|
|
|
enablePersistence: boolean = false
|
|
|
|
): Promise<StirlingFile[]> {
|
2025-08-21 17:30:26 +01:00
|
|
|
// Acquire mutex to prevent race conditions
|
|
|
|
await addFilesMutex.lock();
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
try {
|
2025-09-05 11:33:03 +01:00
|
|
|
const stirlingFileStubs: StirlingFileStub[] = [];
|
2025-09-16 15:08:11 +01:00
|
|
|
const stirlingFiles: StirlingFile[] = [];
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Build quickKey lookup from existing files for deduplication
|
|
|
|
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
const { files = [] } = options;
|
|
|
|
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
for (const file of files) {
|
|
|
|
const quickKey = createQuickKey(file);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Soft deduplication: Check if file already exists by metadata
|
|
|
|
if (existingQuickKeys.has(quickKey)) {
|
|
|
|
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
|
|
|
continue;
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
2025-09-16 15:08:11 +01:00
|
|
|
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
|
|
|
|
|
|
|
const fileId = createFileId();
|
|
|
|
filesRef.current.set(fileId, file);
|
|
|
|
|
|
|
|
// Generate processedFile metadata using centralized function
|
|
|
|
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
|
|
|
|
|
|
|
// Extract thumbnail for non-PDF files or use from processedFile for PDFs
|
|
|
|
let thumbnail: string | undefined;
|
|
|
|
if (processedFileMetadata) {
|
|
|
|
// PDF file - use thumbnail from processedFile metadata
|
|
|
|
thumbnail = processedFileMetadata.thumbnailUrl;
|
|
|
|
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${processedFileMetadata.totalPages} pages, thumbnail: SUCCESS`);
|
|
|
|
} else if (!file.type.startsWith('application/pdf')) {
|
|
|
|
// Non-PDF files: simple thumbnail generation, no processedFile metadata
|
|
|
|
try {
|
|
|
|
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
|
|
|
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
|
|
|
thumbnail = await generateThumbnailForFile(file);
|
|
|
|
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
|
|
|
} catch (error) {
|
|
|
|
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Create new filestub with processedFile metadata
|
|
|
|
const fileStub = createNewStirlingFileStub(file, fileId, thumbnail, processedFileMetadata);
|
|
|
|
if (thumbnail) {
|
|
|
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
|
|
|
if (thumbnail.startsWith('blob:')) {
|
|
|
|
lifecycleManager.trackBlobUrl(thumbnail);
|
|
|
|
}
|
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Store insertion position if provided
|
|
|
|
if (options.insertAfterPageId !== undefined) {
|
|
|
|
fileStub.insertAfterPageId = options.insertAfterPageId;
|
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
existingQuickKeys.add(quickKey);
|
|
|
|
stirlingFileStubs.push(fileStub);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Create StirlingFile directly
|
|
|
|
const stirlingFile = createStirlingFile(file, fileId);
|
|
|
|
stirlingFiles.push(stirlingFile);
|
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Persist to storage if enabled using fileStorage service
|
|
|
|
if (enablePersistence && stirlingFiles.length > 0) {
|
|
|
|
await Promise.all(stirlingFiles.map(async (stirlingFile, index) => {
|
|
|
|
try {
|
|
|
|
// Get corresponding stub with all metadata
|
|
|
|
const fileStub = stirlingFileStubs[index];
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly
|
|
|
|
await fileStorage.storeStirlingFile(stirlingFile, fileStub);
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
if (DEBUG) console.log(`📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, fileStub);
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to persist file to storage:', stirlingFile.name, error);
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
2025-09-16 15:08:11 +01:00
|
|
|
}));
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Dispatch ADD_FILES action if we have new files
|
2025-09-05 11:33:03 +01:00
|
|
|
if (stirlingFileStubs.length > 0) {
|
|
|
|
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
return stirlingFiles;
|
2025-08-21 17:30:26 +01:00
|
|
|
} finally {
|
|
|
|
// Always release mutex even if error occurs
|
|
|
|
addFilesMutex.unlock();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-09-02 15:09:05 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Consume files helper - replace unpinned input files with output files
|
2025-09-16 15:08:11 +01:00
|
|
|
* Now accepts pre-created StirlingFiles and StirlingFileStubs to preserve all metadata
|
2025-09-02 15:09:05 +01:00
|
|
|
*/
|
|
|
|
export async function consumeFiles(
|
|
|
|
inputFileIds: FileId[],
|
2025-09-16 15:08:11 +01:00
|
|
|
outputStirlingFiles: StirlingFile[],
|
|
|
|
outputStirlingFileStubs: StirlingFileStub[],
|
2025-09-02 15:09:05 +01:00
|
|
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
2025-09-16 15:08:11 +01:00
|
|
|
dispatch: React.Dispatch<FileContextAction>
|
2025-09-02 15:09:05 +01:00
|
|
|
): Promise<FileId[]> {
|
2025-09-16 15:08:11 +01:00
|
|
|
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputStirlingFiles.length} output files with pre-created stubs`);
|
2025-09-02 15:09:05 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// 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})`);
|
|
|
|
}
|
2025-09-02 15:09:05 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// 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}`);
|
2025-09-02 15:09:05 +01:00
|
|
|
}
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-16 15:08:11 +01:00
|
|
|
// Mark input files as processed in storage (no longer leaf nodes)
|
|
|
|
if(!outputStirlingFileStubs.reduce((areAllV1, stub) => areAllV1 && (stub.versionNumber == 1), true)) {
|
|
|
|
await Promise.all(
|
|
|
|
inputFileIds.map(async (fileId) => {
|
|
|
|
try {
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 with pre-created stubs (no processing needed)
|
2025-08-28 10:56:07 +01:00
|
|
|
dispatch({
|
|
|
|
type: 'CONSUME_FILES',
|
|
|
|
payload: {
|
|
|
|
inputFileIds,
|
2025-09-16 15:08:11 +01:00
|
|
|
outputStirlingFileStubs: outputStirlingFileStubs
|
2025-08-28 10:56:07 +01:00
|
|
|
}
|
2025-08-21 17:30:26 +01:00
|
|
|
});
|
2025-08-28 10:56:07 +01:00
|
|
|
|
2025-09-05 11:33:03 +01:00
|
|
|
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
2025-09-02 15:09:05 +01:00
|
|
|
// Return the output file IDs for undo tracking
|
2025-09-16 15:08:11 +01:00
|
|
|
return outputStirlingFileStubs.map(stub => stub.id);
|
2025-09-02 15:09:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
|
|
|
*/
|
|
|
|
async function restoreFilesAndCleanup(
|
2025-09-05 11:33:03 +01:00
|
|
|
filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
|
2025-09-02 15:09:05 +01:00
|
|
|
fileIdsToRemove: FileId[],
|
|
|
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
|
|
|
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
|
|
|
): Promise<void> {
|
|
|
|
// Remove files from filesRef
|
|
|
|
fileIdsToRemove.forEach(id => {
|
|
|
|
if (filesRef.current.has(id)) {
|
|
|
|
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
|
|
|
|
filesRef.current.delete(id);
|
|
|
|
} else {
|
|
|
|
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Restore files to filesRef
|
|
|
|
filesToRestore.forEach(({ file, record }) => {
|
|
|
|
if (file && record) {
|
|
|
|
// Validate the file before restoring
|
|
|
|
if (file.size === 0) {
|
|
|
|
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
|
|
|
return;
|
|
|
|
}
|
2025-09-05 11:33:03 +01:00
|
|
|
|
2025-09-02 15:09:05 +01:00
|
|
|
// Restore the file to filesRef
|
|
|
|
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
|
|
|
filesRef.current.set(record.id, file);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Clean up IndexedDB
|
|
|
|
if (indexedDB) {
|
|
|
|
const indexedDBPromises = fileIdsToRemove.map(fileId =>
|
|
|
|
indexedDB.deleteFile(fileId).catch(error => {
|
|
|
|
console.error('Failed to delete file from IndexedDB:', fileId, error);
|
|
|
|
throw error; // Re-throw to trigger rollback
|
|
|
|
})
|
|
|
|
);
|
2025-09-05 11:33:03 +01:00
|
|
|
|
2025-09-02 15:09:05 +01:00
|
|
|
// Execute all IndexedDB operations
|
|
|
|
await Promise.all(indexedDBPromises);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
|
|
|
|
*/
|
|
|
|
export async function undoConsumeFiles(
|
|
|
|
inputFiles: File[],
|
2025-09-05 11:33:03 +01:00
|
|
|
inputStirlingFileStubs: StirlingFileStub[],
|
2025-09-02 15:09:05 +01:00
|
|
|
outputFileIds: FileId[],
|
|
|
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
|
|
|
dispatch: React.Dispatch<FileContextAction>,
|
|
|
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
|
|
|
): Promise<void> {
|
2025-09-05 11:33:03 +01:00
|
|
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
|
2025-09-02 15:09:05 +01:00
|
|
|
|
|
|
|
// Validate inputs
|
2025-09-05 11:33:03 +01:00
|
|
|
if (inputFiles.length !== inputStirlingFileStubs.length) {
|
|
|
|
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
|
2025-09-02 15:09:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Create a backup of current filesRef state for rollback
|
|
|
|
const backupFilesRef = new Map(filesRef.current);
|
2025-09-05 11:33:03 +01:00
|
|
|
|
2025-09-02 15:09:05 +01:00
|
|
|
try {
|
|
|
|
// Prepare files to restore
|
|
|
|
const filesToRestore = inputFiles.map((file, index) => ({
|
|
|
|
file,
|
2025-09-05 11:33:03 +01:00
|
|
|
record: inputStirlingFileStubs[index]
|
2025-09-02 15:09:05 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
// Restore input files and clean up output files
|
|
|
|
await restoreFilesAndCleanup(
|
|
|
|
filesToRestore,
|
|
|
|
outputFileIds,
|
|
|
|
filesRef,
|
|
|
|
indexedDB
|
|
|
|
);
|
|
|
|
|
|
|
|
// Dispatch the undo action (only if everything else succeeded)
|
|
|
|
dispatch({
|
|
|
|
type: 'UNDO_CONSUME_FILES',
|
|
|
|
payload: {
|
2025-09-05 11:33:03 +01:00
|
|
|
inputStirlingFileStubs,
|
2025-09-02 15:09:05 +01:00
|
|
|
outputFileIds
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2025-09-05 11:33:03 +01:00
|
|
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
|
2025-09-02 15:09:05 +01:00
|
|
|
} catch (error) {
|
|
|
|
// Rollback filesRef to previous state
|
|
|
|
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
|
|
|
filesRef.current.clear();
|
|
|
|
backupFilesRef.forEach((file, id) => {
|
|
|
|
filesRef.current.set(id, file);
|
|
|
|
});
|
|
|
|
throw error; // Re-throw to let caller handle
|
|
|
|
}
|
2025-08-21 17:30:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Action factory functions
|
|
|
|
*/
|
2025-09-16 15:08:11 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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}`);
|
|
|
|
|
|
|
|
// Use centralized metadata generation function
|
|
|
|
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
|
|
|
|
if (processedFileMetadata) {
|
|
|
|
record.processedFile = processedFileMetadata;
|
|
|
|
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
|
|
|
|
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
|
|
|
|
} else {
|
|
|
|
// Fallback for files that couldn't be processed
|
|
|
|
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
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 } }),
|
|
|
|
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
|
|
|
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
|
|
|
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
|
|
|
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
|
|
|
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
|
|
|
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
|
|
|
});
|