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 {
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
onDrop={handleFileUpload}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
accept={["*/*"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}

View File

@ -1,7 +1,7 @@
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 { COLOR_TYPES } from "../../../constants/convertConstants";
import { COLOR_TYPES, FIT_OPTIONS } from "../../../constants/convertConstants";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
interface ConvertFromImageSettingsProps {
@ -35,6 +35,46 @@ const ConvertFromImageSettings = ({
]}
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>
);
};

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 ConvertToImageSettings from "./ConvertToImageSettings";
import ConvertFromImageSettings from "./ConvertFromImageSettings";
import ConvertFromPdfToCsvSettings from "./ConvertFromPdfToCsvSettings";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
import {
FROM_FORMAT_OPTIONS,
EXTENSION_TO_ENDPOINT,
COLOR_TYPES,
OUTPUT_OPTIONS
OUTPUT_OPTIONS,
FIT_OPTIONS
} from "../../../constants/convertConstants";
interface ConvertSettingsProps {
@ -84,14 +84,24 @@ const ConvertSettings = ({
const handleFromExtensionChange = (value: string) => {
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
onParameterChange('imageOptions', {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
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) => {
@ -101,13 +111,16 @@ const ConvertSettings = ({
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
});
onParameterChange('pageNumbers', 'all');
};
return (
<Stack gap="md">
{/* Format Selection */}
<Stack gap="sm">
<Text size="sm" fw={500}>
@ -120,7 +133,7 @@ const ConvertSettings = ({
placeholder={t("convert.sourceFormatPlaceholder", "Source format")}
options={enhancedFromOptions}
onChange={handleFromExtensionChange}
disabled={disabled}
disabled={disabled || parameters.isSmartDetection}
minWidth="21.875rem"
/>
</Stack>
@ -150,6 +163,17 @@ const ConvertSettings = ({
/>
</Group>
</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
name="convert-to-dropdown"
@ -178,7 +202,8 @@ const ConvertSettings = ({
{/* 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 />
<ConvertFromImageSettings
@ -187,7 +212,7 @@ const ConvertSettings = ({
disabled={disabled}
/>
</>
)}
) : null}
{/* EML specific options */}
{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>
);
};

View File

@ -86,7 +86,9 @@ const GroupedFormatDropdown = ({
: theme.white,
cursor: disabled ? 'not-allowed' : 'pointer',
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">

View File

@ -10,6 +10,12 @@ export const OUTPUT_OPTIONS = {
MULTIPLE: 'multiple'
} as const;
export const FIT_OPTIONS = {
FIT_PAGE: 'fitDocumentToPage',
MAINTAIN_ASPECT: 'maintainAspectRatio',
FILL_PAGE: 'fillPage'
} as const;
export const CONVERSION_ENDPOINTS = {
'office-pdf': '/api/v1/convert/file/pdf',
@ -48,6 +54,8 @@ export const ENDPOINT_NAMES = {
// Grouped file extensions for dropdowns
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: 'docx', label: 'DOCX', group: 'Document' },
{ value: 'doc', label: 'DOC', group: 'Document' },
@ -65,6 +73,7 @@ export const FROM_FORMAT_OPTIONS = [
{ value: 'bmp', label: 'BMP', group: 'Image' },
{ value: 'tiff', label: 'TIFF', group: 'Image' },
{ value: 'webp', label: 'WEBP', group: 'Image' },
{ value: 'svg', label: 'SVG', group: 'Image' },
{ value: 'html', label: 'HTML', group: 'Web' },
{ value: 'htm', label: 'HTM', group: 'Web' },
{ value: 'md', label: 'MD', group: 'Text' },
@ -96,11 +105,13 @@ export const TO_FORMAT_OPTIONS = [
// Conversion matrix - what each source format can convert to
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'],
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['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'],
'md': ['pdf'],
'txt': ['pdf'], 'rtf': ['pdf'],
@ -109,6 +120,8 @@ export const CONVERSION_MATRIX: Record<string, string[]> = {
// Map extensions to endpoint keys
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': {
'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',
@ -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' },
'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' },
'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' },
'md': { 'pdf': 'markdown-to-pdf' },
'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' },
@ -131,3 +144,4 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES];
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 { FileOperation } from '../../../types/fileContext';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { makeApiUrl } from '../../../utils/api';
import { ConvertParameters } from './useConvertParameters';
import {
CONVERSION_ENDPOINTS,
ENDPOINT_NAMES,
EXTENSION_TO_ENDPOINT
} from '../../../constants/convertConstants';
import { getEndpointUrl, isImageFormat } from '../../../utils/convertUtils';
export interface ConvertOperationHook {
@ -77,13 +72,13 @@ export const useConvertOperation = (): ConvertOperationHook => {
formData.append("outputFormat", toExtension);
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (isImageFormat(fromExtension) && toExtension === 'pdf') {
formData.append("fitOption", "fillPage");
} else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
formData.append("fitOption", imageOptions.fitOption);
formData.append("colorType", imageOptions.colorType);
formData.append("autoRotate", "true");
formData.append("autoRotate", imageOptions.autoRotate.toString());
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
// CSV extraction requires page numbers parameter
formData.append("pageNumbers", parameters.pageNumbers || "all");
// CSV extraction - always process all pages for simplified workflow
formData.append("pageNumbers", "all");
}
return formData;
@ -107,7 +102,6 @@ export const useConvertOperation = (): ConvertOperationHook => {
parameters: {
fromExtension: parameters.fromExtension,
toExtension: parameters.toExtension,
pageNumbers: parameters.pageNumbers,
imageOptions: parameters.imageOptions,
},
fileSize: selectedFiles[0].size
@ -154,6 +148,123 @@ export const useConvertOperation = (): ConvertOperationHook => {
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 formData = buildFormData(parameters, selectedFiles);
@ -176,7 +287,61 @@ export const useConvertOperation = (): ConvertOperationHook => {
const url = window.URL.createObjectURL(blob);
// 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}`;
setDownloadUrl(url);

View File

@ -1,23 +1,29 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
COLOR_TYPES,
OUTPUT_OPTIONS,
FIT_OPTIONS,
TO_FORMAT_OPTIONS,
CONVERSION_MATRIX,
type ColorType,
type OutputOption
type OutputOption,
type FitOption
} from '../../../constants/convertConstants';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl } from '../../../utils/convertUtils';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat } from '../../../utils/convertUtils';
export interface ConvertParameters {
fromExtension: string;
toExtension: string;
pageNumbers: string;
imageOptions: {
colorType: ColorType;
dpi: number;
singleOrMultiple: OutputOption;
fitOption: FitOption;
autoRotate: boolean;
combineImages: boolean;
};
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'none';
}
export interface ConvertParametersHook {
@ -29,17 +35,22 @@ export interface ConvertParametersHook {
getEndpoint: () => string;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
detectFileExtension: (filename: string) => string;
analyzeFileTypes: (files: Array<{name: string}>) => void;
}
const initialParameters: ConvertParameters = {
fromExtension: '',
toExtension: '',
pageNumbers: 'all',
imageOptions: {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
},
isSmartDetection: false,
smartDetectionType: 'none',
};
export const useConvertParameters = (): ConvertParametersHook => {
@ -73,12 +84,34 @@ export const useConvertParameters = (): ConvertParametersHook => {
};
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);
};
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);
};
@ -96,6 +129,66 @@ export const useConvertParameters = (): ConvertParametersHook => {
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 {
parameters,
updateParameter,
@ -105,5 +198,6 @@ export const useConvertParameters = (): ConvertParametersHook => {
getEndpoint,
getAvailableToExtensions,
detectFileExtension,
analyzeFileTypes,
};
};

View File

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

View File

@ -9,37 +9,67 @@
import { test, expect, Page } from '@playwright/test';
import {
ConversionEndpointDiscovery,
conversionDiscovery,
type ConversionEndpoint
} from '../helpers/conversionEndpointDiscovery';
import * as path from 'path';
import * as fs from 'fs';
// Test configuration
const BASE_URL = process.env.BASE_URL || 'http://localhost:5173';
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 = {
pdf: './src/tests/test-fixtures/sample.pdf',
docx: './src/tests/test-fixtures/sample.docx',
doc: './src/tests/test-fixtures/sample.doc',
pptx: './src/tests/test-fixtures/sample.pptx',
ppt: './src/tests/test-fixtures/sample.ppt',
xlsx: './src/tests/test-fixtures/sample.xlsx',
xls: './src/tests/test-fixtures/sample.xls',
png: './src/tests/test-fixtures/sample.png',
jpg: './src/tests/test-fixtures/sample.jpg',
jpeg: './src/tests/test-fixtures/sample.jpeg',
gif: './src/tests/test-fixtures/sample.gif',
bmp: './src/tests/test-fixtures/sample.bmp',
tiff: './src/tests/test-fixtures/sample.tiff',
webp: './src/tests/test-fixtures/sample.webp',
md: './src/tests/test-fixtures/sample.md',
eml: './src/tests/test-fixtures/sample.eml',
html: './src/tests/test-fixtures/sample.html',
txt: './src/tests/test-fixtures/sample.txt',
xml: './src/tests/test-fixtures/sample.xml',
csv: './src/tests/test-fixtures/sample.csv'
pdf: resolveTestFixturePath('sample.pdf'),
docx: resolveTestFixturePath('sample.docx'),
doc: resolveTestFixturePath('sample.doc'),
pptx: resolveTestFixturePath('sample.pptx'),
ppt: resolveTestFixturePath('sample.ppt'),
xlsx: resolveTestFixturePath('sample.xlsx'),
xls: resolveTestFixturePath('sample.xls'),
png: resolveTestFixturePath('sample.png'),
jpg: resolveTestFixturePath('sample.jpg'),
jpeg: resolveTestFixturePath('sample.jpeg'),
gif: resolveTestFixturePath('sample.gif'),
bmp: resolveTestFixturePath('sample.bmp'),
tiff: resolveTestFixturePath('sample.tiff'),
webp: resolveTestFixturePath('sample.webp'),
md: resolveTestFixturePath('sample.md'),
eml: resolveTestFixturePath('sample.eml'),
html: resolveTestFixturePath('sample.html'),
txt: resolveTestFixturePath('sample.txt'),
xml: resolveTestFixturePath('sample.xml'),
csv: resolveTestFixturePath('sample.csv')
};
// File format to test file mapping
@ -303,7 +333,7 @@ test.describe('Convert Tool E2E Tests', () => {
});
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);
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 }) => {
// Create a corrupted file
const fs = require('fs');
const corruptedPath = './src/tests/test-fixtures/corrupted.pdf';
const corruptedPath = resolveTestFixturePath('corrupted.pdf');
fs.writeFileSync(corruptedPath, 'This is not a valid PDF file');
await page.setInputFiles('input[type="file"]', corruptedPath);

View File

@ -84,8 +84,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -134,8 +139,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -162,8 +172,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -189,11 +204,17 @@ describe('Convert Tool Integration Tests', () => {
const parameters: ConvertParameters = {
fromExtension: 'pdf',
toExtension: 'jpg',
pageNumbers: 'all',
imageOptions: {
colorType: 'grayscale',
dpi: 150,
singleOrMultiple: 'single'
}
singleOrMultiple: 'single',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -214,6 +235,57 @@ describe('Convert Tool Integration Tests', () => {
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 () => {
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@ -226,8 +298,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -260,8 +337,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -287,8 +369,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -321,8 +408,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -352,8 +444,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -382,8 +479,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {
@ -411,8 +513,13 @@ describe('Convert Tool Integration Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
};
await act(async () => {

View File

@ -65,8 +65,13 @@ describe('Convert Tool Navigation Tests', () => {
imageOptions: {
colorType: 'color',
dpi: 300,
singleOrMultiple: 'multiple'
}
singleOrMultiple: 'multiple',
fitOption: 'maintainAspectRatio',
autoRotate: true,
combineImages: true
},
isSmartDetection: false,
smartDetectionType: 'none'
},
updateParameter: mockOnParameterChange,
resetParameters: vi.fn(),
@ -99,7 +104,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: '',
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}
getAvailableToExtensions={mockGetAvailableToExtensions}
@ -131,7 +145,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: '',
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}
getAvailableToExtensions={mockGetAvailableToExtensions}
@ -169,7 +192,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: 'pdf',
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}
getAvailableToExtensions={mockGetAvailableToExtensions}
@ -204,7 +236,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: 'pdf',
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}
getAvailableToExtensions={mockGetAvailableToExtensions}
@ -230,7 +271,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: 'eml',
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}
getAvailableToExtensions={mockGetAvailableToExtensions}
@ -260,7 +310,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: 'pdf',
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}
getAvailableToExtensions={mockGetAvailableToExtensions}
@ -277,9 +336,9 @@ describe('Convert Tool Navigation Tests', () => {
fireEvent.click(docxButton);
});
// Should reset TO extension
// Should change to docx default TO extension
expect(mockOnParameterChange).toHaveBeenCalledWith('fromExtension', 'docx');
expect(mockOnParameterChange).toHaveBeenCalledWith('toExtension', '');
expect(mockOnParameterChange).toHaveBeenCalledWith('toExtension', 'pdf');
});
test('should show placeholder when no FROM format is selected', () => {
@ -289,7 +348,16 @@ describe('Convert Tool Navigation Tests', () => {
parameters={{
fromExtension: '',
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}
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 { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
@ -22,6 +22,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const convertParams = useConvertParameters();
const convertOperation = useConvertOperation();
@ -31,22 +32,50 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
convertParams.getEndpointName()
);
// Auto-detect extension when files change
useEffect(() => {
if (selectedFiles.length > 0 && !convertParams.parameters.fromExtension) {
const firstFile = selectedFiles[0];
const detectedExtension = convertParams.detectFileExtension(firstFile.name);
if (detectedExtension) {
convertParams.updateParameter('fromExtension', detectedExtension);
}
// Auto-scroll to bottom when content grows
const scrollToBottom = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [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(() => {
convertOperation.resetResults();
onPreviewFile?.(null);
}, [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 () => {
try {
await convertOperation.executeOperation(
@ -75,11 +104,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setCurrentMode('convert');
};
const hasFiles = selectedFiles.length > 0;
const hasResults = convertOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
const previewResults = useMemo(() =>
convertOperation.files?.map((file, index) => ({
file,
@ -89,8 +113,9 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
);
return (
<ToolStepContainer>
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
<ToolStepContainer>
<Stack gap="sm" p="sm">
{/* Files Step */}
<ToolStep
title="Files"
@ -174,8 +199,9 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
/>
</Stack>
</ToolStep>
</Stack>
</ToolStepContainer>
</Stack>
</ToolStepContainer>
</div>
);
};

View File

@ -41,5 +41,5 @@ export const isConversionSupported = (fromExtension: string, toExtension: string
* Checks if the given extension is an image format
*/
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());
};