From c8eb7ac64798a4e5b35a4e086beb00b8b0c81c2f Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Thu, 14 Aug 2025 22:54:43 +0100 Subject: [PATCH] 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. --- frontend/src/contexts/FileContext.tsx | 200 ++++++++++++++++-- .../hooks/tools/shared/useToolOperation.ts | 36 +++- .../hooks/tools/shared/useToolResources.ts | 34 ++- .../src/hooks/tools/shared/useToolState.ts | 2 + .../src/services/fileOperationsService.ts | 4 +- frontend/src/services/fileStorage.ts | 25 ++- frontend/src/tools/Split.tsx | 14 +- frontend/src/types/fileContext.ts | 1 + frontend/src/utils/fileUtils.ts | 30 ++- frontend/src/utils/thumbnailUtils.ts | 160 ++++++++++++-- 10 files changed, 447 insertions(+), 59 deletions(-) diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index ed444605f..17c26bb15 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -46,6 +46,7 @@ import { EnhancedPDFProcessingService } from '../services/enhancedPDFProcessingS import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { fileStorage } from '../services/fileStorage'; import { fileProcessingService } from '../services/fileProcessingService'; +import { generateThumbnailWithMetadata } from '../utils/thumbnailUtils'; // Get service instances const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance(); @@ -419,7 +420,7 @@ export function FileContextProvider({ // Action implementations const addFiles = useCallback(async (files: File[]): Promise => { - // 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 addedFiles: File[] = []; @@ -443,8 +444,41 @@ export function FileContextProvider({ // Store File in ref map 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); + 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 existingQuickKeys.add(quickKey); @@ -452,29 +486,40 @@ export function FileContextProvider({ fileRecords.push(record); 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 => { // Only update if file still exists in context if (filesRef.current.has(fileId)) { if (result.success && result.metadata) { - // Update with processed metadata using dispatch directly - dispatch({ - type: 'UPDATE_FILE_RECORD', - payload: { - id: fileId, - updates: { - processedFile: result.metadata, - thumbnailUrl: result.metadata.thumbnailUrl + // 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({ + type: 'UPDATE_FILE_RECORD', + payload: { + id: fileId, + updates: { + processedFile: { + ...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) { try { - const thumbnail = result.metadata.thumbnailUrl; - fileStorage.storeFile(file, fileId, thumbnail).then(() => { + const finalThumbnail = thumbnail || result.metadata.thumbnailUrl; + fileStorage.storeFile(file, fileId, finalThumbnail).then(() => { console.log('File persisted to IndexedDB:', fileId); }).catch(error => { console.warn('Failed to persist file to IndexedDB:', error); @@ -484,11 +529,11 @@ export function FileContextProvider({ } } } else { - console.warn(`❌ File processing failed for ${file.name}:`, result.error); + console.warn(`❌ Background file processing failed for ${file.name}:`, result.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; }, [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 => { + 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(); + 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 + // 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 => { const fileRecords: FileRecord[] = []; const addedFiles: File[] = []; @@ -635,6 +791,7 @@ export function FileContextProvider({ // Memoized actions to prevent re-renders const actions = useMemo(() => ({ addFiles, + addProcessedFiles, addStoredFiles, removeFiles, updateFileRecord, @@ -658,7 +815,7 @@ export function FileContextProvider({ setMode: (mode: ModeType) => dispatch({ type: 'SET_CURRENT_MODE', payload: mode }), confirmNavigation, cancelNavigation - }), [addFiles, addStoredFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); + }), [addFiles, addProcessedFiles, addStoredFiles, removeFiles, cleanupAllFiles, setHasUnsavedChanges, confirmNavigation, cancelNavigation]); // Split context values to minimize re-renders const stateValue = useMemo(() => ({ @@ -677,6 +834,7 @@ export function FileContextProvider({ ...state.ui, // Action compatibility layer addFiles, + addProcessedFiles, addStoredFiles, removeFiles, updateFileRecord, @@ -701,7 +859,7 @@ export function FileContextProvider({ get activeFiles() { return selectors.getFiles(); }, // Getter to avoid creating new arrays on every render // 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 useEffect(() => { diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 5c89be44b..4c7d37dbf 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -121,7 +121,7 @@ export const useToolOperation = ( // Composed hooks const { state, actions } = useToolState(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); - const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); + const { generateThumbnails, generateThumbnailsWithMetadata, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); const executeOperation = useCallback(async ( params: TParams, @@ -167,23 +167,31 @@ export const useToolOperation = ( // Use explicit multiFileEndpoint flag to determine processing approach if (config.multiFileEndpoint) { // 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...'); const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles); 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' }); + console.log(`🚀 Received response: ${response.data.size} bytes, type: ${response.data.type}`); // Multi-file responses are typically ZIP files that need extraction if (config.responseHandler) { + console.log(`🚀 Using custom responseHandler for ${config.operationType}`); // Use custom responseHandler for multi-file (handles ZIP extraction) processedFiles = await config.responseHandler(response.data, validFiles); } else { + console.log(`🚀 Using default ZIP extraction for ${config.operationType}`); // Default: assume ZIP response for multi-file endpoints processedFiles = await extractZipFiles(response.data); + console.log(`🚀 Extracted ${processedFiles.length} files from ZIP`); if (processedFiles.length === 0) { + console.log(`🚀 ZIP extraction failed, trying generic fallback`); // Try the generic extraction as fallback processedFiles = await extractAllZipFiles(response.data); + console.log(`🚀 Generic fallback extracted ${processedFiles.length} files`); } } } else { @@ -205,21 +213,35 @@ export const useToolOperation = ( } 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); - // Generate thumbnails and download URL concurrently + // Generate thumbnails with metadata and download URL concurrently actions.setGeneratingThumbnails(true); - const [thumbnails, downloadInfo] = await Promise.all([ - generateThumbnails(processedFiles), + const [thumbnailResults, downloadInfo] = await Promise.all([ + generateThumbnailsWithMetadata(processedFiles), createDownloadInfo(processedFiles, config.operationType) ]); 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.setDownloadInfo(downloadInfo.url, downloadInfo.filename); - // Add to file context - await fileActions.addFiles(processedFiles); + // Add to file context WITH pre-existing thumbnails AND page counts to avoid duplicate processing + 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); } @@ -233,7 +255,7 @@ export const useToolOperation = ( actions.setLoading(false); 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(() => { cancelApiCalls(); diff --git a/frontend/src/hooks/tools/shared/useToolResources.ts b/frontend/src/hooks/tools/shared/useToolResources.ts index edb429a6c..e0327e083 100644 --- a/frontend/src/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/hooks/tools/shared/useToolResources.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react'; -import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; +import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils'; import { zipFileService } from '../../../services/zipFileService'; @@ -43,23 +43,52 @@ export const useToolResources = () => { }, []); // No dependencies - use ref to access current URLs const generateThumbnails = useCallback(async (files: File[]): Promise => { + console.log(`🖼️ useToolResources.generateThumbnails: Starting for ${files.length} files`); const thumbnails: string[] = []; for (const file of files) { try { + console.log(`🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`); const thumbnail = await generateThumbnailForFile(file); + console.log(`🖼️ Generated thumbnail for ${file.name}:`, thumbnail ? 'SUCCESS' : 'FAILED (no thumbnail returned)'); if (thumbnail) { thumbnails.push(thumbnail); + } else { + console.warn(`🖼️ No thumbnail returned for ${file.name}`); + thumbnails.push(''); } } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + console.warn(`🖼️ Failed to generate thumbnail for ${file.name}:`, error); thumbnails.push(''); } } + console.log(`🖼️ useToolResources.generateThumbnails: Complete. Generated ${thumbnails.filter(t => t).length}/${files.length} thumbnails`); return thumbnails; }, []); + const generateThumbnailsWithMetadata = useCallback(async (files: File[]): Promise => { + 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 => { try { const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' }); @@ -116,6 +145,7 @@ export const useToolResources = () => { return { generateThumbnails, + generateThumbnailsWithMetadata, createDownloadInfo, extractZipFiles, extractAllZipFiles, diff --git a/frontend/src/hooks/tools/shared/useToolState.ts b/frontend/src/hooks/tools/shared/useToolState.ts index 196a05e72..883a1555f 100644 --- a/frontend/src/hooks/tools/shared/useToolState.ts +++ b/frontend/src/hooks/tools/shared/useToolState.ts @@ -88,6 +88,8 @@ export const useToolState = () => { }, []); 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 }); }, []); diff --git a/frontend/src/services/fileOperationsService.ts b/frontend/src/services/fileOperationsService.ts index fea7cb4a5..15ac11926 100644 --- a/frontend/src/services/fileOperationsService.ts +++ b/frontend/src/services/fileOperationsService.ts @@ -47,7 +47,7 @@ export const fileOperationsService = { } try { - await fileStorage.init(); + // fileStorage.init() no longer needed - using centralized IndexedDB manager const storedFiles = await fileStorage.getAllFileMetadata(); // Detect if IndexedDB was purged by comparing with current UI state @@ -189,7 +189,7 @@ export const fileOperationsService = { if (currentFiles.length === 0) return false; try { - await fileStorage.init(); + // fileStorage.init() no longer needed - using centralized IndexedDB manager const storedFiles = await fileStorage.getAllFileMetadata(); return storedFiles.length === 0; // Purge detected if no files in storage but UI shows files } catch (error) { diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts index 4e085043a..482e51173 100644 --- a/frontend/src/services/fileStorage.ts +++ b/frontend/src/services/fileStorage.ts @@ -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 { if (!storedFile || !storedFile.data) { @@ -429,13 +430,27 @@ class FileStorageService { lastModified: storedFile.lastModified }); - // Add custom properties for compatibility - Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); - Object.defineProperty(file, 'thumbnail', { value: storedFile.thumbnail, writable: false }); - + // Returns pure File object - no mutations + // 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: string; metadata: { thumbnail?: string } } { + const file = this.createFileFromStored(storedFile); + + return { + file, + originalId: storedFile.id, + metadata: { + thumbnail: storedFile.thumbnail + } + }; + } + /** * Create blob URL for stored file */ diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index 3de08e921..b7de5ba3f 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -69,13 +69,17 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const filesCollapsed = hasFiles; const settingsCollapsed = hasResults; - const previewResults = useMemo(() => - splitOperation.files?.map((file, index) => ({ + const previewResults = useMemo(() => { + const results = splitOperation.files?.map((file, index) => ({ file, 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 ( diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 168a2b9e3..c0231c1f1 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -191,6 +191,7 @@ export type FileContextAction = export interface FileContextActions { // File management - lightweight actions only addFiles: (files: File[]) => Promise; + addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void; updateFileRecord: (id: FileId, updates: Partial) => void; diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index a6e53c174..994149c07 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -1,8 +1,34 @@ import { FileWithUrl } from "../types/file"; 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 { - 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 { try { - await fileStorage.init(); + // fileStorage.init() no longer needed - using centralized IndexedDB manager const storedFiles = await fileStorage.getAllFileMetadata(); if (storedFiles.length === 0) { diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 6f69279d5..22fb86d15 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -1,5 +1,10 @@ import { getDocument } from "pdfjs-dist"; +export interface ThumbnailWithMetadata { + thumbnail: string | undefined; + pageCount: number; +} + /** * Calculate thumbnail scale based on file size * 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 */ export async function generateThumbnailForFile(file: File): Promise { + console.log(`🎯 generateThumbnailForFile: Starting for ${file.name} (${file.type}, ${file.size} bytes)`); + // Skip thumbnail generation for very large files to avoid memory issues if (file.size >= 100 * 1024 * 1024) { // 100MB limit - console.log('Skipping thumbnail generation for large file:', file.name); - return generatePlaceholderThumbnail(file); + console.log('🎯 Skipping thumbnail generation for large file:', file.name); + const placeholder = generatePlaceholderThumbnail(file); + console.log('🎯 Generated placeholder thumbnail for large file:', file.name); + return placeholder; } // Handle image files - use original file directly 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 if (!file.type.startsWith('application/pdf')) { - console.log('File is not a PDF or image, generating placeholder:', file.name); - return generatePlaceholderThumbnail(file); + console.log('🎯 File is not a PDF or image, generating placeholder:', file.name); + const placeholder = generatePlaceholderThumbnail(file); + console.log('🎯 Generated placeholder thumbnail for non-PDF file:', file.name); + return placeholder; } // 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); - 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 { // Only read first 2MB for thumbnail generation to save memory const chunkSize = 2 * 1024 * 1024; // 2MB @@ -215,13 +229,14 @@ export async function generateThumbnailForFile(file: File): Promise { + 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 }; } }