feat: Add support for generating thumbnails with metadata and enhance file processing capabilities

- Introduced `generateThumbnailWithMetadata` function to generate thumbnails and extract page counts for PDF files.
- Updated `FileContextProvider` to handle processed files with pre-existing thumbnails and metadata.
- Enhanced `useToolOperation` to utilize new thumbnail generation method and manage processed files efficiently.
- Refactored `useToolResources` to include the new thumbnail generation method.
- Updated relevant components to improve logging and error handling during thumbnail generation.
This commit is contained in:
Reece Browne 2025-08-14 22:54:43 +01:00
parent f691e690e4
commit c8eb7ac647
10 changed files with 447 additions and 59 deletions

View File

@ -46,6 +46,7 @@ import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingS
import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { fileStorage } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage';
import { fileProcessingService } from '../services/fileProcessingService'; import { fileProcessingService } from '../services/fileProcessingService';
import { generateThumbnailWithMetadata } from '../utils/thumbnailUtils';
// Get service instances // Get service instances
const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance(); const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
@ -419,7 +420,7 @@ export function FileContextProvider({
// Action implementations // Action implementations
const addFiles = useCallback(async (files: File[]): Promise<File[]> => { const addFiles = useCallback(async (files: File[]): Promise<File[]> => {
// Three-tier deduplication: UUID (primary key) + quickKey (soft dedupe) + contentHash (hard dedupe) console.log(`📄 addFiles: Adding ${files.length} files with immediate thumbnail generation`);
const fileRecords: FileRecord[] = []; const fileRecords: FileRecord[] = [];
const addedFiles: File[] = []; const addedFiles: File[] = [];
@ -443,8 +444,41 @@ export function FileContextProvider({
// Store File in ref map // Store File in ref map
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
// Create record // Generate thumbnail and page count immediately
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
console.log(`📄 Generating immediate thumbnail and metadata for ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
console.log(`📄 Generated immediate metadata for ${file.name}: ${pageCount} pages, thumbnail: ${!!thumbnail}`);
} catch (error) {
console.warn(`📄 Failed to generate immediate metadata for ${file.name}:`, error);
// Continue with defaults
}
// Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId); const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
// Create initial processedFile metadata with page count
if (pageCount > 0) {
record.processedFile = {
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()
};
console.log(`📄 addFiles: Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
// Add to deduplication tracking // Add to deduplication tracking
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
@ -452,29 +486,40 @@ export function FileContextProvider({
fileRecords.push(record); fileRecords.push(record);
addedFiles.push(file); addedFiles.push(file);
// Start centralized file processing (async, non-blocking) - SINGLE CALL // Start background processing for validation only (we already have thumbnail and page count)
fileProcessingService.processFile(file, fileId).then(result => { fileProcessingService.processFile(file, fileId).then(result => {
// Only update if file still exists in context // Only update if file still exists in context
if (filesRef.current.has(fileId)) { if (filesRef.current.has(fileId)) {
if (result.success && result.metadata) { if (result.success && result.metadata) {
// Update with processed metadata using dispatch directly // Only log if page count differs from our immediate calculation
const initialPageCount = pageCount;
if (result.metadata.totalPages !== initialPageCount) {
console.log(`📄 Page count validation: ${file.name} initial=${initialPageCount} → final=${result.metadata.totalPages} pages`);
// Update with the validated page count, but preserve existing thumbnail
dispatch({ dispatch({
type: 'UPDATE_FILE_RECORD', type: 'UPDATE_FILE_RECORD',
payload: { payload: {
id: fileId, id: fileId,
updates: { updates: {
processedFile: result.metadata, processedFile: {
thumbnailUrl: result.metadata.thumbnailUrl ...result.metadata,
// Preserve our immediate thumbnail if we have one
thumbnailUrl: thumbnail || result.metadata.thumbnailUrl
},
// Keep existing thumbnailUrl if we have one
thumbnailUrl: thumbnail || result.metadata.thumbnailUrl
} }
} }
}); });
console.log(`✅ File processing complete for ${file.name}: ${result.metadata.totalPages} pages`); } else {
console.log(`✅ Page count validation passed for ${file.name}: ${result.metadata.totalPages} pages (immediate generation was correct)`);
}
// Optional: Persist to IndexedDB if enabled (reuse the same result) // Optional: Persist to IndexedDB if enabled
if (enablePersistence) { if (enablePersistence) {
try { try {
const thumbnail = result.metadata.thumbnailUrl; const finalThumbnail = thumbnail || result.metadata.thumbnailUrl;
fileStorage.storeFile(file, fileId, thumbnail).then(() => { fileStorage.storeFile(file, fileId, finalThumbnail).then(() => {
console.log('File persisted to IndexedDB:', fileId); console.log('File persisted to IndexedDB:', fileId);
}).catch(error => { }).catch(error => {
console.warn('Failed to persist file to IndexedDB:', error); console.warn('Failed to persist file to IndexedDB:', error);
@ -484,11 +529,11 @@ export function FileContextProvider({
} }
} }
} else { } else {
console.warn(`File processing failed for ${file.name}:`, result.error); console.warn(`Background file processing failed for ${file.name}:`, result.error);
} }
} }
}).catch(error => { }).catch(error => {
console.error(`File processing error for ${file.name}:`, error); console.error(`Background file processing error for ${file.name}:`, error);
}); });
} }
@ -502,7 +547,118 @@ export function FileContextProvider({
return addedFiles; return addedFiles;
}, [enablePersistence]); // Remove updateFileRecord dependency }, [enablePersistence]); // Remove updateFileRecord dependency
// NEW: Add processed files with pre-existing thumbnails and metadata (for tool outputs)
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
console.log(`📄 addProcessedFiles: Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
const fileRecords: FileRecord[] = [];
const addedFiles: File[] = [];
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = new Set<string>();
Object.values(stateRef.current.files.byId).forEach(record => {
existingQuickKeys.add(record.quickKey);
});
for (const { file, thumbnail, pageCount } of filesWithThumbnails) {
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) {
console.log(`📄 Skipping duplicate processed file: ${file.name} (already exists)`);
continue; // Skip duplicate file
}
const fileId = createFileId(); // UUID-based, zero collisions
// Store File in ref map
filesRef.current.set(fileId, file);
// Create record with pre-existing thumbnail and page metadata
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
// If we have page count, create initial processedFile metadata
if (pageCount && pageCount > 0) {
record.processedFile = {
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()
};
console.log(`📄 addProcessedFiles: Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
// Add to deduplication tracking
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push(file);
// Start background processing for page metadata only (thumbnail already provided)
fileProcessingService.processFile(file, fileId).then(result => {
// Only update if file still exists in context
if (filesRef.current.has(fileId)) {
if (result.success && result.metadata) {
// Update with processed metadata but preserve existing thumbnail
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: {
processedFile: result.metadata,
// Keep existing thumbnail if we already have one, otherwise use processed one
thumbnailUrl: thumbnail || result.metadata.thumbnailUrl
}
}
});
// Only log if page count changed (meaning our initial guess was wrong)
const initialPageCount = pageCount || 1;
if (result.metadata.totalPages !== initialPageCount) {
console.log(`📄 Page count updated for ${file.name}: ${initialPageCount}${result.metadata.totalPages} pages`);
} else {
console.log(`✅ Processed file metadata complete for ${file.name}: ${result.metadata.totalPages} pages (thumbnail: ${thumbnail ? 'PRE-EXISTING' : 'GENERATED'})`);
}
// Optional: Persist to IndexedDB if enabled
if (enablePersistence) {
try {
const finalThumbnail = thumbnail || result.metadata.thumbnailUrl;
fileStorage.storeFile(file, fileId, finalThumbnail).then(() => {
console.log('Processed file persisted to IndexedDB:', fileId);
}).catch(error => {
console.warn('Failed to persist processed file to IndexedDB:', error);
});
} catch (error) {
console.warn('Failed to initiate processed file persistence:', error);
}
}
} else {
console.warn(`❌ Processed file background processing failed for ${file.name}:`, result.error);
}
}
}).catch(error => {
console.error(`❌ Processed file background processing error for ${file.name}:`, error);
});
}
// Only dispatch if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
}
console.log(`📄 Added ${fileRecords.length} processed files with pre-existing thumbnails`);
return addedFiles;
}, [enablePersistence]);
// NEW: Add stored files with preserved IDs to prevent duplicates across sessions // NEW: Add stored files with preserved IDs to prevent duplicates across sessions
// This is the CORRECT way to handle files from IndexedDB storage - no File object mutation
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>): Promise<File[]> => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>): Promise<File[]> => {
const fileRecords: FileRecord[] = []; const fileRecords: FileRecord[] = [];
const addedFiles: File[] = []; const addedFiles: File[] = [];
@ -635,6 +791,7 @@ export function FileContextProvider({
// Memoized actions to prevent re-renders // Memoized actions to prevent re-renders
const actions = useMemo<FileContextActions>(() => ({ const actions = useMemo<FileContextActions>(() => ({
addFiles, addFiles,
addProcessedFiles,
addStoredFiles, addStoredFiles,
removeFiles, removeFiles,
updateFileRecord, updateFileRecord,
@ -658,7 +815,7 @@ export function FileContextProvider({
setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }),
confirmNavigation, confirmNavigation,
cancelNavigation cancelNavigation
}), [addFiles, addStoredFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); }), [addFiles, addProcessedFiles, addStoredFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]);
// Split context values to minimize re-renders // Split context values to minimize re-renders
const stateValue = useMemo<FileContextStateValue>(() => ({ const stateValue = useMemo<FileContextStateValue>(() => ({
@ -677,6 +834,7 @@ export function FileContextProvider({
...state.ui, ...state.ui,
// Action compatibility layer // Action compatibility layer
addFiles, addFiles,
addProcessedFiles,
addStoredFiles, addStoredFiles,
removeFiles, removeFiles,
updateFileRecord, updateFileRecord,
@ -701,7 +859,7 @@ export function FileContextProvider({
get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render
// Selectors // Selectors
...selectors ...selectors
}), [state, actions, addFiles, addStoredFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency }), [state, actions, addFiles, addProcessedFiles, addStoredFiles, removeFiles, updateFileRecord, setHasUnsavedChanges, requestNavigation, confirmNavigation, cancelNavigation, trackBlobUrl, trackPdfDocument, cleanupFile, scheduleCleanup]); // Removed selectors dependency
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {

View File

@ -121,7 +121,7 @@ export const useToolOperation = <TParams = void>(
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); const { generateThumbnails, generateThumbnailsWithMetadata, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
const executeOperation = useCallback(async ( const executeOperation = useCallback(async (
params: TParams, params: TParams,
@ -167,23 +167,31 @@ export const useToolOperation = <TParams = void>(
// Use explicit multiFileEndpoint flag to determine processing approach // Use explicit multiFileEndpoint flag to determine processing approach
if (config.multiFileEndpoint) { if (config.multiFileEndpoint) {
// Multi-file processing - single API call with all files // Multi-file processing - single API call with all files
console.log(`🚀 useToolOperation: Multi-file processing for ${config.operationType} with ${validFiles.length} files`);
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles); const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
console.log(`🚀 Calling endpoint: ${endpoint}`);
const response = await axios.post(endpoint, formData, { responseType: 'blob' }); const response = await axios.post(endpoint, formData, { responseType: 'blob' });
console.log(`🚀 Received response: ${response.data.size} bytes, type: ${response.data.type}`);
// Multi-file responses are typically ZIP files that need extraction // Multi-file responses are typically ZIP files that need extraction
if (config.responseHandler) { if (config.responseHandler) {
console.log(`🚀 Using custom responseHandler for ${config.operationType}`);
// Use custom responseHandler for multi-file (handles ZIP extraction) // Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles); processedFiles = await config.responseHandler(response.data, validFiles);
} else { } else {
console.log(`🚀 Using default ZIP extraction for ${config.operationType}`);
// Default: assume ZIP response for multi-file endpoints // Default: assume ZIP response for multi-file endpoints
processedFiles = await extractZipFiles(response.data); processedFiles = await extractZipFiles(response.data);
console.log(`🚀 Extracted ${processedFiles.length} files from ZIP`);
if (processedFiles.length === 0) { if (processedFiles.length === 0) {
console.log(`🚀 ZIP extraction failed, trying generic fallback`);
// Try the generic extraction as fallback // Try the generic extraction as fallback
processedFiles = await extractAllZipFiles(response.data); processedFiles = await extractAllZipFiles(response.data);
console.log(`🚀 Generic fallback extracted ${processedFiles.length} files`);
} }
} }
} else { } else {
@ -205,21 +213,35 @@ export const useToolOperation = <TParams = void>(
} }
if (processedFiles.length > 0) { if (processedFiles.length > 0) {
console.log(`🚀 useToolOperation: Processing complete. ${processedFiles.length} files ready for thumbnails:`,
processedFiles.map((f, i) => `[${i}]: ${f.name} (${f.type}, ${f.size} bytes)`));
actions.setFiles(processedFiles); actions.setFiles(processedFiles);
// Generate thumbnails and download URL concurrently // Generate thumbnails with metadata and download URL concurrently
actions.setGeneratingThumbnails(true); actions.setGeneratingThumbnails(true);
const [thumbnails, downloadInfo] = await Promise.all([ const [thumbnailResults, downloadInfo] = await Promise.all([
generateThumbnails(processedFiles), generateThumbnailsWithMetadata(processedFiles),
createDownloadInfo(processedFiles, config.operationType) createDownloadInfo(processedFiles, config.operationType)
]); ]);
actions.setGeneratingThumbnails(false); actions.setGeneratingThumbnails(false);
// Extract thumbnails for tool state and page counts for context
const thumbnails = thumbnailResults.map(r => r.thumbnail || '');
const pageCounts = thumbnailResults.map(r => r.pageCount);
console.log(`⚡ useToolOperation: Generated ${thumbnails.length} thumbnails with page counts for ${config.operationType}:`,
thumbnailResults.map((r, i) => `[${i}]: ${r.thumbnail ? 'PRESENT' : 'MISSING'} (${r.pageCount} pages)`));
actions.setThumbnails(thumbnails); actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Add to file context // Add to file context WITH pre-existing thumbnails AND page counts to avoid duplicate processing
await fileActions.addFiles(processedFiles); const filesWithMetadata = processedFiles.map((file, index) => ({
file,
thumbnail: thumbnails[index] || undefined,
pageCount: pageCounts[index] || undefined
}));
console.log(`📄 useToolOperation: Adding ${filesWithMetadata.length} processed files with pre-existing thumbnails and page counts to context`);
await fileActions.addProcessedFiles(filesWithMetadata);
markOperationApplied(fileId, operationId); markOperationApplied(fileId, operationId);
} }
@ -233,7 +255,7 @@ export const useToolOperation = <TParams = void>(
actions.setLoading(false); actions.setLoading(false);
actions.setProgress(null); actions.setProgress(null);
} }
}, [t, config, actions, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles, fileActions.addFiles]); }, [t, config, actions, processFiles, generateThumbnailsWithMetadata, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles, fileActions.addProcessedFiles]);
const cancelOperation = useCallback(() => { const cancelOperation = useCallback(() => {
cancelApiCalls(); cancelApiCalls();

View File

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { zipFileService } from '../../../services/zipFileService'; import { zipFileService } from '../../../services/zipFileService';
@ -43,23 +43,52 @@ export const useToolResources = () => {
}, []); // No dependencies - use ref to access current URLs }, []); // No dependencies - use ref to access current URLs
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => { const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
console.log(`🖼️ useToolResources.generateThumbnails: Starting for ${files.length} files`);
const thumbnails: string[] = []; const thumbnails: string[] = [];
for (const file of files) { for (const file of files) {
try { try {
console.log(`🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`);
const thumbnail = await generateThumbnailForFile(file); const thumbnail = await generateThumbnailForFile(file);
console.log(`🖼️ Generated thumbnail for ${file.name}:`, thumbnail ? 'SUCCESS' : 'FAILED (no thumbnail returned)');
if (thumbnail) { if (thumbnail) {
thumbnails.push(thumbnail); thumbnails.push(thumbnail);
} else {
console.warn(`🖼️ No thumbnail returned for ${file.name}`);
thumbnails.push('');
} }
} catch (error) { } catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error); console.warn(`🖼️ Failed to generate thumbnail for ${file.name}:`, error);
thumbnails.push(''); thumbnails.push('');
} }
} }
console.log(`🖼️ useToolResources.generateThumbnails: Complete. Generated ${thumbnails.filter(t => t).length}/${files.length} thumbnails`);
return thumbnails; return thumbnails;
}, []); }, []);
const generateThumbnailsWithMetadata = useCallback(async (files: File[]): Promise<ThumbnailWithMetadata[]> => {
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Starting for ${files.length} files`);
const results: ThumbnailWithMetadata[] = [];
for (const file of files) {
try {
console.log(`🖼️ Generating thumbnail with metadata for: ${file.name} (${file.type}, ${file.size} bytes)`);
const result = await generateThumbnailWithMetadata(file);
console.log(`🖼️ Generated thumbnail with metadata for ${file.name}:`,
result.thumbnail ? 'SUCCESS' : 'FAILED (no thumbnail)',
`${result.pageCount} pages`);
results.push(result);
} catch (error) {
console.warn(`🖼️ Failed to generate thumbnail with metadata for ${file.name}:`, error);
results.push({ thumbnail: '', pageCount: 1 });
}
}
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Complete. Generated ${results.filter(r => r.thumbnail).length}/${files.length} thumbnails with metadata`);
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => { const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try { try {
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' }); const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
@ -116,6 +145,7 @@ export const useToolResources = () => {
return { return {
generateThumbnails, generateThumbnails,
generateThumbnailsWithMetadata,
createDownloadInfo, createDownloadInfo,
extractZipFiles, extractZipFiles,
extractAllZipFiles, extractAllZipFiles,

View File

@ -88,6 +88,8 @@ export const useToolState = () => {
}, []); }, []);
const setThumbnails = useCallback((thumbnails: string[]) => { const setThumbnails = useCallback((thumbnails: string[]) => {
console.log(`🔧 useToolState.setThumbnails: Setting ${thumbnails.length} thumbnails:`,
thumbnails.map((t, i) => `[${i}]: ${t ? 'PRESENT' : 'MISSING'}`));
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails }); dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
}, []); }, []);

View File

@ -47,7 +47,7 @@ export const fileOperationsService = {
} }
try { try {
await fileStorage.init(); // fileStorage.init() no longer needed - using centralized IndexedDB manager
const storedFiles = await fileStorage.getAllFileMetadata(); const storedFiles = await fileStorage.getAllFileMetadata();
// Detect if IndexedDB was purged by comparing with current UI state // Detect if IndexedDB was purged by comparing with current UI state
@ -189,7 +189,7 @@ export const fileOperationsService = {
if (currentFiles.length === 0) return false; if (currentFiles.length === 0) return false;
try { try {
await fileStorage.init(); // fileStorage.init() no longer needed - using centralized IndexedDB manager
const storedFiles = await fileStorage.getAllFileMetadata(); const storedFiles = await fileStorage.getAllFileMetadata();
return storedFiles.length === 0; // Purge detected if no files in storage but UI shows files return storedFiles.length === 0; // Purge detected if no files in storage but UI shows files
} catch (error) { } catch (error) {

View File

@ -412,7 +412,8 @@ class FileStorageService {
} }
/** /**
* Convert StoredFile back to File object for compatibility * 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 { createFileFromStored(storedFile: StoredFile): File {
if (!storedFile || !storedFile.data) { if (!storedFile || !storedFile.data) {
@ -429,13 +430,27 @@ class FileStorageService {
lastModified: storedFile.lastModified lastModified: storedFile.lastModified
}); });
// Add custom properties for compatibility // Returns pure File object - no mutations
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); // Use FileContext.addStoredFiles() to properly associate with metadata
Object.defineProperty(file, 'thumbnail', { value: storedFile.thumbnail, writable: false });
return file; 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: string; metadata: { thumbnail?: string } } {
const file = this.createFileFromStored(storedFile);
return {
file,
originalId: storedFile.id,
metadata: {
thumbnail: storedFile.thumbnail
}
};
}
/** /**
* Create blob URL for stored file * Create blob URL for stored file
*/ */

View File

@ -69,13 +69,17 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults; const settingsCollapsed = hasResults;
const previewResults = useMemo(() => const previewResults = useMemo(() => {
splitOperation.files?.map((file, index) => ({ const results = splitOperation.files?.map((file, index) => ({
file, file,
thumbnail: splitOperation.thumbnails[index] thumbnail: splitOperation.thumbnails[index]
})) || [], })) || [];
[splitOperation.files, splitOperation.thumbnails]
); console.log(`🔧 Split tool preview: ${results.length} files, thumbnails:`,
splitOperation.thumbnails.map((t, i) => `[${i}]: ${t ? 'PRESENT' : 'MISSING'}`));
return results;
}, [splitOperation.files, splitOperation.thumbnails]);
return ( return (
<ToolStepContainer> <ToolStepContainer>

View File

@ -191,6 +191,7 @@ export type FileContextAction =
export interface FileContextActions { export interface FileContextActions {
// File management - lightweight actions only // File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>; addFiles: (files: File[]) => Promise<File[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void; updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;

View File

@ -1,8 +1,34 @@
import { FileWithUrl } from "../types/file"; import { FileWithUrl } from "../types/file";
import { StoredFile, fileStorage } from "../services/fileStorage"; import { StoredFile, fileStorage } from "../services/fileStorage";
/**
* @deprecated File objects no longer have mutated ID properties.
* The new system maintains pure File objects and tracks IDs separately in FileContext.
* Use FileContext selectors to get file IDs instead.
*/
export function getFileId(file: File): string | null { export function getFileId(file: File): string | null {
return (file as File & { id?: string }).id || null; const legacyId = (file as File & { id?: string }).id;
if (legacyId) {
console.warn('DEPRECATED: getFileId() found legacy mutated File object. Use FileContext selectors instead.');
}
return legacyId || null;
}
/**
* Get file ID for a File object using FileContext state (new system)
* @param file File object to find ID for
* @param fileState Current FileContext state
* @returns File ID or null if not found
*/
export function getFileIdFromContext(file: File, fileIds: string[], getFile: (id: string) => File | undefined): string | null {
// Find the file ID by comparing File objects
for (const id of fileIds) {
const contextFile = getFile(id);
if (contextFile === file) {
return id;
}
}
return null;
} }
/** /**
@ -81,7 +107,7 @@ export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?:
*/ */
export async function loadFilesFromIndexedDB(): Promise<FileWithUrl[]> { export async function loadFilesFromIndexedDB(): Promise<FileWithUrl[]> {
try { try {
await fileStorage.init(); // fileStorage.init() no longer needed - using centralized IndexedDB manager
const storedFiles = await fileStorage.getAllFileMetadata(); const storedFiles = await fileStorage.getAllFileMetadata();
if (storedFiles.length === 0) { if (storedFiles.length === 0) {

View File

@ -1,5 +1,10 @@
import { getDocument } from "pdfjs-dist"; import { getDocument } from "pdfjs-dist";
export interface ThumbnailWithMetadata {
thumbnail: string | undefined;
pageCount: number;
}
/** /**
* Calculate thumbnail scale based on file size * Calculate thumbnail scale based on file size
* Smaller files get higher quality, larger files get lower quality * Smaller files get higher quality, larger files get lower quality
@ -166,27 +171,36 @@ function formatFileSize(bytes: number): string {
* Returns base64 data URL or undefined if generation fails * Returns base64 data URL or undefined if generation fails
*/ */
export async function generateThumbnailForFile(file: File): Promise<string | undefined> { export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
console.log(`🎯 generateThumbnailForFile: Starting for ${file.name} (${file.type}, ${file.size} bytes)`);
// Skip thumbnail generation for very large files to avoid memory issues // Skip thumbnail generation for very large files to avoid memory issues
if (file.size >= 100 * 1024 * 1024) { // 100MB limit if (file.size >= 100 * 1024 * 1024) { // 100MB limit
console.log('Skipping thumbnail generation for large file:', file.name); console.log('🎯 Skipping thumbnail generation for large file:', file.name);
return generatePlaceholderThumbnail(file); const placeholder = generatePlaceholderThumbnail(file);
console.log('🎯 Generated placeholder thumbnail for large file:', file.name);
return placeholder;
} }
// Handle image files - use original file directly // Handle image files - use original file directly
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
return URL.createObjectURL(file); console.log('🎯 Creating blob URL for image file:', file.name);
const url = URL.createObjectURL(file);
console.log('🎯 Created image blob URL:', url);
return url;
} }
// Handle PDF files // Handle PDF files
if (!file.type.startsWith('application/pdf')) { if (!file.type.startsWith('application/pdf')) {
console.log('File is not a PDF or image, generating placeholder:', file.name); console.log('🎯 File is not a PDF or image, generating placeholder:', file.name);
return generatePlaceholderThumbnail(file); const placeholder = generatePlaceholderThumbnail(file);
console.log('🎯 Generated placeholder thumbnail for non-PDF file:', file.name);
return placeholder;
} }
// Calculate quality scale based on file size // Calculate quality scale based on file size
console.log('Generating thumbnail for', file.name); console.log('🎯 Generating PDF thumbnail for', file.name);
const scale = calculateScaleFromFileSize(file.size); const scale = calculateScaleFromFileSize(file.size);
console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`); console.log(`🎯 Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
try { try {
// Only read first 2MB for thumbnail generation to save memory // Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB const chunkSize = 2 * 1024 * 1024; // 2MB
@ -215,13 +229,14 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
// Immediately clean up memory after thumbnail generation // Immediately clean up memory after thumbnail generation
pdf.destroy(); pdf.destroy();
console.log('Thumbnail generated and PDF destroyed for', file.name); console.log('🎯 PDF thumbnail successfully generated for', file.name, 'size:', thumbnail.length);
return thumbnail; return thumbnail;
} catch (error) { } catch (error) {
console.warn('🎯 Error generating PDF thumbnail for', file.name, ':', error);
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === 'InvalidPDFException') { if (error.name === 'InvalidPDFException') {
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`); console.warn(`🎯 PDF structure issue for ${file.name} - trying fallback with full file`);
// Return a placeholder or try with full file instead of chunk // Return a placeholder or try with full file instead of chunk
try { try {
const fullArrayBuffer = await file.arrayBuffer(); const fullArrayBuffer = await file.arrayBuffer();
@ -247,17 +262,132 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
const thumbnail = canvas.toDataURL(); const thumbnail = canvas.toDataURL();
pdf.destroy(); pdf.destroy();
console.log('🎯 Fallback PDF thumbnail generation succeeded for', file.name);
return thumbnail; return thumbnail;
} catch (fallbackError) { } catch (fallbackError) {
console.warn('Fallback thumbnail generation also failed for', file.name, fallbackError); console.warn('🎯 Fallback thumbnail generation also failed for', file.name, fallbackError);
return undefined; console.log('🎯 Using placeholder thumbnail for', file.name);
return generatePlaceholderThumbnail(file);
} }
} else { } else {
console.warn('Failed to generate thumbnail for', file.name, error); console.warn('🎯 Non-PDF error generating thumbnail for', file.name, error);
return undefined; console.log('🎯 Using placeholder thumbnail for', file.name);
return generatePlaceholderThumbnail(file);
} }
} }
console.warn('Unknown error generating thumbnail for', file.name, error); console.warn('🎯 Unknown error generating thumbnail for', file.name, error);
return undefined; console.log('🎯 Using placeholder thumbnail for', file.name);
return generatePlaceholderThumbnail(file);
}
}
/**
* Generate thumbnail and extract page count for a PDF file
* Returns both thumbnail and metadata in a single pass
*/
export async function generateThumbnailWithMetadata(file: File): Promise<ThumbnailWithMetadata> {
console.log(`🎯 generateThumbnailWithMetadata: Starting for ${file.name} (${file.type}, ${file.size} bytes)`);
// Non-PDF files default to 1 page
if (!file.type.startsWith('application/pdf')) {
console.log('🎯 File is not a PDF, generating placeholder with pageCount=1:', file.name);
const thumbnail = await generateThumbnailForFile(file);
return { thumbnail, pageCount: 1 };
}
// Skip thumbnail generation for very large files to avoid memory issues
if (file.size >= 100 * 1024 * 1024) { // 100MB limit
console.log('🎯 Skipping processing for large PDF file:', file.name);
const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 }; // Default to 1 for large files
}
// Calculate quality scale based on file size
const scale = calculateScaleFromFileSize(file.size);
console.log(`🎯 Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
try {
// Read file chunk for processing
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const arrayBuffer = await chunk.arrayBuffer();
const pdf = await getDocument({
data: arrayBuffer,
disableAutoFetch: true,
disableStream: true,
verbosity: 0
}).promise;
const pageCount = pdf.numPages;
console.log(`🎯 PDF ${file.name} has ${pageCount} pages`);
// Generate thumbnail for first page
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) {
pdf.destroy();
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
// Clean up
pdf.destroy();
console.log('🎯 Successfully generated thumbnail with metadata for', file.name, `${pageCount} pages, thumbnail size:`, thumbnail.length);
return { thumbnail, pageCount };
} catch (error) {
console.warn('🎯 Error generating PDF thumbnail with metadata for', file.name, ':', error);
// Try fallback with full file if chunk approach failed
if (error instanceof Error && error.name === 'InvalidPDFException') {
try {
console.warn(`🎯 Trying fallback with full file for ${file.name}`);
const fullArrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({
data: fullArrayBuffer,
disableAutoFetch: true,
disableStream: true,
verbosity: 0
}).promise;
const pageCount = pdf.numPages;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) {
pdf.destroy();
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
pdf.destroy();
console.log('🎯 Fallback successful for', file.name, `${pageCount} pages`);
return { thumbnail, pageCount };
} catch (fallbackError) {
console.warn('🎯 Fallback also failed for', file.name, fallbackError);
}
}
// Final fallback: placeholder thumbnail with default page count
console.log('🎯 Using placeholder thumbnail with default pageCount=1 for', file.name);
const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 };
} }
} }