mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 02:25:21 +00:00
V2: Convert Tool (#3828)
🔄 Dynamic Processing Strategies - Adaptive routing: Same tool uses different backend endpoints based on file analysis - Combined vs separate processing: Intelligently chooses between merge operations and individual file processing - Cross-format workflows: Enable complex conversions like "mixed files → PDF" that other tools can't handle ⚙️ Format-Specific Intelligence Each conversion type gets tailored options: - HTML/ZIP → PDF: Zoom controls (0.1-3.0 increments) with live preview - Email → PDF: Attachment handling, size limits, recipient control - PDF → PDF/A: Digital signature detection with warnings - Images → PDF: Smart combining vs individual file options File Architecture Core Implementation: ├── Convert.tsx # Main stepped workflow UI ├── ConvertSettings.tsx # Centralized settings with smart detection ├── GroupedFormatDropdown.tsx # Enhanced format selector with grouping ├── useConvertParameters.ts # Smart detection & parameter management ├── useConvertOperation.ts # Multi-strategy processing logic └── Settings Components: ├── ConvertFromWebSettings.tsx # HTML zoom controls ├── ConvertFromEmailSettings.tsx # Email attachment options ├── ConvertToPdfaSettings.tsx # PDF/A with signature detection ├── ConvertFromImageSettings.tsx # Image PDF options └── ConvertToImageSettings.tsx # PDF to image options Utility Layer Utils & Services: ├── convertUtils.ts # Format detection & endpoint routing ├── fileResponseUtils.ts # Generic API response handling └── setupTests.ts # Enhanced test environment with crypto mocks Testing & Quality Comprehensive Test Coverage Test Suite: ├── useConvertParameters.test.ts # Parameter logic & smart detection ├── useConvertParametersAutoDetection.test.ts # File type analysis ├── ConvertIntegration.test.tsx # End-to-end conversion workflows ├── ConvertSmartDetectionIntegration.test.tsx # Mixed file scenarios ├── ConvertE2E.spec.ts # Playwright browser tests ├── convertUtils.test.ts # Utility function validation └── fileResponseUtils.test.ts # API response handling Advanced Test Features - Crypto API mocking: Proper test environment for file hashing - File.arrayBuffer() polyfills: Complete browser API simulation - Multi-file scenario testing: Complex batch processing validation - CI/CD integration: Vitest runs in GitHub Actions with proper artifacts --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
parent
8881f19b03
commit
9c9acbfb5b
@ -6,7 +6,10 @@
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"Bash(npm test)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -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
3
frontend/.gitignore
vendored
@ -22,3 +22,6 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
playwright-report
|
||||
test-results
|
2616
frontend/package-lock.json
generated
2616
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
75
frontend/playwright.config.ts
Normal file
75
frontend/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
@ -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",
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
29
frontend/public/locales/en/translation.json
Normal file
29
frontend/public/locales/en/translation.json
Normal 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"
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
@ -12,6 +12,7 @@ import { FileOperation } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import styles from '../pageEditor/PageEditor.module.css';
|
||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||
import DragDropGrid from '../pageEditor/DragDropGrid';
|
||||
@ -34,6 +35,7 @@ interface FileEditorProps {
|
||||
toolMode?: boolean;
|
||||
showUpload?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
supportedExtensions?: string[];
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
@ -41,10 +43,17 @@ const FileEditor = ({
|
||||
onMergeFiles,
|
||||
toolMode = false,
|
||||
showUpload = true,
|
||||
showBulkActions = true
|
||||
showBulkActions = true,
|
||||
supportedExtensions = ["pdf"]
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||
const extension = detectFileExtension(fileName);
|
||||
return extension ? supportedExtensions.includes(extension) : false;
|
||||
}, [supportedExtensions]);
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const {
|
||||
@ -224,49 +233,46 @@ const FileEditor = ({
|
||||
// Handle PDF files normally
|
||||
allExtractedFiles.push(file);
|
||||
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Handle ZIP files
|
||||
// Handle ZIP files - only expand if they contain PDFs
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await zipFileService.validateZipFile(file);
|
||||
if (!validation.isValid) {
|
||||
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract PDF files from ZIP
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
|
||||
|
||||
if (validation.isValid && validation.containsPDFs) {
|
||||
// ZIP contains PDFs - extract them
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: progress.currentFile,
|
||||
progress: progress.progress,
|
||||
extractedCount: progress.extractedCount,
|
||||
totalFiles: progress.totalFiles
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
});
|
||||
|
||||
// Reset extraction progress
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: progress.currentFile,
|
||||
progress: progress.progress,
|
||||
extractedCount: progress.extractedCount,
|
||||
totalFiles: progress.totalFiles
|
||||
});
|
||||
});
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
// Reset extraction progress
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
@ -290,8 +296,13 @@ const FileEditor = ({
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
// ZIP doesn't contain PDFs or is invalid - treat as regular file
|
||||
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
|
||||
allExtractedFiles.push(file);
|
||||
}
|
||||
} catch (zipError) {
|
||||
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
|
||||
@ -304,7 +315,8 @@ const FileEditor = ({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push(`Unsupported file type: ${file.name} (${file.type})`);
|
||||
console.log(`Adding none PDF file: ${file.name} (${file.type})`);
|
||||
allExtractedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
@ -681,7 +693,7 @@ const FileEditor = ({
|
||||
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
accept={["*/*"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{ display: 'contents' }}
|
||||
@ -804,6 +816,7 @@ const FileEditor = ({
|
||||
onSplitFile={handleSplitFile}
|
||||
onSetStatus={setStatus}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(file, index) => (
|
||||
|
@ -18,9 +18,10 @@ interface FileCardProps {
|
||||
onEdit?: () => void;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||
}
|
||||
|
||||
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => {
|
||||
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@ -35,15 +36,18 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
||||
width: 225,
|
||||
minWidth: 180,
|
||||
maxWidth: 260,
|
||||
cursor: onDoubleClick ? "pointer" : undefined,
|
||||
cursor: onDoubleClick && isSupported ? "pointer" : undefined,
|
||||
position: 'relative',
|
||||
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined,
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onSelect}
|
||||
data-testid="file-card"
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
@ -179,6 +183,11 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
||||
DB
|
||||
</Badge>
|
||||
)}
|
||||
{!isSupported && (
|
||||
<Badge color="orange" variant="filled" size="sm">
|
||||
{t("fileManager.unsupported", "Unsupported")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
@ -37,6 +38,7 @@ interface FileThumbnailProps {
|
||||
onViewFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
|
||||
const FileThumbnail = ({
|
||||
@ -60,7 +62,9 @@ const FileThumbnail = ({
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
toolMode = false,
|
||||
isSupported = true,
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
@ -81,6 +85,7 @@ const FileThumbnail = ({
|
||||
}
|
||||
}}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
@ -106,7 +111,9 @@ const FileThumbnail = ({
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(file.id)}
|
||||
@ -119,6 +126,7 @@ const FileThumbnail = ({
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
data-testid="file-thumbnail-checkbox"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
@ -140,9 +148,12 @@ const FileThumbnail = ({
|
||||
checked={selectedFiles.includes(file.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleFile(file.id);
|
||||
if (isSupported) {
|
||||
onToggleFile(file.id);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!isSupported}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@ -193,6 +204,23 @@ const FileThumbnail = ({
|
||||
{file.pageCount} pages
|
||||
</Badge>
|
||||
|
||||
{/* Unsupported badge */}
|
||||
{!isSupported && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: selectionMode ? 48 : 8, // Avoid overlap with checkbox
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{t("fileManager.unsupported", "Unsupported")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* File name overlay */}
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
@ -238,7 +266,7 @@ const FileThumbnail = ({
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{!toolMode && (
|
||||
{!toolMode && isSupported && (
|
||||
<>
|
||||
<Tooltip label="View File">
|
||||
<ActionIcon
|
||||
|
@ -20,6 +20,7 @@ interface FileGridProps {
|
||||
onShowAll?: () => void;
|
||||
showingAll?: boolean;
|
||||
onDeleteAll?: () => void;
|
||||
isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported
|
||||
}
|
||||
|
||||
type SortOption = 'date' | 'name' | 'size';
|
||||
@ -37,7 +38,8 @@ const FileGrid = ({
|
||||
maxDisplay,
|
||||
onShowAll,
|
||||
showingAll = false,
|
||||
onDeleteAll
|
||||
onDeleteAll,
|
||||
isFileSupported
|
||||
}: FileGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@ -123,16 +125,18 @@ const FileGrid = ({
|
||||
{displayFiles.map((file, idx) => {
|
||||
const fileId = file.id || file.name;
|
||||
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={file}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
|
||||
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined}
|
||||
onView={onView ? () => onView(file) : undefined}
|
||||
onEdit={onEdit ? () => onEdit(file) : undefined}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
|
||||
onView={onView && supported ? () => onView(file) : undefined}
|
||||
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
|
||||
isSelected={selectedFiles.includes(fileId)}
|
||||
onSelect={onSelect ? () => onSelect(fileId) : undefined}
|
||||
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
|
||||
isSupported={supported}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -5,6 +5,7 @@ import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { FileWithUrl } from '../../types/file';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import FileGrid from './FileGrid';
|
||||
import MultiSelectControls from './MultiSelectControls';
|
||||
import { useFileManager } from '../../hooks/useFileManager';
|
||||
@ -20,6 +21,7 @@ interface FileUploadSelectorProps {
|
||||
onFileSelect?: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
accept?: string[];
|
||||
supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png'])
|
||||
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
@ -38,6 +40,7 @@ const FileUploadSelector = ({
|
||||
onFileSelect,
|
||||
onFilesSelect,
|
||||
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
|
||||
supportedExtensions = ["pdf"], // Default to PDF only for most tools
|
||||
loading = false,
|
||||
disabled = false,
|
||||
showRecentFiles = true,
|
||||
@ -51,6 +54,12 @@ const FileUploadSelector = ({
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
|
||||
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||
const extension = detectFileExtension(fileName);
|
||||
return extension ? supportedExtensions.includes(extension) : false;
|
||||
}, [supportedExtensions]);
|
||||
|
||||
const refreshRecentFiles = useCallback(async () => {
|
||||
const files = await loadRecentFiles();
|
||||
setRecentFiles(files);
|
||||
@ -155,6 +164,7 @@ const FileUploadSelector = ({
|
||||
disabled={disabled || loading}
|
||||
style={{ width: '100%', height: "5rem" }}
|
||||
activateOnClick={true}
|
||||
data-testid="file-dropzone"
|
||||
>
|
||||
<Center>
|
||||
<Stack align="center" gap="sm">
|
||||
@ -192,6 +202,7 @@ const FileUploadSelector = ({
|
||||
accept={accept.join(',')}
|
||||
onChange={handleFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
@ -225,6 +236,7 @@ const FileUploadSelector = ({
|
||||
selectedFiles={selectedFiles}
|
||||
showSearch={true}
|
||||
showSort={true}
|
||||
isFileSupported={isFileSupported}
|
||||
onDeleteAll={async () => {
|
||||
await Promise.all(recentFiles.map(async (file) => {
|
||||
await fileStorage.deleteFile(file.id || file.name);
|
||||
|
@ -35,6 +35,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps
|
||||
filteredTools.map(([id, { icon, name }]) => (
|
||||
<Button
|
||||
key={id}
|
||||
data-testid={`tool-${id}`}
|
||||
variant={selectedToolKey === id ? "filled" : "subtle"}
|
||||
onClick={() => onSelect(id)}
|
||||
size="md"
|
||||
|
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, NumberInput, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
|
||||
interface ConvertFromEmailSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertFromEmailSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ConvertFromEmailSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="email-settings">
|
||||
<Text size="sm" fw={500}>{t("convert.emailOptions", "Email to PDF Options")}:</Text>
|
||||
|
||||
<Checkbox
|
||||
label={t("convert.includeAttachments", "Include email attachments")}
|
||||
checked={parameters.emailOptions.includeAttachments}
|
||||
onChange={(event) => onParameterChange('emailOptions', {
|
||||
...parameters.emailOptions,
|
||||
includeAttachments: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
data-testid="include-attachments-checkbox"
|
||||
/>
|
||||
|
||||
{parameters.emailOptions.includeAttachments && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>{t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}:</Text>
|
||||
<NumberInput
|
||||
value={parameters.emailOptions.maxAttachmentSizeMB}
|
||||
onChange={(value) => onParameterChange('emailOptions', {
|
||||
...parameters.emailOptions,
|
||||
maxAttachmentSizeMB: Number(value) || 10
|
||||
})}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
data-testid="max-attachment-size-input"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={t("convert.includeAllRecipients", "Include CC and BCC recipients in header")}
|
||||
checked={parameters.emailOptions.includeAllRecipients}
|
||||
onChange={(event) => onParameterChange('emailOptions', {
|
||||
...parameters.emailOptions,
|
||||
includeAllRecipients: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
data-testid="include-all-recipients-checkbox"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t("convert.downloadHtml", "Download HTML intermediate file instead of PDF")}
|
||||
checked={parameters.emailOptions.downloadHtml}
|
||||
onChange={(event) => onParameterChange('emailOptions', {
|
||||
...parameters.emailOptions,
|
||||
downloadHtml: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
data-testid="download-html-checkbox"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertFromEmailSettings;
|
@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, Select, Switch } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { COLOR_TYPES, FIT_OPTIONS } from "../../../constants/convertConstants";
|
||||
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
|
||||
|
||||
interface ConvertFromImageSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertFromImageSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ConvertFromImageSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="pdf-options-section">
|
||||
<Text size="sm" fw={500}>{t("convert.pdfOptions", "PDF Options")}:</Text>
|
||||
<Select
|
||||
data-testid="color-type-select"
|
||||
label={t("convert.colorType", "Color Type")}
|
||||
value={parameters.imageOptions.colorType}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
|
||||
})}
|
||||
data={[
|
||||
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
|
||||
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
|
||||
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Select
|
||||
data-testid="fit-option-select"
|
||||
label={t("convert.fitOption", "Fit Option")}
|
||||
value={parameters.imageOptions.fitOption}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
fitOption: val as typeof FIT_OPTIONS[keyof typeof FIT_OPTIONS]
|
||||
})}
|
||||
data={[
|
||||
{ value: FIT_OPTIONS.MAINTAIN_ASPECT, label: t("convert.maintainAspectRatio", "Maintain Aspect Ratio") },
|
||||
{ value: FIT_OPTIONS.FIT_PAGE, label: t("convert.fitDocumentToPage", "Fit Document to Page") },
|
||||
{ value: FIT_OPTIONS.FILL_PAGE, label: t("convert.fillPage", "Fill Page") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
data-testid="auto-rotate-switch"
|
||||
label={t("convert.autoRotate", "Auto Rotate")}
|
||||
description={t("convert.autoRotateDescription", "Automatically rotate images to better fit the PDF page")}
|
||||
checked={parameters.imageOptions.autoRotate}
|
||||
onChange={(event) => onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
autoRotate: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
data-testid="combine-images-switch"
|
||||
label={t("convert.combineImages", "Combine Images")}
|
||||
description={t("convert.combineImagesDescription", "Combine all images into one PDF, or create separate PDFs for each image")}
|
||||
checked={parameters.imageOptions.combineImages}
|
||||
onChange={(event) => onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
combineImages: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertFromImageSettings;
|
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, NumberInput, Slider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
|
||||
interface ConvertFromWebSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertFromWebSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ConvertFromWebSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="web-settings">
|
||||
<Text size="sm" fw={500}>{t("convert.webOptions", "Web to PDF Options")}:</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>{t("convert.zoomLevel", "Zoom Level")}:</Text>
|
||||
<NumberInput
|
||||
value={parameters.htmlOptions.zoomLevel}
|
||||
onChange={(value) => onParameterChange('htmlOptions', {
|
||||
...parameters.htmlOptions,
|
||||
zoomLevel: Number(value) || 1.0
|
||||
})}
|
||||
min={0.1}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
precision={1}
|
||||
disabled={disabled}
|
||||
data-testid="zoom-level-input"
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.htmlOptions.zoomLevel}
|
||||
onChange={(value) => onParameterChange('htmlOptions', {
|
||||
...parameters.htmlOptions,
|
||||
zoomLevel: value
|
||||
})}
|
||||
min={0.1}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
disabled={disabled}
|
||||
data-testid="zoom-level-slider"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertFromWebSettings;
|
318
frontend/src/components/tools/convert/ConvertSettings.tsx
Normal file
318
frontend/src/components/tools/convert/ConvertSettings.tsx
Normal 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;
|
@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, Select, NumberInput, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { COLOR_TYPES, OUTPUT_OPTIONS } from "../../../constants/convertConstants";
|
||||
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
|
||||
|
||||
interface ConvertToImageSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertToImageSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ConvertToImageSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="image-options-section">
|
||||
<Text size="sm" fw={500} data-testid="image-options-title">{t("convert.imageOptions", "Image Options")}:</Text>
|
||||
<Group grow>
|
||||
<Select
|
||||
data-testid="color-type-select"
|
||||
label={t("convert.colorType", "Color Type")}
|
||||
value={parameters.imageOptions.colorType}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
|
||||
})}
|
||||
data={[
|
||||
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
|
||||
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
|
||||
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
data-testid="dpi-input"
|
||||
label={t("convert.dpi", "DPI")}
|
||||
value={parameters.imageOptions.dpi}
|
||||
onChange={(val) => typeof val === 'number' && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
dpi: val
|
||||
})}
|
||||
min={72}
|
||||
max={600}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
data-testid="output-type-select"
|
||||
label={t("convert.output", "Output")}
|
||||
value={parameters.imageOptions.singleOrMultiple}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
singleOrMultiple: val as typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS]
|
||||
})}
|
||||
data={[
|
||||
{ value: OUTPUT_OPTIONS.SINGLE, label: t("convert.single", "Single") },
|
||||
{ value: OUTPUT_OPTIONS.MULTIPLE, label: t("convert.multiple", "Multiple") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertToImageSettings;
|
@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Select, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||
|
||||
interface ConvertToPdfaSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
selectedFiles: File[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertToPdfaSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
selectedFiles,
|
||||
disabled = false
|
||||
}: ConvertToPdfaSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles);
|
||||
|
||||
const pdfaFormatOptions = [
|
||||
{ value: 'pdfa-1', label: 'PDF/A-1b' },
|
||||
{ value: 'pdfa', label: 'PDF/A-2b' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="pdfa-settings">
|
||||
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
|
||||
|
||||
{hasDigitalSignatures && (
|
||||
<Alert color="yellow" size="sm">
|
||||
<Text size="sm">
|
||||
{t("convert.pdfaDigitalSignatureWarning", "The PDF contains a digital signature. This will be removed in the next step.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>{t("convert.outputFormat", "Output Format")}:</Text>
|
||||
<Select
|
||||
value={parameters.pdfaOptions.outputFormat}
|
||||
onChange={(value) => onParameterChange('pdfaOptions', {
|
||||
...parameters.pdfaOptions,
|
||||
outputFormat: value || 'pdfa-1'
|
||||
})}
|
||||
data={pdfaFormatOptions}
|
||||
disabled={disabled || isChecking}
|
||||
data-testid="pdfa-output-format-select"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("convert.pdfaNote", "PDF/A-1b is more compatible, PDF/A-2b supports more features.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertToPdfaSettings;
|
156
frontend/src/components/tools/convert/GroupedFormatDropdown.tsx
Normal file
156
frontend/src/components/tools/convert/GroupedFormatDropdown.tsx
Normal 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;
|
@ -13,6 +13,7 @@ export interface OperationButtonProps {
|
||||
fullWidth?: boolean;
|
||||
mt?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const OperationButton = ({
|
||||
@ -25,7 +26,8 @@ const OperationButton = ({
|
||||
color = 'blue',
|
||||
fullWidth = true,
|
||||
mt = 'md',
|
||||
type = 'button'
|
||||
type = 'button',
|
||||
'data-testid': dataTestId
|
||||
}: OperationButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -39,6 +41,7 @@ const OperationButton = ({
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
color={color}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{isLoading
|
||||
? (loadingText || t("loading", "Loading..."))
|
||||
|
@ -37,28 +37,29 @@ const ResultsPreview = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
|
||||
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }} data-testid="results-preview-container">
|
||||
{title && (
|
||||
<Text fw={500} size="md" mb="sm">
|
||||
<Text fw={500} size="md" mb="sm" data-testid="results-preview-title">
|
||||
{title} ({files.length} files)
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isGeneratingThumbnails ? (
|
||||
<Center p="lg">
|
||||
<Center p="lg" data-testid="results-preview-loading">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">{loadingMessage}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid>
|
||||
<Grid data-testid="results-preview-grid">
|
||||
{files.map((result, index) => (
|
||||
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
|
||||
<Paper
|
||||
p="xs"
|
||||
withBorder
|
||||
onClick={() => onFileClick?.(result.file)}
|
||||
data-testid={`results-preview-thumbnail-${index}`}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
height: '10rem',
|
||||
|
@ -43,7 +43,7 @@ const ToolStep = ({
|
||||
return parent ? parent.visibleStepCount >= 3 : false;
|
||||
}, [showNumber, parent]);
|
||||
|
||||
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
|
||||
const stepNumber = parent?.getStepNumber?.() || 1;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
|
149
frontend/src/constants/convertConstants.ts
Normal file
149
frontend/src/constants/convertConstants.ts
Normal 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];
|
425
frontend/src/hooks/tools/convert/useConvertOperation.ts
Normal file
425
frontend/src/hooks/tools/convert/useConvertOperation.ts
Normal 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,
|
||||
};
|
||||
};
|
223
frontend/src/hooks/tools/convert/useConvertParameters.test.ts
Normal file
223
frontend/src/hooks/tools/convert/useConvertParameters.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
327
frontend/src/hooks/tools/convert/useConvertParameters.ts
Normal file
327
frontend/src/hooks/tools/convert/useConvertParameters.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
66
frontend/src/hooks/usePdfSignatureDetection.ts
Normal file
66
frontend/src/hooks/usePdfSignatureDetection.ts
Normal 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
|
||||
};
|
||||
};
|
@ -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;
|
||||
|
48
frontend/src/i18n/config.ts
Normal file
48
frontend/src/i18n/config.ts
Normal 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;
|
@ -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
123
frontend/src/setupTests.ts
Normal 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 })
|
429
frontend/src/tests/convert/ConvertE2E.spec.ts
Normal file
429
frontend/src/tests/convert/ConvertE2E.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
581
frontend/src/tests/convert/ConvertIntegration.test.tsx
Normal file
581
frontend/src/tests/convert/ConvertIntegration.test.tsx
Normal 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
|
||||
*/
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
264
frontend/src/tests/convert/README.md
Normal file
264
frontend/src/tests/convert/README.md
Normal 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.
|
304
frontend/src/tests/helpers/conversionEndpointDiscovery.ts
Normal file
304
frontend/src/tests/helpers/conversionEndpointDiscovery.ts
Normal 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
|
||||
};
|
||||
}
|
132
frontend/src/tests/test-fixtures/README.md
Normal file
132
frontend/src/tests/test-fixtures/README.md
Normal 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
|
1
frontend/src/tests/test-fixtures/corrupted.pdf
Normal file
1
frontend/src/tests/test-fixtures/corrupted.pdf
Normal file
@ -0,0 +1 @@
|
||||
This is not a valid PDF file
|
6
frontend/src/tests/test-fixtures/sample.csv
Normal file
6
frontend/src/tests/test-fixtures/sample.csv
Normal 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
|
|
10
frontend/src/tests/test-fixtures/sample.doc
Normal file
10
frontend/src/tests/test-fixtures/sample.doc
Normal 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.
|
BIN
frontend/src/tests/test-fixtures/sample.docx
Normal file
BIN
frontend/src/tests/test-fixtures/sample.docx
Normal file
Binary file not shown.
105
frontend/src/tests/test-fixtures/sample.eml
Normal file
105
frontend/src/tests/test-fixtures/sample.eml
Normal 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--
|
125
frontend/src/tests/test-fixtures/sample.htm
Normal file
125
frontend/src/tests/test-fixtures/sample.htm
Normal 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>
|
125
frontend/src/tests/test-fixtures/sample.html
Normal file
125
frontend/src/tests/test-fixtures/sample.html
Normal 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>
|
BIN
frontend/src/tests/test-fixtures/sample.jpg
Normal file
BIN
frontend/src/tests/test-fixtures/sample.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
49
frontend/src/tests/test-fixtures/sample.md
Normal file
49
frontend/src/tests/test-fixtures/sample.md
Normal 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.
|
BIN
frontend/src/tests/test-fixtures/sample.pdf
Normal file
BIN
frontend/src/tests/test-fixtures/sample.pdf
Normal file
Binary file not shown.
BIN
frontend/src/tests/test-fixtures/sample.png
Normal file
BIN
frontend/src/tests/test-fixtures/sample.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
12
frontend/src/tests/test-fixtures/sample.pptx
Normal file
12
frontend/src/tests/test-fixtures/sample.pptx
Normal 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.
|
32
frontend/src/tests/test-fixtures/sample.svg
Normal file
32
frontend/src/tests/test-fixtures/sample.svg
Normal 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 |
8
frontend/src/tests/test-fixtures/sample.txt
Normal file
8
frontend/src/tests/test-fixtures/sample.txt
Normal 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.
|
6
frontend/src/tests/test-fixtures/sample.xlsx
Normal file
6
frontend/src/tests/test-fixtures/sample.xlsx
Normal 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
|
18
frontend/src/tests/test-fixtures/sample.xml
Normal file
18
frontend/src/tests/test-fixtures/sample.xml
Normal 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>
|
207
frontend/src/tools/Convert.tsx
Normal file
207
frontend/src/tools/Convert.tsx
Normal 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;
|
334
frontend/src/utils/convertUtils.test.ts
Normal file
334
frontend/src/utils/convertUtils.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
59
frontend/src/utils/convertUtils.ts
Normal file
59
frontend/src/utils/convertUtils.ts
Normal 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());
|
||||
};
|
147
frontend/src/utils/fileResponseUtils.test.ts
Normal file
147
frontend/src/utils/fileResponseUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
37
frontend/src/utils/fileResponseUtils.ts
Normal file
37
frontend/src/utils/fileResponseUtils.ts
Normal 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 });
|
||||
};
|
@ -125,4 +125,51 @@ export function cleanupFileUrls(files: FileWithUrl[]): void {
|
||||
export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean {
|
||||
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
return file.size > FILE_SIZE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and normalizes file extension from filename
|
||||
* @param filename - The filename to extract extension from
|
||||
* @returns Normalized file extension in lowercase, empty string if no extension
|
||||
*/
|
||||
export function detectFileExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
const parts = filename.split('.');
|
||||
// If there's no extension (no dots or only one part), return empty string
|
||||
if (parts.length <= 1) return '';
|
||||
|
||||
// Get the last part (extension) in lowercase
|
||||
let extension = parts[parts.length - 1].toLowerCase();
|
||||
|
||||
// Normalize common extension variants
|
||||
if (extension === 'jpeg') extension = 'jpg';
|
||||
|
||||
return extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the filename without extension
|
||||
* @param filename - The filename to process
|
||||
* @returns Filename without extension
|
||||
*/
|
||||
export function getFilenameWithoutExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
|
||||
const parts = filename.split('.');
|
||||
if (parts.length <= 1) return filename;
|
||||
|
||||
// Return all parts except the last one (extension)
|
||||
return parts.slice(0, -1).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new filename with a different extension
|
||||
* @param filename - Original filename
|
||||
* @param newExtension - New extension (without dot)
|
||||
* @returns New filename with the specified extension
|
||||
*/
|
||||
export function changeFileExtension(filename: string, newExtension: string): string {
|
||||
const nameWithoutExt = getFilenameWithoutExtension(filename);
|
||||
return `${nameWithoutExt}.${newExtension}`;
|
||||
}
|
@ -24,6 +24,11 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
console.log('Skipping thumbnail generation for large file:', file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
|
@ -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
40
frontend/vitest.config.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
9
frontend/vitest.minimal.config.ts
Normal file
9
frontend/vitest.minimal.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
testTimeout: 5000,
|
||||
include: ['src/utils/convertUtils.test.ts']
|
||||
},
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user