From 95e5f23d25cca9e4dad753bc4d3db595bb6813a4 Mon Sep 17 00:00:00 2001 From: Reece Date: Wed, 23 Jul 2025 12:15:50 +0100 Subject: [PATCH] Multi file batching for compress --- .../tools/compress/useCompressOperation.ts | 200 ++++++++++-------- frontend/src/services/zipFileService.ts | 31 +++ frontend/src/tools/Compress.tsx | 8 +- 3 files changed, 146 insertions(+), 93 deletions(-) diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index 4582068a6..e66b3f43f 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -20,7 +20,7 @@ export interface CompressOperationHook { parameters: CompressParameters, selectedFiles: File[] ) => Promise; - + // Flattened result properties for cleaner access files: File[]; thumbnails: string[]; @@ -30,7 +30,7 @@ export interface CompressOperationHook { status: string; errorMessage: string | null; isLoading: boolean; - + // Result management functions resetResults: () => void; clearError: () => void; @@ -38,13 +38,13 @@ export interface CompressOperationHook { export const useCompressOperation = (): CompressOperationHook => { const { t } = useTranslation(); - const { - recordOperation, - markOperationApplied, + const { + recordOperation, + markOperationApplied, markOperationFailed, addFiles } = useFileContext(); - + // Internal state management const [files, setFiles] = useState([]); const [thumbnails, setThumbnails] = useState([]); @@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => { const [errorMessage, setErrorMessage] = useState(null); const [isLoading, setIsLoading] = useState(false); + // Track blob URLs for cleanup + const [blobUrls, setBlobUrls] = useState([]); + + const cleanupBlobUrls = useCallback(() => { + blobUrls.forEach(url => { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn('Failed to revoke blob URL:', error); + } + }); + setBlobUrls([]); + }, [blobUrls]); + const buildFormData = useCallback(( parameters: CompressParameters, - selectedFiles: File[] + file: File ) => { const formData = new FormData(); - - selectedFiles.forEach(file => { - formData.append("fileInput", file); - }); + + formData.append("fileInput", file); if (parameters.compressionMethod === 'quality') { formData.append("optimizeLevel", parameters.compressionLevel.toString()); @@ -74,7 +86,7 @@ export const useCompressOperation = (): CompressOperationHook => { formData.append("expectedOutputSize", fileSize); } } - + formData.append("grayscale", parameters.grayscale.toString()); const endpoint = "/api/v1/misc/compress-pdf"; @@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => { selectedFiles: File[] ): { operation: FileOperation; operationId: string; fileId: string } => { const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const fileId = selectedFiles[0].name; + const fileId = selectedFiles.map(f => f.name).join(','); const operation: FileOperation = { id: operationId, @@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => { fileIds: selectedFiles.map(f => f.name), status: 'pending', metadata: { - originalFileName: selectedFiles[0].name, + originalFileNames: selectedFiles.map(f => f.name), parameters: { compressionLevel: parameters.compressionLevel, grayscale: parameters.grayscale, expectedSize: parameters.expectedSize, }, - fileSize: selectedFiles[0].size + totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0), + fileCount: selectedFiles.length } }; return { operation, operationId, fileId }; }, []); - const processResults = useCallback(async (blob: Blob, selectedFiles: File[]) => { - try { - // Check if the response is a PDF file directly or a ZIP file - const contentType = blob.type; - console.log('Response content type:', contentType); - - if (contentType === 'application/pdf') { - // Direct PDF response - const originalFileName = selectedFiles[0].name; - const pdfFile = new File([blob], `compressed_${originalFileName}`, { type: "application/pdf" }); - setFiles([pdfFile]); - setThumbnails([]); - setIsGeneratingThumbnails(true); - - // Add file to FileContext - await addFiles([pdfFile]); - - // Generate thumbnail - const thumbnail = await generateThumbnailForFile(pdfFile); - setThumbnails([thumbnail || '']); - setIsGeneratingThumbnails(false); - } else { - // ZIP file response (like split operation) - const zipFile = new File([blob], "compress_result.zip", { type: "application/zip" }); - const extractionResult = await zipFileService.extractPdfFiles(zipFile); - - if (extractionResult.success && extractionResult.extractedFiles.length > 0) { - // Set local state for preview - setFiles(extractionResult.extractedFiles); - setThumbnails([]); - setIsGeneratingThumbnails(true); - - // Add extracted files to FileContext for future use - await addFiles(extractionResult.extractedFiles); - - const thumbnails = await Promise.all( - extractionResult.extractedFiles.map(async (file) => { - try { - const thumbnail = await generateThumbnailForFile(file); - return thumbnail || ''; - } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); - return ''; - } - }) - ); - - setThumbnails(thumbnails); - setIsGeneratingThumbnails(false); - } - } - } catch (extractError) { - console.warn('Failed to process results:', extractError); - } - }, [addFiles]); const executeOperation = useCallback(async ( parameters: CompressParameters, @@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => { setStatus(t("noFileSelected")); return; } + const validFiles = selectedFiles.filter(file => file.size > 0); + if (validFiles.length === 0) { + setErrorMessage('No valid files to compress. All selected files are empty.'); + return; + } + + if (validFiles.length < selectedFiles.length) { + console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`); + } const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); - const { formData, endpoint } = buildFormData(parameters, selectedFiles); recordOperation(fileId, operation); setStatus(t("loading")); setIsLoading(true); setErrorMessage(null); + setFiles([]); + setThumbnails([]); try { - const response = await axios.post(endpoint, formData, { responseType: "blob" }); - - // Determine the correct content type from the response - const contentType = response.headers['content-type'] || 'application/zip'; - const blob = new Blob([response.data], { type: contentType }); - const url = window.URL.createObjectURL(blob); - - // Generate dynamic filename based on original file and content type - const originalFileName = selectedFiles[0].name; - const filename = `compressed_${originalFileName}`; - setDownloadFilename(filename); - setDownloadUrl(url); - setStatus(t("downloadComplete")); + const compressedFiles: File[] = []; - await processResults(blob, selectedFiles); + const failedFiles: string[] = []; + + for (let i = 0; i < validFiles.length; i++) { + const file = validFiles[i]; + setStatus(`Compressing ${file.name} (${i + 1}/${validFiles.length})`); + + try { + const { formData, endpoint } = buildFormData(parameters, file); + const response = await axios.post(endpoint, formData, { responseType: "blob" }); + + const contentType = response.headers['content-type'] || 'application/pdf'; + const blob = new Blob([response.data], { type: contentType }); + const compressedFile = new File([blob], `compressed_${file.name}`, { type: contentType }); + + compressedFiles.push(compressedFile); + } catch (fileError) { + console.error(`Failed to compress ${file.name}:`, fileError); + failedFiles.push(file.name); + } + } + + if (failedFiles.length > 0 && compressedFiles.length === 0) { + throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`); + } + + if (failedFiles.length > 0) { + setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`); + } + + setFiles(compressedFiles); + setIsGeneratingThumbnails(true); + + await addFiles(compressedFiles); + + cleanupBlobUrls(); + + if (compressedFiles.length === 1) { + const url = window.URL.createObjectURL(compressedFiles[0]); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`compressed_${selectedFiles[0].name}`); + } else { + const { zipFile } = await zipFileService.createZipFromFiles(compressedFiles, 'compressed_files.zip'); + const url = window.URL.createObjectURL(zipFile); + setDownloadUrl(url); + setBlobUrls([url]); + setDownloadFilename(`compressed_${validFiles.length}_files.zip`); + } + + const thumbnails = await Promise.all( + compressedFiles.map(async (file) => { + try { + const thumbnail = await generateThumbnailForFile(file); + return thumbnail || ''; + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + return ''; + } + }) + ); + + setThumbnails(thumbnails); + setIsGeneratingThumbnails(false); + setStatus(t("downloadComplete")); markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); @@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => { } finally { setIsLoading(false); } - }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]); + }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]); const resetResults = useCallback(() => { + cleanupBlobUrls(); setFiles([]); setThumbnails([]); setIsGeneratingThumbnails(false); @@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => { setStatus(''); setErrorMessage(null); setIsLoading(false); - }, []); + }, [cleanupBlobUrls]); const clearError = useCallback(() => { setErrorMessage(null); @@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => { return { executeOperation, - - // Flattened result properties for cleaner access files, thumbnails, isGeneratingThumbnails, @@ -242,9 +260,9 @@ export const useCompressOperation = (): CompressOperationHook => { status, errorMessage, isLoading, - + // Result management functions resetResults, clearError, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 3c238e159..90f5b2574 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -103,6 +103,37 @@ export class ZipFileService { } } + /** + * Create a ZIP file from an array of files + */ + async createZipFromFiles(files: File[], zipFilename: string): Promise<{ zipFile: File; size: number }> { + try { + const zip = new JSZip(); + + // Add each file to the ZIP + for (const file of files) { + const content = await file.arrayBuffer(); + zip.file(file.name, content); + } + + // Generate ZIP blob + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }); + + const zipFile = new File([zipBlob], zipFilename, { + type: 'application/zip', + lastModified: Date.now() + }); + + return { zipFile, size: zipFile.size }; + } catch (error) { + throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + /** * Extract PDF files from a ZIP archive */ diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 89b850889..cc0cd5cbc 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -63,7 +63,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }; const hasFiles = selectedFiles.length > 0; - const hasResults = compressOperation.downloadUrl !== null; + const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null; const filesCollapsed = hasFiles; const settingsCollapsed = hasResults; @@ -84,7 +84,11 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isVisible={true} isCollapsed={filesCollapsed} isCompleted={filesCollapsed} - completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined} + completedMessage={hasFiles ? + selectedFiles.length === 1 + ? `Selected: ${selectedFiles[0].name}` + : `Selected: ${selectedFiles.length} files` + : undefined} >