From 5b8eea686e41ecb8ccbe0cf9fce191dc912f5af5 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Thu, 31 Jul 2025 14:46:14 +0100 Subject: [PATCH] eml to pdf --- .../convert/ConvertFromEmailSettings.tsx | 77 +++++++++ .../tools/convert/ConvertFromWebSettings.tsx | 55 +++++++ .../tools/convert/ConvertSettings.tsx | 66 ++++---- .../tools/convert/useConvertOperation.ts | 26 ++-- .../convert/useConvertParameters.test.ts | 35 +++++ .../tools/convert/useConvertParameters.ts | 12 ++ .../useConvertParametersAutoDetection.test.ts | 71 +++++++++ .../tests/convert/ConvertIntegration.test.tsx | 24 +-- .../ConvertSmartDetectionIntegration.test.tsx | 113 ++++++++++++++ frontend/src/utils/convertUtils.test.ts | 2 - frontend/src/utils/fileResponseUtils.test.ts | 147 ++++++++++++++++++ frontend/src/utils/fileResponseUtils.ts | 37 +++++ 12 files changed, 603 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx create mode 100644 frontend/src/components/tools/convert/ConvertFromWebSettings.tsx create mode 100644 frontend/src/utils/fileResponseUtils.test.ts create mode 100644 frontend/src/utils/fileResponseUtils.ts diff --git a/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx b/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx new file mode 100644 index 000000000..59fa824ee --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Stack, Text, NumberInput, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; + +interface ConvertFromEmailSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + disabled?: boolean; +} + +const ConvertFromEmailSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromEmailSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t("convert.emailOptions", "Email to PDF Options")}: + + onParameterChange('emailOptions', { + ...parameters.emailOptions, + includeAttachments: event.currentTarget.checked + })} + disabled={disabled} + data-testid="include-attachments-checkbox" + /> + + {parameters.emailOptions.includeAttachments && ( + + {t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}: + onParameterChange('emailOptions', { + ...parameters.emailOptions, + maxAttachmentSizeMB: Number(value) || 10 + })} + min={1} + max={100} + step={1} + disabled={disabled} + data-testid="max-attachment-size-input" + /> + + )} + + onParameterChange('emailOptions', { + ...parameters.emailOptions, + includeAllRecipients: event.currentTarget.checked + })} + disabled={disabled} + data-testid="include-all-recipients-checkbox" + /> + + onParameterChange('emailOptions', { + ...parameters.emailOptions, + downloadHtml: event.currentTarget.checked + })} + disabled={disabled} + data-testid="download-html-checkbox" + /> + + ); +}; + +export default ConvertFromEmailSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx b/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx new file mode 100644 index 000000000..2cb8474a5 --- /dev/null +++ b/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Stack, Text, NumberInput, Slider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; + +interface ConvertFromWebSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: keyof ConvertParameters, value: any) => void; + disabled?: boolean; +} + +const ConvertFromWebSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromWebSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t("convert.webOptions", "Web to PDF Options")}: + + + {t("convert.zoomLevel", "Zoom Level")}: + onParameterChange('htmlOptions', { + ...parameters.htmlOptions, + zoomLevel: Number(value) || 1.0 + })} + min={0.1} + max={3.0} + step={0.1} + precision={1} + disabled={disabled} + data-testid="zoom-level-input" + /> + onParameterChange('htmlOptions', { + ...parameters.htmlOptions, + zoomLevel: value + })} + min={0.1} + max={3.0} + step={0.1} + disabled={disabled} + data-testid="zoom-level-slider" + /> + + + ); +}; + +export default ConvertFromWebSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index b6c311297..355ce042f 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme, NumberInput, Slider } from "@mantine/core"; +import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { useTranslation } from "react-i18next"; import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig"; @@ -10,6 +10,8 @@ import { detectFileExtension } from "../../../utils/fileUtils"; import GroupedFormatDropdown from "./GroupedFormatDropdown"; import ConvertToImageSettings from "./ConvertToImageSettings"; import ConvertFromImageSettings from "./ConvertFromImageSettings"; +import ConvertFromWebSettings from "./ConvertFromWebSettings"; +import ConvertFromEmailSettings from "./ConvertFromEmailSettings"; import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; import { FROM_FORMAT_OPTIONS, @@ -106,6 +108,12 @@ const ConvertSettings = ({ autoRotate: true, combineImages: true, }); + onParameterChange('emailOptions', { + includeAttachments: true, + maxAttachmentSizeMB: 10, + downloadHtml: false, + includeAllRecipients: false, + }); // Disable smart detection when manually changing source format onParameterChange('isSmartDetection', false); onParameterChange('smartDetectionType', 'none'); @@ -147,6 +155,12 @@ const ConvertSettings = ({ autoRotate: true, combineImages: true, }); + onParameterChange('emailOptions', { + includeAttachments: true, + maxAttachmentSizeMB: 10, + downloadHtml: false, + includeAllRecipients: false, + }); }; @@ -235,50 +249,28 @@ const ConvertSettings = ({ ) : null} - {/* HTML to PDF specific options */} + {/* Web to PDF options */} {((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || - (parameters.isSmartDetection && parameters.smartDetectionType === 'web')) && ( + (parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? ( <> - - {t("convert.htmlOptions", "HTML Options")}: - - - {t("convert.zoomLevel", "Zoom Level")}: - onParameterChange('htmlOptions', { ...parameters.htmlOptions, zoomLevel: Number(value) || 1.0 })} - min={0.1} - max={3.0} - step={0.1} - precision={1} - disabled={disabled} - data-testid="zoom-level-input" - /> - onParameterChange('htmlOptions', { ...parameters.htmlOptions, zoomLevel: value })} - min={0.1} - max={3.0} - step={0.1} - disabled={disabled} - data-testid="zoom-level-slider" - /> - - + - )} + ) : null} - {/* EML specific options */} + {/* Email to PDF options */} {parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && ( <> - - {t("convert.emlOptions", "Email Options")}: - - {t("convert.emlNote", "Email attachments and embedded images will be included in the PDF conversion.")} - - + )} diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index eaa77d66a..cf01ee147 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -6,6 +6,7 @@ import { FileOperation } from '../../../types/fileContext'; import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; import { ConvertParameters } from './useConvertParameters'; import { detectFileExtension } from '../../../utils/fileUtils'; +import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; @@ -56,7 +57,7 @@ const shouldProcessFilesSeparately = ( }; /** - * Creates a file from API response with appropriate naming + * Creates a file from API response with fallback naming */ const createFileFromResponse = ( responseData: any, @@ -64,20 +65,10 @@ const createFileFromResponse = ( originalFileName: string, targetExtension: string ): File => { - const contentType = headers?.['content-type'] || 'application/octet-stream'; - const blob = new Blob([responseData], { type: contentType }); - const originalName = originalFileName.split('.')[0]; - let filename: string; + const fallbackFilename = `${originalName}_converted.${targetExtension}`; - // Check if response is a ZIP - if (contentType.includes('zip') || headers?.['content-disposition']?.includes('.zip')) { - filename = `${originalName}_converted.zip`; - } else { - filename = `${originalName}_converted.${targetExtension}`; - } - - return new File([blob], filename, { type: contentType }); + return createFileFromApiResponse(responseData, headers, fallbackFilename); }; /** @@ -152,7 +143,7 @@ export const useConvertOperation = (): ConvertOperationHook => { formData.append("fileInput", file); }); - const { fromExtension, toExtension, imageOptions, htmlOptions } = parameters; + const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions } = parameters; // Add conversion-specific parameters if (isImageFormat(toExtension)) { @@ -173,6 +164,12 @@ export const useConvertOperation = (): ConvertOperationHook => { } else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') { // HTML to PDF conversion with zoom level (includes ZIP files with HTML) formData.append("zoom", htmlOptions.zoomLevel.toString()); + } else if (fromExtension === 'eml' && toExtension === 'pdf') { + // Email to PDF conversion with email-specific options + formData.append("includeAttachments", emailOptions.includeAttachments.toString()); + formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString()); + formData.append("downloadHtml", emailOptions.downloadHtml.toString()); + formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString()); } else if (fromExtension === 'pdf' && toExtension === 'csv') { // CSV extraction - always process all pages for simplified workflow formData.append("pageNumbers", "all"); @@ -201,6 +198,7 @@ export const useConvertOperation = (): ConvertOperationHook => { toExtension: parameters.toExtension, imageOptions: parameters.imageOptions, htmlOptions: parameters.htmlOptions, + emailOptions: parameters.emailOptions, }, fileSize: selectedFiles[0].size } diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts index f71ceae6d..7ed63fcd9 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts @@ -18,6 +18,11 @@ describe('useConvertParameters', () => { expect(result.current.parameters.imageOptions.colorType).toBe('color'); expect(result.current.parameters.imageOptions.dpi).toBe(300); expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('multiple'); + expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.0); + expect(result.current.parameters.emailOptions.includeAttachments).toBe(true); + expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(10); + expect(result.current.parameters.emailOptions.downloadHtml).toBe(false); + expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(false); }); test('should update individual parameters', () => { @@ -47,6 +52,36 @@ describe('useConvertParameters', () => { expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('single'); }); + test('should update nested HTML options', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('htmlOptions', { + zoomLevel: 1.5 + }); + }); + + expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5); + }); + + test('should update nested email options', () => { + const { result } = renderHook(() => useConvertParameters()); + + act(() => { + result.current.updateParameter('emailOptions', { + includeAttachments: false, + maxAttachmentSizeMB: 20, + downloadHtml: true, + includeAllRecipients: true + }); + }); + + expect(result.current.parameters.emailOptions.includeAttachments).toBe(false); + expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(20); + expect(result.current.parameters.emailOptions.downloadHtml).toBe(true); + expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(true); + }); + test('should reset parameters to defaults', () => { const { result } = renderHook(() => useConvertParameters()); diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.ts b/frontend/src/hooks/tools/convert/useConvertParameters.ts index 10c638deb..19c536d65 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.ts @@ -26,6 +26,12 @@ export interface ConvertParameters { htmlOptions: { zoomLevel: number; }; + emailOptions: { + includeAttachments: boolean; + maxAttachmentSizeMB: number; + downloadHtml: boolean; + includeAllRecipients: boolean; + }; isSmartDetection: boolean; smartDetectionType: 'mixed' | 'images' | 'web' | 'none'; } @@ -55,6 +61,12 @@ const initialParameters: ConvertParameters = { htmlOptions: { zoomLevel: 1.0, }, + emailOptions: { + includeAttachments: true, + maxAttachmentSizeMB: 10, + downloadHtml: false, + includeAllRecipients: false, + }, isSmartDetection: false, smartDetectionType: 'none', }; diff --git a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts index cd88a9eba..32f49e816 100644 --- a/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParametersAutoDetection.test.ts @@ -149,6 +149,61 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => { }); }); + describe('Smart Detection - All Web Files', () => { + + test('should detect all web files and enable web smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const webFiles = [ + { name: 'page1.html' }, + { name: 'archive.zip' } + ]; + + act(() => { + result.current.analyzeFileTypes(webFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('html'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('web'); + }); + + test('should handle mixed case web extensions', () => { + const { result } = renderHook(() => useConvertParameters()); + + const webFiles = [ + { name: 'page1.HTML' }, + { name: 'archive.ZIP' } + ]; + + act(() => { + result.current.analyzeFileTypes(webFiles); + }); + + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('web'); + }); + + test('should detect multiple web files and enable web smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const zipFiles = [ + { name: 'site1.zip' }, + { name: 'site2.html' } + ]; + + act(() => { + result.current.analyzeFileTypes(zipFiles); + }); + + expect(result.current.parameters.fromExtension).toBe('html'); + expect(result.current.parameters.toExtension).toBe('pdf'); + expect(result.current.parameters.isSmartDetection).toBe(true); + expect(result.current.parameters.smartDetectionType).toBe('web'); + }); + }); + describe('Smart Detection - Mixed File Types', () => { test('should detect mixed file types and enable smart detection', () => { @@ -223,6 +278,22 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => { expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf'); }); + test('should return correct endpoint for web smart detection', () => { + const { result } = renderHook(() => useConvertParameters()); + + const webFiles = [ + { name: 'page1.html' }, + { name: 'archive.zip' } + ]; + + act(() => { + result.current.analyzeFileTypes(webFiles); + }); + + expect(result.current.getEndpointName()).toBe('html-to-pdf'); + expect(result.current.getEndpoint()).toBe('/api/v1/convert/html/pdf'); + }); + test('should return correct endpoint for mixed smart detection', () => { const { result } = renderHook(() => useConvertParameters()); diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 38f9ec0a8..c9a636035 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -328,9 +328,10 @@ describe('Convert Tool Integration Tests', () => { const { result } = renderHook(() => useConvertOperation(), { wrapper: TestWrapper }); - - const file1 = createPDFFile(); - const file2 = createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf'); + const files = [ + createPDFFile(), + createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf') + ] const parameters: ConvertParameters = { fromExtension: 'pdf', toExtension: 'png', @@ -347,15 +348,20 @@ describe('Convert Tool Integration Tests', () => { }; await act(async () => { - await result.current.executeOperation(parameters, [file1, file2]); + await result.current.executeOperation(parameters, files); }); // Verify both files were uploaded - const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData; - const fileInputs = formDataCall.getAll('fileInput'); - expect(fileInputs).toHaveLength(2); - expect(fileInputs[0]).toBe(file1); - expect(fileInputs[1]).toBe(file2); + const calls = mockedAxios.post.mock.calls; + + for (let i = 0; i < calls.length; i++) { + const formData = calls[i][1] as FormData; + const fileInputs = formData.getAll('fileInput'); + expect(fileInputs).toHaveLength(1); + expect(fileInputs[0]).toBeInstanceOf(File); + expect(fileInputs[0].name).toBe(files[i].name); + } + }); test('should handle no files selected', async () => { diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index f28b73ffb..e08ae0462 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -209,6 +209,119 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { responseType: 'blob' }); }); + + test('should detect all web files and use html-to-pdf endpoint', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + // Create mock web files + const webFiles = [ + new File(['content'], 'page1.html', { type: 'text/html' }), + new File(['zip content'], 'site.zip', { type: 'application/zip' }) + ]; + + // Test smart detection for web files + act(() => { + paramsResult.current.analyzeFileTypes(webFiles); + }); + + await waitFor(() => { + expect(paramsResult.current.parameters.fromExtension).toBe('html'); + expect(paramsResult.current.parameters.toExtension).toBe('pdf'); + expect(paramsResult.current.parameters.isSmartDetection).toBe(true); + expect(paramsResult.current.parameters.smartDetectionType).toBe('web'); + }); + + // Test conversion operation + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + webFiles + ); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), { + responseType: 'blob' + }); + + // Should process files separately for web files + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + }); + + describe('Web and Email Conversion Options Integration', () => { + + test('should send correct HTML parameters for web-to-pdf conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const htmlFile = new File(['content'], 'page.html', { type: 'text/html' }); + + // Set up HTML conversion parameters + act(() => { + paramsResult.current.analyzeFileTypes([htmlFile]); + paramsResult.current.updateParameter('htmlOptions', { + zoomLevel: 1.5 + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [htmlFile] + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('zoom')).toBe('1.5'); + }); + + test('should send correct email parameters for eml-to-pdf conversion', async () => { + const { result: paramsResult } = renderHook(() => useConvertParameters(), { + wrapper: TestWrapper + }); + + const { result: operationResult } = renderHook(() => useConvertOperation(), { + wrapper: TestWrapper + }); + + const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' }); + + // Set up email conversion parameters + act(() => { + paramsResult.current.updateParameter('fromExtension', 'eml'); + paramsResult.current.updateParameter('toExtension', 'pdf'); + paramsResult.current.updateParameter('emailOptions', { + includeAttachments: false, + maxAttachmentSizeMB: 20, + downloadHtml: true, + includeAllRecipients: true + }); + }); + + await act(async () => { + await operationResult.current.executeOperation( + paramsResult.current.parameters, + [emlFile] + ); + }); + + const formData = mockedAxios.post.mock.calls[0][1] as FormData; + expect(formData.get('includeAttachments')).toBe('false'); + expect(formData.get('maxAttachmentSizeMB')).toBe('20'); + expect(formData.get('downloadHtml')).toBe('true'); + expect(formData.get('includeAllRecipients')).toBe('true'); + }); }); describe('Image Conversion Options Integration', () => { diff --git a/frontend/src/utils/convertUtils.test.ts b/frontend/src/utils/convertUtils.test.ts index ddf713a58..4f44f949b 100644 --- a/frontend/src/utils/convertUtils.test.ts +++ b/frontend/src/utils/convertUtils.test.ts @@ -68,7 +68,6 @@ describe('convertUtils', () => { // Web formats to PDF expect(getEndpointName('html', 'pdf')).toBe('html-to-pdf'); - expect(getEndpointName('htm', 'pdf')).toBe('html-to-pdf'); // Markdown to PDF expect(getEndpointName('md', 'pdf')).toBe('markdown-to-pdf'); @@ -151,7 +150,6 @@ describe('convertUtils', () => { // Web formats to PDF expect(getEndpointUrl('html', 'pdf')).toBe('/api/v1/convert/html/pdf'); - expect(getEndpointUrl('htm', 'pdf')).toBe('/api/v1/convert/html/pdf'); // Markdown to PDF expect(getEndpointUrl('md', 'pdf')).toBe('/api/v1/convert/markdown/pdf'); diff --git a/frontend/src/utils/fileResponseUtils.test.ts b/frontend/src/utils/fileResponseUtils.test.ts new file mode 100644 index 000000000..2f16a7c61 --- /dev/null +++ b/frontend/src/utils/fileResponseUtils.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for file response utility functions + */ + +import { describe, test, expect } from 'vitest'; +import { getFilenameFromHeaders, createFileFromApiResponse } from './fileResponseUtils'; + +describe('fileResponseUtils', () => { + + describe('getFilenameFromHeaders', () => { + + test('should extract filename from content-disposition header', () => { + const contentDisposition = 'attachment; filename="document.pdf"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('document.pdf'); + }); + + test('should extract filename without quotes', () => { + const contentDisposition = 'attachment; filename=document.pdf'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('document.pdf'); + }); + + test('should handle single quotes', () => { + const contentDisposition = "attachment; filename='document.pdf'"; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('document.pdf'); + }); + + test('should return null for malformed header', () => { + const contentDisposition = 'attachment; invalid=format'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe(null); + }); + + test('should return null for empty header', () => { + const filename = getFilenameFromHeaders(''); + + expect(filename).toBe(null); + }); + + test('should return null for undefined header', () => { + const filename = getFilenameFromHeaders(); + + expect(filename).toBe(null); + }); + + test('should handle complex filenames with spaces and special chars', () => { + const contentDisposition = 'attachment; filename="My Document (1).pdf"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('My Document (1).pdf'); + }); + + test('should handle filename with extension when downloadHtml is enabled', () => { + const contentDisposition = 'attachment; filename="email_content.html"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe('email_content.html'); + }); + }); + + describe('createFileFromApiResponse', () => { + + test('should create file using header filename when available', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="server_filename.pdf"' + }; + const fallbackFilename = 'fallback.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('server_filename.pdf'); + expect(file.type).toBe('application/pdf'); + expect(file.size).toBe(4); + }); + + test('should use fallback filename when no header filename', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = { + 'content-type': 'application/pdf' + }; + const fallbackFilename = 'converted_file.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('converted_file.pdf'); + expect(file.type).toBe('application/pdf'); + }); + + test('should handle HTML response when downloadHtml is enabled', () => { + const responseData = 'Test'; + const headers = { + 'content-type': 'text/html', + 'content-disposition': 'attachment; filename="email_content.html"' + }; + const fallbackFilename = 'fallback.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('email_content.html'); + expect(file.type).toBe('text/html'); + }); + + test('should handle ZIP response', () => { + const responseData = new Uint8Array([80, 75, 3, 4]); // ZIP file signature + const headers = { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="converted_files.zip"' + }; + const fallbackFilename = 'fallback.pdf'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('converted_files.zip'); + expect(file.type).toBe('application/zip'); + }); + + test('should use default content-type when none provided', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = {}; + const fallbackFilename = 'test.bin'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('test.bin'); + expect(file.type).toBe('application/octet-stream'); + }); + + test('should handle null/undefined headers gracefully', () => { + const responseData = new Uint8Array([1, 2, 3, 4]); + const headers = null; + const fallbackFilename = 'test.bin'; + + const file = createFileFromApiResponse(responseData, headers, fallbackFilename); + + expect(file.name).toBe('test.bin'); + expect(file.type).toBe('application/octet-stream'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/fileResponseUtils.ts b/frontend/src/utils/fileResponseUtils.ts new file mode 100644 index 000000000..6e4422099 --- /dev/null +++ b/frontend/src/utils/fileResponseUtils.ts @@ -0,0 +1,37 @@ +/** + * Generic utility functions for handling file responses from API endpoints + */ + +/** + * Extracts filename from Content-Disposition header + * @param contentDisposition - Content-Disposition header value + * @returns Filename if found, null otherwise + */ +export const getFilenameFromHeaders = (contentDisposition: string = ''): string | null => { + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (match && match[1]) { + return match[1].replace(/['"]/g, ''); + } + return null; +}; + +/** + * Creates a File object from API response using the filename from headers + * @param responseData - The response data (blob/arraybuffer/string) + * @param headers - Response headers object + * @param fallbackFilename - Filename to use if none provided in headers + * @returns File object + */ +export const createFileFromApiResponse = ( + responseData: any, + headers: any, + fallbackFilename: string +): File => { + const contentType = headers?.['content-type'] || 'application/octet-stream'; + const contentDisposition = headers?.['content-disposition'] || ''; + + const filename = getFilenameFromHeaders(contentDisposition) || fallbackFilename; + const blob = new Blob([responseData], { type: contentType }); + + return new File([blob], filename, { type: contentType }); +}; \ No newline at end of file