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 <connor@stirlingpdf.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
ConnorYoh 2025-08-01 16:08:04 +01:00 committed by GitHub
parent 8881f19b03
commit 9c9acbfb5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 9173 additions and 87 deletions

View File

@ -6,7 +6,10 @@
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(find:*)"
"Bash(find:*)",
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)"
],
"deny": []
}

View File

@ -130,7 +130,7 @@ jobs:
- name: Build frontend
run: cd frontend && npm run build
- name: Run frontend tests
run: cd frontend && npm test --passWithNoTests --watchAll=false || true
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@v4.6.2
with:

3
frontend/.gitignore vendored
View File

@ -22,3 +22,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
playwright-report
test-results

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,13 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"generate-licenses": "node scripts/generate-licenses.js"
"generate-licenses": "node scripts/generate-licenses.js",
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install"
},
"eslintConfig": {
"extends": [
@ -58,15 +64,19 @@
]
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1",
"postcss": "^8.5.3",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^1.0.0"
}
}

View File

@ -0,0 +1,75 @@
import { defineConfig, devices } from '@playwright/test';
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/tests',
testMatch: '**/*.spec.ts',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 }
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});

View File

@ -347,6 +347,10 @@
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"convert": {
"title": "Convert",
"desc": "Convert files between different formats",
},
"imageToPdf": {
"title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
@ -647,6 +651,73 @@
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
"submit": "Rotate"
},
"convert": {
"title": "Convert",
"desc": "Convert files between different formats",
"files": "Files",
"selectFilesPlaceholder": "Select files in the main view to get started",
"settings": "Settings",
"conversionCompleted": "Conversion completed",
"results": "Results",
"defaultFilename": "converted_file",
"conversionResults": "Conversion Results",
"convertFrom": "Convert from",
"convertTo": "Convert to",
"sourceFormatPlaceholder": "Source format",
"targetFormatPlaceholder": "Target format",
"selectSourceFormatFirst": "Select a source format first",
"outputOptions": "Output Options",
"pdfOptions": "PDF Options",
"imageOptions": "Image Options",
"colorType": "Colour Type",
"color": "Colour",
"greyscale": "Greyscale",
"blackwhite": "Black & White",
"dpi": "DPI",
"output": "Output",
"single": "Single",
"multiple": "Multiple",
"fitOption": "Fit Option",
"maintainAspectRatio": "Maintain Aspect Ratio",
"fitDocumentToPage": "Fit Document to Page",
"fillPage": "Fill Page",
"autoRotate": "Auto Rotate",
"autoRotateDescription": "Automatically rotate images to better fit the PDF page",
"combineImages": "Combine Images",
"combineImagesDescription": "Combine all images into one PDF, or create separate PDFs for each image",
"webOptions": "Web to PDF Options",
"zoomLevel": "Zoom Level",
"emailOptions": "Email to PDF Options",
"includeAttachments": "Include email attachments",
"maxAttachmentSize": "Maximum attachment size (MB)",
"includeAllRecipients": "Include CC and BCC recipients in header",
"downloadHtml": "Download HTML intermediate file instead of PDF",
"pdfaOptions": "PDF/A Options",
"outputFormat": "Output Format",
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.",
"pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step.",
"fileFormat": "File Format",
"wordDoc": "Word Document",
"wordDocExt": "Word Document (.docx)",
"odtExt": "OpenDocument Text (.odt)",
"pptExt": "PowerPoint (.pptx)",
"odpExt": "OpenDocument Presentation (.odp)",
"txtExt": "Plain Text (.txt)",
"rtfExt": "Rich Text Format (.rtf)",
"selectedFiles": "Selected files",
"noFileSelected": "No file selected. Use the file panel to add files.",
"convertFiles": "Convert Files",
"converting": "Converting...",
"downloadConverted": "Download Converted File",
"errorNoFiles": "Please select at least one file to convert.",
"errorNoFormat": "Please select both source and target formats.",
"errorNotSupported": "Conversion from {{from}} to {{to}} is not supported.",
"images": "Images",
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF"
},
"imageToPdf": {
"tags": "conversion,img,jpg,picture,photo"
},
@ -1582,18 +1653,6 @@
"pageEditor": "Page Editor",
"fileManager": "File Manager"
},
"fileManager": {
"dragDrop": "Drag & Drop files here",
"clickToUpload": "Click to upload files",
"selectedFiles": "Selected Files",
"clearAll": "Clear All",
"storage": "Storage",
"filesStored": "files stored",
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"uploadError": "Failed to upload some files.",
"supportMessage": "Powered by browser database storage for unlimited capacity"
},
"pageEditor": {
"title": "Page Editor",
"save": "Save Changes",
@ -1665,7 +1724,16 @@
"failedToLoad": "Failed to load file to active set.",
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
"clearAll": "Clear All",
"reloadFiles": "Reload Files"
"reloadFiles": "Reload Files",
"dragDrop": "Drag & Drop files here",
"clickToUpload": "Click to upload files",
"selectedFiles": "Selected Files",
"storage": "Storage",
"filesStored": "files stored",
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"supportMessage": "Powered by browser database storage for unlimited capacity",
"noFileSelected": "No files selected"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",

View File

@ -1567,5 +1567,51 @@
"description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with."
}
}
},
"convert": {
"files": "Files",
"selectFilesPlaceholder": "Select files in the main view to get started",
"settings": "Settings",
"conversionCompleted": "Conversion completed",
"results": "Results",
"defaultFilename": "converted_file",
"conversionResults": "Conversion Results",
"converting": "Converting...",
"convertFiles": "Convert Files",
"downloadConverted": "Download Converted File",
"convertFrom": "Convert from",
"convertTo": "Convert to",
"sourceFormatPlaceholder": "Source format",
"targetFormatPlaceholder": "Target format",
"selectSourceFormatFirst": "Select a source format first",
"imageOptions": "Image Options",
"colorType": "Color Type",
"color": "Color",
"greyscale": "Greyscale",
"blackwhite": "Black & White",
"dpi": "DPI",
"output": "Output",
"single": "Single",
"multiple": "Multiple",
"pdfOptions": "PDF Options",
"fitOption": "Fit Option",
"maintainAspectRatio": "Maintain Aspect Ratio",
"fitDocumentToPage": "Fit Document to Page",
"fillPage": "Fill Page",
"autoRotate": "Auto Rotate",
"autoRotateDescription": "Automatically rotate images to better fit the PDF page",
"combineImages": "Combine Images",
"combineImagesDescription": "Combine all images into one PDF, or create separate PDFs for each image",
"webOptions": "Web to PDF Options",
"zoomLevel": "Zoom Level",
"emailOptions": "Email to PDF Options",
"includeAttachments": "Include email attachments",
"maxAttachmentSize": "Maximum attachment size (MB)",
"includeAllRecipients": "Include CC and BCC recipients in header",
"downloadHtml": "Download HTML intermediate file instead of PDF",
"pdfaOptions": "PDF/A Options",
"outputFormat": "Output Format",
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.",
"pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step."
}
}

View File

@ -0,0 +1,29 @@
{
"convert": {
"selectSourceFormat": "Select source file format",
"selectTargetFormat": "Select target file format",
"selectFirst": "Select a source format first",
"imageOptions": "Image Options:",
"emailOptions": "Email Options:",
"colorType": "Color Type",
"dpi": "DPI",
"singleOrMultiple": "Output",
"emailNote": "Email attachments and embedded images will be included"
},
"common": {
"color": "Color",
"grayscale": "Grayscale",
"blackWhite": "Black & White",
"single": "Single Image",
"multiple": "Multiple Images"
},
"groups": {
"document": "Document",
"spreadsheet": "Spreadsheet",
"presentation": "Presentation",
"image": "Image",
"web": "Web",
"text": "Text",
"email": "Email"
}
}

View File

@ -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();
});

View File

@ -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);
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
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) => (

View File

@ -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

View File

@ -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

View File

@ -20,6 +20,7 @@ interface FileGridProps {
onShowAll?: () => void;
showingAll?: boolean;
onDeleteAll?: () => void;
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
}
type SortOption = 'date' | 'name' | 'size';
@ -37,7 +38,8 @@ const FileGrid = ({
maxDisplay,
onShowAll,
showingAll = false,
onDeleteAll
onDeleteAll,
isFileSupported
}: FileGridProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
@ -123,16 +125,18 @@ const FileGrid = ({
{displayFiles.map((file, idx) => {
const fileId = file.id || file.name;
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
const supported = isFileSupported ? isFileSupported(file.name) : true;
return (
<FileCard
key={fileId + idx}
file={file}
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined}
onView={onView ? () => onView(file) : undefined}
onEdit={onEdit ? () => onEdit(file) : undefined}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
onView={onView && supported ? () => onView(file) : undefined}
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect ? () => onSelect(fileId) : undefined}
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}
/>
);
})}

View File

@ -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);

View File

@ -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"

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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..."))

View File

@ -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',

View File

@ -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

View File

@ -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];

View File

@ -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,
};
};

View File

@ -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([]);
});
});
});

View File

@ -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,
};
};

View File

@ -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');
});
});
});

View File

@ -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
};
};

View File

@ -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;

View File

@ -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;

View File

@ -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}

123
frontend/src/setupTests.ts Normal file
View File

@ -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 })

View File

@ -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);
});
});
});

View File

@ -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
*/

View File

@ -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);
});
});
});
});

View File

@ -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.

View File

@ -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
};
}

View File

@ -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: test@example.com
To: recipient@example.com
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

View File

@ -0,0 +1 @@
This is not a valid PDF file

View 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
1 Name Age City Country
2 John Doe 30 New York USA
3 Jane Smith 25 London UK
4 Bob Johnson 35 Toronto Canada
5 Alice Brown 28 Sydney Australia
6 Charlie Wilson 42 Berlin Germany

View File

@ -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.

View File

@ -0,0 +1,105 @@
Return-Path: <test@example.com>
Delivered-To: recipient@example.com
Received: from mail.example.com (mail.example.com [192.168.1.1])
by mx.example.com (Postfix) with ESMTP id 1234567890
for <recipient@example.com>; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
Message-ID: <test123@example.com>
Date: Mon, 1 Jan 2024 12:00:00 +0000
From: Test Sender <test@example.com>
User-Agent: Mozilla/5.0 (compatible; Test Email Client)
MIME-Version: 1.0
To: Test Recipient <recipient@example.com>
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: test@example.com
- To: recipient@example.com
- 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> test@example.com</li>
<li><strong>To:</strong> recipient@example.com</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--

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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>

View File

@ -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;

View File

@ -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
});
});
});

View File

@ -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());
};

View File

@ -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');
});
});
});

View File

@ -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 });
};

View File

@ -126,3 +126,50 @@ 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}`;
}

View File

@ -25,6 +25,11 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
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);

View File

@ -11,7 +11,7 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"jsx": "react-jsx", /* Specify what JSX code is generated. */
// "libReplacement": true, /* Enable lib replacement. */

40
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,40 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
css: false, // Disable CSS processing to speed up tests
include: [
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
exclude: [
'node_modules/',
'src/**/*.spec.ts', // Exclude Playwright E2E tests
'src/tests/test-fixtures/**'
],
testTimeout: 10000, // 10 second timeout
hookTimeout: 10000, // 10 second timeout for setup/teardown
coverage: {
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/setupTests.ts',
'**/*.d.ts',
'src/tests/test-fixtures/**',
'src/**/*.spec.ts' // Exclude Playwright files from coverage
]
}
},
esbuild: {
target: 'es2020' // Use older target to avoid warnings
},
resolve: {
alias: {
'@': '/src'
}
}
})

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
testTimeout: 5000,
include: ['src/utils/convertUtils.test.ts']
},
})