V2: Convert Tool (#3828)

🔄 Dynamic Processing Strategies

- Adaptive routing: Same tool uses different backend endpoints based on
file analysis
- Combined vs separate processing: Intelligently chooses between merge
operations and individual file processing
- Cross-format workflows: Enable complex conversions like "mixed files →
PDF" that other tools can't handle

  ⚙️ Format-Specific Intelligence

  Each conversion type gets tailored options:
  - HTML/ZIP → PDF: Zoom controls (0.1-3.0 increments) with live preview
  - Email → PDF: Attachment handling, size limits, recipient control
  - PDF → PDF/A: Digital signature detection with warnings
  - Images → PDF: Smart combining vs individual file options

 File Architecture

  Core Implementation:
  ├── Convert.tsx                     # Main stepped workflow UI
├── ConvertSettings.tsx # Centralized settings with smart detection
├── GroupedFormatDropdown.tsx # Enhanced format selector with grouping
├── useConvertParameters.ts # Smart detection & parameter management
  ├── useConvertOperation.ts         # Multi-strategy processing logic
  └── Settings Components:
      ├── ConvertFromWebSettings.tsx      # HTML zoom controls
      ├── ConvertFromEmailSettings.tsx    # Email attachment options
├── ConvertToPdfaSettings.tsx # PDF/A with signature detection
      ├── ConvertFromImageSettings.tsx    # Image PDF options
      └── ConvertToImageSettings.tsx      # PDF to image options

 Utility Layer

  Utils & Services:
├── convertUtils.ts # Format detection & endpoint routing
  ├── fileResponseUtils.ts          # Generic API response handling
└── setupTests.ts # Enhanced test environment with crypto mocks

  Testing & Quality

  Comprehensive Test Coverage

  Test Suite:
├── useConvertParameters.test.ts # Parameter logic & smart detection
  ├── useConvertParametersAutoDetection.test.ts  # File type analysis
├── ConvertIntegration.test.tsx # End-to-end conversion workflows
  ├── ConvertSmartDetectionIntegration.test.tsx  # Mixed file scenarios
  ├── ConvertE2E.spec.ts                     # Playwright browser tests
├── convertUtils.test.ts # Utility function validation
  └── fileResponseUtils.test.ts              # API response handling

  Advanced Test Features

  - Crypto API mocking: Proper test environment for file hashing
  - File.arrayBuffer() polyfills: Complete browser API simulation
  - Multi-file scenario testing: Complex batch processing validation
- CI/CD integration: Vitest runs in GitHub Actions with proper artifacts

---------

Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
ConnorYoh
2025-08-01 16:08:04 +01:00
committed by GitHub
co-authored by Connor Yoh Anthony Stirling
parent 8881f19b03
commit 9c9acbfb5b
68 changed files with 9173 additions and 87 deletions
@@ -12,6 +12,7 @@ import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid';
@@ -34,6 +35,7 @@ interface FileEditorProps {
toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
supportedExtensions?: string[];
}
const FileEditor = ({
@@ -41,10 +43,17 @@ const FileEditor = ({
onMergeFiles,
toolMode = false,
showUpload = true,
showBulkActions = true
showBulkActions = true,
supportedExtensions = ["pdf"]
}: FileEditorProps) => {
const { t } = useTranslation();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
// Get file context
const fileContext = useFileContext();
const {
@@ -224,49 +233,46 @@ const FileEditor = ({
// Handle PDF files normally
allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
// Handle ZIP files
// Handle ZIP files - only expand if they contain PDFs
try {
// Validate ZIP file first
const validation = await zipFileService.validateZipFile(file);
if (!validation.isValid) {
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
continue;
}
// Extract PDF files from ZIP
setZipExtractionProgress({
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
if (validation.isValid && validation.containsPDFs) {
// ZIP contains PDFs - extract them
setZipExtractionProgress({
isExtracting: true,
currentFile: progress.currentFile,
progress: progress.progress,
extractedCount: progress.extractedCount,
totalFiles: progress.totalFiles
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
});
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({
isExtracting: true,
currentFile: progress.currentFile,
progress: progress.progress,
extractedCount: progress.extractedCount,
totalFiles: progress.totalFiles
});
});
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
@@ -290,8 +296,13 @@ const FileEditor = ({
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
// ZIP doesn't contain PDFs or is invalid - treat as regular file
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
allExtractedFiles.push(file);
}
} catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
@@ -304,7 +315,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 +693,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' }}
@@ -804,6 +816,7 @@ const FileEditor = ({
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/>
)}
renderSplitMarker={(file, index) => (
@@ -18,9 +18,10 @@ interface FileCardProps {
onEdit?: () => void;
isSelected?: boolean;
onSelect?: () => void;
isSupported?: boolean; // Whether the file format is supported by the current tool
}
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => {
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false);
@@ -35,15 +36,18 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined,
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined,
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
data-testid="file-card"
>
<Stack gap={6} align="center">
<Box
@@ -179,6 +183,11 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
DB
</Badge>
)}
{!isSupported && (
<Badge color="orange" variant="filled" size="sm">
{t("fileManager.unsupported", "Unsupported")}
</Badge>
)}
</Group>
<Button
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import HistoryIcon from '@mui/icons-material/History';
@@ -37,6 +38,7 @@ interface FileThumbnailProps {
onViewFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
toolMode?: boolean;
isSupported?: boolean;
}
const FileThumbnail = ({
@@ -60,7 +62,9 @@ const FileThumbnail = ({
onViewFile,
onSetStatus,
toolMode = false,
isSupported = true,
}: FileThumbnailProps) => {
const { t } = useTranslation();
const [showHistory, setShowHistory] = useState(false);
const formatFileSize = (bytes: number) => {
@@ -81,6 +85,7 @@ const FileThumbnail = ({
}
}}
data-file-id={file.id}
data-testid="file-thumbnail"
className={`
${styles.pageContainer}
!rounded-lg
@@ -106,7 +111,9 @@ const FileThumbnail = ({
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
opacity: isSupported ? 1 : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
draggable
onDragStart={() => onDragStart(file.id)}
@@ -119,6 +126,7 @@ const FileThumbnail = ({
{selectionMode && (
<div
className={styles.checkboxContainer}
data-testid="file-thumbnail-checkbox"
style={{
position: 'absolute',
top: 8,
@@ -140,9 +148,12 @@ const FileThumbnail = ({
checked={selectedFiles.includes(file.id)}
onChange={(event) => {
event.stopPropagation();
onToggleFile(file.id);
if (isSupported) {
onToggleFile(file.id);
}
}}
onClick={(e) => e.stopPropagation()}
disabled={!isSupported}
size="sm"
/>
</div>
@@ -193,6 +204,23 @@ const FileThumbnail = ({
{file.pageCount} pages
</Badge>
{/* Unsupported badge */}
{!isSupported && (
<Badge
size="sm"
variant="filled"
color="orange"
style={{
position: 'absolute',
top: 8,
right: selectionMode ? 48 : 8, // Avoid overlap with checkbox
zIndex: 3,
}}
>
{t("fileManager.unsupported", "Unsupported")}
</Badge>
)}
{/* File name overlay */}
<Text
className={styles.pageNumber}
@@ -238,7 +266,7 @@ const FileThumbnail = ({
whiteSpace: 'nowrap'
}}
>
{!toolMode && (
{!toolMode && isSupported && (
<>
<Tooltip label="View File">
<ActionIcon
+9 -5
View File
@@ -20,6 +20,7 @@ interface FileGridProps {
onShowAll?: () => void;
showingAll?: boolean;
onDeleteAll?: () => void;
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
}
type SortOption = 'date' | 'name' | 'size';
@@ -37,7 +38,8 @@ const FileGrid = ({
maxDisplay,
onShowAll,
showingAll = false,
onDeleteAll
onDeleteAll,
isFileSupported
}: FileGridProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
@@ -123,16 +125,18 @@ const FileGrid = ({
{displayFiles.map((file, idx) => {
const fileId = file.id || file.name;
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
const supported = isFileSupported ? isFileSupported(file.name) : true;
return (
<FileCard
key={fileId + idx}
file={file}
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined}
onView={onView ? () => onView(file) : undefined}
onEdit={onEdit ? () => onEdit(file) : undefined}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
onView={onView && supported ? () => onView(file) : undefined}
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect ? () => onSelect(fileId) : undefined}
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}
/>
);
})}
@@ -5,6 +5,7 @@ import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next';
import { fileStorage } from '../../services/fileStorage';
import { FileWithUrl } from '../../types/file';
import { detectFileExtension } from '../../utils/fileUtils';
import FileGrid from './FileGrid';
import MultiSelectControls from './MultiSelectControls';
import { useFileManager } from '../../hooks/useFileManager';
@@ -20,6 +21,7 @@ interface FileUploadSelectorProps {
onFileSelect?: (file: File) => void;
onFilesSelect: (files: File[]) => void;
accept?: string[];
supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png'])
// Loading state
loading?: boolean;
@@ -38,6 +40,7 @@ const FileUploadSelector = ({
onFileSelect,
onFilesSelect,
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
supportedExtensions = ["pdf"], // Default to PDF only for most tools
loading = false,
disabled = false,
showRecentFiles = true,
@@ -51,6 +54,12 @@ const FileUploadSelector = ({
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
@@ -155,6 +164,7 @@ const FileUploadSelector = ({
disabled={disabled || loading}
style={{ width: '100%', height: "5rem" }}
activateOnClick={true}
data-testid="file-dropzone"
>
<Center>
<Stack align="center" gap="sm">
@@ -192,6 +202,7 @@ const FileUploadSelector = ({
accept={accept.join(',')}
onChange={handleFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
</Stack>
)}
@@ -225,6 +236,7 @@ const FileUploadSelector = ({
selectedFiles={selectedFiles}
showSearch={true}
showSort={true}
isFileSupported={isFileSupported}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
@@ -35,6 +35,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps
filteredTools.map(([id, { icon, name }]) => (
<Button
key={id}
data-testid={`tool-${id}`}
variant={selectedToolKey === id ? "filled" : "subtle"}
onClick={() => onSelect(id)}
size="md"
@@ -0,0 +1,77 @@
import React from 'react';
import { Stack, Text, NumberInput, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
interface ConvertFromEmailSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromEmailSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromEmailSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="email-settings">
<Text size="sm" fw={500}>{t("convert.emailOptions", "Email to PDF Options")}:</Text>
<Checkbox
label={t("convert.includeAttachments", "Include email attachments")}
checked={parameters.emailOptions.includeAttachments}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAttachments: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-attachments-checkbox"
/>
{parameters.emailOptions.includeAttachments && (
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}:</Text>
<NumberInput
value={parameters.emailOptions.maxAttachmentSizeMB}
onChange={(value) => onParameterChange('emailOptions', {
...parameters.emailOptions,
maxAttachmentSizeMB: Number(value) || 10
})}
min={1}
max={100}
step={1}
disabled={disabled}
data-testid="max-attachment-size-input"
/>
</Stack>
)}
<Checkbox
label={t("convert.includeAllRecipients", "Include CC and BCC recipients in header")}
checked={parameters.emailOptions.includeAllRecipients}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAllRecipients: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-all-recipients-checkbox"
/>
<Checkbox
label={t("convert.downloadHtml", "Download HTML intermediate file instead of PDF")}
checked={parameters.emailOptions.downloadHtml}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
downloadHtml: event.currentTarget.checked
})}
disabled={disabled}
data-testid="download-html-checkbox"
/>
</Stack>
);
};
export default ConvertFromEmailSettings;
@@ -0,0 +1,82 @@
import React from "react";
import { Stack, Text, Select, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { COLOR_TYPES, FIT_OPTIONS } from "../../../constants/convertConstants";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
interface ConvertFromImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromImageSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromImageSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="pdf-options-section">
<Text size="sm" fw={500}>{t("convert.pdfOptions", "PDF Options")}:</Text>
<Select
data-testid="color-type-select"
label={t("convert.colorType", "Color Type")}
value={parameters.imageOptions.colorType}
onChange={(val) => val && onParameterChange('imageOptions', {
...parameters.imageOptions,
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
})}
data={[
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
]}
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>
);
};
export default ConvertFromImageSettings;
@@ -0,0 +1,55 @@
import React from 'react';
import { Stack, Text, NumberInput, Slider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
interface ConvertFromWebSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromWebSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromWebSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="web-settings">
<Text size="sm" fw={500}>{t("convert.webOptions", "Web to PDF Options")}:</Text>
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.zoomLevel", "Zoom Level")}:</Text>
<NumberInput
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: Number(value) || 1.0
})}
min={0.1}
max={3.0}
step={0.1}
precision={1}
disabled={disabled}
data-testid="zoom-level-input"
/>
<Slider
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: value
})}
min={0.1}
max={3.0}
step={0.1}
disabled={disabled}
data-testid="zoom-level-slider"
/>
</Stack>
</Stack>
);
};
export default ConvertFromWebSettings;
@@ -0,0 +1,318 @@
import React, { useMemo } from "react";
import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
import { useFileContext } from "../../../contexts/FileContext";
import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings";
import ConvertFromImageSettings from "./ConvertFromImageSettings";
import ConvertFromWebSettings from "./ConvertFromWebSettings";
import ConvertFromEmailSettings from "./ConvertFromEmailSettings";
import ConvertToPdfaSettings from "./ConvertToPdfaSettings";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
import {
FROM_FORMAT_OPTIONS,
EXTENSION_TO_ENDPOINT,
COLOR_TYPES,
OUTPUT_OPTIONS,
FIT_OPTIONS
} from "../../../constants/convertConstants";
interface ConvertSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: File[];
disabled?: boolean;
}
const ConvertSettings = ({
parameters,
onParameterChange,
getAvailableToExtensions,
selectedFiles,
disabled = false
}: ConvertSettingsProps) => {
const { t } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { setSelectedFiles } = useFileSelectionActions();
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
const allEndpoints = useMemo(() => {
const endpoints = new Set<string>();
Object.values(EXTENSION_TO_ENDPOINT).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
}, []);
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false;
return endpointStatus[endpointKey] === true;
};
// Enhanced FROM options with endpoint availability
const enhancedFromOptions = useMemo(() => {
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
// Check if this source format has any available conversions
const availableConversions = getAvailableToExtensions(option.value) || [];
const hasAvailableConversions = availableConversions.some(targetOption =>
isConversionAvailable(option.value, targetOption.value)
);
return {
...option,
enabled: hasAvailableConversions
};
});
// Add dynamic format option if current selection is a file-<extension> format
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
const extension = parameters.fromExtension.replace('file-', '');
const dynamicOption = {
value: parameters.fromExtension,
label: extension.toUpperCase(),
group: 'File',
enabled: true
};
// Add the dynamic option at the beginning
return [dynamicOption, ...baseOptions];
}
return baseOptions;
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
// Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
return availableOptions.map(option => ({
...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value)
}));
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
});
onParameterChange('emailOptions', {
includeAttachments: true,
maxAttachmentSizeMB: 10,
downloadHtml: false,
includeAllRecipients: false,
});
onParameterChange('pdfaOptions', {
outputFormat: 'pdfa-1',
});
onParameterChange('isSmartDetection', false);
onParameterChange('smartDetectionType', 'none');
};
const setAutoTargetExtension = (fromExtension: string) => {
const availableToOptions = getAvailableToExtensions(fromExtension);
const autoTarget = availableToOptions.length === 1 ? availableToOptions[0].value : '';
onParameterChange('toExtension', autoTarget);
};
const filterFilesByExtension = (extension: string) => {
return activeFiles.filter(file => {
const fileExtension = detectFileExtension(file.name);
if (extension === 'any') {
return true;
} else if (extension === 'image') {
return isImageFormat(fileExtension);
} else {
return fileExtension === extension;
}
});
};
const updateFileSelection = (files: File[]) => {
setSelectedFiles(files);
const fileIds = files.map(file => (file as any).id || file.name);
setContextSelectedFiles(fileIds);
};
const handleFromExtensionChange = (value: string) => {
onParameterChange('fromExtension', value);
setAutoTargetExtension(value);
resetParametersToDefaults();
if (activeFiles.length > 0) {
const matchingFiles = filterFilesByExtension(value);
updateFileSelection(matchingFiles);
} else {
updateFileSelection([]);
}
};
const handleToExtensionChange = (value: string) => {
onParameterChange('toExtension', value);
onParameterChange('imageOptions', {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
});
onParameterChange('emailOptions', {
includeAttachments: true,
maxAttachmentSizeMB: 10,
downloadHtml: false,
includeAllRecipients: false,
});
onParameterChange('pdfaOptions', {
outputFormat: 'pdfa-1',
});
};
return (
<Stack gap="md">
{/* Format Selection */}
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("convert.convertFrom", "Convert from")}:
</Text>
<GroupedFormatDropdown
name="convert-from-dropdown"
data-testid="from-format-dropdown"
value={parameters.fromExtension}
placeholder={t("convert.sourceFormatPlaceholder", "Source format")}
options={enhancedFromOptions}
onChange={handleFromExtensionChange}
disabled={disabled}
minWidth="18rem"
/>
</Stack>
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("convert.convertTo", "Convert to")}:
</Text>
{!parameters.fromExtension ? (
<UnstyledButton
style={{
padding: '0.5rem 0.75rem',
border: `0.0625rem solid ${theme.colors.gray[4]}`,
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
cursor: 'not-allowed'
}}
>
<Group justify="space-between">
<Text size="sm">{t("convert.selectSourceFormatFirst", "Select a source format first")}</Text>
<KeyboardArrowDownIcon
style={{
fontSize: '1rem',
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
}}
/>
</Group>
</UnstyledButton>
) : (
<GroupedFormatDropdown
name="convert-to-dropdown"
data-testid="to-format-dropdown"
value={parameters.toExtension}
placeholder={t("convert.targetFormatPlaceholder", "Target format")}
options={enhancedToOptions}
onChange={handleToExtensionChange}
disabled={disabled}
minWidth="18rem"
/>
)}
</Stack>
{/* Format-specific options */}
{isImageFormat(parameters.toExtension) && (
<>
<Divider />
<ConvertToImageSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* Color options for image to PDF conversion */}
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? (
<>
<Divider />
<ConvertFromImageSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
) : null}
{/* Web to PDF options */}
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
<>
<Divider />
<ConvertFromWebSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
) : null}
{/* Email to PDF options */}
{parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && (
<>
<Divider />
<ConvertFromEmailSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* PDF to PDF/A options */}
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && (
<>
<Divider />
<ConvertToPdfaSettings
parameters={parameters}
onParameterChange={onParameterChange}
selectedFiles={selectedFiles}
disabled={disabled}
/>
</>
)}
</Stack>
);
};
export default ConvertSettings;
@@ -0,0 +1,71 @@
import React from "react";
import { Stack, Text, Select, NumberInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { COLOR_TYPES, OUTPUT_OPTIONS } from "../../../constants/convertConstants";
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
interface ConvertToImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertToImageSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertToImageSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="image-options-section">
<Text size="sm" fw={500} data-testid="image-options-title">{t("convert.imageOptions", "Image Options")}:</Text>
<Group grow>
<Select
data-testid="color-type-select"
label={t("convert.colorType", "Color Type")}
value={parameters.imageOptions.colorType}
onChange={(val) => val && onParameterChange('imageOptions', {
...parameters.imageOptions,
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
})}
data={[
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
]}
disabled={disabled}
/>
<NumberInput
data-testid="dpi-input"
label={t("convert.dpi", "DPI")}
value={parameters.imageOptions.dpi}
onChange={(val) => typeof val === 'number' && onParameterChange('imageOptions', {
...parameters.imageOptions,
dpi: val
})}
min={72}
max={600}
step={1}
disabled={disabled}
/>
</Group>
<Select
data-testid="output-type-select"
label={t("convert.output", "Output")}
value={parameters.imageOptions.singleOrMultiple}
onChange={(val) => val && onParameterChange('imageOptions', {
...parameters.imageOptions,
singleOrMultiple: val as typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS]
})}
data={[
{ value: OUTPUT_OPTIONS.SINGLE, label: t("convert.single", "Single") },
{ value: OUTPUT_OPTIONS.MULTIPLE, label: t("convert.multiple", "Multiple") },
]}
disabled={disabled}
/>
</Stack>
);
};
export default ConvertToImageSettings;
@@ -0,0 +1,60 @@
import React from 'react';
import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
selectedFiles: File[];
disabled?: boolean;
}
const ConvertToPdfaSettings = ({
parameters,
onParameterChange,
selectedFiles,
disabled = false
}: ConvertToPdfaSettingsProps) => {
const { t } = useTranslation();
const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles);
const pdfaFormatOptions = [
{ value: 'pdfa-1', label: 'PDF/A-1b' },
{ value: 'pdfa', label: 'PDF/A-2b' }
];
return (
<Stack gap="sm" data-testid="pdfa-settings">
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
{hasDigitalSignatures && (
<Alert color="yellow" size="sm">
<Text size="sm">
{t("convert.pdfaDigitalSignatureWarning", "The PDF contains a digital signature. This will be removed in the next step.")}
</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.outputFormat", "Output Format")}:</Text>
<Select
value={parameters.pdfaOptions.outputFormat}
onChange={(value) => onParameterChange('pdfaOptions', {
...parameters.pdfaOptions,
outputFormat: value || 'pdfa-1'
})}
data={pdfaFormatOptions}
disabled={disabled || isChecking}
data-testid="pdfa-output-format-select"
/>
<Text size="xs" c="dimmed">
{t("convert.pdfaNote", "PDF/A-1b is more compatible, PDF/A-2b supports more features.")}
</Text>
</Stack>
</Stack>
);
};
export default ConvertToPdfaSettings;
@@ -0,0 +1,156 @@
import React, { useState, useMemo } from "react";
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
interface FormatOption {
value: string;
label: string;
group: string;
enabled?: boolean;
}
interface GroupedFormatDropdownProps {
value?: string;
placeholder?: string;
options: FormatOption[];
onChange: (value: string) => void;
disabled?: boolean;
minWidth?: string;
name?: string;
}
const GroupedFormatDropdown = ({
value,
placeholder = "Select an option",
options,
onChange,
disabled = false,
minWidth = "18.75rem",
name
}: GroupedFormatDropdownProps) => {
const [dropdownOpened, setDropdownOpened] = useState(false);
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const groupedOptions = useMemo(() => {
const groups: Record<string, FormatOption[]> = {};
options.forEach(option => {
if (!groups[option.group]) {
groups[option.group] = [];
}
groups[option.group].push(option);
});
return groups;
}, [options]);
const selectedLabel = useMemo(() => {
if (!value) return placeholder;
const selected = options.find(opt => opt.value === value);
return selected ? `${selected.group} (${selected.label})` : value.toUpperCase();
}, [value, options, placeholder]);
const handleOptionSelect = (selectedValue: string) => {
onChange(selectedValue);
setDropdownOpened(false);
};
return (
<Popover
opened={dropdownOpened}
onDismiss={() => setDropdownOpened(false)}
position="bottom-start"
withArrow
shadow="sm"
disabled={disabled}
closeOnEscape={true}
trapFocus
>
<Popover.Target>
<UnstyledButton
name={name}
data-testid={name}
onClick={() => setDropdownOpened(!dropdownOpened)}
disabled={disabled}
style={{
padding: '0.5rem 0.75rem',
border: `0.0625rem solid ${theme.colors.gray[4]}`,
borderRadius: theme.radius.sm,
backgroundColor: disabled
? theme.colors.gray[1]
: colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
cursor: disabled ? 'not-allowed' : 'pointer',
width: '100%',
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">
<Text size="sm" c={value ? undefined : 'dimmed'}>
{selectedLabel}
</Text>
<KeyboardArrowDownIcon
style={{
fontSize: '1rem',
transform: dropdownOpened ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
}}
/>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
style={{
minWidth: Math.min(350, parseInt(minWidth.replace('rem', '')) * 16),
maxWidth: '90vw',
maxHeight: '40vh',
overflow: 'auto',
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[7] : theme.white,
border: `0.0625rem solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4]}`,
}}
>
<Stack gap="md">
{Object.entries(groupedOptions).map(([groupName, groupOptions]) => (
<Box key={groupName}>
<Text
size="sm"
fw={600}
c={colorScheme === 'dark' ? 'dark.2' : 'gray.6'}
mb="xs"
>
{groupName}
</Text>
<Group gap="xs" style={{ flexWrap: 'wrap' }}>
{groupOptions.map((option) => (
<Button
key={option.value}
data-testid={`format-option-${option.value}`}
variant={value === option.value ? "filled" : "outline"}
size="sm"
onClick={() => handleOptionSelect(option.value)}
disabled={option.enabled === false}
style={{
fontSize: '0.75rem',
height: '2rem',
padding: '0 0.75rem',
flexShrink: 0
}}
>
{option.label}
</Button>
))}
</Group>
</Box>
))}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
export default GroupedFormatDropdown;
@@ -13,6 +13,7 @@ export interface OperationButtonProps {
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
'data-testid'?: string;
}
const OperationButton = ({
@@ -25,7 +26,8 @@ const OperationButton = ({
color = 'blue',
fullWidth = true,
mt = 'md',
type = 'button'
type = 'button',
'data-testid': dataTestId
}: OperationButtonProps) => {
const { t } = useTranslation();
@@ -39,6 +41,7 @@ const OperationButton = ({
disabled={disabled}
variant={variant}
color={color}
data-testid={dataTestId}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
@@ -37,28 +37,29 @@ const ResultsPreview = ({
}
return (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }} data-testid="results-preview-container">
{title && (
<Text fw={500} size="md" mb="sm">
<Text fw={500} size="md" mb="sm" data-testid="results-preview-title">
{title} ({files.length} files)
</Text>
)}
{isGeneratingThumbnails ? (
<Center p="lg">
<Center p="lg" data-testid="results-preview-loading">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
) : (
<Grid>
<Grid data-testid="results-preview-grid">
{files.map((result, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
onClick={() => onFileClick?.(result.file)}
data-testid={`results-preview-thumbnail-${index}`}
style={{
textAlign: 'center',
height: '10rem',
@@ -43,7 +43,7 @@ const ToolStep = ({
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber, parent]);
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
const stepNumber = parent?.getStepNumber?.() || 1;
return (
<Paper