pdf-csv simplified

This commit is contained in:
Connor Yoh 2025-07-30 14:24:23 +01:00
parent fa7dc1234a
commit 8dff995a1c
14 changed files with 687 additions and 164 deletions

View File

@ -304,7 +304,8 @@ const FileEditor = ({
}); });
} }
} else { } else {
errors.push(`Unsupported file type: ${file.name} (${file.type})`); console.log(`Adding none PDF file: ${file.name} (${file.type})`);
allExtractedFiles.push(file);
} }
} }
@ -681,7 +682,7 @@ const FileEditor = ({
<Dropzone <Dropzone
onDrop={handleFileUpload} onDrop={handleFileUpload}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]} accept={["*/*"]}
multiple={true} multiple={true}
maxSize={2 * 1024 * 1024 * 1024} maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }} style={{ display: 'contents' }}

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Stack, Text, Select } from "@mantine/core"; import { Stack, Text, Select, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { COLOR_TYPES } from "../../../constants/convertConstants"; import { COLOR_TYPES, FIT_OPTIONS } from "../../../constants/convertConstants";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
interface ConvertFromImageSettingsProps { interface ConvertFromImageSettingsProps {
@ -35,6 +35,46 @@ const ConvertFromImageSettings = ({
]} ]}
disabled={disabled} disabled={disabled}
/> />
<Select
data-testid="fit-option-select"
label={t("convert.fitOption", "Fit Option")}
value={parameters.imageOptions.fitOption}
onChange={(val) => val && onParameterChange('imageOptions', {
...parameters.imageOptions,
fitOption: val as typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS]
})}
data={[
{ value: FIT_OPTIONS.MAINTAIN_ASPECT, label: t("convert.maintainAspectRatio", "Maintain Aspect Ratio") },
{ value: FIT_OPTIONS.FIT_PAGE, label: t("convert.fitDocumentToPage", "Fit Document to Page") },
{ value: FIT_OPTIONS.FILL_PAGE, label: t("convert.fillPage", "Fill Page") },
]}
disabled={disabled}
/>
<Switch
data-testid="auto-rotate-switch"
label={t("convert.autoRotate", "Auto Rotate")}
description={t("convert.autoRotateDescription", "Automatically rotate images to better fit the PDF page")}
checked={parameters.imageOptions.autoRotate}
onChange={(event) => onParameterChange('imageOptions', {
...parameters.imageOptions,
autoRotate: event.currentTarget.checked
})}
disabled={disabled}
/>
<Switch
data-testid="combine-images-switch"
label={t("convert.combineImages", "Combine Images")}
description={t("convert.combineImagesDescription", "Combine all images into one PDF, or create separate PDFs for each image")}
checked={parameters.imageOptions.combineImages}
onChange={(event) => onParameterChange('imageOptions', {
...parameters.imageOptions,
combineImages: event.currentTarget.checked
})}
disabled={disabled}
/>
</Stack> </Stack>
); );
}; };

View File

@ -1,37 +0,0 @@
import React from "react";
import { Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
interface ConvertFromPdfToCsvSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromPdfToCsvSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromPdfToCsvSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="csv-options-section">
<Text size="sm" fw={500} data-testid="csv-options-title">
{t("convert.csvOptions", "CSV Options")}:
</Text>
<TextInput
data-testid="page-numbers-input"
label={t("convert.pageNumbers", "Page Numbers")}
placeholder={t("convert.pageNumbersPlaceholder", "e.g., 1,3,5-9, 2n+1, or 'all'")}
description={t("convert.pageNumbersDescription", "Specify pages to extract CSV data from. Supports ranges (e.g., '1,3,5-9'), functions (e.g., '2n+1', '3n'), or 'all' for all pages.")}
value={parameters.pageNumbers}
onChange={(event) => onParameterChange('pageNumbers', event.currentTarget.value)}
disabled={disabled}
/>
</Stack>
);
};
export default ConvertFromPdfToCsvSettings;

View File

@ -7,13 +7,13 @@ import { isImageFormat } from "../../../utils/convertUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown"; import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings"; import ConvertToImageSettings from "./ConvertToImageSettings";
import ConvertFromImageSettings from "./ConvertFromImageSettings"; import ConvertFromImageSettings from "./ConvertFromImageSettings";
import ConvertFromPdfToCsvSettings from "./ConvertFromPdfToCsvSettings";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters"; import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
import { import {
FROM_FORMAT_OPTIONS, FROM_FORMAT_OPTIONS,
EXTENSION_TO_ENDPOINT, EXTENSION_TO_ENDPOINT,
COLOR_TYPES, COLOR_TYPES,
OUTPUT_OPTIONS OUTPUT_OPTIONS,
FIT_OPTIONS
} from "../../../constants/convertConstants"; } from "../../../constants/convertConstants";
interface ConvertSettingsProps { interface ConvertSettingsProps {
@ -84,14 +84,24 @@ const ConvertSettings = ({
const handleFromExtensionChange = (value: string) => { const handleFromExtensionChange = (value: string) => {
onParameterChange('fromExtension', value); onParameterChange('fromExtension', value);
// Reset to extension when from extension changes
onParameterChange('toExtension', ''); // Auto-select target if only one option available
const availableToOptions = getAvailableToExtensions(value);
const autoTarget = availableToOptions.length === 1 ? availableToOptions[0].value : '';
onParameterChange('toExtension', autoTarget);
// Reset format-specific options // Reset format-specific options
onParameterChange('imageOptions', { onParameterChange('imageOptions', {
colorType: COLOR_TYPES.COLOR, colorType: COLOR_TYPES.COLOR,
dpi: 300, dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE, singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
}); });
// Disable smart detection when manually changing source format
onParameterChange('isSmartDetection', false);
onParameterChange('smartDetectionType', 'none');
}; };
const handleToExtensionChange = (value: string) => { const handleToExtensionChange = (value: string) => {
@ -101,13 +111,16 @@ const ConvertSettings = ({
colorType: COLOR_TYPES.COLOR, colorType: COLOR_TYPES.COLOR,
dpi: 300, dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE, singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
}); });
onParameterChange('pageNumbers', 'all');
}; };
return ( return (
<Stack gap="md"> <Stack gap="md">
{/* Format Selection */} {/* Format Selection */}
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@ -120,7 +133,7 @@ const ConvertSettings = ({
placeholder={t("convert.sourceFormatPlaceholder", "Source format")} placeholder={t("convert.sourceFormatPlaceholder", "Source format")}
options={enhancedFromOptions} options={enhancedFromOptions}
onChange={handleFromExtensionChange} onChange={handleFromExtensionChange}
disabled={disabled} disabled={disabled || parameters.isSmartDetection}
minWidth="21.875rem" minWidth="21.875rem"
/> />
</Stack> </Stack>
@ -150,6 +163,17 @@ const ConvertSettings = ({
/> />
</Group> </Group>
</UnstyledButton> </UnstyledButton>
) : parameters.isSmartDetection ? (
<GroupedFormatDropdown
name="convert-to-dropdown"
data-testid="to-format-dropdown"
value="pdf"
placeholder="PDF"
options={[{ value: 'pdf', label: 'PDF', group: 'Document' }]}
onChange={() => {}} // No-op since it's disabled
disabled={true}
minWidth="21.875rem"
/>
) : ( ) : (
<GroupedFormatDropdown <GroupedFormatDropdown
name="convert-to-dropdown" name="convert-to-dropdown"
@ -178,7 +202,8 @@ const ConvertSettings = ({
{/* Color options for image to PDF conversion */} {/* Color options for image to PDF conversion */}
{isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf' && ( {(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? (
<> <>
<Divider /> <Divider />
<ConvertFromImageSettings <ConvertFromImageSettings
@ -187,7 +212,7 @@ const ConvertSettings = ({
disabled={disabled} disabled={disabled}
/> />
</> </>
)} ) : null}
{/* EML specific options */} {/* EML specific options */}
{parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && ( {parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && (
@ -202,17 +227,6 @@ const ConvertSettings = ({
</> </>
)} )}
{/* CSV specific options */}
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'csv' && (
<>
<Divider />
<ConvertFromPdfToCsvSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
</Stack> </Stack>
); );
}; };

View File

@ -86,7 +86,9 @@ const GroupedFormatDropdown = ({
: theme.white, : theme.white,
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',
width: '100%', width: '100%',
color: colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.dark[9] color: disabled
? colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.dark[7]
: colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.dark[9]
}} }}
> >
<Group justify="space-between"> <Group justify="space-between">

View File

@ -10,6 +10,12 @@ export const OUTPUT_OPTIONS = {
MULTIPLE: 'multiple' MULTIPLE: 'multiple'
} as const; } as const;
export const FIT_OPTIONS = {
FIT_PAGE: 'fitDocumentToPage',
MAINTAIN_ASPECT: 'maintainAspectRatio',
FILL_PAGE: 'fillPage'
} as const;
export const CONVERSION_ENDPOINTS = { export const CONVERSION_ENDPOINTS = {
'office-pdf': '/api/v1/convert/file/pdf', 'office-pdf': '/api/v1/convert/file/pdf',
@ -48,6 +54,8 @@ export const ENDPOINT_NAMES = {
// Grouped file extensions for dropdowns // Grouped file extensions for dropdowns
export const FROM_FORMAT_OPTIONS = [ export const FROM_FORMAT_OPTIONS = [
{ value: 'any', label: 'Any', group: 'Multiple Files' },
{ value: 'image', label: 'Images', group: 'Multiple Files' },
{ value: 'pdf', label: 'PDF', group: 'Document' }, { value: 'pdf', label: 'PDF', group: 'Document' },
{ value: 'docx', label: 'DOCX', group: 'Document' }, { value: 'docx', label: 'DOCX', group: 'Document' },
{ value: 'doc', label: 'DOC', group: 'Document' }, { value: 'doc', label: 'DOC', group: 'Document' },
@ -65,6 +73,7 @@ export const FROM_FORMAT_OPTIONS = [
{ value: 'bmp', label: 'BMP', group: 'Image' }, { value: 'bmp', label: 'BMP', group: 'Image' },
{ value: 'tiff', label: 'TIFF', group: 'Image' }, { value: 'tiff', label: 'TIFF', group: 'Image' },
{ value: 'webp', label: 'WEBP', group: 'Image' }, { value: 'webp', label: 'WEBP', group: 'Image' },
{ value: 'svg', label: 'SVG', group: 'Image' },
{ value: 'html', label: 'HTML', group: 'Web' }, { value: 'html', label: 'HTML', group: 'Web' },
{ value: 'htm', label: 'HTM', group: 'Web' }, { value: 'htm', label: 'HTM', group: 'Web' },
{ value: 'md', label: 'MD', group: 'Text' }, { value: 'md', label: 'MD', group: 'Text' },
@ -96,11 +105,13 @@ export const TO_FORMAT_OPTIONS = [
// Conversion matrix - what each source format can convert to // Conversion matrix - what each source format can convert to
export const CONVERSION_MATRIX: Record<string, string[]> = { export const CONVERSION_MATRIX: Record<string, string[]> = {
'any': ['pdf'], // Mixed files always convert to PDF
'image': ['pdf'], // Multiple images always convert to PDF
'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa'], 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa'],
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'], 'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'], 'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'],
'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'], 'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'],
'jpg': ['pdf'], 'jpeg': ['pdf'], 'png': ['pdf'], 'gif': ['pdf'], 'bmp': ['pdf'], 'tiff': ['pdf'], 'webp': ['pdf'], 'jpg': ['pdf'], 'jpeg': ['pdf'], 'png': ['pdf'], 'gif': ['pdf'], 'bmp': ['pdf'], 'tiff': ['pdf'], 'webp': ['pdf'], 'svg': ['pdf'],
'html': ['pdf'], 'htm': ['pdf'], 'html': ['pdf'], 'htm': ['pdf'],
'md': ['pdf'], 'md': ['pdf'],
'txt': ['pdf'], 'rtf': ['pdf'], 'txt': ['pdf'], 'rtf': ['pdf'],
@ -109,6 +120,8 @@ export const CONVERSION_MATRIX: Record<string, string[]> = {
// Map extensions to endpoint keys // Map extensions to endpoint keys
export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = { export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
'any': { 'pdf': 'file-to-pdf' }, // Mixed files use file-to-pdf endpoint
'image': { 'pdf': 'img-to-pdf' }, // Multiple images use img-to-pdf endpoint
'pdf': { 'pdf': {
'png': 'pdf-to-img', 'jpg': 'pdf-to-img', 'gif': 'pdf-to-img', 'tiff': 'pdf-to-img', 'bmp': 'pdf-to-img', 'webp': 'pdf-to-img', 'png': 'pdf-to-img', 'jpg': 'pdf-to-img', 'gif': 'pdf-to-img', 'tiff': 'pdf-to-img', 'bmp': 'pdf-to-img', 'webp': 'pdf-to-img',
'docx': 'pdf-to-word', 'odt': 'pdf-to-word', 'docx': 'pdf-to-word', 'odt': 'pdf-to-word',
@ -122,7 +135,7 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' }, 'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' },
'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' }, 'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' },
'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' }, 'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' },
'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'svg': { 'pdf': 'img-to-pdf' },
'html': { 'pdf': 'html-to-pdf' }, 'htm': { 'pdf': 'html-to-pdf' }, 'html': { 'pdf': 'html-to-pdf' }, 'htm': { 'pdf': 'html-to-pdf' },
'md': { 'pdf': 'markdown-to-pdf' }, 'md': { 'pdf': 'markdown-to-pdf' },
'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' }, 'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' },
@ -130,4 +143,5 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
}; };
export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES]; export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES];
export type OutputOption = typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS]; export type OutputOption = typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS];
export type FitOption = typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS];

View File

@ -4,13 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext'; import { useFileContext } from '../../../contexts/FileContext';
import { FileOperation } from '../../../types/fileContext'; import { FileOperation } from '../../../types/fileContext';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { makeApiUrl } from '../../../utils/api';
import { ConvertParameters } from './useConvertParameters'; import { ConvertParameters } from './useConvertParameters';
import {
CONVERSION_ENDPOINTS,
ENDPOINT_NAMES,
EXTENSION_TO_ENDPOINT
} from '../../../constants/convertConstants';
import { getEndpointUrl, isImageFormat } from '../../../utils/convertUtils'; import { getEndpointUrl, isImageFormat } from '../../../utils/convertUtils';
export interface ConvertOperationHook { export interface ConvertOperationHook {
@ -77,13 +72,13 @@ export const useConvertOperation = (): ConvertOperationHook => {
formData.append("outputFormat", toExtension); formData.append("outputFormat", toExtension);
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) { } else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
formData.append("outputFormat", toExtension); formData.append("outputFormat", toExtension);
} else if (isImageFormat(fromExtension) && toExtension === 'pdf') { } else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
formData.append("fitOption", "fillPage"); formData.append("fitOption", imageOptions.fitOption);
formData.append("colorType", imageOptions.colorType); formData.append("colorType", imageOptions.colorType);
formData.append("autoRotate", "true"); formData.append("autoRotate", imageOptions.autoRotate.toString());
} else if (fromExtension === 'pdf' && toExtension === 'csv') { } else if (fromExtension === 'pdf' && toExtension === 'csv') {
// CSV extraction requires page numbers parameter // CSV extraction - always process all pages for simplified workflow
formData.append("pageNumbers", parameters.pageNumbers || "all"); formData.append("pageNumbers", "all");
} }
return formData; return formData;
@ -107,7 +102,6 @@ export const useConvertOperation = (): ConvertOperationHook => {
parameters: { parameters: {
fromExtension: parameters.fromExtension, fromExtension: parameters.fromExtension,
toExtension: parameters.toExtension, toExtension: parameters.toExtension,
pageNumbers: parameters.pageNumbers,
imageOptions: parameters.imageOptions, imageOptions: parameters.imageOptions,
}, },
fileSize: selectedFiles[0].size fileSize: selectedFiles[0].size
@ -154,6 +148,123 @@ export const useConvertOperation = (): ConvertOperationHook => {
return; return;
} }
// Check if this should be processed as separate files
const shouldProcessSeparately = selectedFiles.length > 1 && (
// Image to PDF with combineImages = false
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
// Mixed file types (smart detection)
(parameters.isSmartDetection && parameters.smartDetectionType === 'mixed')
);
if (shouldProcessSeparately) {
// Process each file separately with appropriate endpoint
await executeMultipleSeparateFiles(parameters, selectedFiles);
} else {
// Process all files together (default behavior)
await executeSingleCombinedOperation(parameters, selectedFiles);
}
}, [t]);
const executeMultipleSeparateFiles = async (
parameters: ConvertParameters,
selectedFiles: File[]
) => {
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
const results: File[] = [];
const thumbnails: string[] = [];
try {
// Process each file separately
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
setStatus(t("convert.processingFile", `Processing file ${i + 1} of ${selectedFiles.length}...`));
// Detect the specific file type for this file
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
// Determine the best endpoint for this specific file type
let endpoint = getEndpointUrl(fileExtension, parameters.toExtension);
let fileSpecificParams = { ...parameters, fromExtension: fileExtension };
// Fallback to file-to-pdf if specific endpoint doesn't exist
if (!endpoint && parameters.toExtension === 'pdf') {
endpoint = '/api/v1/convert/file/pdf';
console.log(`Using file-to-pdf fallback for ${fileExtension} file: ${file.name}`);
}
if (!endpoint) {
console.error(`No endpoint available for ${fileExtension} to ${parameters.toExtension}`);
continue; // Skip this file
}
// Create individual operation for this file
const { operation, operationId, fileId } = createOperation(fileSpecificParams, [file]);
const formData = buildFormData(fileSpecificParams, [file]);
recordOperation(fileId, operation);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const blob = new Blob([response.data]);
// Generate filename for this specific file
const originalName = file.name.split('.')[0];
const filename = `${originalName}_converted.${parameters.toExtension}`;
const convertedFile = new File([blob], filename, { type: blob.type });
results.push(convertedFile);
// Generate thumbnail
try {
const thumbnail = await generateThumbnailForFile(convertedFile);
thumbnails.push(thumbnail);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${filename}:`, error);
thumbnails.push('');
}
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(`Error converting file ${file.name}:`, error);
markOperationFailed(fileId, operationId);
// Continue with other files even if one fails
}
}
if (results.length > 0) {
// Set results for multiple files
setFiles(results);
setThumbnails(thumbnails);
// Add all converted files to FileContext
await addFiles(results);
// For multiple separate files, use the first file for download
const firstFileBlob = new Blob([results[0]]);
const firstFileUrl = window.URL.createObjectURL(firstFileBlob);
setDownloadUrl(firstFileUrl);
setDownloadFilename(results[0].name);
setStatus(t("convert.multipleFilesComplete", `Converted ${results.length} files successfully`));
} else {
setErrorMessage(t("convert.errorAllFilesFailed", "All files failed to convert"));
}
} catch (error) {
console.error('Error in multiple operations:', error);
setErrorMessage(t("convert.errorMultipleConversion", "An error occurred while converting multiple files"));
} finally {
setIsLoading(false);
}
};
const executeSingleCombinedOperation = async (
parameters: ConvertParameters,
selectedFiles: File[]
) => {
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
const formData = buildFormData(parameters, selectedFiles); const formData = buildFormData(parameters, selectedFiles);
@ -176,7 +287,61 @@ export const useConvertOperation = (): ConvertOperationHook => {
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
// Generate filename based on conversion // Generate filename based on conversion
const originalName = selectedFiles[0].name.split('.')[0]; const originalName = selectedFiles.length === 1
? selectedFiles[0].name.split('.')[0]
: 'combined_images';
const filename = `${originalName}_converted.${parameters.toExtension}`;
setDownloadUrl(url);
setDownloadFilename(filename);
setStatus(t("downloadComplete"));
await processResults(blob, filename);
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
let errorMsg = t("convert.errorConversion", "An error occurred while converting the file.");
if (error.response?.data && typeof error.response.data === 'string') {
errorMsg = error.response.data;
} else if (error.message) {
errorMsg = error.message;
}
setErrorMessage(errorMsg);
markOperationFailed(fileId, operationId, errorMsg);
} finally {
setIsLoading(false);
}
};
const executeSingleOperation = useCallback(async (
parameters: ConvertParameters,
selectedFiles: File[]
) => {
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
const formData = buildFormData(parameters, selectedFiles);
// Get endpoint using utility function
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
if (!endpoint) {
setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension }));
return;
}
recordOperation(fileId, operation);
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
// Generate filename based on conversion
const originalName = selectedFiles.length === 1
? selectedFiles[0].name.split('.')[0]
: 'combined_images';
const filename = `${originalName}_converted.${parameters.toExtension}`; const filename = `${originalName}_converted.${parameters.toExtension}`;
setDownloadUrl(url); setDownloadUrl(url);

View File

@ -1,23 +1,29 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
COLOR_TYPES, COLOR_TYPES,
OUTPUT_OPTIONS, OUTPUT_OPTIONS,
FIT_OPTIONS,
TO_FORMAT_OPTIONS, TO_FORMAT_OPTIONS,
CONVERSION_MATRIX, CONVERSION_MATRIX,
type ColorType, type ColorType,
type OutputOption type OutputOption,
type FitOption
} from '../../../constants/convertConstants'; } from '../../../constants/convertConstants';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl } from '../../../utils/convertUtils'; import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat } from '../../../utils/convertUtils';
export interface ConvertParameters { export interface ConvertParameters {
fromExtension: string; fromExtension: string;
toExtension: string; toExtension: string;
pageNumbers: string;
imageOptions: { imageOptions: {
colorType: ColorType; colorType: ColorType;
dpi: number; dpi: number;
singleOrMultiple: OutputOption; singleOrMultiple: OutputOption;
fitOption: FitOption;
autoRotate: boolean;
combineImages: boolean;
}; };
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'none';
} }
export interface ConvertParametersHook { export interface ConvertParametersHook {
@ -29,17 +35,22 @@ export interface ConvertParametersHook {
getEndpoint: () => string; getEndpoint: () => string;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
detectFileExtension: (filename: string) => string; detectFileExtension: (filename: string) => string;
analyzeFileTypes: (files: Array<{name: string}>) => void;
} }
const initialParameters: ConvertParameters = { const initialParameters: ConvertParameters = {
fromExtension: '', fromExtension: '',
toExtension: '', toExtension: '',
pageNumbers: 'all',
imageOptions: { imageOptions: {
colorType: COLOR_TYPES.COLOR, colorType: COLOR_TYPES.COLOR,
dpi: 300, dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE, singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
}, },
isSmartDetection: false,
smartDetectionType: 'none',
}; };
export const useConvertParameters = (): ConvertParametersHook => { export const useConvertParameters = (): ConvertParametersHook => {
@ -73,12 +84,34 @@ export const useConvertParameters = (): ConvertParametersHook => {
}; };
const getEndpointName = () => { const getEndpointName = () => {
const { fromExtension, toExtension } = parameters; const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters;
if (isSmartDetection) {
if (smartDetectionType === 'mixed') {
// Mixed file types -> PDF using file-to-pdf endpoint
return 'file-to-pdf';
} else if (smartDetectionType === 'images') {
// All images -> PDF using img-to-pdf endpoint
return 'img-to-pdf';
}
}
return getEndpointNameUtil(fromExtension, toExtension); return getEndpointNameUtil(fromExtension, toExtension);
}; };
const getEndpoint = () => { const getEndpoint = () => {
const { fromExtension, toExtension } = parameters; const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters;
if (isSmartDetection) {
if (smartDetectionType === 'mixed') {
// Mixed file types -> PDF using file-to-pdf endpoint
return '/api/v1/convert/file/pdf';
} else if (smartDetectionType === 'images') {
// All images -> PDF using img-to-pdf endpoint
return '/api/v1/convert/img/pdf';
}
}
return getEndpointUrl(fromExtension, toExtension); return getEndpointUrl(fromExtension, toExtension);
}; };
@ -96,6 +129,66 @@ export const useConvertParameters = (): ConvertParametersHook => {
return extension || ''; return extension || '';
}; };
const analyzeFileTypes = (files: Array<{name: string}>) => {
if (files.length <= 1) {
// Single file or no files - use regular detection with auto-target selection
const fromExt = files.length === 1 ? detectFileExtension(files[0].name) : '';
const availableTargets = fromExt ? CONVERSION_MATRIX[fromExt] || [] : [];
const autoTarget = availableTargets.length === 1 ? availableTargets[0] : '';
setParameters(prev => ({
...prev,
isSmartDetection: false,
smartDetectionType: 'none',
fromExtension: fromExt,
toExtension: autoTarget
}));
return;
}
// Multiple files - analyze file types
const extensions = files.map(file => detectFileExtension(file.name));
const uniqueExtensions = [...new Set(extensions)];
if (uniqueExtensions.length === 1) {
// All files are the same type - use regular detection with auto-target selection
const fromExt = uniqueExtensions[0];
const availableTargets = CONVERSION_MATRIX[fromExt] || [];
const autoTarget = availableTargets.length === 1 ? availableTargets[0] : '';
setParameters(prev => ({
...prev,
isSmartDetection: false,
smartDetectionType: 'none',
fromExtension: fromExt,
toExtension: autoTarget
}));
} else {
// Mixed file types
const allImages = uniqueExtensions.every(ext => isImageFormat(ext));
if (allImages) {
// All files are images - use image-to-pdf conversion
setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'images',
fromExtension: 'image',
toExtension: 'pdf'
}));
} else {
// Mixed non-image types - use file-to-pdf conversion
setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'mixed',
fromExtension: 'any',
toExtension: 'pdf'
}));
}
}
};
return { return {
parameters, parameters,
updateParameter, updateParameter,
@ -105,5 +198,6 @@ export const useConvertParameters = (): ConvertParametersHook => {
getEndpoint, getEndpoint,
getAvailableToExtensions, getAvailableToExtensions,
detectFileExtension, detectFileExtension,
analyzeFileTypes,
}; };
}; };

View File

@ -196,7 +196,7 @@ function HomePageContent() {
onFilesSelect={(files) => { onFilesSelect={(files) => {
files.forEach(addToActiveFiles); files.forEach(addToActiveFiles);
}} }}
accept={["application/pdf"]} accept={["*/*"]}
loading={false} loading={false}
showRecentFiles={true} showRecentFiles={true}
maxRecentFiles={8} maxRecentFiles={8}
@ -286,7 +286,7 @@ function HomePageContent() {
onFilesSelect={(files) => { onFilesSelect={(files) => {
files.forEach(addToActiveFiles); files.forEach(addToActiveFiles);
}} }}
accept={["application/pdf"]} accept={["*/*"]}
loading={false} loading={false}
showRecentFiles={true} showRecentFiles={true}
maxRecentFiles={8} maxRecentFiles={8}

View File

@ -9,37 +9,67 @@
import { test, expect, Page } from '@playwright/test'; import { test, expect, Page } from '@playwright/test';
import { import {
ConversionEndpointDiscovery,
conversionDiscovery, conversionDiscovery,
type ConversionEndpoint type ConversionEndpoint
} from '../helpers/conversionEndpointDiscovery'; } from '../helpers/conversionEndpointDiscovery';
import * as path from 'path';
import * as fs from 'fs';
// Test configuration // Test configuration
const BASE_URL = process.env.BASE_URL || 'http://localhost:5173'; const BASE_URL = process.env.BASE_URL || 'http://localhost:5173';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8080'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8080';
// Test file paths (these would need to exist in your test fixtures) /**
* Resolves test fixture paths dynamically based on current working directory.
* Works from both top-level project directory and frontend subdirectory.
*/
function resolveTestFixturePath(filename: string): string {
const cwd = process.cwd();
// Try frontend/src/tests/test-fixtures/ first (from top-level)
const topLevelPath = path.join(cwd, 'frontend', 'src', 'tests', 'test-fixtures', filename);
if (fs.existsSync(topLevelPath)) {
return topLevelPath;
}
// Try src/tests/test-fixtures/ (from frontend directory)
const frontendPath = path.join(cwd, 'src', 'tests', 'test-fixtures', filename);
if (fs.existsSync(frontendPath)) {
return frontendPath;
}
// Try relative path from current test file location
const relativePath = path.join(__dirname, '..', 'test-fixtures', filename);
if (fs.existsSync(relativePath)) {
return relativePath;
}
// Fallback to the original path format (should work from top-level)
return path.join('.', 'frontend', 'src', 'tests', 'test-fixtures', filename);
}
// Test file paths (dynamically resolved based on current working directory)
const TEST_FILES = { const TEST_FILES = {
pdf: './src/tests/test-fixtures/sample.pdf', pdf: resolveTestFixturePath('sample.pdf'),
docx: './src/tests/test-fixtures/sample.docx', docx: resolveTestFixturePath('sample.docx'),
doc: './src/tests/test-fixtures/sample.doc', doc: resolveTestFixturePath('sample.doc'),
pptx: './src/tests/test-fixtures/sample.pptx', pptx: resolveTestFixturePath('sample.pptx'),
ppt: './src/tests/test-fixtures/sample.ppt', ppt: resolveTestFixturePath('sample.ppt'),
xlsx: './src/tests/test-fixtures/sample.xlsx', xlsx: resolveTestFixturePath('sample.xlsx'),
xls: './src/tests/test-fixtures/sample.xls', xls: resolveTestFixturePath('sample.xls'),
png: './src/tests/test-fixtures/sample.png', png: resolveTestFixturePath('sample.png'),
jpg: './src/tests/test-fixtures/sample.jpg', jpg: resolveTestFixturePath('sample.jpg'),
jpeg: './src/tests/test-fixtures/sample.jpeg', jpeg: resolveTestFixturePath('sample.jpeg'),
gif: './src/tests/test-fixtures/sample.gif', gif: resolveTestFixturePath('sample.gif'),
bmp: './src/tests/test-fixtures/sample.bmp', bmp: resolveTestFixturePath('sample.bmp'),
tiff: './src/tests/test-fixtures/sample.tiff', tiff: resolveTestFixturePath('sample.tiff'),
webp: './src/tests/test-fixtures/sample.webp', webp: resolveTestFixturePath('sample.webp'),
md: './src/tests/test-fixtures/sample.md', md: resolveTestFixturePath('sample.md'),
eml: './src/tests/test-fixtures/sample.eml', eml: resolveTestFixturePath('sample.eml'),
html: './src/tests/test-fixtures/sample.html', html: resolveTestFixturePath('sample.html'),
txt: './src/tests/test-fixtures/sample.txt', txt: resolveTestFixturePath('sample.txt'),
xml: './src/tests/test-fixtures/sample.xml', xml: resolveTestFixturePath('sample.xml'),
csv: './src/tests/test-fixtures/sample.csv' csv: resolveTestFixturePath('sample.csv')
}; };
// File format to test file mapping // File format to test file mapping
@ -303,7 +333,7 @@ test.describe('Convert Tool E2E Tests', () => {
}); });
test('Image to PDF conversion', async ({ page }) => { test('Image to PDF conversion', async ({ page }) => {
const conversion = { endpoint: '/api/v1/convert/img/pdf', fromFormat: 'image', toFormat: 'pdf' }; const conversion = { endpoint: '/api/v1/convert/img/pdf', fromFormat: 'png', toFormat: 'pdf' };
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint); const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
@ -400,8 +430,7 @@ test.describe('Convert Tool E2E Tests', () => {
test('should handle corrupted file gracefully', async ({ page }) => { test('should handle corrupted file gracefully', async ({ page }) => {
// Create a corrupted file // Create a corrupted file
const fs = require('fs'); const corruptedPath = resolveTestFixturePath('corrupted.pdf');
const corruptedPath = './src/tests/test-fixtures/corrupted.pdf';
fs.writeFileSync(corruptedPath, 'This is not a valid PDF file'); fs.writeFileSync(corruptedPath, 'This is not a valid PDF file');
await page.setInputFiles('input[type="file"]', corruptedPath); await page.setInputFiles('input[type="file"]', corruptedPath);

View File

@ -84,8 +84,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -134,8 +139,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -162,8 +172,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -189,11 +204,17 @@ describe('Convert Tool Integration Tests', () => {
const parameters: ConvertParameters = { const parameters: ConvertParameters = {
fromExtension: 'pdf', fromExtension: 'pdf',
toExtension: 'jpg', toExtension: 'jpg',
pageNumbers: 'all',
imageOptions: { imageOptions: {
colorType: 'grayscale', colorType: 'grayscale',
dpi: 150, dpi: 150,
singleOrMultiple: 'single' singleOrMultiple: 'single',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -214,6 +235,57 @@ describe('Convert Tool Integration Tests', () => {
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
}); });
test('should make correct API call for PDF to CSV conversion with simplified workflow', async () => {
const mockBlob = new Blob(['fake-csv-data'], { type: 'text/csv' });
mockedAxios.post.mockResolvedValueOnce({
data: mockBlob,
status: 200,
statusText: 'OK'
});
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
});
const testFile = createPDFFile();
const parameters: ConvertParameters = {
fromExtension: 'pdf',
toExtension: 'csv',
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
await result.current.executeOperation(parameters, [testFile]);
});
// Verify correct endpoint is called
expect(mockedAxios.post).toHaveBeenCalledWith(
'/api/v1/convert/pdf/csv',
expect.any(FormData),
{ responseType: 'blob' }
);
// Verify FormData contains correct parameters for simplified CSV conversion
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
expect(formDataCall.get('pageNumbers')).toBe('all'); // Always "all" for simplified workflow
expect(formDataCall.get('fileInput')).toBe(testFile);
// Verify hook state updates correctly
expect(result.current.downloadUrl).toBeTruthy();
expect(result.current.downloadFilename).toBe('test_converted.csv');
expect(result.current.isLoading).toBe(false);
expect(result.current.errorMessage).toBe(null);
});
test('should handle complete unsupported conversion workflow', async () => { test('should handle complete unsupported conversion workflow', async () => {
const { result } = renderHook(() => useConvertOperation(), { const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper wrapper: TestWrapper
@ -226,8 +298,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -260,8 +337,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -287,8 +369,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -321,8 +408,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -352,8 +444,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -382,8 +479,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {
@ -411,8 +513,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}; };
await act(async () => { await act(async () => {

View File

@ -65,8 +65,13 @@ describe('Convert Tool Navigation Tests', () => {
imageOptions: { imageOptions: {
colorType: 'color', colorType: 'color',
dpi: 300, dpi: 300,
singleOrMultiple: 'multiple' singleOrMultiple: 'multiple',
} fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}, },
updateParameter: mockOnParameterChange, updateParameter: mockOnParameterChange,
resetParameters: vi.fn(), resetParameters: vi.fn(),
@ -99,7 +104,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: '', fromExtension: '',
toExtension: '', toExtension: '',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}
@ -131,7 +145,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: '', fromExtension: '',
toExtension: '', toExtension: '',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}
@ -169,7 +192,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: 'pdf', fromExtension: 'pdf',
toExtension: '', toExtension: '',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}
@ -204,7 +236,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: 'pdf', fromExtension: 'pdf',
toExtension: 'png', toExtension: 'png',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}
@ -230,7 +271,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: 'eml', fromExtension: 'eml',
toExtension: 'pdf', toExtension: 'pdf',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}
@ -260,7 +310,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: 'pdf', fromExtension: 'pdf',
toExtension: 'png', toExtension: 'png',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}
@ -277,9 +336,9 @@ describe('Convert Tool Navigation Tests', () => {
fireEvent.click(docxButton); fireEvent.click(docxButton);
}); });
// Should reset TO extension // Should change to docx default TO extension
expect(mockOnParameterChange).toHaveBeenCalledWith('fromExtension', 'docx'); expect(mockOnParameterChange).toHaveBeenCalledWith('fromExtension', 'docx');
expect(mockOnParameterChange).toHaveBeenCalledWith('toExtension', ''); expect(mockOnParameterChange).toHaveBeenCalledWith('toExtension', 'pdf');
}); });
test('should show placeholder when no FROM format is selected', () => { test('should show placeholder when no FROM format is selected', () => {
@ -289,7 +348,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{ parameters={{
fromExtension: '', fromExtension: '',
toExtension: '', toExtension: '',
imageOptions: { colorType: 'color', dpi: 300, singleOrMultiple: 'multiple' } imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
}} }}
onParameterChange={mockOnParameterChange} onParameterChange={mockOnParameterChange}
getAvailableToExtensions={mockGetAvailableToExtensions} getAvailableToExtensions={mockGetAvailableToExtensions}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useRef } from "react";
import { Button, Stack, Text } from "@mantine/core"; import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
@ -22,6 +22,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext(); const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection(); const { selectedFiles } = useToolFileSelection();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const convertParams = useConvertParameters(); const convertParams = useConvertParameters();
const convertOperation = useConvertOperation(); const convertOperation = useConvertOperation();
@ -31,22 +32,50 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
convertParams.getEndpointName() convertParams.getEndpointName()
); );
// Auto-detect extension when files change // Auto-scroll to bottom when content grows
useEffect(() => { const scrollToBottom = () => {
if (selectedFiles.length > 0 && !convertParams.parameters.fromExtension) { if (scrollContainerRef.current) {
const firstFile = selectedFiles[0]; scrollContainerRef.current.scrollTo({
const detectedExtension = convertParams.detectFileExtension(firstFile.name); top: scrollContainerRef.current.scrollHeight,
if (detectedExtension) { behavior: 'smooth'
convertParams.updateParameter('fromExtension', detectedExtension); });
}
} }
}, [selectedFiles, convertParams.parameters.fromExtension]); };
// Calculate state variables first
const hasFiles = selectedFiles.length > 0;
const hasResults = convertOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
// Auto-detect extension when files change - now with smart detection
useEffect(() => {
if (selectedFiles.length > 0) {
convertParams.analyzeFileTypes(selectedFiles);
} else {
convertParams.resetParameters();
}
}, [selectedFiles]);
useEffect(() => { useEffect(() => {
convertOperation.resetResults(); convertOperation.resetResults();
onPreviewFile?.(null); onPreviewFile?.(null);
}, [convertParams.parameters, selectedFiles]); }, [convertParams.parameters, selectedFiles]);
// Auto-scroll when settings step becomes visible (files selected)
useEffect(() => {
if (hasFiles) {
setTimeout(scrollToBottom, 100); // Small delay to ensure DOM update
}
}, [hasFiles]);
// Auto-scroll when results appear
useEffect(() => {
if (hasResults) {
setTimeout(scrollToBottom, 100); // Small delay to ensure DOM update
}
}, [hasResults]);
const handleConvert = async () => { const handleConvert = async () => {
try { try {
await convertOperation.executeOperation( await convertOperation.executeOperation(
@ -75,11 +104,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setCurrentMode('convert'); setCurrentMode('convert');
}; };
const hasFiles = selectedFiles.length > 0;
const hasResults = convertOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const previewResults = useMemo(() => const previewResults = useMemo(() =>
convertOperation.files?.map((file, index) => ({ convertOperation.files?.map((file, index) => ({
file, file,
@ -89,8 +113,9 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
); );
return ( return (
<ToolStepContainer> <div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}> <ToolStepContainer>
<Stack gap="sm" p="sm">
{/* Files Step */} {/* Files Step */}
<ToolStep <ToolStep
title="Files" title="Files"
@ -174,8 +199,9 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
/> />
</Stack> </Stack>
</ToolStep> </ToolStep>
</Stack> </Stack>
</ToolStepContainer> </ToolStepContainer>
</div>
); );
}; };

View File

@ -41,5 +41,5 @@ export const isConversionSupported = (fromExtension: string, toExtension: string
* Checks if the given extension is an image format * Checks if the given extension is an image format
*/ */
export const isImageFormat = (extension: string): boolean => { export const isImageFormat = (extension: string): boolean => {
return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp'].includes(extension.toLowerCase()); return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp', 'svg'].includes(extension.toLowerCase());
}; };