mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
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:
co-authored by
Connor Yoh
Anthony Stirling
parent
8881f19b03
commit
9c9acbfb5b
@@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
|
||||
export const COLOR_TYPES = {
|
||||
COLOR: 'color',
|
||||
GREYSCALE: 'greyscale',
|
||||
BLACK_WHITE: 'blackwhite'
|
||||
} as const;
|
||||
|
||||
export const OUTPUT_OPTIONS = {
|
||||
SINGLE: 'single',
|
||||
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',
|
||||
'pdf-image': '/api/v1/convert/pdf/img',
|
||||
'image-pdf': '/api/v1/convert/img/pdf',
|
||||
'pdf-office-word': '/api/v1/convert/pdf/word',
|
||||
'pdf-office-presentation': '/api/v1/convert/pdf/presentation',
|
||||
'pdf-office-text': '/api/v1/convert/pdf/text',
|
||||
'pdf-csv': '/api/v1/convert/pdf/csv',
|
||||
'pdf-markdown': '/api/v1/convert/pdf/markdown',
|
||||
'pdf-html': '/api/v1/convert/pdf/html',
|
||||
'pdf-xml': '/api/v1/convert/pdf/xml',
|
||||
'pdf-pdfa': '/api/v1/convert/pdf/pdfa',
|
||||
'html-pdf': '/api/v1/convert/html/pdf',
|
||||
'markdown-pdf': '/api/v1/convert/markdown/pdf',
|
||||
'eml-pdf': '/api/v1/convert/eml/pdf'
|
||||
} as const;
|
||||
|
||||
export const ENDPOINT_NAMES = {
|
||||
'office-pdf': 'file-to-pdf',
|
||||
'pdf-image': 'pdf-to-img',
|
||||
'image-pdf': 'img-to-pdf',
|
||||
'pdf-office-word': 'pdf-to-word',
|
||||
'pdf-office-presentation': 'pdf-to-presentation',
|
||||
'pdf-office-text': 'pdf-to-text',
|
||||
'pdf-csv': 'pdf-to-csv',
|
||||
'pdf-markdown': 'pdf-to-markdown',
|
||||
'pdf-html': 'pdf-to-html',
|
||||
'pdf-xml': 'pdf-to-xml',
|
||||
'pdf-pdfa': 'pdf-to-pdfa',
|
||||
'html-pdf': 'html-to-pdf',
|
||||
'markdown-pdf': 'markdown-to-pdf',
|
||||
'eml-pdf': 'eml-to-pdf'
|
||||
} as const;
|
||||
|
||||
|
||||
// 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' },
|
||||
{ value: 'odt', label: 'ODT', group: 'Document' },
|
||||
{ value: 'xlsx', label: 'XLSX', group: 'Spreadsheet' },
|
||||
{ value: 'xls', label: 'XLS', group: 'Spreadsheet' },
|
||||
{ value: 'ods', label: 'ODS', group: 'Spreadsheet' },
|
||||
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
||||
{ value: 'ppt', label: 'PPT', group: 'Presentation' },
|
||||
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
||||
{ value: 'jpg', label: 'JPG', group: 'Image' },
|
||||
{ value: 'jpeg', label: 'JPEG', group: 'Image' },
|
||||
{ value: 'png', label: 'PNG', group: 'Image' },
|
||||
{ value: 'gif', label: 'GIF', group: 'Image' },
|
||||
{ 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: 'zip', label: 'ZIP', group: 'Web' },
|
||||
{ value: 'md', label: 'MD', group: 'Text' },
|
||||
{ value: 'txt', label: 'TXT', group: 'Text' },
|
||||
{ value: 'rtf', label: 'RTF', group: 'Text' },
|
||||
{ value: 'eml', label: 'EML', group: 'Email' },
|
||||
];
|
||||
|
||||
export const TO_FORMAT_OPTIONS = [
|
||||
{ value: 'pdf', label: 'PDF', group: 'Document' },
|
||||
{ value: 'pdfa', label: 'PDF/A', group: 'Document' },
|
||||
{ value: 'docx', label: 'DOCX', group: 'Document' },
|
||||
{ value: 'odt', label: 'ODT', group: 'Document' },
|
||||
{ value: 'csv', label: 'CSV', group: 'Spreadsheet' },
|
||||
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
||||
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
||||
{ value: 'txt', label: 'TXT', group: 'Text' },
|
||||
{ value: 'rtf', label: 'RTF', group: 'Text' },
|
||||
{ value: 'md', label: 'MD', group: 'Text' },
|
||||
{ value: 'png', label: 'PNG', group: 'Image' },
|
||||
{ value: 'jpg', label: 'JPG', group: 'Image' },
|
||||
{ value: 'gif', label: 'GIF', group: 'Image' },
|
||||
{ value: 'tiff', label: 'TIFF', group: 'Image' },
|
||||
{ value: 'bmp', label: 'BMP', group: 'Image' },
|
||||
{ value: 'webp', label: 'WEBP', group: 'Image' },
|
||||
{ value: 'html', label: 'HTML', group: 'Web' },
|
||||
{ value: 'xml', label: 'XML', group: 'Web' },
|
||||
];
|
||||
|
||||
// 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'], 'svg': ['pdf'],
|
||||
'html': ['pdf'],
|
||||
'zip': ['pdf'],
|
||||
'md': ['pdf'],
|
||||
'txt': ['pdf'], 'rtf': ['pdf'],
|
||||
'eml': ['pdf']
|
||||
};
|
||||
|
||||
// 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',
|
||||
'pptx': 'pdf-to-presentation', 'odp': 'pdf-to-presentation',
|
||||
'csv': 'pdf-to-csv',
|
||||
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown',
|
||||
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
|
||||
'pdfa': 'pdf-to-pdfa'
|
||||
},
|
||||
'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { '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' },
|
||||
'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' }, 'svg': { 'pdf': 'img-to-pdf' },
|
||||
'html': { 'pdf': 'html-to-pdf' },
|
||||
'zip': { 'pdf': 'html-to-pdf' },
|
||||
'md': { 'pdf': 'markdown-to-pdf' },
|
||||
'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' },
|
||||
'eml': { 'pdf': 'eml-to-pdf' }
|
||||
};
|
||||
|
||||
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];
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { FileOperation } from '../../../types/fileContext';
|
||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||
import { ConvertParameters } from './useConvertParameters';
|
||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
|
||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
|
||||
export interface ConvertOperationHook {
|
||||
executeOperation: (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => Promise<void>;
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files: File[];
|
||||
thumbnails: string[];
|
||||
isGeneratingThumbnails: boolean;
|
||||
downloadUrl: string | null;
|
||||
downloadFilename: string;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
|
||||
// Result management functions
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const shouldProcessFilesSeparately = (
|
||||
selectedFiles: File[],
|
||||
parameters: ConvertParameters
|
||||
): boolean => {
|
||||
return selectedFiles.length > 1 && (
|
||||
// Image to PDF with combineImages = false
|
||||
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
|
||||
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
|
||||
// PDF to image conversions (each PDF should generate its own image file)
|
||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||
// PDF to PDF/A conversions (each PDF should be processed separately)
|
||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
|
||||
// Web files to PDF conversions (each web file should generate its own PDF)
|
||||
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
||||
parameters.toExtension === 'pdf') ||
|
||||
// Web files smart detection
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web') ||
|
||||
// Mixed file types (smart detection)
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'mixed')
|
||||
);
|
||||
};
|
||||
|
||||
const createFileFromResponse = (
|
||||
responseData: any,
|
||||
headers: any,
|
||||
originalFileName: string,
|
||||
targetExtension: string
|
||||
): File => {
|
||||
const originalName = originalFileName.split('.')[0];
|
||||
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
||||
|
||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
};
|
||||
|
||||
const generateThumbnailsForFiles = async (files: File[]): Promise<string[]> => {
|
||||
const thumbnails: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
thumbnails.push(thumbnail);
|
||||
} catch (error) {
|
||||
thumbnails.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return thumbnails;
|
||||
};
|
||||
|
||||
const createDownloadInfo = async (files: File[]): Promise<{ url: string; filename: string }> => {
|
||||
if (files.length === 1) {
|
||||
const url = window.URL.createObjectURL(files[0]);
|
||||
return { url, filename: files[0].name };
|
||||
} else {
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
files.forEach(file => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipUrl = window.URL.createObjectURL(zipBlob);
|
||||
|
||||
return { url: zipUrl, filename: 'converted_files.zip' };
|
||||
}
|
||||
};
|
||||
|
||||
export const useConvertOperation = (): ConvertOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
markOperationFailed,
|
||||
addFiles
|
||||
} = useFileContext();
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const buildFormData = useCallback((
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
|
||||
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters;
|
||||
|
||||
if (isImageFormat(toExtension)) {
|
||||
formData.append("imageFormat", toExtension);
|
||||
formData.append("colorType", imageOptions.colorType);
|
||||
formData.append("dpi", imageOptions.dpi.toString());
|
||||
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
|
||||
} else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) {
|
||||
formData.append("outputFormat", toExtension);
|
||||
} else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) {
|
||||
formData.append("outputFormat", toExtension);
|
||||
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
|
||||
formData.append("outputFormat", toExtension);
|
||||
} else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
|
||||
formData.append("fitOption", imageOptions.fitOption);
|
||||
formData.append("colorType", imageOptions.colorType);
|
||||
formData.append("autoRotate", imageOptions.autoRotate.toString());
|
||||
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
|
||||
formData.append("zoom", htmlOptions.zoomLevel.toString());
|
||||
} else if (fromExtension === 'eml' && toExtension === 'pdf') {
|
||||
formData.append("includeAttachments", emailOptions.includeAttachments.toString());
|
||||
formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString());
|
||||
formData.append("downloadHtml", emailOptions.downloadHtml.toString());
|
||||
formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString());
|
||||
} else if (fromExtension === 'pdf' && toExtension === 'pdfa') {
|
||||
formData.append("outputFormat", pdfaOptions.outputFormat);
|
||||
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
|
||||
formData.append("pageNumbers", "all");
|
||||
}
|
||||
|
||||
return formData;
|
||||
}, []);
|
||||
|
||||
const createOperation = useCallback((
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `convert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles[0].name;
|
||||
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
fileIds: selectedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: selectedFiles[0].name,
|
||||
parameters: {
|
||||
fromExtension: parameters.fromExtension,
|
||||
toExtension: parameters.toExtension,
|
||||
imageOptions: parameters.imageOptions,
|
||||
htmlOptions: parameters.htmlOptions,
|
||||
emailOptions: parameters.emailOptions,
|
||||
pdfaOptions: parameters.pdfaOptions,
|
||||
},
|
||||
fileSize: selectedFiles[0].size
|
||||
}
|
||||
};
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
}, []);
|
||||
|
||||
const processResults = useCallback(async (blob: Blob, filename: string) => {
|
||||
try {
|
||||
// For single file conversions, create a file directly
|
||||
const convertedFile = new File([blob], filename, { type: blob.type });
|
||||
|
||||
// Set local state for preview
|
||||
setFiles([convertedFile]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
// Add converted file to FileContext for future use
|
||||
await addFiles([convertedFile]);
|
||||
|
||||
// Generate thumbnail for preview
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(convertedFile);
|
||||
setThumbnails([thumbnail]);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${filename}:`, error);
|
||||
setThumbnails(['']);
|
||||
}
|
||||
|
||||
setIsGeneratingThumbnails(false);
|
||||
} catch (error) {
|
||||
console.warn('Failed to process conversion result:', error);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
||||
await executeMultipleSeparateFiles(parameters, selectedFiles);
|
||||
} else {
|
||||
await executeSingleCombinedOperation(parameters, selectedFiles);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const executeMultipleSeparateFiles = async (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
setStatus(t("loading"));
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
const results: File[] = [];
|
||||
|
||||
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}...`));
|
||||
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
let endpoint = getEndpointUrl(fileExtension, parameters.toExtension);
|
||||
let fileSpecificParams = { ...parameters, fromExtension: fileExtension };
|
||||
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;
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
// Use utility function to create file from response
|
||||
const convertedFile = createFileFromResponse(
|
||||
response.data,
|
||||
response.headers,
|
||||
file.name,
|
||||
parameters.toExtension
|
||||
);
|
||||
results.push(convertedFile);
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(`Error converting file ${file.name}:`, error);
|
||||
markOperationFailed(fileId, operationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
|
||||
const generatedThumbnails = await generateThumbnailsForFiles(results);
|
||||
|
||||
setFiles(results);
|
||||
setThumbnails(generatedThumbnails);
|
||||
|
||||
await addFiles(results);
|
||||
|
||||
try {
|
||||
const { url, filename } = await createDownloadInfo(results);
|
||||
setDownloadUrl(url);
|
||||
setDownloadFilename(filename);
|
||||
} catch (error) {
|
||||
console.error('Failed to create download info:', error);
|
||||
const url = window.URL.createObjectURL(results[0]);
|
||||
setDownloadUrl(url);
|
||||
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);
|
||||
|
||||
// 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" });
|
||||
|
||||
// Use utility function to create file from response
|
||||
const originalFileName = selectedFiles.length === 1
|
||||
? selectedFiles[0].name
|
||||
: 'combined_files.pdf'; // Default extension for combined files
|
||||
|
||||
const convertedFile = createFileFromResponse(
|
||||
response.data,
|
||||
response.headers,
|
||||
originalFileName,
|
||||
parameters.toExtension
|
||||
);
|
||||
|
||||
const url = window.URL.createObjectURL(convertedFile);
|
||||
setDownloadUrl(url);
|
||||
setDownloadFilename(convertedFile.name);
|
||||
setStatus(t("downloadComplete"));
|
||||
|
||||
await processResults(new Blob([convertedFile]), convertedFile.name);
|
||||
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 resetResults = useCallback(() => {
|
||||
// Clean up blob URLs to prevent memory leaks
|
||||
if (downloadUrl) {
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(false);
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, [downloadUrl]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (downloadUrl) {
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
};
|
||||
}, [downloadUrl]);
|
||||
|
||||
return {
|
||||
executeOperation,
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files,
|
||||
thumbnails,
|
||||
isGeneratingThumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
status,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
|
||||
// Result management functions
|
||||
resetResults,
|
||||
clearError,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Unit tests for useConvertParameters hook
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useConvertParameters } from './useConvertParameters';
|
||||
|
||||
describe('useConvertParameters', () => {
|
||||
|
||||
describe('Parameter Management', () => {
|
||||
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('');
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
expect(result.current.parameters.imageOptions.colorType).toBe('color');
|
||||
expect(result.current.parameters.imageOptions.dpi).toBe(300);
|
||||
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('multiple');
|
||||
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.0);
|
||||
expect(result.current.parameters.emailOptions.includeAttachments).toBe(true);
|
||||
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(10);
|
||||
expect(result.current.parameters.emailOptions.downloadHtml).toBe(false);
|
||||
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(false);
|
||||
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa-1');
|
||||
});
|
||||
|
||||
test('should update individual parameters', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
expect(result.current.parameters.toExtension).toBe(''); // Should not affect other params
|
||||
});
|
||||
|
||||
test('should update nested image options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('imageOptions', {
|
||||
colorType: 'grayscale',
|
||||
dpi: 150,
|
||||
singleOrMultiple: 'single'
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.parameters.imageOptions.colorType).toBe('grayscale');
|
||||
expect(result.current.parameters.imageOptions.dpi).toBe(150);
|
||||
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('single');
|
||||
});
|
||||
|
||||
test('should update nested HTML options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('htmlOptions', {
|
||||
zoomLevel: 1.5
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5);
|
||||
});
|
||||
|
||||
test('should update nested email options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('emailOptions', {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 20,
|
||||
downloadHtml: true,
|
||||
includeAllRecipients: true
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.parameters.emailOptions.includeAttachments).toBe(false);
|
||||
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(20);
|
||||
expect(result.current.parameters.emailOptions.downloadHtml).toBe(true);
|
||||
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(true);
|
||||
});
|
||||
|
||||
test('should update nested PDF/A options', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('pdfaOptions', {
|
||||
outputFormat: 'pdfa'
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa');
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('');
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Validation', () => {
|
||||
|
||||
test('should validate parameters correctly', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
// No parameters - should be invalid
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
|
||||
// Only fromExtension - should be invalid
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
|
||||
// Both extensions with supported conversion - should be valid
|
||||
act(() => {
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
});
|
||||
|
||||
test('should validate unsupported conversions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'unsupported');
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Endpoint Generation', () => {
|
||||
|
||||
test('should generate correct endpoint names', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
|
||||
const endpointName = result.current.getEndpointName();
|
||||
expect(endpointName).toBe('pdf-to-img');
|
||||
});
|
||||
|
||||
test('should generate correct endpoint URLs', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'pdf');
|
||||
result.current.updateParameter('toExtension', 'png');
|
||||
});
|
||||
|
||||
const endpoint = result.current.getEndpoint();
|
||||
expect(endpoint).toBe('/api/v1/convert/pdf/img');
|
||||
});
|
||||
|
||||
test('should return empty strings for invalid conversions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('fromExtension', 'invalid');
|
||||
result.current.updateParameter('toExtension', 'invalid');
|
||||
});
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('');
|
||||
expect(result.current.getEndpoint()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Available Extensions', () => {
|
||||
|
||||
test('should return available extensions for valid source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('pdf');
|
||||
|
||||
expect(availableExtensions.length).toBeGreaterThan(0);
|
||||
expect(availableExtensions.some(ext => ext.value === 'png')).toBe(true);
|
||||
expect(availableExtensions.some(ext => ext.value === 'jpg')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return empty array for invalid source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('invalid');
|
||||
|
||||
expect(availableExtensions).toEqual([{
|
||||
"group": "Document",
|
||||
"label": "PDF",
|
||||
"value": "pdf",
|
||||
}]);
|
||||
});
|
||||
|
||||
test('should return empty array for empty source format', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const availableExtensions = result.current.getAvailableToExtensions('');
|
||||
|
||||
expect(availableExtensions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS,
|
||||
TO_FORMAT_OPTIONS,
|
||||
CONVERSION_MATRIX,
|
||||
type ColorType,
|
||||
type OutputOption,
|
||||
type FitOption
|
||||
} from '../../../constants/convertConstants';
|
||||
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
|
||||
|
||||
export interface ConvertParameters {
|
||||
fromExtension: string;
|
||||
toExtension: string;
|
||||
imageOptions: {
|
||||
colorType: ColorType;
|
||||
dpi: number;
|
||||
singleOrMultiple: OutputOption;
|
||||
fitOption: FitOption;
|
||||
autoRotate: boolean;
|
||||
combineImages: boolean;
|
||||
};
|
||||
htmlOptions: {
|
||||
zoomLevel: number;
|
||||
};
|
||||
emailOptions: {
|
||||
includeAttachments: boolean;
|
||||
maxAttachmentSizeMB: number;
|
||||
downloadHtml: boolean;
|
||||
includeAllRecipients: boolean;
|
||||
};
|
||||
pdfaOptions: {
|
||||
outputFormat: string;
|
||||
};
|
||||
isSmartDetection: boolean;
|
||||
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
|
||||
}
|
||||
|
||||
export interface ConvertParametersHook {
|
||||
parameters: ConvertParameters;
|
||||
updateParameter: (parameter: keyof ConvertParameters, value: any) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
getEndpoint: () => string;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
analyzeFileTypes: (files: Array<{name: string}>) => void;
|
||||
}
|
||||
|
||||
const initialParameters: ConvertParameters = {
|
||||
fromExtension: '',
|
||||
toExtension: '',
|
||||
imageOptions: {
|
||||
colorType: COLOR_TYPES.COLOR,
|
||||
dpi: 300,
|
||||
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
|
||||
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
|
||||
autoRotate: true,
|
||||
combineImages: true,
|
||||
},
|
||||
htmlOptions: {
|
||||
zoomLevel: 1.0,
|
||||
},
|
||||
emailOptions: {
|
||||
includeAttachments: true,
|
||||
maxAttachmentSizeMB: 10,
|
||||
downloadHtml: false,
|
||||
includeAllRecipients: false,
|
||||
},
|
||||
pdfaOptions: {
|
||||
outputFormat: 'pdfa-1',
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none',
|
||||
};
|
||||
|
||||
export const useConvertParameters = (): ConvertParametersHook => {
|
||||
const [parameters, setParameters] = useState<ConvertParameters>(initialParameters);
|
||||
|
||||
const updateParameter = (parameter: keyof ConvertParameters, value: any) => {
|
||||
setParameters(prev => ({ ...prev, [parameter]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(initialParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
const { fromExtension, toExtension } = parameters;
|
||||
|
||||
if (!fromExtension || !toExtension) return false;
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
let supportedToExtensions: string[] = [];
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options
|
||||
supportedToExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
} else {
|
||||
// Regular format - check conversion matrix
|
||||
supportedToExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
}
|
||||
|
||||
if (!supportedToExtensions.includes(toExtension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
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';
|
||||
} else if (smartDetectionType === 'web') {
|
||||
// All web files -> PDF using html-to-pdf endpoint
|
||||
return 'html-to-pdf';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use file-to-pdf endpoint
|
||||
return 'file-to-pdf';
|
||||
}
|
||||
|
||||
return getEndpointNameUtil(fromExtension, toExtension);
|
||||
};
|
||||
|
||||
const getEndpoint = () => {
|
||||
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';
|
||||
} else if (smartDetectionType === 'web') {
|
||||
// All web files -> PDF using html-to-pdf endpoint
|
||||
return '/api/v1/convert/html/pdf';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use file-to-pdf endpoint
|
||||
return '/api/v1/convert/file/pdf';
|
||||
}
|
||||
|
||||
return getEndpointUrl(fromExtension, toExtension);
|
||||
};
|
||||
|
||||
const getAvailableToExtensions = (fromExtension: string) => {
|
||||
if (!fromExtension) return [];
|
||||
|
||||
// Handle dynamic format identifiers (file-<extension>)
|
||||
if (fromExtension.startsWith('file-')) {
|
||||
// Dynamic format - use 'any' conversion options (file-to-pdf)
|
||||
const supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
}
|
||||
|
||||
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
|
||||
// If no explicit conversion exists, but file-to-pdf might be available,
|
||||
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
|
||||
if (supportedExtensions.length === 0 && fromExtension !== 'any') {
|
||||
supportedExtensions = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const analyzeFileTypes = (files: Array<{name: string}>) => {
|
||||
if (files.length === 0) {
|
||||
// No files - only reset smart detection, keep user's format choices
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
// Don't reset fromExtension and toExtension - let user keep their choices
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 1) {
|
||||
// Single file - use regular detection with smart target selection
|
||||
const detectedExt = detectFileExtensionUtil(files[0].name);
|
||||
let fromExt = detectedExt;
|
||||
let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : [];
|
||||
|
||||
// If no explicit conversion exists for this file type, create a dynamic format entry
|
||||
// and fall back to 'any' conversion logic for the actual endpoint
|
||||
if (availableTargets.length === 0 && detectedExt) {
|
||||
fromExt = `file-${detectedExt}`; // Create dynamic format identifier
|
||||
availableTargets = CONVERSION_MATRIX['any'] || [];
|
||||
} else if (availableTargets.length === 0) {
|
||||
// No extension detected - fall back to 'any'
|
||||
fromExt = 'any';
|
||||
availableTargets = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
setParameters(prev => {
|
||||
// Check if current toExtension is still valid for the new fromExtension
|
||||
const currentToExt = prev.toExtension;
|
||||
const isCurrentToExtValid = availableTargets.includes(currentToExt);
|
||||
|
||||
// Auto-select target only if:
|
||||
// 1. No current target is set, OR
|
||||
// 2. Current target is invalid for new source type, OR
|
||||
// 3. There's only one possible target (forced conversion)
|
||||
let newToExtension = currentToExt;
|
||||
if (!currentToExt || !isCurrentToExtValid) {
|
||||
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none',
|
||||
fromExtension: fromExt,
|
||||
toExtension: newToExtension
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple files - analyze file types
|
||||
const extensions = files.map(file => detectFileExtensionUtil(file.name));
|
||||
const uniqueExtensions = [...new Set(extensions)];
|
||||
|
||||
if (uniqueExtensions.length === 1) {
|
||||
// All files are the same type - use regular detection with smart target selection
|
||||
const detectedExt = uniqueExtensions[0];
|
||||
let fromExt = detectedExt;
|
||||
let availableTargets = CONVERSION_MATRIX[detectedExt] || [];
|
||||
|
||||
// If no explicit conversion exists for this file type, fall back to 'any'
|
||||
if (availableTargets.length === 0) {
|
||||
fromExt = 'any';
|
||||
availableTargets = CONVERSION_MATRIX['any'] || [];
|
||||
}
|
||||
|
||||
setParameters(prev => {
|
||||
// Check if current toExtension is still valid for the new fromExtension
|
||||
const currentToExt = prev.toExtension;
|
||||
const isCurrentToExtValid = availableTargets.includes(currentToExt);
|
||||
|
||||
// Auto-select target only if:
|
||||
// 1. No current target is set, OR
|
||||
// 2. Current target is invalid for new source type, OR
|
||||
// 3. There's only one possible target (forced conversion)
|
||||
let newToExtension = currentToExt;
|
||||
if (!currentToExt || !isCurrentToExtValid) {
|
||||
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none',
|
||||
fromExtension: fromExt,
|
||||
toExtension: newToExtension
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Mixed file types
|
||||
const allImages = uniqueExtensions.every(ext => isImageFormat(ext));
|
||||
const allWeb = uniqueExtensions.every(ext => isWebFormat(ext));
|
||||
|
||||
if (allImages) {
|
||||
// All files are images - use image-to-pdf conversion
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'images',
|
||||
fromExtension: 'image',
|
||||
toExtension: 'pdf'
|
||||
}));
|
||||
} else if (allWeb) {
|
||||
// All files are web files - use html-to-pdf conversion
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
isSmartDetection: true,
|
||||
smartDetectionType: 'web',
|
||||
fromExtension: 'html',
|
||||
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,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
getEndpoint,
|
||||
getAvailableToExtensions,
|
||||
analyzeFileTypes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Tests for auto-detection and smart conversion features in useConvertParameters
|
||||
* This covers the analyzeFileTypes function and related smart detection logic
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertParameters } from './useConvertParameters';
|
||||
|
||||
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
|
||||
|
||||
describe('Single File Detection', () => {
|
||||
|
||||
test('should detect single file extension and set auto-target', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const pdfFile = [{ name: 'document.pdf' }];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(pdfFile);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
expect(result.current.parameters.toExtension).toBe(''); // No auto-selection for multiple targets
|
||||
expect(result.current.parameters.isSmartDetection).toBe(false);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('none');
|
||||
});
|
||||
|
||||
test('should handle unknown file types with file-to-pdf fallback', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(unknownFile);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle files without extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const noExtFile = [{ name: 'document' }];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(noExtFile);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('Multiple Identical Files', () => {
|
||||
|
||||
test('should detect multiple PDF files and set auto-target', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const pdfFiles = [
|
||||
{ name: 'doc1.pdf' },
|
||||
{ name: 'doc2.pdf' },
|
||||
{ name: 'doc3.pdf' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(pdfFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
expect(result.current.parameters.toExtension).toBe(''); // Auto-selected
|
||||
expect(result.current.parameters.isSmartDetection).toBe(false);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('none');
|
||||
});
|
||||
|
||||
test('should handle multiple unknown file types with fallback', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const unknownFiles = [
|
||||
{ name: 'file1.xyz' },
|
||||
{ name: 'file2.xyz' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(unknownFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smart Detection - All Images', () => {
|
||||
|
||||
test('should detect all image files and enable smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.jpg' },
|
||||
{ name: 'photo2.png' },
|
||||
{ name: 'photo3.gif' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('image');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('images');
|
||||
});
|
||||
|
||||
test('should handle mixed case image extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.JPG' },
|
||||
{ name: 'photo2.PNG' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('images');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smart Detection - All Web Files', () => {
|
||||
|
||||
test('should detect all web files and enable web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.html' },
|
||||
{ name: 'archive.zip' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('html');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
|
||||
test('should handle mixed case web extensions', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.HTML' },
|
||||
{ name: 'archive.ZIP' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
|
||||
test('should detect multiple web files and enable web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const zipFiles = [
|
||||
{ name: 'site1.zip' },
|
||||
{ name: 'site2.html' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(zipFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('html');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smart Detection - Mixed File Types', () => {
|
||||
|
||||
test('should detect mixed file types and enable smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'spreadsheet.xlsx' },
|
||||
{ name: 'presentation.pptx' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
|
||||
test('should detect mixed images and documents as mixed type', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'photo.jpg' },
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'text.txt' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
|
||||
test('should handle mixed with unknown file types', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'unknown.xyz' },
|
||||
{ name: 'noextension' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smart Detection Endpoint Resolution', () => {
|
||||
|
||||
test('should return correct endpoint for image smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const imageFiles = [
|
||||
{ name: 'photo1.jpg' },
|
||||
{ name: 'photo2.png' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('img-to-pdf');
|
||||
expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf');
|
||||
});
|
||||
|
||||
test('should return correct endpoint for web smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const webFiles = [
|
||||
{ name: 'page1.html' },
|
||||
{ name: 'archive.zip' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('html-to-pdf');
|
||||
expect(result.current.getEndpoint()).toBe('/api/v1/convert/html/pdf');
|
||||
});
|
||||
|
||||
test('should return correct endpoint for mixed smart detection', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const mixedFiles = [
|
||||
{ name: 'document.pdf' },
|
||||
{ name: 'spreadsheet.xlsx' }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('file-to-pdf');
|
||||
expect(result.current.getEndpoint()).toBe('/api/v1/convert/file/pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-Target Selection Logic', () => {
|
||||
|
||||
test('should select single available target automatically', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
// Markdown has only one conversion target (PDF)
|
||||
const mdFile = [{ name: 'readme.md' }];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(mdFile);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('md');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf'); // Only available target
|
||||
});
|
||||
|
||||
test('should not auto-select when multiple targets available', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
// PDF has multiple conversion targets, so no auto-selection
|
||||
const pdfFile = [{ name: 'document.pdf' }];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(pdfFile);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('pdf');
|
||||
// Should NOT auto-select when multiple targets available
|
||||
expect(result.current.parameters.toExtension).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
test('should handle empty file names', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const emptyFiles = [{ name: '' }];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(emptyFiles);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.fromExtension).toBe('any');
|
||||
expect(result.current.parameters.toExtension).toBe('pdf');
|
||||
});
|
||||
|
||||
test('should handle malformed file objects', () => {
|
||||
const { result } = renderHook(() => useConvertParameters());
|
||||
|
||||
const malformedFiles = [
|
||||
{ name: 'valid.pdf' },
|
||||
// @ts-ignore - Testing runtime resilience
|
||||
{ name: null },
|
||||
// @ts-ignore
|
||||
{ name: undefined }
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.analyzeFileTypes(malformedFiles);
|
||||
});
|
||||
|
||||
// Should still process the valid file and handle gracefully
|
||||
expect(result.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(result.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
export interface PdfSignatureDetectionResult {
|
||||
hasDigitalSignatures: boolean;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
|
||||
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForDigitalSignatures = async () => {
|
||||
if (files.length === 0) {
|
||||
setHasDigitalSignatures(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(true);
|
||||
let foundSignature = false;
|
||||
|
||||
try {
|
||||
// Set up PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-legacy/pdf.worker.mjs';
|
||||
|
||||
for (const file of files) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
try {
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const annotations = await page.getAnnotations({ intent: 'display' });
|
||||
|
||||
annotations.forEach(annotation => {
|
||||
if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') {
|
||||
foundSignature = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (foundSignature) break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error analyzing PDF for signatures:', error);
|
||||
}
|
||||
|
||||
if (foundSignature) break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error checking for digital signatures:', error);
|
||||
}
|
||||
|
||||
setHasDigitalSignatures(foundSignature);
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
checkForDigitalSignatures();
|
||||
}, [files]);
|
||||
|
||||
return {
|
||||
hasDigitalSignatures,
|
||||
isChecking
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import ApiIcon from "@mui/icons-material/Api";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
||||
@@ -27,6 +28,33 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
||||
description: "Reduce PDF file size",
|
||||
endpoints: ["compress-pdf"]
|
||||
},
|
||||
convert: {
|
||||
id: "convert",
|
||||
icon: <SwapHorizIcon />,
|
||||
component: React.lazy(() => import("../tools/Convert")),
|
||||
maxFiles: -1,
|
||||
category: "manipulation",
|
||||
description: "Change to and from PDF and other formats",
|
||||
endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
|
||||
supportedFormats: [
|
||||
// Microsoft Office
|
||||
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
|
||||
// OpenDocument
|
||||
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
|
||||
// Text formats
|
||||
"txt", "text", "xml", "rtf", "html", "lwp", "md",
|
||||
// Images
|
||||
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
|
||||
// StarOffice
|
||||
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
|
||||
// Email formats
|
||||
"eml",
|
||||
// Archive formats
|
||||
"zip",
|
||||
// Other
|
||||
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
|
||||
]
|
||||
},
|
||||
swagger: {
|
||||
id: "swagger",
|
||||
icon: <ApiIcon />,
|
||||
@@ -50,7 +78,6 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
||||
|
||||
};
|
||||
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: Tool | null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import Backend from 'i18next-http-backend';
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
// For testing environment, provide fallback resources
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
'convert.selectSourceFormat': 'Select source file format',
|
||||
'convert.selectTargetFormat': 'Select target file format',
|
||||
'convert.selectFirst': 'Select a source format first',
|
||||
'convert.imageOptions': 'Image Options:',
|
||||
'convert.emailOptions': 'Email Options:',
|
||||
'convert.colorType': 'Color Type',
|
||||
'convert.dpi': 'DPI',
|
||||
'convert.singleOrMultiple': 'Output',
|
||||
'convert.emailNote': 'Email attachments and embedded images will be included',
|
||||
'common.color': 'Color',
|
||||
'common.grayscale': 'Grayscale',
|
||||
'common.blackWhite': 'Black & White',
|
||||
'common.single': 'Single Image',
|
||||
'common.multiple': 'Multiple Images',
|
||||
'groups.document': 'Document',
|
||||
'groups.spreadsheet': 'Spreadsheet',
|
||||
'groups.presentation': 'Presentation',
|
||||
'groups.image': 'Image',
|
||||
'groups.web': 'Web',
|
||||
'groups.text': 'Text',
|
||||
'groups.email': 'Email'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -196,7 +196,8 @@ function HomePageContent() {
|
||||
onFilesSelect={(files) => {
|
||||
files.forEach(addToActiveFiles);
|
||||
}}
|
||||
accept={["application/pdf"]}
|
||||
accept={["*/*"]}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
loading={false}
|
||||
showRecentFiles={true}
|
||||
maxRecentFiles={8}
|
||||
@@ -207,6 +208,7 @@ function HomePageContent() {
|
||||
toolMode={!!selectedToolKey}
|
||||
showUpload={true}
|
||||
showBulkActions={!selectedToolKey}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
{...(!selectedToolKey && {
|
||||
onOpenPageEditor: (file) => {
|
||||
handleViewChange("pageEditor");
|
||||
@@ -236,6 +238,11 @@ function HomePageContent() {
|
||||
setCurrentView('compress');
|
||||
setLeftPanelView('toolContent');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else if (previousMode === 'convert') {
|
||||
selectTool('convert');
|
||||
setCurrentView('convert');
|
||||
setLeftPanelView('toolContent');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
setCurrentView('fileEditor');
|
||||
}
|
||||
@@ -281,7 +288,8 @@ function HomePageContent() {
|
||||
onFilesSelect={(files) => {
|
||||
files.forEach(addToActiveFiles);
|
||||
}}
|
||||
accept={["application/pdf"]}
|
||||
accept={["*/*"]}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
loading={false}
|
||||
showRecentFiles={true}
|
||||
maxRecentFiles={8}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock i18next for tests
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock i18next-http-backend
|
||||
vi.mock('i18next-http-backend', () => ({
|
||||
default: {
|
||||
type: 'backend',
|
||||
init: vi.fn(),
|
||||
read: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock window.URL.createObjectURL and revokeObjectURL for tests
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Mock File and Blob API methods that aren't available in jsdom
|
||||
if (!globalThis.File.prototype.arrayBuffer) {
|
||||
globalThis.File.prototype.arrayBuffer = function() {
|
||||
// Return a simple ArrayBuffer with some mock data
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
return Promise.resolve(buffer);
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.Blob.prototype.arrayBuffer) {
|
||||
globalThis.Blob.prototype.arrayBuffer = function() {
|
||||
// Return a simple ArrayBuffer with some mock data
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
return Promise.resolve(buffer);
|
||||
};
|
||||
}
|
||||
|
||||
// Mock crypto.subtle for hashing in tests - force override even if exists
|
||||
const mockHashBuffer = new ArrayBuffer(32);
|
||||
const mockHashView = new Uint8Array(mockHashBuffer);
|
||||
// Fill with predictable mock hash data
|
||||
for (let i = 0; i < 32; i++) {
|
||||
mockHashView[i] = i;
|
||||
}
|
||||
|
||||
// Force override crypto.subtle to avoid Node.js native implementation
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: {
|
||||
subtle: {
|
||||
digest: vi.fn().mockImplementation(async (algorithm: string, data: any) => {
|
||||
// Always return the mock hash buffer regardless of input
|
||||
return mockHashBuffer.slice();
|
||||
}),
|
||||
},
|
||||
getRandomValues: vi.fn().mockImplementation((array: any) => {
|
||||
// Mock getRandomValues if needed
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return array;
|
||||
}),
|
||||
} as Crypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock Worker for tests (Web Workers not available in test environment)
|
||||
global.Worker = vi.fn().mockImplementation(() => ({
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
}))
|
||||
|
||||
// Mock ResizeObserver for Mantine components
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock IntersectionObserver for components that might use it
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock matchMedia for responsive components
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Set global test timeout to prevent hangs
|
||||
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 })
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* End-to-End Tests for Convert Tool
|
||||
*
|
||||
* These tests dynamically discover available conversion endpoints and test them.
|
||||
* Tests are automatically skipped if the backend endpoint is not available.
|
||||
*
|
||||
* Run with: npm run test:e2e or npx playwright test
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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: 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
|
||||
const getTestFileForFormat = (format: string): string => {
|
||||
const formatMap: Record<string, string> = {
|
||||
'pdf': TEST_FILES.pdf,
|
||||
'docx': TEST_FILES.docx,
|
||||
'doc': TEST_FILES.doc,
|
||||
'pptx': TEST_FILES.pptx,
|
||||
'ppt': TEST_FILES.ppt,
|
||||
'xlsx': TEST_FILES.xlsx,
|
||||
'xls': TEST_FILES.xls,
|
||||
'office': TEST_FILES.docx, // Default office file
|
||||
'image': TEST_FILES.png, // Default image file
|
||||
'png': TEST_FILES.png,
|
||||
'jpg': TEST_FILES.jpg,
|
||||
'jpeg': TEST_FILES.jpeg,
|
||||
'gif': TEST_FILES.gif,
|
||||
'bmp': TEST_FILES.bmp,
|
||||
'tiff': TEST_FILES.tiff,
|
||||
'webp': TEST_FILES.webp,
|
||||
'md': TEST_FILES.md,
|
||||
'eml': TEST_FILES.eml,
|
||||
'html': TEST_FILES.html,
|
||||
'txt': TEST_FILES.txt,
|
||||
'xml': TEST_FILES.xml,
|
||||
'csv': TEST_FILES.csv
|
||||
};
|
||||
|
||||
return formatMap[format] || TEST_FILES.pdf; // Fallback to PDF
|
||||
};
|
||||
|
||||
// Expected file extensions for target formats
|
||||
const getExpectedExtension = (toFormat: string): string => {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'pdf': '.pdf',
|
||||
'docx': '.docx',
|
||||
'pptx': '.pptx',
|
||||
'txt': '.txt',
|
||||
'html': '.zip', // HTML is zipped
|
||||
'xml': '.xml',
|
||||
'csv': '.csv',
|
||||
'md': '.md',
|
||||
'image': '.png', // Default for image conversion
|
||||
'png': '.png',
|
||||
'jpg': '.jpg',
|
||||
'jpeg': '.jpeg',
|
||||
'gif': '.gif',
|
||||
'bmp': '.bmp',
|
||||
'tiff': '.tiff',
|
||||
'webp': '.webp',
|
||||
'pdfa': '.pdf'
|
||||
};
|
||||
|
||||
return extensionMap[toFormat] || '.pdf';
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic test function for any conversion
|
||||
*/
|
||||
async function testConversion(page: Page, conversion: ConversionEndpoint) {
|
||||
const expectedExtension = getExpectedExtension(conversion.toFormat);
|
||||
|
||||
console.log(`Testing ${conversion.endpoint}: ${conversion.fromFormat} → ${conversion.toFormat}`);
|
||||
|
||||
// File should already be uploaded, click the Convert tool button
|
||||
await page.click('[data-testid="tool-convert"]');
|
||||
|
||||
// Wait for the FileEditor to load in convert mode with file thumbnails
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 });
|
||||
|
||||
// Click the file thumbnail checkbox to select it in the FileEditor
|
||||
await page.click('[data-testid="file-thumbnail-checkbox"]');
|
||||
|
||||
// Wait for the conversion settings to appear after file selection
|
||||
await page.waitForSelector('[data-testid="convert-from-dropdown"]', { timeout: 5000 });
|
||||
|
||||
// Select FROM format
|
||||
await page.click('[data-testid="convert-from-dropdown"]');
|
||||
const fromFormatOption = page.locator(`[data-testid="format-option-${conversion.fromFormat}"]`);
|
||||
await fromFormatOption.scrollIntoViewIfNeeded();
|
||||
await fromFormatOption.click();
|
||||
|
||||
// Select TO format
|
||||
await page.click('[data-testid="convert-to-dropdown"]');
|
||||
const toFormatOption = page.locator(`[data-testid="format-option-${conversion.toFormat}"]`);
|
||||
await toFormatOption.scrollIntoViewIfNeeded();
|
||||
await toFormatOption.click();
|
||||
|
||||
// Handle format-specific options
|
||||
if (conversion.toFormat === 'image' || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'webp'].includes(conversion.toFormat)) {
|
||||
// Set image conversion options if they appear
|
||||
const imageOptionsVisible = await page.locator('[data-testid="image-options-section"]').isVisible().catch(() => false);
|
||||
if (imageOptionsVisible) {
|
||||
// Click the color type dropdown and select "Color"
|
||||
await page.click('[data-testid="color-type-select"]');
|
||||
await page.getByRole('option', { name: 'Color' }).click();
|
||||
|
||||
// Set DPI value
|
||||
await page.fill('[data-testid="dpi-input"]', '150');
|
||||
|
||||
// Click the output type dropdown and select "Multiple"
|
||||
await page.click('[data-testid="output-type-select"]');
|
||||
|
||||
await page.getByRole('option', { name: 'single' }).click();
|
||||
}
|
||||
}
|
||||
|
||||
if (conversion.fromFormat === 'image' && conversion.toFormat === 'pdf') {
|
||||
// Set PDF creation options if they appear
|
||||
const pdfOptionsVisible = await page.locator('[data-testid="pdf-options-section"]').isVisible().catch(() => false);
|
||||
if (pdfOptionsVisible) {
|
||||
// Click the color type dropdown and select "Color"
|
||||
await page.click('[data-testid="color-type-select"]');
|
||||
await page.locator('[data-value="color"]').click();
|
||||
}
|
||||
}
|
||||
|
||||
if (conversion.fromFormat === 'pdf' && conversion.toFormat === 'csv') {
|
||||
// Set CSV extraction options if they appear
|
||||
const csvOptionsVisible = await page.locator('[data-testid="csv-options-section"]').isVisible().catch(() => false);
|
||||
if (csvOptionsVisible) {
|
||||
// Set specific page numbers for testing (test pages 1-2)
|
||||
await page.fill('[data-testid="page-numbers-input"]', '1-2');
|
||||
}
|
||||
}
|
||||
|
||||
// Start conversion
|
||||
await page.click('[data-testid="convert-button"]');
|
||||
|
||||
// Wait for conversion to complete (with generous timeout)
|
||||
await page.waitForSelector('[data-testid="download-button"]', { timeout: 60000 });
|
||||
|
||||
// Verify download is available
|
||||
const downloadButton = page.locator('[data-testid="download-button"]');
|
||||
await expect(downloadButton).toBeVisible();
|
||||
|
||||
// Start download and verify file
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await downloadButton.click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Verify file extension
|
||||
expect(download.suggestedFilename()).toMatch(new RegExp(`\\${expectedExtension}$`));
|
||||
|
||||
// Save and verify file is not empty
|
||||
const path = await download.path();
|
||||
if (path) {
|
||||
const fs = require('fs');
|
||||
const stats = fs.statSync(path);
|
||||
expect(stats.size).toBeGreaterThan(0);
|
||||
|
||||
// Format-specific validations
|
||||
if (conversion.toFormat === 'pdf' || conversion.toFormat === 'pdfa') {
|
||||
// Verify PDF header
|
||||
const buffer = fs.readFileSync(path);
|
||||
const header = buffer.toString('utf8', 0, 4);
|
||||
expect(header).toBe('%PDF');
|
||||
}
|
||||
|
||||
if (conversion.toFormat === 'txt') {
|
||||
// Verify text content exists
|
||||
const content = fs.readFileSync(path, 'utf8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
if (conversion.toFormat === 'csv') {
|
||||
// Verify CSV content contains separators
|
||||
const content = fs.readFileSync(path, 'utf8');
|
||||
expect(content).toContain(',');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover conversions at module level before tests are defined
|
||||
let allConversions: ConversionEndpoint[] = [];
|
||||
let availableConversions: ConversionEndpoint[] = [];
|
||||
let unavailableConversions: ConversionEndpoint[] = [];
|
||||
|
||||
// Pre-populate conversions synchronously for test generation
|
||||
(async () => {
|
||||
try {
|
||||
availableConversions = await conversionDiscovery.getAvailableConversions();
|
||||
unavailableConversions = await conversionDiscovery.getUnavailableConversions();
|
||||
allConversions = [...availableConversions, ...unavailableConversions];
|
||||
} catch (error) {
|
||||
console.error('Failed to discover conversions during module load:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
test.describe('Convert Tool E2E Tests', () => {
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Re-discover to ensure fresh data at test time
|
||||
console.log('Re-discovering available conversion endpoints...');
|
||||
availableConversions = await conversionDiscovery.getAvailableConversions();
|
||||
unavailableConversions = await conversionDiscovery.getUnavailableConversions();
|
||||
|
||||
console.log(`Found ${availableConversions.length} available conversions:`);
|
||||
availableConversions.forEach(conv => {
|
||||
console.log(` ✓ ${conv.endpoint}: ${conv.fromFormat} → ${conv.toFormat}`);
|
||||
});
|
||||
|
||||
if (unavailableConversions.length > 0) {
|
||||
console.log(`Found ${unavailableConversions.length} unavailable conversions:`);
|
||||
unavailableConversions.forEach(conv => {
|
||||
console.log(` ✗ ${conv.endpoint}: ${conv.fromFormat} → ${conv.toFormat}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to the homepage
|
||||
await page.goto(`${BASE_URL}`);
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for the file upload area to appear (shown when no active files)
|
||||
await page.waitForSelector('[data-testid="file-dropzone"]', { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.describe('Dynamic Conversion Tests', () => {
|
||||
|
||||
// Generate a test for each potentially available conversion
|
||||
// We'll discover all possible conversions and then skip unavailable ones at runtime
|
||||
test('PDF to PNG conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/img', fromFormat: 'pdf', toFormat: 'png' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to DOCX conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/word', fromFormat: 'pdf', toFormat: 'docx' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('DOCX to PDF conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/file/pdf', fromFormat: 'docx', toFormat: 'pdf' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('Image to PDF conversion', async ({ page }) => {
|
||||
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`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to TXT conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/text', fromFormat: 'pdf', toFormat: 'txt' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to HTML conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/html', fromFormat: 'pdf', toFormat: 'html' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to XML conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/xml', fromFormat: 'pdf', toFormat: 'xml' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to CSV conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/csv', fromFormat: 'pdf', toFormat: 'csv' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
|
||||
test('PDF to PDFA conversion', async ({ page }) => {
|
||||
const conversion = { endpoint: '/api/v1/convert/pdf/pdfa', fromFormat: 'pdf', toFormat: 'pdfa' };
|
||||
const isAvailable = availableConversions.some(c => c.apiPath === conversion.endpoint);
|
||||
test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`);
|
||||
|
||||
const testFile = getTestFileForFormat(conversion.fromFormat);
|
||||
await page.setInputFiles('input[type="file"]', testFile);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
await testConversion(page, conversion);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Static Tests', () => {
|
||||
|
||||
// Test that disabled conversions don't appear in dropdowns when they shouldn't
|
||||
test('should not show conversion button when no valid conversions available', async ({ page }) => {
|
||||
// This test ensures the convert button is disabled when no valid conversion is possible
|
||||
await page.setInputFiles('input[type="file"]', TEST_FILES.pdf);
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 });
|
||||
|
||||
// Click the Convert tool button
|
||||
await page.click('[data-testid="tool-convert"]');
|
||||
|
||||
// Wait for convert mode and select file
|
||||
await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 });
|
||||
await page.click('[data-testid="file-thumbnail-checkbox"]');
|
||||
|
||||
// Don't select any formats - convert button should not exist
|
||||
const convertButton = page.locator('[data-testid="convert-button"]');
|
||||
await expect(convertButton).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Integration tests for Convert Tool - Tests actual conversion functionality
|
||||
*
|
||||
* These tests verify the integration between frontend components and backend:
|
||||
* 1. useConvertOperation hook makes correct API calls
|
||||
* 2. File upload/download flow functions properly
|
||||
* 3. Error handling works for various failure scenarios
|
||||
* 4. Parameter passing works between frontend and backend
|
||||
* 5. FileContext integration works correctly
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||
import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||
import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
const mockedAxios = vi.mocked(axios);
|
||||
|
||||
// Mock utility modules
|
||||
vi.mock('../../utils/thumbnailUtils', () => ({
|
||||
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/api', () => ({
|
||||
makeApiUrl: vi.fn((path: string) => `/api/v1${path}`)
|
||||
}));
|
||||
|
||||
// Create realistic test files
|
||||
const createTestFile = (name: string, content: string, type: string): File => {
|
||||
return new File([content], name, { type });
|
||||
};
|
||||
|
||||
const createPDFFile = (): File => {
|
||||
const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF';
|
||||
return createTestFile('test.pdf', pdfContent, 'application/pdf');
|
||||
};
|
||||
|
||||
// Test wrapper component
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Setup default axios mock
|
||||
mockedAxios.post = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useConvertOperation Integration', () => {
|
||||
|
||||
test('should make correct API call for PDF to PNG conversion', async () => {
|
||||
const mockBlob = new Blob(['fake-image-data'], { type: 'image/png' });
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
data: mockBlob,
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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 axios was called with correct parameters
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/convert/pdf/img',
|
||||
expect.any(FormData),
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// Verify FormData contains correct parameters
|
||||
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('imageFormat')).toBe('png');
|
||||
expect(formDataCall.get('colorType')).toBe('color');
|
||||
expect(formDataCall.get('dpi')).toBe('300');
|
||||
expect(formDataCall.get('singleOrMultiple')).toBe('multiple');
|
||||
|
||||
// Verify hook state updates
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.downloadFilename).toBe('test_converted.png');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
});
|
||||
|
||||
test('should handle API error responses correctly', async () => {
|
||||
const errorMessage = 'Invalid file format';
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 400,
|
||||
data: errorMessage
|
||||
},
|
||||
message: errorMessage
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain');
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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 error handling
|
||||
expect(result.current.errorMessage).toBe(errorMessage);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.downloadUrl).toBe(null);
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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]);
|
||||
});
|
||||
|
||||
expect(result.current.errorMessage).toBe('Network error');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API and Hook Integration', () => {
|
||||
|
||||
test('should correctly map image conversion parameters to API call', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'jpg',
|
||||
pageNumbers: 'all',
|
||||
imageOptions: {
|
||||
colorType: 'grayscale',
|
||||
dpi: 150,
|
||||
singleOrMultiple: 'single',
|
||||
fitOption: 'maintainAspectRatio',
|
||||
autoRotate: true,
|
||||
combineImages: true
|
||||
},
|
||||
isSmartDetection: false,
|
||||
smartDetectionType: 'none'
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeOperation(parameters, [testFile]);
|
||||
});
|
||||
|
||||
// Verify integration: hook parameters → FormData → axios call → hook state
|
||||
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formDataCall.get('imageFormat')).toBe('jpg');
|
||||
expect(formDataCall.get('colorType')).toBe('grayscale');
|
||||
expect(formDataCall.get('dpi')).toBe('150');
|
||||
expect(formDataCall.get('singleOrMultiple')).toBe('single');
|
||||
|
||||
// Verify complete workflow: API response → hook state → FileContext integration
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.files).toHaveLength(1);
|
||||
expect(result.current.files[0].name).toBe('test_converted.jpg');
|
||||
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
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'unsupported',
|
||||
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 integration: utils validation prevents API call, hook shows error
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
expect(result.current.errorMessage).toContain('errorNotSupported');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.downloadUrl).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Upload Integration', () => {
|
||||
|
||||
test('should handle multiple file uploads correctly', async () => {
|
||||
const mockBlob = new Blob(['zip-content'], { type: 'application/zip' });
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
const files = [
|
||||
createPDFFile(),
|
||||
createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
||||
]
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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, files);
|
||||
});
|
||||
|
||||
// Verify both files were uploaded
|
||||
const calls = mockedAxios.post.mock.calls;
|
||||
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const formData = calls[i][1] as FormData;
|
||||
const fileInputs = formData.getAll('fileInput');
|
||||
expect(fileInputs).toHaveLength(1);
|
||||
expect(fileInputs[0]).toBeInstanceOf(File);
|
||||
expect(fileInputs[0].name).toBe(files[i].name);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
test('should handle no files selected', async () => {
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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, []);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toContain('noFileSelected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Boundary Integration', () => {
|
||||
|
||||
test('should handle corrupted file gracefully', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
data: 'Processing failed'
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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, [corruptedFile]);
|
||||
});
|
||||
|
||||
expect(result.current.errorMessage).toBe('Processing failed');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle backend service unavailable', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 503,
|
||||
data: 'Service unavailable'
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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]);
|
||||
});
|
||||
|
||||
expect(result.current.errorMessage).toBe('Service unavailable');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileContext Integration', () => {
|
||||
|
||||
test('should record operation in FileContext', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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 operation was successful and files were processed
|
||||
expect(result.current.files).toHaveLength(1);
|
||||
expect(result.current.files[0].name).toBe('test_converted.png');
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should clean up blob URLs on reset', async () => {
|
||||
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
|
||||
|
||||
const { result } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createPDFFile();
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
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]);
|
||||
});
|
||||
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
result.current.resetResults();
|
||||
});
|
||||
|
||||
expect(result.current.downloadUrl).toBe(null);
|
||||
expect(result.current.files).toHaveLength(0);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Additional Integration Tests That Require Real Backend
|
||||
*
|
||||
* These tests would require a running backend server and are better suited
|
||||
* for E2E testing with tools like Playwright or Cypress:
|
||||
*
|
||||
* 1. **Real File Conversion Tests**
|
||||
* - Upload actual PDF files and verify conversion quality
|
||||
* - Test image format outputs are valid and viewable
|
||||
* - Test CSV/TXT outputs contain expected content
|
||||
* - Test file size limits and memory constraints
|
||||
*
|
||||
* 2. **Performance Integration Tests**
|
||||
* - Test conversion time for various file sizes
|
||||
* - Test memory usage during large file conversions
|
||||
* - Test concurrent conversion requests
|
||||
* - Test timeout handling for long-running conversions
|
||||
*
|
||||
* 3. **Authentication Integration**
|
||||
* - Test conversions with and without authentication
|
||||
* - Test rate limiting and user quotas
|
||||
* - Test permission-based endpoint access
|
||||
*
|
||||
* 4. **File Preview Integration**
|
||||
* - Test that converted files integrate correctly with viewer
|
||||
* - Test thumbnail generation for converted files
|
||||
* - Test file download functionality
|
||||
* - Test FileContext persistence across tool switches
|
||||
*
|
||||
* 5. **Endpoint Availability Tests**
|
||||
* - Test real endpoint availability checking
|
||||
* - Test graceful degradation when endpoints are disabled
|
||||
* - Test dynamic endpoint configuration updates
|
||||
*/
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Integration tests for Convert Tool Smart Detection with real file scenarios
|
||||
* Tests the complete flow from file upload through auto-detection to API calls
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
|
||||
import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
|
||||
import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
const mockedAxios = vi.mocked(axios);
|
||||
|
||||
// Mock utility modules
|
||||
vi.mock('../../utils/thumbnailUtils', () => ({
|
||||
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
|
||||
}));
|
||||
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock successful API response
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: new Blob(['fake converted content'], { type: 'application/pdf' })
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any blob URLs created during tests
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Single File Auto-Detection Flow', () => {
|
||||
test('should auto-detect PDF from DOCX and convert to PDF', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
// Create mock DOCX file
|
||||
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes([docxFile]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('docx');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(false);
|
||||
});
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[docxFile]
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle unknown file type with file-to-pdf fallback', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
// Create mock unknown file
|
||||
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes([unknownFile]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('file-xyz');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf'); // Fallback
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(false);
|
||||
});
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[unknownFile]
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-File Smart Detection Flow', () => {
|
||||
|
||||
test('should detect all images and use img-to-pdf endpoint', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
// Create mock image files
|
||||
const imageFiles = [
|
||||
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['png content'], 'photo2.png', { type: 'image/png' }),
|
||||
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
|
||||
];
|
||||
|
||||
// Test smart detection for all images
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(imageFiles);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('image');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(paramsResult.current.parameters.smartDetectionType).toBe('images');
|
||||
});
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
imageFiles
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Should send all files in single request
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
const files = formData.getAll('fileInput');
|
||||
expect(files).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('should detect mixed file types and use file-to-pdf endpoint', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
// Create mixed file types
|
||||
const mixedFiles = [
|
||||
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
|
||||
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
];
|
||||
|
||||
// Test smart detection for mixed types
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('any');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(paramsResult.current.parameters.smartDetectionType).toBe('mixed');
|
||||
});
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
mixedFiles
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
|
||||
test('should detect all web files and use html-to-pdf endpoint', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
// Create mock web files
|
||||
const webFiles = [
|
||||
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
|
||||
new File(['zip content'], 'site.zip', { type: 'application/zip' })
|
||||
];
|
||||
|
||||
// Test smart detection for web files
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(webFiles);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paramsResult.current.parameters.fromExtension).toBe('html');
|
||||
expect(paramsResult.current.parameters.toExtension).toBe('pdf');
|
||||
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
|
||||
expect(paramsResult.current.parameters.smartDetectionType).toBe('web');
|
||||
});
|
||||
|
||||
// Test conversion operation
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
webFiles
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Should process files separately for web files
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web and Email Conversion Options Integration', () => {
|
||||
|
||||
test('should send correct HTML parameters for web-to-pdf conversion', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' });
|
||||
|
||||
// Set up HTML conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes([htmlFile]);
|
||||
paramsResult.current.updateParameter('htmlOptions', {
|
||||
zoomLevel: 1.5
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[htmlFile]
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formData.get('zoom')).toBe('1.5');
|
||||
});
|
||||
|
||||
test('should send correct email parameters for eml-to-pdf conversion', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
|
||||
|
||||
// Set up email conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.updateParameter('fromExtension', 'eml');
|
||||
paramsResult.current.updateParameter('toExtension', 'pdf');
|
||||
paramsResult.current.updateParameter('emailOptions', {
|
||||
includeAttachments: false,
|
||||
maxAttachmentSizeMB: 20,
|
||||
downloadHtml: true,
|
||||
includeAllRecipients: true
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[emlFile]
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formData.get('includeAttachments')).toBe('false');
|
||||
expect(formData.get('maxAttachmentSizeMB')).toBe('20');
|
||||
expect(formData.get('downloadHtml')).toBe('true');
|
||||
expect(formData.get('includeAllRecipients')).toBe('true');
|
||||
});
|
||||
|
||||
test('should send correct PDF/A parameters for pdf-to-pdfa conversion', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
|
||||
|
||||
// Set up PDF/A conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.updateParameter('fromExtension', 'pdf');
|
||||
paramsResult.current.updateParameter('toExtension', 'pdfa');
|
||||
paramsResult.current.updateParameter('pdfaOptions', {
|
||||
outputFormat: 'pdfa'
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
[pdfFile]
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formData.get('outputFormat')).toBe('pdfa');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), {
|
||||
responseType: 'blob'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image Conversion Options Integration', () => {
|
||||
|
||||
test('should send correct parameters for image-to-pdf conversion', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
|
||||
// Set up image conversion parameters
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(imageFiles);
|
||||
paramsResult.current.updateParameter('imageOptions', {
|
||||
colorType: 'grayscale',
|
||||
dpi: 150,
|
||||
singleOrMultiple: 'single',
|
||||
fitOption: 'fitToPage',
|
||||
autoRotate: false,
|
||||
combineImages: true
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
imageFiles
|
||||
);
|
||||
});
|
||||
|
||||
const formData = mockedAxios.post.mock.calls[0][1] as FormData;
|
||||
expect(formData.get('fitOption')).toBe('fitToPage');
|
||||
expect(formData.get('colorType')).toBe('grayscale');
|
||||
expect(formData.get('autoRotate')).toBe('false');
|
||||
});
|
||||
|
||||
test('should process images separately when combineImages is false', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
|
||||
// Set up for separate processing
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(imageFiles);
|
||||
paramsResult.current.updateParameter('imageOptions', {
|
||||
...paramsResult.current.parameters.imageOptions,
|
||||
combineImages: false
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
imageFiles
|
||||
);
|
||||
});
|
||||
|
||||
// Should make separate API calls for each file
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Scenarios in Smart Detection', () => {
|
||||
|
||||
|
||||
test('should handle partial failures in multi-file processing', async () => {
|
||||
const { result: paramsResult } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const { result: operationResult } = renderHook(() => useConvertOperation(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
// Mock one success, one failure
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({
|
||||
data: new Blob(['converted1'], { type: 'application/pdf' })
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('File 2 failed'));
|
||||
|
||||
const mixedFiles = [
|
||||
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
|
||||
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
|
||||
];
|
||||
|
||||
// Set up for separate processing (mixed smart detection)
|
||||
act(() => {
|
||||
paramsResult.current.analyzeFileTypes(mixedFiles);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await operationResult.current.executeOperation(
|
||||
paramsResult.current.parameters,
|
||||
mixedFiles
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should have processed at least one file successfully
|
||||
expect(operationResult.current.files.length).toBeGreaterThan(0);
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real File Extension Detection', () => {
|
||||
|
||||
test('should correctly detect various file extensions', async () => {
|
||||
const { result } = renderHook(() => useConvertParameters(), {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{ filename: 'document.PDF', expected: 'pdf' },
|
||||
{ filename: 'image.JPEG', expected: 'jpg' }, // JPEG should normalize to jpg
|
||||
{ filename: 'photo.jpeg', expected: 'jpg' }, // jpeg should normalize to jpg
|
||||
{ filename: 'archive.tar.gz', expected: 'gz' },
|
||||
{ filename: 'file.', expected: '' },
|
||||
{ filename: '.hidden', expected: 'hidden' },
|
||||
{ filename: 'noextension', expected: '' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ filename, expected }) => {
|
||||
const detected = detectFileExtension(filename);
|
||||
expect(detected).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
# Convert Tool Test Suite
|
||||
|
||||
This directory contains comprehensive tests for the Convert Tool functionality.
|
||||
|
||||
## Test Files Overview
|
||||
|
||||
### 1. ConvertTool.test.tsx
|
||||
**Purpose**: Unit/Component testing for the Convert Tool UI components
|
||||
- Tests dropdown behavior and navigation
|
||||
- Tests format availability based on endpoint status
|
||||
- Tests UI state management and form validation
|
||||
- Mocks backend dependencies for isolated testing
|
||||
|
||||
**Key Test Areas**:
|
||||
- FROM dropdown enables/disables formats based on endpoint availability
|
||||
- TO dropdown shows correct conversions for selected source format
|
||||
- Format-specific options appear/disappear correctly
|
||||
- Parameter validation and state management
|
||||
|
||||
### 2. ConvertIntegration.test.ts
|
||||
**Purpose**: Integration testing for Convert Tool business logic
|
||||
- Tests parameter validation and conversion matrix logic
|
||||
- Tests endpoint resolution and availability checking
|
||||
- Tests file extension detection
|
||||
- Provides framework for testing actual conversions (requires backend)
|
||||
|
||||
**Key Test Areas**:
|
||||
- Endpoint availability checking matches real backend status
|
||||
- Conversion parameters are correctly validated
|
||||
- File extension detection works properly
|
||||
- Conversion matrix returns correct available formats
|
||||
|
||||
### 3. ConvertE2E.spec.ts
|
||||
**Purpose**: End-to-End testing using Playwright with Dynamic Endpoint Discovery
|
||||
- **Automatically discovers available conversion endpoints** from the backend
|
||||
- Tests complete user workflows from file upload to download
|
||||
- Tests actual file conversions with real backend
|
||||
- **Skips tests for unavailable endpoints** automatically
|
||||
- Tests error handling and edge cases
|
||||
- Tests UI/UX flow and user interactions
|
||||
|
||||
**Key Test Areas**:
|
||||
- **Dynamic endpoint discovery** using `/api/v1/config/endpoints-enabled` API
|
||||
- Complete conversion workflows for **all available endpoints**
|
||||
- **Unavailable endpoint testing** - verifies disabled conversions are properly blocked
|
||||
- File upload, conversion, and download process
|
||||
- Error handling for corrupted files and network issues
|
||||
- Performance testing with large files
|
||||
- UI responsiveness and progress indicators
|
||||
|
||||
**Supported Conversions** (tested if available):
|
||||
- PDF ↔ Images (PNG, JPG, GIF, BMP, TIFF, WebP)
|
||||
- PDF ↔ Office (DOCX, PPTX)
|
||||
- PDF ↔ Text (TXT, HTML, XML, CSV, Markdown)
|
||||
- Office → PDF (DOCX, PPTX, XLSX, etc.)
|
||||
- Email (EML) → PDF
|
||||
- HTML → PDF, URL → PDF
|
||||
- Markdown → PDF
|
||||
|
||||
## Running the Tests
|
||||
|
||||
**Important**: All commands should be run from the `frontend/` directory:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
### Setup (First Time Only)
|
||||
```bash
|
||||
# Install dependencies (includes test frameworks)
|
||||
npm install
|
||||
|
||||
# Install Playwright browsers for E2E tests
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Unit Tests (ConvertTool.test.tsx)
|
||||
```bash
|
||||
# Run all unit tests
|
||||
npm test
|
||||
|
||||
# Run specific test file
|
||||
npm test ConvertTool.test.tsx
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Run in watch mode (re-runs on file changes)
|
||||
npm run test:watch
|
||||
|
||||
# Run specific test pattern
|
||||
npm test -- --grep "dropdown"
|
||||
```
|
||||
|
||||
### Integration Tests (ConvertIntegration.test.ts)
|
||||
```bash
|
||||
# Run integration tests
|
||||
npm test ConvertIntegration.test.ts
|
||||
|
||||
# Run with verbose output
|
||||
npm test ConvertIntegration.test.ts -- --reporter=verbose
|
||||
```
|
||||
|
||||
### E2E Tests (ConvertE2E.spec.ts)
|
||||
```bash
|
||||
# Prerequisites: Backend must be running on localhost:8080
|
||||
# Start backend first, then:
|
||||
|
||||
# Run all E2E tests (automatically discovers available endpoints)
|
||||
npm run test:e2e
|
||||
|
||||
# Run specific E2E test file
|
||||
npx playwright test ConvertE2E.spec.ts
|
||||
|
||||
# Run with UI mode for debugging
|
||||
npx playwright test --ui
|
||||
|
||||
# Run specific test by endpoint name (dynamic)
|
||||
npx playwright test -g "pdf-to-img:"
|
||||
|
||||
# Run only available conversion tests
|
||||
npx playwright test -g "Dynamic Conversion Tests"
|
||||
|
||||
# Run only unavailable conversion tests
|
||||
npx playwright test -g "Unavailable Conversions"
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Generate HTML report
|
||||
npx playwright test ConvertE2E.spec.ts --reporter=html
|
||||
```
|
||||
|
||||
**Test Discovery Process:**
|
||||
1. Tests automatically query `/api/v1/config/endpoints-enabled` to discover available conversions
|
||||
2. Tests are generated dynamically for each available endpoint
|
||||
3. Tests for unavailable endpoints verify they're properly disabled in the UI
|
||||
4. Console output shows which endpoints were discovered
|
||||
|
||||
## Test Requirements
|
||||
|
||||
### For Unit Tests
|
||||
- No special requirements
|
||||
- All dependencies are mocked
|
||||
- Can run in any environment
|
||||
|
||||
### For Integration Tests
|
||||
- May require backend API for full functionality
|
||||
- Uses mock data for endpoint availability
|
||||
- Tests business logic in isolation
|
||||
|
||||
### For E2E Tests
|
||||
- **Requires running backend server** (localhost:8080)
|
||||
- **Requires test fixture files** (see ../test-fixtures/README.md)
|
||||
- Requires frontend dev server (localhost:5173)
|
||||
- Tests real conversion functionality
|
||||
|
||||
## Test Data
|
||||
|
||||
The tests use realistic endpoint availability data based on your current server configuration:
|
||||
|
||||
**Available Endpoints** (should pass):
|
||||
- `file-to-pdf`: true (DOCX, XLSX, PPTX → PDF)
|
||||
- `img-to-pdf`: true (PNG, JPG, etc. → PDF)
|
||||
- `markdown-to-pdf`: true (MD → PDF)
|
||||
- `pdf-to-csv`: true (PDF → CSV)
|
||||
- `pdf-to-img`: true (PDF → PNG, JPG, etc.)
|
||||
- `pdf-to-text`: true (PDF → TXT)
|
||||
|
||||
**Disabled Endpoints** (should be blocked):
|
||||
- `eml-to-pdf`: false
|
||||
- `html-to-pdf`: false
|
||||
- `pdf-to-html`: false
|
||||
- `pdf-to-markdown`: false
|
||||
- `pdf-to-pdfa`: false
|
||||
- `pdf-to-presentation`: false
|
||||
- `pdf-to-word`: false
|
||||
- `pdf-to-xml`: false
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Success Scenarios (Available Endpoints)
|
||||
1. **PDF → Image**: PDF to PNG/JPG with various DPI and color settings
|
||||
2. **PDF → Data**: PDF to CSV (table extraction), PDF to TXT (text extraction)
|
||||
3. **Office → PDF**: DOCX/XLSX/PPTX to PDF conversion
|
||||
4. **Image → PDF**: PNG/JPG to PDF with image options
|
||||
5. **Markdown → PDF**: MD to PDF with formatting preservation
|
||||
|
||||
### Blocked Scenarios (Disabled Endpoints)
|
||||
1. **EML conversions**: Should be disabled in FROM dropdown
|
||||
2. **PDF → Office**: PDF to Word/PowerPoint should be disabled
|
||||
3. **PDF → Web**: PDF to HTML/XML should be disabled
|
||||
4. **PDF → PDF/A**: Should be disabled
|
||||
|
||||
### Error Scenarios
|
||||
1. **Corrupted files**: Should show helpful error messages
|
||||
2. **Network failures**: Should handle backend unavailability
|
||||
3. **Large files**: Should handle memory constraints gracefully
|
||||
4. **Invalid parameters**: Should validate before submission
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new conversion formats:
|
||||
|
||||
1. **Update ConvertTool.test.tsx**:
|
||||
- Add the new format to test data
|
||||
- Test dropdown behavior for the new format
|
||||
- Test format-specific options if any
|
||||
|
||||
2. **Update ConvertIntegration.test.ts**:
|
||||
- Add endpoint availability test cases
|
||||
- Add conversion matrix test cases
|
||||
- Add parameter validation tests
|
||||
|
||||
3. **Update ConvertE2E.spec.ts**:
|
||||
- Add end-to-end workflow tests
|
||||
- Add test fixture files
|
||||
- Test actual conversion functionality
|
||||
|
||||
4. **Update test fixtures**:
|
||||
- Add sample files for the new format
|
||||
- Update ../test-fixtures/README.md
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
### Unit Test Failures
|
||||
- Check mock data matches real endpoint status
|
||||
- Verify component props and state management
|
||||
- Check for React hook dependency issues
|
||||
|
||||
### Integration Test Failures
|
||||
- Verify conversion matrix includes new formats
|
||||
- Check endpoint name mappings
|
||||
- Ensure parameter validation logic is correct
|
||||
|
||||
### E2E Test Failures
|
||||
- Ensure backend server is running
|
||||
- Check test fixture files exist and are valid
|
||||
- Verify element selectors match current UI
|
||||
- Check for timing issues (increase timeouts if needed)
|
||||
|
||||
## Test Maintenance
|
||||
|
||||
### Regular Updates Needed
|
||||
1. **Endpoint Status**: Update mock data when backend endpoints change
|
||||
2. **UI Selectors**: Update test selectors when UI changes
|
||||
3. **Test Fixtures**: Replace old test files with new ones periodically
|
||||
4. **Performance Benchmarks**: Update expected performance metrics
|
||||
|
||||
### CI/CD Integration
|
||||
- Unit tests: Run on every commit
|
||||
- Integration tests: Run on pull requests
|
||||
- E2E tests: Run on staging deployment
|
||||
- Performance tests: Run weekly or on major releases
|
||||
|
||||
## Performance Expectations
|
||||
|
||||
These tests focus on frontend functionality, not backend performance:
|
||||
|
||||
- **File upload/UI**: < 1 second for small test files
|
||||
- **Dropdown interactions**: < 200ms
|
||||
- **Form validation**: < 100ms
|
||||
- **Conversion UI flow**: < 5 seconds for small test files
|
||||
|
||||
Tests will fail if UI interactions are slow, indicating frontend performance issues.
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Conversion Endpoint Discovery for E2E Testing
|
||||
*
|
||||
* Uses the backend's endpoint configuration API to discover available conversions
|
||||
*/
|
||||
|
||||
import { useMultipleEndpointsEnabled } from '../../hooks/useEndpointConfig';
|
||||
|
||||
export interface ConversionEndpoint {
|
||||
endpoint: string;
|
||||
fromFormat: string;
|
||||
toFormat: string;
|
||||
description: string;
|
||||
apiPath: string;
|
||||
}
|
||||
|
||||
// Complete list of conversion endpoints based on EndpointConfiguration.java
|
||||
const ALL_CONVERSION_ENDPOINTS: ConversionEndpoint[] = [
|
||||
{
|
||||
endpoint: 'pdf-to-img',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'image',
|
||||
description: 'Convert PDF to images (PNG, JPG, GIF, etc.)',
|
||||
apiPath: '/api/v1/convert/pdf/img'
|
||||
},
|
||||
{
|
||||
endpoint: 'img-to-pdf',
|
||||
fromFormat: 'image',
|
||||
toFormat: 'pdf',
|
||||
description: 'Convert images to PDF',
|
||||
apiPath: '/api/v1/convert/img/pdf'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-pdfa',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'pdfa',
|
||||
description: 'Convert PDF to PDF/A',
|
||||
apiPath: '/api/v1/convert/pdf/pdfa'
|
||||
},
|
||||
{
|
||||
endpoint: 'file-to-pdf',
|
||||
fromFormat: 'office',
|
||||
toFormat: 'pdf',
|
||||
description: 'Convert office files to PDF',
|
||||
apiPath: '/api/v1/convert/file/pdf'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-word',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'docx',
|
||||
description: 'Convert PDF to Word document',
|
||||
apiPath: '/api/v1/convert/pdf/word'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-presentation',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'pptx',
|
||||
description: 'Convert PDF to PowerPoint presentation',
|
||||
apiPath: '/api/v1/convert/pdf/presentation'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-text',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'txt',
|
||||
description: 'Convert PDF to plain text',
|
||||
apiPath: '/api/v1/convert/pdf/text'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-html',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'html',
|
||||
description: 'Convert PDF to HTML',
|
||||
apiPath: '/api/v1/convert/pdf/html'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-xml',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'xml',
|
||||
description: 'Convert PDF to XML',
|
||||
apiPath: '/api/v1/convert/pdf/xml'
|
||||
},
|
||||
{
|
||||
endpoint: 'html-to-pdf',
|
||||
fromFormat: 'html',
|
||||
toFormat: 'pdf',
|
||||
description: 'Convert HTML to PDF',
|
||||
apiPath: '/api/v1/convert/html/pdf'
|
||||
},
|
||||
{
|
||||
endpoint: 'url-to-pdf',
|
||||
fromFormat: 'url',
|
||||
toFormat: 'pdf',
|
||||
description: 'Convert web page to PDF',
|
||||
apiPath: '/api/v1/convert/url/pdf'
|
||||
},
|
||||
{
|
||||
endpoint: 'markdown-to-pdf',
|
||||
fromFormat: 'md',
|
||||
toFormat: 'pdf',
|
||||
description: 'Convert Markdown to PDF',
|
||||
apiPath: '/api/v1/convert/markdown/pdf'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-csv',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'csv',
|
||||
description: 'Extract CSV data from PDF',
|
||||
apiPath: '/api/v1/convert/pdf/csv'
|
||||
},
|
||||
{
|
||||
endpoint: 'pdf-to-markdown',
|
||||
fromFormat: 'pdf',
|
||||
toFormat: 'md',
|
||||
description: 'Convert PDF to Markdown',
|
||||
apiPath: '/api/v1/convert/pdf/markdown'
|
||||
},
|
||||
{
|
||||
endpoint: 'eml-to-pdf',
|
||||
fromFormat: 'eml',
|
||||
toFormat: 'pdf',
|
||||
description: 'Convert email (EML) to PDF',
|
||||
apiPath: '/api/v1/convert/eml/pdf'
|
||||
}
|
||||
];
|
||||
|
||||
export class ConversionEndpointDiscovery {
|
||||
private baseUrl: string;
|
||||
private cache: Map<string, boolean> | null = null;
|
||||
private cacheExpiry: number = 0;
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(baseUrl: string = process.env.BACKEND_URL || 'http://localhost:8080') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available conversion endpoints by checking with backend
|
||||
*/
|
||||
async getAvailableConversions(): Promise<ConversionEndpoint[]> {
|
||||
const endpointStatuses = await this.getEndpointStatuses();
|
||||
|
||||
return ALL_CONVERSION_ENDPOINTS.filter(conversion =>
|
||||
endpointStatuses.get(conversion.endpoint) === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unavailable conversion endpoints
|
||||
*/
|
||||
async getUnavailableConversions(): Promise<ConversionEndpoint[]> {
|
||||
const endpointStatuses = await this.getEndpointStatuses();
|
||||
|
||||
return ALL_CONVERSION_ENDPOINTS.filter(conversion =>
|
||||
endpointStatuses.get(conversion.endpoint) === false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific conversion is available
|
||||
*/
|
||||
async isConversionAvailable(endpoint: string): Promise<boolean> {
|
||||
const endpointStatuses = await this.getEndpointStatuses();
|
||||
return endpointStatuses.get(endpoint) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available conversions grouped by source format
|
||||
*/
|
||||
async getConversionsByFormat(): Promise<Record<string, ConversionEndpoint[]>> {
|
||||
const availableConversions = await this.getAvailableConversions();
|
||||
|
||||
const grouped: Record<string, ConversionEndpoint[]> = {};
|
||||
|
||||
availableConversions.forEach(conversion => {
|
||||
if (!grouped[conversion.fromFormat]) {
|
||||
grouped[conversion.fromFormat] = [];
|
||||
}
|
||||
grouped[conversion.fromFormat].push(conversion);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported target formats for a given source format
|
||||
*/
|
||||
async getSupportedTargetFormats(fromFormat: string): Promise<string[]> {
|
||||
const availableConversions = await this.getAvailableConversions();
|
||||
|
||||
return availableConversions
|
||||
.filter(conversion => conversion.fromFormat === fromFormat)
|
||||
.map(conversion => conversion.toFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported source formats
|
||||
*/
|
||||
async getSupportedSourceFormats(): Promise<string[]> {
|
||||
const availableConversions = await this.getAvailableConversions();
|
||||
|
||||
const sourceFormats = new Set(
|
||||
availableConversions.map(conversion => conversion.fromFormat)
|
||||
);
|
||||
|
||||
return Array.from(sourceFormats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get endpoint statuses from backend using batch API
|
||||
*/
|
||||
private async getEndpointStatuses(): Promise<Map<string, boolean>> {
|
||||
// Return cached result if still valid
|
||||
if (this.cache && Date.now() < this.cacheExpiry) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
try {
|
||||
const endpointNames = ALL_CONVERSION_ENDPOINTS.map(conv => conv.endpoint);
|
||||
const endpointsParam = endpointNames.join(',');
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch endpoint statuses: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const statusMap: Record<string, boolean> = await response.json();
|
||||
|
||||
// Convert to Map and cache
|
||||
this.cache = new Map(Object.entries(statusMap));
|
||||
this.cacheExpiry = Date.now() + this.CACHE_DURATION;
|
||||
|
||||
console.log(`Retrieved status for ${Object.keys(statusMap).length} conversion endpoints`);
|
||||
return this.cache;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get endpoint statuses:', error);
|
||||
|
||||
// Fallback: assume all endpoints are disabled
|
||||
const fallbackMap = new Map<string, boolean>();
|
||||
ALL_CONVERSION_ENDPOINTS.forEach(conv => {
|
||||
fallbackMap.set(conv.endpoint, false);
|
||||
});
|
||||
|
||||
return fallbackMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to create a skipping condition for tests
|
||||
*/
|
||||
static createSkipCondition(endpoint: string, discovery: ConversionEndpointDiscovery) {
|
||||
return async () => {
|
||||
const available = await discovery.isConversionAvailable(endpoint);
|
||||
return !available;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed conversion info by endpoint name
|
||||
*/
|
||||
getConversionInfo(endpoint: string): ConversionEndpoint | undefined {
|
||||
return ALL_CONVERSION_ENDPOINTS.find(conv => conv.endpoint === endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversion endpoints (regardless of availability)
|
||||
*/
|
||||
getAllConversions(): ConversionEndpoint[] {
|
||||
return [...ALL_CONVERSION_ENDPOINTS];
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for reuse across tests
|
||||
export const conversionDiscovery = new ConversionEndpointDiscovery();
|
||||
|
||||
/**
|
||||
* React hook version for use in components (wraps the class)
|
||||
*/
|
||||
export function useConversionEndpoints() {
|
||||
const endpointNames = ALL_CONVERSION_ENDPOINTS.map(conv => conv.endpoint);
|
||||
const { endpointStatus, loading, error, refetch } = useMultipleEndpointsEnabled(endpointNames);
|
||||
|
||||
const availableConversions = ALL_CONVERSION_ENDPOINTS.filter(
|
||||
conv => endpointStatus[conv.endpoint] === true
|
||||
);
|
||||
|
||||
const unavailableConversions = ALL_CONVERSION_ENDPOINTS.filter(
|
||||
conv => endpointStatus[conv.endpoint] === false
|
||||
);
|
||||
|
||||
return {
|
||||
availableConversions,
|
||||
unavailableConversions,
|
||||
allConversions: ALL_CONVERSION_ENDPOINTS,
|
||||
endpointStatus,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
isConversionAvailable: (endpoint: string) => endpointStatus[endpoint] === true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Test Fixtures for Convert Tool Testing
|
||||
|
||||
This directory contains sample files for testing the convert tool functionality.
|
||||
|
||||
## Required Test Files
|
||||
|
||||
To run the full test suite, please add the following test files to this directory:
|
||||
|
||||
### 1. sample.pdf
|
||||
- A small PDF document (1-2 pages)
|
||||
- Should contain text and ideally a simple table for CSV conversion testing
|
||||
- Should be under 1MB for fast testing
|
||||
|
||||
### 2. sample.docx
|
||||
- A Microsoft Word document with basic formatting
|
||||
- Should contain headers, paragraphs, and possibly a table
|
||||
- Should be under 500KB
|
||||
|
||||
### 3. sample.png
|
||||
- A small PNG image (e.g., 500x500 pixels)
|
||||
- Should be a real image, not just a test pattern
|
||||
- Should be under 100KB
|
||||
|
||||
### 3b. sample.jpg
|
||||
- A small JPG image (same image as PNG, different format)
|
||||
- Should be under 100KB
|
||||
- Can be created by converting sample.png to JPG
|
||||
|
||||
### 4. sample.md
|
||||
- A Markdown file with various formatting elements:
|
||||
```markdown
|
||||
# Test Document
|
||||
|
||||
This is a **test** markdown file.
|
||||
|
||||
## Features
|
||||
|
||||
- Lists
|
||||
- **Bold text**
|
||||
- *Italic text*
|
||||
- [Links](https://example.com)
|
||||
|
||||
### Code Block
|
||||
|
||||
```javascript
|
||||
console.log('Hello, world!');
|
||||
```
|
||||
|
||||
| Column 1 | Column 2 |
|
||||
|----------|----------|
|
||||
| Data 1 | Data 2 |
|
||||
```
|
||||
|
||||
### 5. sample.eml (Optional)
|
||||
- An email file with headers and body
|
||||
- Can be exported from any email client
|
||||
- Should contain some attachments for testing
|
||||
|
||||
### 6. sample.html (Optional)
|
||||
- A simple HTML file with various elements
|
||||
- Should include text, headings, and basic styling
|
||||
|
||||
|
||||
## File Creation Tips
|
||||
|
||||
### Creating a test PDF:
|
||||
1. Create a document in LibreOffice Writer or Google Docs
|
||||
2. Add some text, headers, and a simple table
|
||||
3. Export/Save as PDF
|
||||
|
||||
### Creating a test DOCX:
|
||||
1. Create a document in Microsoft Word or LibreOffice Writer
|
||||
2. Add formatted content (headers, bold, italic, lists)
|
||||
3. Save as DOCX format
|
||||
|
||||
### Creating a test PNG:
|
||||
1. Use any image editor or screenshot tool
|
||||
2. Create a simple image with text or shapes
|
||||
3. Save as PNG format
|
||||
|
||||
### Creating a test EML:
|
||||
1. In your email client, save an email as .eml format
|
||||
2. Or create manually with proper headers:
|
||||
```
|
||||
From: [email protected]
|
||||
To: [email protected]
|
||||
Subject: Test Email
|
||||
Date: Mon, 1 Jan 2024 12:00:00 +0000
|
||||
|
||||
This is a test email for conversion testing.
|
||||
```
|
||||
|
||||
## Test File Structure
|
||||
|
||||
```
|
||||
frontend/src/tests/test-fixtures/
|
||||
├── README.md (this file)
|
||||
├── sample.pdf
|
||||
├── sample.docx
|
||||
├── sample.png
|
||||
├── sample.jpg
|
||||
├── sample.md
|
||||
├── sample.eml (optional)
|
||||
└── sample.html (optional)
|
||||
```
|
||||
|
||||
## Usage in Tests
|
||||
|
||||
These files are referenced in the test files:
|
||||
|
||||
- `ConvertE2E.spec.ts` - Uses all files for E2E testing
|
||||
- `ConvertIntegration.test.ts` - Uses files for integration testing
|
||||
- Manual testing scenarios
|
||||
|
||||
## Security Note
|
||||
|
||||
These are test files only and should not contain any sensitive information. They will be committed to the repository and used in automated testing.
|
||||
|
||||
## File Size Guidelines
|
||||
|
||||
- Keep test files small for fast CI/CD pipelines and frontend testing
|
||||
- PDF files: < 1MB (preferably 100-500KB)
|
||||
- Image files: < 100KB
|
||||
- Text files: < 50KB
|
||||
- Focus on frontend functionality, not backend performance
|
||||
|
||||
## Maintenance
|
||||
|
||||
When updating the convert tool with new formats:
|
||||
1. Add corresponding test files to this directory
|
||||
2. Update the test files list above
|
||||
3. Update the test cases to include the new formats
|
||||
@@ -0,0 +1 @@
|
||||
This is not a valid PDF file
|
||||
@@ -0,0 +1,6 @@
|
||||
Name,Age,City,Country
|
||||
John Doe,30,New York,USA
|
||||
Jane Smith,25,London,UK
|
||||
Bob Johnson,35,Toronto,Canada
|
||||
Alice Brown,28,Sydney,Australia
|
||||
Charlie Wilson,42,Berlin,Germany
|
||||
|
@@ -0,0 +1,10 @@
|
||||
# Test DOC File
|
||||
|
||||
This is a test DOC file for conversion testing.
|
||||
|
||||
Content:
|
||||
- Test bullet point 1
|
||||
- Test bullet point 2
|
||||
- Test bullet point 3
|
||||
|
||||
This file should be sufficient for testing office document conversions.
|
||||
Binary file not shown.
@@ -0,0 +1,105 @@
|
||||
Return-Path: <[email protected]>
|
||||
Delivered-To: [email protected]
|
||||
Received: from mail.example.com (mail.example.com [192.168.1.1])
|
||||
by mx.example.com (Postfix) with ESMTP id 1234567890
|
||||
for <[email protected]>; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
|
||||
Message-ID: <[email protected]>
|
||||
Date: Mon, 1 Jan 2024 12:00:00 +0000
|
||||
From: Test Sender <[email protected]>
|
||||
User-Agent: Mozilla/5.0 (compatible; Test Email Client)
|
||||
MIME-Version: 1.0
|
||||
To: Test Recipient <[email protected]>
|
||||
Subject: Test Email for Convert Tool
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="------------boundary123456789"
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--------------boundary123456789
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Test Email for Convert Tool
|
||||
===========================
|
||||
|
||||
This is a test email for testing the EML to PDF conversion functionality.
|
||||
|
||||
Email Details:
|
||||
- From: [email protected]
|
||||
- To: [email protected]
|
||||
- Subject: Test Email for Convert Tool
|
||||
- Date: January 1, 2024
|
||||
|
||||
Content Features:
|
||||
- Plain text content
|
||||
- HTML content (in alternative part)
|
||||
- Headers and metadata
|
||||
- MIME structure
|
||||
|
||||
This email should convert to a PDF that includes:
|
||||
1. Email headers (From, To, Subject, Date)
|
||||
2. Email body content
|
||||
3. Proper formatting
|
||||
|
||||
Important Notes:
|
||||
- This is a test email only
|
||||
- Generated for Stirling PDF testing
|
||||
- Contains no sensitive information
|
||||
- Should preserve email formatting in PDF
|
||||
|
||||
Best regards,
|
||||
Test Email System
|
||||
|
||||
--------------boundary123456789
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Test Email</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<h1 style="color: #2c3e50;">Test Email for Convert Tool</h1>
|
||||
|
||||
<p>This is a <strong>test email</strong> for testing the EML to PDF conversion functionality.</p>
|
||||
|
||||
<h2 style="color: #34495e;">Email Details:</h2>
|
||||
<ul>
|
||||
<li><strong>From:</strong> [email protected]</li>
|
||||
<li><strong>To:</strong> [email protected]</li>
|
||||
<li><strong>Subject:</strong> Test Email for Convert Tool</li>
|
||||
<li><strong>Date:</strong> January 1, 2024</li>
|
||||
</ul>
|
||||
|
||||
<h2 style="color: #34495e;">Content Features:</h2>
|
||||
<ul>
|
||||
<li>Plain text content</li>
|
||||
<li><em>HTML content</em> (this part)</li>
|
||||
<li>Headers and metadata</li>
|
||||
<li>MIME structure</li>
|
||||
</ul>
|
||||
|
||||
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #007bff; margin: 20px 0;">
|
||||
<p><strong>This email should convert to a PDF that includes:</strong></p>
|
||||
<ol>
|
||||
<li>Email headers (From, To, Subject, Date)</li>
|
||||
<li>Email body content</li>
|
||||
<li>Proper formatting</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h3 style="color: #6c757d;">Important Notes:</h3>
|
||||
<ul>
|
||||
<li>This is a test email only</li>
|
||||
<li>Generated for Stirling PDF testing</li>
|
||||
<li>Contains no sensitive information</li>
|
||||
<li>Should preserve email formatting in PDF</li>
|
||||
</ul>
|
||||
|
||||
<p>Best regards,<br>
|
||||
<strong>Test Email System</strong></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--------------boundary123456789--
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test HTML Document</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #34495e;
|
||||
margin-top: 30px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
font-weight: bold;
|
||||
}
|
||||
.highlight {
|
||||
background-color: #fff3cd;
|
||||
padding: 10px;
|
||||
border-left: 4px solid #ffc107;
|
||||
margin: 20px 0;
|
||||
}
|
||||
code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test HTML Document for Convert Tool</h1>
|
||||
|
||||
<p>This is a <strong>test HTML file</strong> for testing the HTML to PDF conversion functionality. It contains various HTML elements to ensure proper conversion.</p>
|
||||
|
||||
<h2>Text Formatting</h2>
|
||||
<p>This paragraph contains <strong>bold text</strong>, <em>italic text</em>, and <code>inline code</code>.</p>
|
||||
|
||||
<div class="highlight">
|
||||
<p><strong>Important:</strong> This is a highlighted section that should be preserved in the PDF output.</p>
|
||||
</div>
|
||||
|
||||
<h2>Lists</h2>
|
||||
<h3>Unordered List</h3>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li>Second item with <a href="https://example.com">a link</a></li>
|
||||
<li>Third item</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ordered List</h3>
|
||||
<ol>
|
||||
<li>Primary point</li>
|
||||
<li>Secondary point</li>
|
||||
<li>Tertiary point</li>
|
||||
</ol>
|
||||
|
||||
<h2>Table</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column 1</th>
|
||||
<th>Column 2</th>
|
||||
<th>Column 3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Data A</td>
|
||||
<td>Data B</td>
|
||||
<td>Data C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Test 1</td>
|
||||
<td>Test 2</td>
|
||||
<td>Test 3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sample X</td>
|
||||
<td>Sample Y</td>
|
||||
<td>Sample Z</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Code Block</h2>
|
||||
<pre><code>function testFunction() {
|
||||
console.log("This is a test function");
|
||||
return "Hello from HTML to PDF conversion";
|
||||
}</code></pre>
|
||||
|
||||
<h2>Final Notes</h2>
|
||||
<p>This HTML document should convert to a well-formatted PDF that preserves:</p>
|
||||
<ul>
|
||||
<li>Text formatting (bold, italic)</li>
|
||||
<li>Headings and hierarchy</li>
|
||||
<li>Tables with proper borders</li>
|
||||
<li>Lists (ordered and unordered)</li>
|
||||
<li>Code formatting</li>
|
||||
<li>Basic CSS styling</li>
|
||||
</ul>
|
||||
|
||||
<p><small>Generated for Stirling PDF Convert Tool testing purposes.</small></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test HTML Document</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #34495e;
|
||||
margin-top: 30px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
font-weight: bold;
|
||||
}
|
||||
.highlight {
|
||||
background-color: #fff3cd;
|
||||
padding: 10px;
|
||||
border-left: 4px solid #ffc107;
|
||||
margin: 20px 0;
|
||||
}
|
||||
code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test HTML Document for Convert Tool</h1>
|
||||
|
||||
<p>This is a <strong>test HTML file</strong> for testing the HTML to PDF conversion functionality. It contains various HTML elements to ensure proper conversion.</p>
|
||||
|
||||
<h2>Text Formatting</h2>
|
||||
<p>This paragraph contains <strong>bold text</strong>, <em>italic text</em>, and <code>inline code</code>.</p>
|
||||
|
||||
<div class="highlight">
|
||||
<p><strong>Important:</strong> This is a highlighted section that should be preserved in the PDF output.</p>
|
||||
</div>
|
||||
|
||||
<h2>Lists</h2>
|
||||
<h3>Unordered List</h3>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li>Second item with <a href="https://example.com">a link</a></li>
|
||||
<li>Third item</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ordered List</h3>
|
||||
<ol>
|
||||
<li>Primary point</li>
|
||||
<li>Secondary point</li>
|
||||
<li>Tertiary point</li>
|
||||
</ol>
|
||||
|
||||
<h2>Table</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column 1</th>
|
||||
<th>Column 2</th>
|
||||
<th>Column 3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Data A</td>
|
||||
<td>Data B</td>
|
||||
<td>Data C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Test 1</td>
|
||||
<td>Test 2</td>
|
||||
<td>Test 3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sample X</td>
|
||||
<td>Sample Y</td>
|
||||
<td>Sample Z</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Code Block</h2>
|
||||
<pre><code>function testFunction() {
|
||||
console.log("This is a test function");
|
||||
return "Hello from HTML to PDF conversion";
|
||||
}</code></pre>
|
||||
|
||||
<h2>Final Notes</h2>
|
||||
<p>This HTML document should convert to a well-formatted PDF that preserves:</p>
|
||||
<ul>
|
||||
<li>Text formatting (bold, italic)</li>
|
||||
<li>Headings and hierarchy</li>
|
||||
<li>Tables with proper borders</li>
|
||||
<li>Lists (ordered and unordered)</li>
|
||||
<li>Code formatting</li>
|
||||
<li>Basic CSS styling</li>
|
||||
</ul>
|
||||
|
||||
<p><small>Generated for Stirling PDF Convert Tool testing purposes.</small></p>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,49 @@
|
||||
# Test Document for Convert Tool
|
||||
|
||||
This is a **test** markdown file for testing the markdown to PDF conversion functionality.
|
||||
|
||||
## Features Being Tested
|
||||
|
||||
- **Bold text**
|
||||
- *Italic text*
|
||||
- [Links](https://example.com)
|
||||
- Lists and formatting
|
||||
|
||||
### Code Block
|
||||
|
||||
```javascript
|
||||
console.log('Hello, world!');
|
||||
function testFunction() {
|
||||
return "This is a test";
|
||||
}
|
||||
```
|
||||
|
||||
### Table
|
||||
|
||||
| Column 1 | Column 2 | Column 3 |
|
||||
|----------|----------|----------|
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
| Test A | Test B | Test C |
|
||||
|
||||
## Lists
|
||||
|
||||
### Unordered List
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Nested item
|
||||
- Another nested item
|
||||
- Item 3
|
||||
|
||||
### Ordered List
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
## Blockquote
|
||||
|
||||
> This is a blockquote for testing purposes.
|
||||
> It should be properly formatted in the PDF output.
|
||||
|
||||
## Conclusion
|
||||
|
||||
This markdown file contains various elements to test the conversion functionality. The PDF output should preserve formatting, tables, code blocks, and other markdown elements.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,12 @@
|
||||
# Test PPTX Presentation
|
||||
|
||||
## Slide 1: Title
|
||||
This is a test PowerPoint presentation for conversion testing.
|
||||
|
||||
## Slide 2: Content
|
||||
- Test bullet point 1
|
||||
- Test bullet point 2
|
||||
- Test bullet point 3
|
||||
|
||||
## Slide 3: Conclusion
|
||||
This file should be sufficient for testing presentation conversions.
|
||||
@@ -0,0 +1,32 @@
|
||||
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="400" height="300" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="200" y="40" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="#2c3e50">
|
||||
Test Image for Convert Tool
|
||||
</text>
|
||||
|
||||
<!-- Shapes for visual content -->
|
||||
<circle cx="100" cy="120" r="30" fill="#3498db" stroke="#2980b9" stroke-width="2"/>
|
||||
<rect x="180" y="90" width="60" height="60" fill="#e74c3c" stroke="#c0392b" stroke-width="2"/>
|
||||
<polygon points="320,90 350,150 290,150" fill="#f39c12" stroke="#e67e22" stroke-width="2"/>
|
||||
|
||||
<!-- Labels -->
|
||||
<text x="100" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#7f8c8d">Circle</text>
|
||||
<text x="210" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#7f8c8d">Square</text>
|
||||
<text x="320" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#7f8c8d">Triangle</text>
|
||||
|
||||
<!-- Description -->
|
||||
<text x="200" y="210" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#34495e">
|
||||
This image tests conversion functionality
|
||||
</text>
|
||||
<text x="200" y="230" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#95a5a6">
|
||||
PNG/JPG ↔ PDF conversions
|
||||
</text>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="200" y="270" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#bdc3c7">
|
||||
Generated for Stirling PDF testing - 400x300px
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,8 @@
|
||||
This is a test text file for conversion testing.
|
||||
|
||||
It contains multiple lines of text to test various conversion scenarios.
|
||||
Special characters: àáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ
|
||||
Numbers: 1234567890
|
||||
Symbols: !@#$%^&*()_+-=[]{}|;':\",./<>?
|
||||
|
||||
This file should be sufficient for testing text-based conversions.
|
||||
@@ -0,0 +1,6 @@
|
||||
Name,Age,City,Country,Department,Salary
|
||||
John Doe,30,New York,USA,Engineering,75000
|
||||
Jane Smith,25,London,UK,Marketing,65000
|
||||
Bob Johnson,35,Toronto,Canada,Sales,70000
|
||||
Alice Brown,28,Sydney,Australia,Design,68000
|
||||
Charlie Wilson,42,Berlin,Germany,Operations,72000
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document>
|
||||
<title>Test Document</title>
|
||||
<content>
|
||||
<section id="1">
|
||||
<heading>Introduction</heading>
|
||||
<paragraph>This is a test XML document for conversion testing.</paragraph>
|
||||
</section>
|
||||
<section id="2">
|
||||
<heading>Data</heading>
|
||||
<data>
|
||||
<item name="test1" value="value1"/>
|
||||
<item name="test2" value="value2"/>
|
||||
<item name="test3" value="value3"/>
|
||||
</data>
|
||||
</section>
|
||||
</content>
|
||||
</document>
|
||||
@@ -0,0 +1,207 @@
|
||||
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";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||
|
||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
|
||||
import { useConvertParameters } from "../hooks/tools/convert/useConvertParameters";
|
||||
import { useConvertOperation } from "../hooks/tools/convert/useConvertOperation";
|
||||
import { BaseToolProps } from "../types/tool";
|
||||
|
||||
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setCurrentMode, activeFiles } = useFileContext();
|
||||
const { selectedFiles } = useToolFileSelection();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const convertParams = useConvertParameters();
|
||||
const convertOperation = useConvertOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
||||
convertParams.getEndpointName()
|
||||
);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = convertOperation.downloadUrl !== null;
|
||||
const filesCollapsed = hasFiles;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
convertParams.analyzeFileTypes(selectedFiles);
|
||||
} else {
|
||||
// Only reset when there are no active files at all
|
||||
// If there are active files but no selected files, keep current format (user filtered by format)
|
||||
if (activeFiles.length === 0) {
|
||||
convertParams.resetParameters();
|
||||
}
|
||||
}
|
||||
}, [selectedFiles, activeFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only clear results if we're not currently processing and parameters changed
|
||||
if (!convertOperation.isLoading) {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
}, [convertParams.parameters.fromExtension, convertParams.parameters.toExtension]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFiles) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
}, [hasFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasResults) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
}, [hasResults]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
try {
|
||||
await convertOperation.executeOperation(
|
||||
convertParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
if (convertOperation.files && onComplete) {
|
||||
onComplete(convertOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error.message : 'Convert operation failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem('previousMode', 'convert');
|
||||
setCurrentMode('viewer');
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
setCurrentMode('convert');
|
||||
};
|
||||
|
||||
const previewResults = useMemo(() =>
|
||||
convertOperation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: convertOperation.thumbnails[index]
|
||||
})) || [],
|
||||
[convertOperation.files, convertOperation.thumbnails]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full max-h-screen overflow-y-auto" ref={scrollContainerRef}>
|
||||
<ToolStepContainer>
|
||||
<Stack gap="sm" p="sm">
|
||||
<ToolStep
|
||||
title={t("convert.files", "Files")}
|
||||
isVisible={true}
|
||||
isCollapsed={filesCollapsed}
|
||||
isCompleted={filesCollapsed}
|
||||
completedMessage={hasFiles ? `${selectedFiles.length} ${t("filesSelected", "files selected")}` : undefined}
|
||||
>
|
||||
<FileStatusIndicator
|
||||
selectedFiles={selectedFiles}
|
||||
placeholder={t("convert.selectFilesPlaceholder", "Select files in the main view to get started")}
|
||||
/>
|
||||
</ToolStep>
|
||||
|
||||
<ToolStep
|
||||
title={t("convert.settings", "Settings")}
|
||||
isVisible={true}
|
||||
isCollapsed={settingsCollapsed}
|
||||
isCompleted={settingsCollapsed}
|
||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
||||
completedMessage={settingsCollapsed ? t("convert.conversionCompleted", "Conversion completed") : undefined}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ConvertSettings
|
||||
parameters={convertParams.parameters}
|
||||
onParameterChange={convertParams.updateParameter}
|
||||
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
||||
selectedFiles={selectedFiles}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
|
||||
{hasFiles && convertParams.parameters.fromExtension && convertParams.parameters.toExtension && (
|
||||
<OperationButton
|
||||
onClick={handleConvert}
|
||||
isLoading={convertOperation.isLoading}
|
||||
disabled={!convertParams.validateParameters() || !hasFiles || !endpointEnabled}
|
||||
loadingText={t("convert.converting", "Converting...")}
|
||||
submitText={t("convert.convertFiles", "Convert Files")}
|
||||
data-testid="convert-button"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
|
||||
<ToolStep
|
||||
title={t("convert.results", "Results")}
|
||||
isVisible={hasResults}
|
||||
data-testid="conversion-results"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{convertOperation.status && (
|
||||
<Text size="sm" c="dimmed">{convertOperation.status}</Text>
|
||||
)}
|
||||
|
||||
<ErrorNotification
|
||||
error={convertOperation.errorMessage}
|
||||
onClose={convertOperation.clearError}
|
||||
/>
|
||||
|
||||
{convertOperation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={convertOperation.downloadUrl}
|
||||
download={convertOperation.downloadFilename || t("convert.defaultFilename", "converted_file")}
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
mb="md"
|
||||
data-testid="download-button"
|
||||
>
|
||||
{t("convert.downloadConverted", "Download Converted File")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ResultsPreview
|
||||
files={previewResults}
|
||||
onFileClick={handleThumbnailClick}
|
||||
isGeneratingThumbnails={convertOperation.isGeneratingThumbnails}
|
||||
title={t("convert.conversionResults", "Conversion Results")}
|
||||
/>
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
</Stack>
|
||||
</ToolStepContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Convert;
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Unit tests for convertUtils
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
getEndpointName,
|
||||
getEndpointUrl,
|
||||
isConversionSupported,
|
||||
isImageFormat
|
||||
} from './convertUtils';
|
||||
|
||||
describe('convertUtils', () => {
|
||||
|
||||
describe('getEndpointName', () => {
|
||||
|
||||
test('should return correct endpoint names for all supported conversions', () => {
|
||||
// PDF to Image formats
|
||||
expect(getEndpointName('pdf', 'png')).toBe('pdf-to-img');
|
||||
expect(getEndpointName('pdf', 'jpg')).toBe('pdf-to-img');
|
||||
expect(getEndpointName('pdf', 'gif')).toBe('pdf-to-img');
|
||||
expect(getEndpointName('pdf', 'tiff')).toBe('pdf-to-img');
|
||||
expect(getEndpointName('pdf', 'bmp')).toBe('pdf-to-img');
|
||||
expect(getEndpointName('pdf', 'webp')).toBe('pdf-to-img');
|
||||
|
||||
// PDF to Office formats
|
||||
expect(getEndpointName('pdf', 'docx')).toBe('pdf-to-word');
|
||||
expect(getEndpointName('pdf', 'odt')).toBe('pdf-to-word');
|
||||
expect(getEndpointName('pdf', 'pptx')).toBe('pdf-to-presentation');
|
||||
expect(getEndpointName('pdf', 'odp')).toBe('pdf-to-presentation');
|
||||
|
||||
// PDF to Data formats
|
||||
expect(getEndpointName('pdf', 'csv')).toBe('pdf-to-csv');
|
||||
expect(getEndpointName('pdf', 'txt')).toBe('pdf-to-text');
|
||||
expect(getEndpointName('pdf', 'rtf')).toBe('pdf-to-text');
|
||||
expect(getEndpointName('pdf', 'md')).toBe('pdf-to-markdown');
|
||||
|
||||
// PDF to Web formats
|
||||
expect(getEndpointName('pdf', 'html')).toBe('pdf-to-html');
|
||||
expect(getEndpointName('pdf', 'xml')).toBe('pdf-to-xml');
|
||||
|
||||
// PDF to PDF/A
|
||||
expect(getEndpointName('pdf', 'pdfa')).toBe('pdf-to-pdfa');
|
||||
|
||||
// Office Documents to PDF
|
||||
expect(getEndpointName('docx', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('doc', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('odt', 'pdf')).toBe('file-to-pdf');
|
||||
|
||||
// Spreadsheets to PDF
|
||||
expect(getEndpointName('xlsx', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('xls', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('ods', 'pdf')).toBe('file-to-pdf');
|
||||
|
||||
// Presentations to PDF
|
||||
expect(getEndpointName('pptx', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('ppt', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('odp', 'pdf')).toBe('file-to-pdf');
|
||||
|
||||
// Images to PDF
|
||||
expect(getEndpointName('jpg', 'pdf')).toBe('img-to-pdf');
|
||||
expect(getEndpointName('jpeg', 'pdf')).toBe('img-to-pdf');
|
||||
expect(getEndpointName('png', 'pdf')).toBe('img-to-pdf');
|
||||
expect(getEndpointName('gif', 'pdf')).toBe('img-to-pdf');
|
||||
expect(getEndpointName('bmp', 'pdf')).toBe('img-to-pdf');
|
||||
expect(getEndpointName('tiff', 'pdf')).toBe('img-to-pdf');
|
||||
expect(getEndpointName('webp', 'pdf')).toBe('img-to-pdf');
|
||||
|
||||
// Web formats to PDF
|
||||
expect(getEndpointName('html', 'pdf')).toBe('html-to-pdf');
|
||||
|
||||
// Markdown to PDF
|
||||
expect(getEndpointName('md', 'pdf')).toBe('markdown-to-pdf');
|
||||
|
||||
// Text formats to PDF
|
||||
expect(getEndpointName('txt', 'pdf')).toBe('file-to-pdf');
|
||||
expect(getEndpointName('rtf', 'pdf')).toBe('file-to-pdf');
|
||||
|
||||
// Email to PDF
|
||||
expect(getEndpointName('eml', 'pdf')).toBe('eml-to-pdf');
|
||||
});
|
||||
|
||||
test('should return empty string for unsupported conversions', () => {
|
||||
expect(getEndpointName('pdf', 'exe')).toBe('');
|
||||
expect(getEndpointName('wav', 'pdf')).toBe('file-to-pdf'); // Try using file to pdf as fallback
|
||||
expect(getEndpointName('png', 'docx')).toBe(''); // Images can't convert to Word docs
|
||||
});
|
||||
|
||||
test('should handle empty or invalid inputs', () => {
|
||||
expect(getEndpointName('', '')).toBe('');
|
||||
expect(getEndpointName('pdf', '')).toBe('');
|
||||
expect(getEndpointName('', 'pdf')).toBe('');
|
||||
expect(getEndpointName('nonexistent', 'alsononexistent')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEndpointUrl', () => {
|
||||
|
||||
test('should return correct endpoint URLs for all supported conversions', () => {
|
||||
// PDF to Image formats
|
||||
expect(getEndpointUrl('pdf', 'png')).toBe('/api/v1/convert/pdf/img');
|
||||
expect(getEndpointUrl('pdf', 'jpg')).toBe('/api/v1/convert/pdf/img');
|
||||
expect(getEndpointUrl('pdf', 'gif')).toBe('/api/v1/convert/pdf/img');
|
||||
expect(getEndpointUrl('pdf', 'tiff')).toBe('/api/v1/convert/pdf/img');
|
||||
expect(getEndpointUrl('pdf', 'bmp')).toBe('/api/v1/convert/pdf/img');
|
||||
expect(getEndpointUrl('pdf', 'webp')).toBe('/api/v1/convert/pdf/img');
|
||||
|
||||
// PDF to Office formats
|
||||
expect(getEndpointUrl('pdf', 'docx')).toBe('/api/v1/convert/pdf/word');
|
||||
expect(getEndpointUrl('pdf', 'odt')).toBe('/api/v1/convert/pdf/word');
|
||||
expect(getEndpointUrl('pdf', 'pptx')).toBe('/api/v1/convert/pdf/presentation');
|
||||
expect(getEndpointUrl('pdf', 'odp')).toBe('/api/v1/convert/pdf/presentation');
|
||||
|
||||
// PDF to Data formats
|
||||
expect(getEndpointUrl('pdf', 'csv')).toBe('/api/v1/convert/pdf/csv');
|
||||
expect(getEndpointUrl('pdf', 'txt')).toBe('/api/v1/convert/pdf/text');
|
||||
expect(getEndpointUrl('pdf', 'rtf')).toBe('/api/v1/convert/pdf/text');
|
||||
expect(getEndpointUrl('pdf', 'md')).toBe('/api/v1/convert/pdf/markdown');
|
||||
|
||||
// PDF to Web formats
|
||||
expect(getEndpointUrl('pdf', 'html')).toBe('/api/v1/convert/pdf/html');
|
||||
expect(getEndpointUrl('pdf', 'xml')).toBe('/api/v1/convert/pdf/xml');
|
||||
|
||||
// PDF to PDF/A
|
||||
expect(getEndpointUrl('pdf', 'pdfa')).toBe('/api/v1/convert/pdf/pdfa');
|
||||
|
||||
// Office Documents to PDF
|
||||
expect(getEndpointUrl('docx', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('doc', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('odt', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
|
||||
// Spreadsheets to PDF
|
||||
expect(getEndpointUrl('xlsx', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('xls', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('ods', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
|
||||
// Presentations to PDF
|
||||
expect(getEndpointUrl('pptx', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('ppt', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('odp', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
|
||||
// Images to PDF
|
||||
expect(getEndpointUrl('jpg', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
expect(getEndpointUrl('jpeg', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
expect(getEndpointUrl('png', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
expect(getEndpointUrl('gif', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
expect(getEndpointUrl('bmp', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
expect(getEndpointUrl('tiff', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
expect(getEndpointUrl('webp', 'pdf')).toBe('/api/v1/convert/img/pdf');
|
||||
|
||||
// Web formats to PDF
|
||||
expect(getEndpointUrl('html', 'pdf')).toBe('/api/v1/convert/html/pdf');
|
||||
|
||||
// Markdown to PDF
|
||||
expect(getEndpointUrl('md', 'pdf')).toBe('/api/v1/convert/markdown/pdf');
|
||||
|
||||
// Text formats to PDF
|
||||
expect(getEndpointUrl('txt', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
expect(getEndpointUrl('rtf', 'pdf')).toBe('/api/v1/convert/file/pdf');
|
||||
|
||||
// Email to PDF
|
||||
expect(getEndpointUrl('eml', 'pdf')).toBe('/api/v1/convert/eml/pdf');
|
||||
});
|
||||
|
||||
test('should return empty string for unsupported conversions', () => {
|
||||
expect(getEndpointUrl('pdf', 'exe')).toBe('');
|
||||
expect(getEndpointUrl('wav', 'pdf')).toBe('/api/v1/convert/file/pdf'); // Try using file to pdf as fallback
|
||||
expect(getEndpointUrl('invalid', 'invalid')).toBe('');
|
||||
});
|
||||
|
||||
test('should handle empty inputs', () => {
|
||||
expect(getEndpointUrl('', '')).toBe('');
|
||||
expect(getEndpointUrl('pdf', '')).toBe('');
|
||||
expect(getEndpointUrl('', 'pdf')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConversionSupported', () => {
|
||||
|
||||
test('should return true for all supported conversions', () => {
|
||||
// PDF to Image formats
|
||||
expect(isConversionSupported('pdf', 'png')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'jpg')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'gif')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'tiff')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'bmp')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'webp')).toBe(true);
|
||||
|
||||
// PDF to Office formats
|
||||
expect(isConversionSupported('pdf', 'docx')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'odt')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'pptx')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'odp')).toBe(true);
|
||||
|
||||
// PDF to Data formats
|
||||
expect(isConversionSupported('pdf', 'csv')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'txt')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'rtf')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'md')).toBe(true);
|
||||
|
||||
// PDF to Web formats
|
||||
expect(isConversionSupported('pdf', 'html')).toBe(true);
|
||||
expect(isConversionSupported('pdf', 'xml')).toBe(true);
|
||||
|
||||
// PDF to PDF/A
|
||||
expect(isConversionSupported('pdf', 'pdfa')).toBe(true);
|
||||
|
||||
// Office Documents to PDF
|
||||
expect(isConversionSupported('docx', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('doc', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('odt', 'pdf')).toBe(true);
|
||||
|
||||
// Spreadsheets to PDF
|
||||
expect(isConversionSupported('xlsx', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('xls', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('ods', 'pdf')).toBe(true);
|
||||
|
||||
// Presentations to PDF
|
||||
expect(isConversionSupported('pptx', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('ppt', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('odp', 'pdf')).toBe(true);
|
||||
|
||||
// Images to PDF
|
||||
expect(isConversionSupported('jpg', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('jpeg', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('png', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('gif', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('bmp', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('tiff', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('webp', 'pdf')).toBe(true);
|
||||
|
||||
// Web formats to PDF
|
||||
expect(isConversionSupported('html', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('htm', 'pdf')).toBe(true);
|
||||
|
||||
// Markdown to PDF
|
||||
expect(isConversionSupported('md', 'pdf')).toBe(true);
|
||||
|
||||
// Text formats to PDF
|
||||
expect(isConversionSupported('txt', 'pdf')).toBe(true);
|
||||
expect(isConversionSupported('rtf', 'pdf')).toBe(true);
|
||||
|
||||
// Email to PDF
|
||||
expect(isConversionSupported('eml', 'pdf')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for unsupported conversions', () => {
|
||||
expect(isConversionSupported('pdf', 'exe')).toBe(false);
|
||||
expect(isConversionSupported('wav', 'pdf')).toBe(true); // Fallback to file to pdf
|
||||
expect(isConversionSupported('png', 'docx')).toBe(false);
|
||||
expect(isConversionSupported('nonexistent', 'alsononexistent')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle empty inputs', () => {
|
||||
expect(isConversionSupported('', '')).toBe(false);
|
||||
expect(isConversionSupported('pdf', '')).toBe(false);
|
||||
expect(isConversionSupported('', 'pdf')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isImageFormat', () => {
|
||||
|
||||
test('should return true for image formats', () => {
|
||||
expect(isImageFormat('png')).toBe(true);
|
||||
expect(isImageFormat('jpg')).toBe(true);
|
||||
expect(isImageFormat('jpeg')).toBe(true);
|
||||
expect(isImageFormat('gif')).toBe(true);
|
||||
expect(isImageFormat('tiff')).toBe(true);
|
||||
expect(isImageFormat('bmp')).toBe(true);
|
||||
expect(isImageFormat('webp')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for non-image formats', () => {
|
||||
expect(isImageFormat('pdf')).toBe(false);
|
||||
expect(isImageFormat('docx')).toBe(false);
|
||||
expect(isImageFormat('txt')).toBe(false);
|
||||
expect(isImageFormat('csv')).toBe(false);
|
||||
expect(isImageFormat('html')).toBe(false);
|
||||
expect(isImageFormat('xml')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle case insensitivity', () => {
|
||||
expect(isImageFormat('PNG')).toBe(true);
|
||||
expect(isImageFormat('JPG')).toBe(true);
|
||||
expect(isImageFormat('JPEG')).toBe(true);
|
||||
expect(isImageFormat('Png')).toBe(true);
|
||||
expect(isImageFormat('JpG')).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle empty and invalid inputs', () => {
|
||||
expect(isImageFormat('')).toBe(false);
|
||||
expect(isImageFormat('invalid')).toBe(false);
|
||||
expect(isImageFormat('123')).toBe(false);
|
||||
expect(isImageFormat('.')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle mixed case and edge cases', () => {
|
||||
expect(isImageFormat('webP')).toBe(true);
|
||||
expect(isImageFormat('WEBP')).toBe(true);
|
||||
expect(isImageFormat('tIFf')).toBe(true);
|
||||
expect(isImageFormat('bMp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
|
||||
test('should handle null and undefined inputs gracefully', () => {
|
||||
// Note: TypeScript prevents these, but test runtime behavior for robustness
|
||||
// The current implementation handles these gracefully by returning falsy values
|
||||
expect(getEndpointName(null as any, null as any)).toBe('');
|
||||
expect(getEndpointUrl(undefined as any, undefined as any)).toBe('');
|
||||
expect(isConversionSupported(null as any, null as any)).toBe(false);
|
||||
|
||||
// isImageFormat will throw because it calls toLowerCase() on null/undefined
|
||||
expect(() => isImageFormat(null as any)).toThrow();
|
||||
expect(() => isImageFormat(undefined as any)).toThrow();
|
||||
});
|
||||
|
||||
test('should handle special characters in file extensions', () => {
|
||||
expect(isImageFormat('png@')).toBe(false);
|
||||
expect(isImageFormat('jpg#')).toBe(false);
|
||||
expect(isImageFormat('png.')).toBe(false);
|
||||
expect(getEndpointName('pdf@', 'png')).toBe('');
|
||||
expect(getEndpointName('pdf', 'png#')).toBe('');
|
||||
});
|
||||
|
||||
test('should handle very long extension names', () => {
|
||||
const longExtension = 'a'.repeat(100);
|
||||
expect(isImageFormat(longExtension)).toBe(false);
|
||||
expect(getEndpointName('pdf', longExtension)).toBe('');
|
||||
expect(getEndpointName(longExtension, 'pdf')).toBe('file-to-pdf'); // Fallback to file to pdf
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
CONVERSION_ENDPOINTS,
|
||||
ENDPOINT_NAMES,
|
||||
EXTENSION_TO_ENDPOINT
|
||||
} from '../constants/convertConstants';
|
||||
|
||||
/**
|
||||
* Resolves the endpoint name for a given conversion
|
||||
*/
|
||||
export const getEndpointName = (fromExtension: string, toExtension: string): string => {
|
||||
if (!fromExtension || !toExtension) return '';
|
||||
|
||||
let endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
|
||||
|
||||
// If no explicit mapping exists and we're converting to PDF,
|
||||
// fall back to 'any' which uses file-to-pdf endpoint
|
||||
if (!endpointKey && toExtension === 'pdf' && fromExtension !== 'any') {
|
||||
endpointKey = EXTENSION_TO_ENDPOINT['any']?.[toExtension];
|
||||
}
|
||||
|
||||
return endpointKey || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the full endpoint URL for a given conversion
|
||||
*/
|
||||
export const getEndpointUrl = (fromExtension: string, toExtension: string): string => {
|
||||
const endpointName = getEndpointName(fromExtension, toExtension);
|
||||
if (!endpointName) return '';
|
||||
|
||||
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
|
||||
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
|
||||
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a conversion is supported
|
||||
*/
|
||||
export const isConversionSupported = (fromExtension: string, toExtension: string): boolean => {
|
||||
return getEndpointName(fromExtension, toExtension) !== '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given extension is an image format
|
||||
*/
|
||||
export const isImageFormat = (extension: string): boolean => {
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp', 'svg'].includes(extension.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given extension is a web format
|
||||
*/
|
||||
export const isWebFormat = (extension: string): boolean => {
|
||||
return ['html', 'zip'].includes(extension.toLowerCase());
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Unit tests for file response utility functions
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { getFilenameFromHeaders, createFileFromApiResponse } from './fileResponseUtils';
|
||||
|
||||
describe('fileResponseUtils', () => {
|
||||
|
||||
describe('getFilenameFromHeaders', () => {
|
||||
|
||||
test('should extract filename from content-disposition header', () => {
|
||||
const contentDisposition = 'attachment; filename="document.pdf"';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe('document.pdf');
|
||||
});
|
||||
|
||||
test('should extract filename without quotes', () => {
|
||||
const contentDisposition = 'attachment; filename=document.pdf';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe('document.pdf');
|
||||
});
|
||||
|
||||
test('should handle single quotes', () => {
|
||||
const contentDisposition = "attachment; filename='document.pdf'";
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe('document.pdf');
|
||||
});
|
||||
|
||||
test('should return null for malformed header', () => {
|
||||
const contentDisposition = 'attachment; invalid=format';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe(null);
|
||||
});
|
||||
|
||||
test('should return null for empty header', () => {
|
||||
const filename = getFilenameFromHeaders('');
|
||||
|
||||
expect(filename).toBe(null);
|
||||
});
|
||||
|
||||
test('should return null for undefined header', () => {
|
||||
const filename = getFilenameFromHeaders();
|
||||
|
||||
expect(filename).toBe(null);
|
||||
});
|
||||
|
||||
test('should handle complex filenames with spaces and special chars', () => {
|
||||
const contentDisposition = 'attachment; filename="My Document (1).pdf"';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe('My Document (1).pdf');
|
||||
});
|
||||
|
||||
test('should handle filename with extension when downloadHtml is enabled', () => {
|
||||
const contentDisposition = 'attachment; filename="email_content.html"';
|
||||
const filename = getFilenameFromHeaders(contentDisposition);
|
||||
|
||||
expect(filename).toBe('email_content.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFileFromApiResponse', () => {
|
||||
|
||||
test('should create file using header filename when available', () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = {
|
||||
'content-type': 'application/pdf',
|
||||
'content-disposition': 'attachment; filename="server_filename.pdf"'
|
||||
};
|
||||
const fallbackFilename = 'fallback.pdf';
|
||||
|
||||
const file = createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
|
||||
expect(file.name).toBe('server_filename.pdf');
|
||||
expect(file.type).toBe('application/pdf');
|
||||
expect(file.size).toBe(4);
|
||||
});
|
||||
|
||||
test('should use fallback filename when no header filename', () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = {
|
||||
'content-type': 'application/pdf'
|
||||
};
|
||||
const fallbackFilename = 'converted_file.pdf';
|
||||
|
||||
const file = createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
|
||||
expect(file.name).toBe('converted_file.pdf');
|
||||
expect(file.type).toBe('application/pdf');
|
||||
});
|
||||
|
||||
test('should handle HTML response when downloadHtml is enabled', () => {
|
||||
const responseData = '<html><body>Test</body></html>';
|
||||
const headers = {
|
||||
'content-type': 'text/html',
|
||||
'content-disposition': 'attachment; filename="email_content.html"'
|
||||
};
|
||||
const fallbackFilename = 'fallback.pdf';
|
||||
|
||||
const file = createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
|
||||
expect(file.name).toBe('email_content.html');
|
||||
expect(file.type).toBe('text/html');
|
||||
});
|
||||
|
||||
test('should handle ZIP response', () => {
|
||||
const responseData = new Uint8Array([80, 75, 3, 4]); // ZIP file signature
|
||||
const headers = {
|
||||
'content-type': 'application/zip',
|
||||
'content-disposition': 'attachment; filename="converted_files.zip"'
|
||||
};
|
||||
const fallbackFilename = 'fallback.pdf';
|
||||
|
||||
const file = createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
|
||||
expect(file.name).toBe('converted_files.zip');
|
||||
expect(file.type).toBe('application/zip');
|
||||
});
|
||||
|
||||
test('should use default content-type when none provided', () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = {};
|
||||
const fallbackFilename = 'test.bin';
|
||||
|
||||
const file = createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
|
||||
expect(file.name).toBe('test.bin');
|
||||
expect(file.type).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
test('should handle null/undefined headers gracefully', () => {
|
||||
const responseData = new Uint8Array([1, 2, 3, 4]);
|
||||
const headers = null;
|
||||
const fallbackFilename = 'test.bin';
|
||||
|
||||
const file = createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
|
||||
expect(file.name).toBe('test.bin');
|
||||
expect(file.type).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Generic utility functions for handling file responses from API endpoints
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts filename from Content-Disposition header
|
||||
* @param contentDisposition - Content-Disposition header value
|
||||
* @returns Filename if found, null otherwise
|
||||
*/
|
||||
export const getFilenameFromHeaders = (contentDisposition: string = ''): string | null => {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
return match[1].replace(/['"]/g, '');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a File object from API response using the filename from headers
|
||||
* @param responseData - The response data (blob/arraybuffer/string)
|
||||
* @param headers - Response headers object
|
||||
* @param fallbackFilename - Filename to use if none provided in headers
|
||||
* @returns File object
|
||||
*/
|
||||
export const createFileFromApiResponse = (
|
||||
responseData: any,
|
||||
headers: any,
|
||||
fallbackFilename: string
|
||||
): File => {
|
||||
const contentType = headers?.['content-type'] || 'application/octet-stream';
|
||||
const contentDisposition = headers?.['content-disposition'] || '';
|
||||
|
||||
const filename = getFilenameFromHeaders(contentDisposition) || fallbackFilename;
|
||||
const blob = new Blob([responseData], { type: contentType });
|
||||
|
||||
return new File([blob], filename, { type: contentType });
|
||||
};
|
||||
@@ -125,4 +125,51 @@ export function cleanupFileUrls(files: FileWithUrl[]): void {
|
||||
export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean {
|
||||
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
return file.size > FILE_SIZE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and normalizes file extension from filename
|
||||
* @param filename - The filename to extract extension from
|
||||
* @returns Normalized file extension in lowercase, empty string if no extension
|
||||
*/
|
||||
export function detectFileExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
const parts = filename.split('.');
|
||||
// If there's no extension (no dots or only one part), return empty string
|
||||
if (parts.length <= 1) return '';
|
||||
|
||||
// Get the last part (extension) in lowercase
|
||||
let extension = parts[parts.length - 1].toLowerCase();
|
||||
|
||||
// Normalize common extension variants
|
||||
if (extension === 'jpeg') extension = 'jpg';
|
||||
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the filename without extension
|
||||
* @param filename - The filename to process
|
||||
* @returns Filename without extension
|
||||
*/
|
||||
export function getFilenameWithoutExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
const parts = filename.split('.');
|
||||
if (parts.length <= 1) return filename;
|
||||
|
||||
// Return all parts except the last one (extension)
|
||||
return parts.slice(0, -1).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new filename with a different extension
|
||||
* @param filename - Original filename
|
||||
* @param newExtension - New extension (without dot)
|
||||
* @returns New filename with the specified extension
|
||||
*/
|
||||
export function changeFileExtension(filename: string, newExtension: string): string {
|
||||
const nameWithoutExt = getFilenameWithoutExtension(filename);
|
||||
return `${nameWithoutExt}.${newExtension}`;
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
console.log('Skipping thumbnail generation for large file:', file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
|
||||
Reference in New Issue
Block a user