Multi file batching for compress

This commit is contained in:
Reece 2025-07-23 12:15:50 +01:00
parent f821a364cb
commit 95e5f23d25
3 changed files with 146 additions and 93 deletions

View File

@ -20,7 +20,7 @@ export interface CompressOperationHook {
parameters: CompressParameters, parameters: CompressParameters,
selectedFiles: File[] selectedFiles: File[]
) => Promise<void>; ) => Promise<void>;
// Flattened result properties for cleaner access // Flattened result properties for cleaner access
files: File[]; files: File[];
thumbnails: string[]; thumbnails: string[];
@ -30,7 +30,7 @@ export interface CompressOperationHook {
status: string; status: string;
errorMessage: string | null; errorMessage: string | null;
isLoading: boolean; isLoading: boolean;
// Result management functions // Result management functions
resetResults: () => void; resetResults: () => void;
clearError: () => void; clearError: () => void;
@ -38,13 +38,13 @@ export interface CompressOperationHook {
export const useCompressOperation = (): CompressOperationHook => { export const useCompressOperation = (): CompressOperationHook => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
recordOperation, recordOperation,
markOperationApplied, markOperationApplied,
markOperationFailed, markOperationFailed,
addFiles addFiles
} = useFileContext(); } = useFileContext();
// Internal state management // Internal state management
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [thumbnails, setThumbnails] = useState<string[]>([]); const [thumbnails, setThumbnails] = useState<string[]>([]);
@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Track blob URLs for cleanup
const [blobUrls, setBlobUrls] = useState<string[]>([]);
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(( const buildFormData = useCallback((
parameters: CompressParameters, parameters: CompressParameters,
selectedFiles: File[] file: File
) => { ) => {
const formData = new FormData(); const formData = new FormData();
selectedFiles.forEach(file => { formData.append("fileInput", file);
formData.append("fileInput", file);
});
if (parameters.compressionMethod === 'quality') { if (parameters.compressionMethod === 'quality') {
formData.append("optimizeLevel", parameters.compressionLevel.toString()); formData.append("optimizeLevel", parameters.compressionLevel.toString());
@ -74,7 +86,7 @@ export const useCompressOperation = (): CompressOperationHook => {
formData.append("expectedOutputSize", fileSize); formData.append("expectedOutputSize", fileSize);
} }
} }
formData.append("grayscale", parameters.grayscale.toString()); formData.append("grayscale", parameters.grayscale.toString());
const endpoint = "/api/v1/misc/compress-pdf"; const endpoint = "/api/v1/misc/compress-pdf";
@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => {
selectedFiles: File[] selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: string } => { ): { operation: FileOperation; operationId: string; fileId: string } => {
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 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 = { const operation: FileOperation = {
id: operationId, id: operationId,
@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => {
fileIds: selectedFiles.map(f => f.name), fileIds: selectedFiles.map(f => f.name),
status: 'pending', status: 'pending',
metadata: { metadata: {
originalFileName: selectedFiles[0].name, originalFileNames: selectedFiles.map(f => f.name),
parameters: { parameters: {
compressionLevel: parameters.compressionLevel, compressionLevel: parameters.compressionLevel,
grayscale: parameters.grayscale, grayscale: parameters.grayscale,
expectedSize: parameters.expectedSize, expectedSize: parameters.expectedSize,
}, },
fileSize: selectedFiles[0].size totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0),
fileCount: selectedFiles.length
} }
}; };
return { operation, operationId, fileId }; 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 ( const executeOperation = useCallback(async (
parameters: CompressParameters, parameters: CompressParameters,
@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => {
setStatus(t("noFileSelected")); setStatus(t("noFileSelected"));
return; 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 { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
const { formData, endpoint } = buildFormData(parameters, selectedFiles);
recordOperation(fileId, operation); recordOperation(fileId, operation);
setStatus(t("loading")); setStatus(t("loading"));
setIsLoading(true); setIsLoading(true);
setErrorMessage(null); setErrorMessage(null);
setFiles([]);
setThumbnails([]);
try { try {
const response = await axios.post(endpoint, formData, { responseType: "blob" }); const compressedFiles: File[] = [];
// 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"));
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); markOperationApplied(fileId, operationId);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]); }, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]);
const resetResults = useCallback(() => { const resetResults = useCallback(() => {
cleanupBlobUrls();
setFiles([]); setFiles([]);
setThumbnails([]); setThumbnails([]);
setIsGeneratingThumbnails(false); setIsGeneratingThumbnails(false);
@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => {
setStatus(''); setStatus('');
setErrorMessage(null); setErrorMessage(null);
setIsLoading(false); setIsLoading(false);
}, []); }, [cleanupBlobUrls]);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setErrorMessage(null); setErrorMessage(null);
@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => {
return { return {
executeOperation, executeOperation,
// Flattened result properties for cleaner access
files, files,
thumbnails, thumbnails,
isGeneratingThumbnails, isGeneratingThumbnails,
@ -242,9 +260,9 @@ export const useCompressOperation = (): CompressOperationHook => {
status, status,
errorMessage, errorMessage,
isLoading, isLoading,
// Result management functions // Result management functions
resetResults, resetResults,
clearError, clearError,
}; };
}; };

View File

@ -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 * Extract PDF files from a ZIP archive
*/ */

View File

@ -63,7 +63,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}; };
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.downloadUrl !== null; const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
const filesCollapsed = hasFiles; const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults; const settingsCollapsed = hasResults;
@ -84,7 +84,11 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isVisible={true} isVisible={true}
isCollapsed={filesCollapsed} isCollapsed={filesCollapsed}
isCompleted={filesCollapsed} isCompleted={filesCollapsed}
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined} completedMessage={hasFiles ?
selectedFiles.length === 1
? `Selected: ${selectedFiles[0].name}`
: `Selected: ${selectedFiles.length} files`
: undefined}
> >
<FileStatusIndicator <FileStatusIndicator
selectedFiles={selectedFiles} selectedFiles={selectedFiles}