Add Sanitize UI (#4123)

# Description of Changes

Implementation of Sanitize UI for V2.

Also removes parameter validation from standard tool hooks because the
logic would have to be duplicated between parameter handling and
operation hooks, and the nicer workflow is for the tools to reject using
the Go button if the validation fails, rather than the operation hook
checking it, since that can't appear in the UI.

Co-authored-by: James <james@crosscourtanalytics.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
This commit is contained in:
James Brunton 2025-08-12 16:05:59 +01:00 committed by GitHub
parent adf6feea27
commit 8eeb4c148c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 688 additions and 56 deletions

View File

@ -38,7 +38,8 @@
"save": "Save", "save": "Save",
"saveToBrowser": "Save to Browser", "saveToBrowser": "Save to Browser",
"close": "Close", "close": "Close",
"filesSelected": "files selected", "fileSelected": "Selected: {{filename}}",
"filesSelected": "{{count}} files selected",
"noFavourites": "No favourites added", "noFavourites": "No favourites added",
"downloadComplete": "Download Complete", "downloadComplete": "Download Complete",
"bored": "Bored Waiting?", "bored": "Bored Waiting?",
@ -391,6 +392,10 @@
"title": "Compress", "title": "Compress",
"desc": "Compress PDFs to reduce their file size." "desc": "Compress PDFs to reduce their file size."
}, },
"sanitize": {
"title": "Sanitise",
"desc": "Remove potentially harmful elements from PDF files."
},
"unlockPDFForms": { "unlockPDFForms": {
"title": "Unlock PDF Forms", "title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document." "desc": "Remove read-only property of form fields in a PDF document."
@ -504,7 +509,7 @@
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code" "desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
}, },
"sanitizePdf": { "sanitizePdf": {
"title": "Sanitize", "title": "Sanitise",
"desc": "Remove scripts and other elements from PDF files" "desc": "Remove scripts and other elements from PDF files"
}, },
"URLToPDF": { "URLToPDF": {
@ -1425,8 +1430,8 @@
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
}, },
"sanitizePDF": { "sanitizePDF": {
"title": "Sanitize PDF", "title": "Sanitise PDF",
"header": "Sanitize a PDF file", "header": "Sanitise a PDF file",
"selectText": { "selectText": {
"1": "Remove JavaScript actions", "1": "Remove JavaScript actions",
"2": "Remove embedded files", "2": "Remove embedded files",
@ -1761,5 +1766,37 @@
"fileTooLarge": "File too large. Maximum size per file is", "fileTooLarge": "File too large. Maximum size per file is",
"storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.", "storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.",
"approximateSize": "Approximate size" "approximateSize": "Approximate size"
},
"sanitize": {
"submit": "Sanitise PDF",
"completed": "Sanitisation completed successfully",
"error.generic": "Sanitisation failed",
"error.failed": "An error occurred while sanitising the PDF.",
"filenamePrefix": "sanitised",
"sanitizationResults": "Sanitisation Results",
"steps": {
"files": "Files",
"settings": "Settings",
"results": "Results"
},
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"options": {
"title": "Sanitisation Options",
"note": "Select the elements you want to remove from the PDF. At least one option must be selected.",
"removeJavaScript": "Remove JavaScript",
"removeJavaScript.desc": "Remove JavaScript actions and scripts from the PDF",
"removeEmbeddedFiles": "Remove Embedded Files",
"removeEmbeddedFiles.desc": "Remove any files embedded within the PDF",
"removeXMPMetadata": "Remove XMP Metadata",
"removeXMPMetadata.desc": "Remove XMP metadata from the PDF",
"removeMetadata": "Remove Document Metadata",
"removeMetadata.desc": "Remove document information metadata (title, author, etc.)",
"removeLinks": "Remove Links",
"removeLinks.desc": "Remove external links and launch actions from the PDF",
"removeFonts": "Remove Fonts",
"removeFonts.desc": "Remove embedded fonts from the PDF"
}
} }
} }

View File

@ -38,7 +38,8 @@
"save": "Save", "save": "Save",
"saveToBrowser": "Save to Browser", "saveToBrowser": "Save to Browser",
"close": "Close", "close": "Close",
"filesSelected": "files selected", "fileSelected": "Selected: {{filename}}",
"filesSelected": "{{count}} files selected",
"noFavourites": "No favorites added", "noFavourites": "No favorites added",
"downloadComplete": "Download Complete", "downloadComplete": "Download Complete",
"bored": "Bored Waiting?", "bored": "Bored Waiting?",
@ -387,6 +388,10 @@
"title": "Compress", "title": "Compress",
"desc": "Compress PDFs to reduce their file size." "desc": "Compress PDFs to reduce their file size."
}, },
"sanitize": {
"title": "Sanitize",
"desc": "Remove potentially harmful elements from PDF files."
},
"unlockPDFForms": { "unlockPDFForms": {
"title": "Unlock PDF Forms", "title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document." "desc": "Remove read-only property of form fields in a PDF document."
@ -1612,6 +1617,38 @@
"pdfaOptions": "PDF/A Options", "pdfaOptions": "PDF/A Options",
"outputFormat": "Output Format", "outputFormat": "Output Format",
"pdfaNote": "PDF/A-1b is more compatible, PDF/A-2b supports more features.", "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." "pdfaDigitalSignatureWarning": "The PDF contains a digital signature. This will be removed in the next step.",
"sanitize": {
"submit": "Sanitize PDF",
"completed": "Sanitization completed successfully",
"error.generic": "Sanitization failed",
"error.failed": "An error occurred while sanitizing the PDF.",
"filenamePrefix": "sanitized",
"sanitizationResults": "Sanitization Results",
"steps": {
"files": "Files",
"settings": "Settings",
"results": "Results"
},
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"options": {
"title": "Sanitization Options",
"note": "Select the elements you want to remove from the PDF. At least one option must be selected.",
"removeJavaScript": "Remove JavaScript",
"removeJavaScript.desc": "Remove JavaScript actions and scripts from the PDF",
"removeEmbeddedFiles": "Remove Embedded Files",
"removeEmbeddedFiles.desc": "Remove any files embedded within the PDF",
"removeXMPMetadata": "Remove XMP Metadata",
"removeXMPMetadata.desc": "Remove XMP metadata from the PDF",
"removeMetadata": "Remove Document Metadata",
"removeMetadata.desc": "Remove document information metadata (title, author, etc.)",
"removeLinks": "Remove Links",
"removeLinks.desc": "Remove external links and launch actions from the PDF",
"removeFonts": "Remove Fonts",
"removeFonts.desc": "Remove embedded fonts from the PDF"
}
}
} }
} }

View File

@ -0,0 +1,194 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import SanitizeSettings from './SanitizeSettings';
import { SanitizeParameters } from '../../../hooks/tools/sanitize/useSanitizeParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `mock-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('SanitizeSettings', () => {
const defaultParameters: SanitizeParameters = {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
};
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all sanitization option checkboxes', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Should render one checkbox for each parameter
const expectedCheckboxCount = Object.keys(defaultParameters).length;
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(expectedCheckboxCount);
});
test('should show correct initial checkbox states based on parameters', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
const parameterValues = Object.values(defaultParameters);
parameterValues.forEach((value, index) => {
if (value) {
expect(checkboxes[index]).toBeChecked();
} else {
expect(checkboxes[index]).not.toBeChecked();
}
});
});
test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
// Click the first checkbox (removeJavaScript - should toggle from true to false)
fireEvent.click(checkboxes[0]);
expect(mockOnParameterChange).toHaveBeenCalledWith('removeJavaScript', false);
// Click the third checkbox (removeXMPMetadata - should toggle from false to true)
fireEvent.click(checkboxes[2]);
expect(mockOnParameterChange).toHaveBeenCalledWith('removeXMPMetadata', true);
});
test('should disable all checkboxes when disabled prop is true', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach(checkbox => {
expect(checkbox).toBeDisabled();
});
});
test('should enable all checkboxes when disabled prop is false or undefined', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={false}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach(checkbox => {
expect(checkbox).not.toBeDisabled();
});
});
test('should handle different parameter combinations', () => {
const allEnabledParameters: SanitizeParameters = {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: true,
removeFonts: true,
};
render(
<TestWrapper>
<SanitizeSettings
parameters={allEnabledParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach(checkbox => {
expect(checkbox).toBeChecked();
});
});
test('should call translation function with correct keys', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Verify that translation keys are being called (just check that it was called, not specific order)
expect(mockT).toHaveBeenCalledWith('sanitize.options.title', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles', expect.any(String));
expect(mockT).toHaveBeenCalledWith('sanitize.options.note', expect.any(String));
});
test('should not call onParameterChange when disabled', () => {
render(
<TestWrapper>
<SanitizeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
const checkboxes = screen.getAllByRole('checkbox');
// Verify checkboxes are disabled
checkboxes.forEach(checkbox => {
expect(checkbox).toBeDisabled();
});
// Try to click a disabled checkbox - this might still fire the event in tests
// but we can verify the checkbox state doesn't actually change
const firstCheckbox = checkboxes[0] as HTMLInputElement;
const initialChecked = firstCheckbox.checked;
fireEvent.click(firstCheckbox);
expect(firstCheckbox.checked).toBe(initialChecked);
});
});

View File

@ -0,0 +1,51 @@
import { Stack, Text, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sanitize/useSanitizeParameters";
interface SanitizeSettingsProps {
parameters: SanitizeParameters;
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void;
disabled?: boolean;
}
const SanitizeSettings = ({ parameters, onParameterChange, disabled = false }: SanitizeSettingsProps) => {
const { t } = useTranslation();
const options = (Object.keys(defaultParameters) as Array<keyof SanitizeParameters>).map((key) => ({
key: key,
label: t(`sanitize.options.${key}`, key),
description: t(`sanitize.options.${key}.desc`, `${key} from the PDF`),
default: defaultParameters[key],
}));
return (
<Stack gap="md">
<Text size="sm" fw={500}>
{t('sanitize.options.title', 'Sanitization Options')}
</Text>
<Stack gap="sm">
{options.map((option) => (
<Checkbox
key={option.key}
checked={parameters[option.key]}
onChange={(event) => onParameterChange(option.key, event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">{option.description}</Text>
</div>
}
/>
))}
</Stack>
<Text size="xs" c="dimmed">
{t('sanitize.options.note', 'Select the elements you want to remove from the PDF. At least one option must be selected.')}
</Text>
</Stack>
);
};
export default SanitizeSettings;

View File

@ -38,12 +38,6 @@ export const useCompressOperation = () => {
buildFormData, buildFormData,
filePrefix: 'compressed_', filePrefix: 'compressed_',
multiFileEndpoint: false, // Individual API calls per file multiFileEndpoint: false, // Individual API calls per file
validateParams: (params) => {
if (params.compressionMethod === 'filesize' && !params.fileSizeValue) {
return { valid: false, errors: [t('compress.validation.fileSizeRequired', 'File size value is required when using filesize method')] };
}
return { valid: true };
},
getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.')) getErrorMessage: createStandardErrorHandler(t('compress.error.failed', 'An error occurred while compressing the PDF.'))
}); });
}; };

View File

@ -134,9 +134,6 @@ export const useConvertOperation = () => {
buildFormData, // Not used with customProcessor but required buildFormData, // Not used with customProcessor but required
filePrefix: 'converted_', filePrefix: 'converted_',
customProcessor: customConvertProcessor, // Convert handles its own routing customProcessor: customConvertProcessor, // Convert handles its own routing
validateParams: (params) => {
return { valid: true };
},
getErrorMessage: (error) => { getErrorMessage: (error) => {
if (error.response?.data && typeof error.response.data === 'string') { if (error.response?.data && typeof error.response.data === 'string') {
return error.response.data; return error.response.data;

View File

@ -103,10 +103,6 @@ export const useOCROperation = () => {
filePrefix: 'ocr_', filePrefix: 'ocr_',
multiFileEndpoint: false, // Process files individually multiFileEndpoint: false, // Process files individually
responseHandler, // use shared flow responseHandler, // use shared flow
validateParams: (params) =>
params.languages.length === 0
? { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] }
: { valid: true },
getErrorMessage: (error) => getErrorMessage: (error) =>
error.message?.includes('OCR tools') && error.message?.includes('not installed') error.message?.includes('OCR tools') && error.message?.includes('not installed')
? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.' ? 'OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually.'

View File

@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SanitizeParameters } from './useSanitizeParameters';
const buildFormData = (parameters: SanitizeParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters
formData.append('removeJavaScript', parameters.removeJavaScript.toString());
formData.append('removeEmbeddedFiles', parameters.removeEmbeddedFiles.toString());
formData.append('removeXMPMetadata', parameters.removeXMPMetadata.toString());
formData.append('removeMetadata', parameters.removeMetadata.toString());
formData.append('removeLinks', parameters.removeLinks.toString());
formData.append('removeFonts', parameters.removeFonts.toString());
return formData;
};
export const useSanitizeOperation = () => {
const { t } = useTranslation();
return useToolOperation<SanitizeParameters>({
operationType: 'sanitize',
endpoint: '/api/v1/security/sanitize-pdf',
buildFormData,
filePrefix: t('sanitize.filenamePrefix', 'sanitized') + '_',
multiFileEndpoint: false, // Individual API calls per file
getErrorMessage: createStandardErrorHandler(t('sanitize.error.failed', 'An error occurred while sanitising the PDF.'))
});
};

View File

@ -0,0 +1,90 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useSanitizeParameters } from './useSanitizeParameters';
describe('useSanitizeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useSanitizeParameters());
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
});
expect(result.current.parameters).toStrictEqual({
...defaultParameters, // Other params unchanged
removeXMPMetadata: true,
});
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useSanitizeParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
result.current.updateParameter('removeJavaScript', false);
});
expect(result.current.parameters.removeXMPMetadata).toBe(true);
expect(result.current.parameters.removeJavaScript).toBe(false);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.getEndpointName()).toBe('sanitize-pdf');
});
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
expect(result.current.validateParameters()).toBe(true);
// Turn off all parameters - should be invalid
act(() => {
result.current.updateParameter('removeJavaScript', false);
result.current.updateParameter('removeEmbeddedFiles', false);
});
expect(result.current.validateParameters()).toBe(false);
// Turn on one parameter - should be valid again
act(() => {
result.current.updateParameter('removeLinks', true);
});
expect(result.current.validateParameters()).toBe(true);
});
test('should handle all parameter types correctly', () => {
const { result } = renderHook(() => useSanitizeParameters());
const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[];
allParameters.forEach(param => {
act(() => {
result.current.updateParameter(param, true);
});
expect(result.current.parameters[param]).toBe(true);
act(() => {
result.current.updateParameter(param, false);
});
expect(result.current.parameters[param]).toBe(false);
});
});
});

View File

@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
export interface SanitizeParameters {
removeJavaScript: boolean;
removeEmbeddedFiles: boolean;
removeXMPMetadata: boolean;
removeMetadata: boolean;
removeLinks: boolean;
removeFonts: boolean;
}
export const defaultParameters: SanitizeParameters = {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
};
export const useSanitizeParameters = () => {
const [parameters, setParameters] = useState<SanitizeParameters>(defaultParameters);
const updateParameter = useCallback(<K extends keyof SanitizeParameters>(
key: K,
value: SanitizeParameters[K]
) => {
setParameters(prev => ({
...prev,
[key]: value
}));
}, []);
const resetParameters = useCallback(() => {
setParameters(defaultParameters);
}, []);
const validateParameters = useCallback(() => {
return Object.values(parameters).some(value => value === true);
}, [parameters]);
const getEndpointName = () => {
return 'sanitize-pdf'
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};

View File

@ -9,11 +9,6 @@ import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker'; import { createOperation } from '../../../utils/toolOperationTracker';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
export interface ValidationResult {
valid: boolean;
errors?: string[];
}
// Re-export for backwards compatibility // Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler }; export type { ProcessingProgress, ResponseHandler };
@ -64,9 +59,6 @@ export interface ToolOperationConfig<TParams = void> {
*/ */
customProcessor?: (params: TParams, files: File[]) => Promise<File[]>; customProcessor?: (params: TParams, files: File[]) => Promise<File[]>;
/** Validate parameters before execution. Return validation errors if invalid. */
validateParams?: (params: TParams) => ValidationResult;
/** Extract user-friendly error messages from API errors */ /** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string; getErrorMessage?: (error: any) => string;
} }
@ -129,14 +121,6 @@ export const useToolOperation = <TParams = void>(
return; return;
} }
if (config.validateParams) {
const validation = config.validateParams(params);
if (!validation.valid) {
actions.setError(validation.errors?.join(', ') || 'Invalid parameters');
return;
}
}
const validFiles = selectedFiles.filter(file => file.size > 0); const validFiles = selectedFiles.filter(file => file.size > 0);
if (validFiles.length === 0) { if (validFiles.length === 0) {
actions.setError(t('noValidFiles', 'No valid files to process')); actions.setError(t('noValidFiles', 'No valid files to process'));
@ -186,7 +170,7 @@ export const useToolOperation = <TParams = void>(
// Individual file processing - separate API call per file // Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = { const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint, endpoint: config.endpoint,
buildFormData: (file: File, params: TParams) => (config.buildFormData as any /* FIX ME */)(file, params), buildFormData: (file: File, params: TParams) => (config.buildFormData as (params: TParams, file: File) => FormData /* FIX ME */)(params, file),
filePrefix: config.filePrefix, filePrefix: config.filePrefix,
responseHandler: config.responseHandler responseHandler: config.responseHandler
}; };

View File

@ -1,7 +1,5 @@
import { useCallback } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SplitParameters } from '../../../components/tools/split/SplitSettings'; import { SplitParameters } from '../../../components/tools/split/SplitSettings';
import { SPLIT_MODES } from '../../../constants/splitConstants'; import { SPLIT_MODES } from '../../../constants/splitConstants';
@ -66,17 +64,6 @@ export const useSplitOperation = () => {
buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData
filePrefix: 'split_', filePrefix: 'split_',
multiFileEndpoint: true, // Single API call with all files multiFileEndpoint: true, // Single API call with all files
validateParams: (params) => {
if (!params.mode) {
return { valid: false, errors: [t('split.validation.modeRequired', 'Split mode is required')] };
}
if (params.mode === SPLIT_MODES.BY_PAGES && !params.pages) {
return { valid: false, errors: [t('split.validation.pagesRequired', 'Page numbers are required for split by pages')] };
}
return { valid: true };
},
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.')) getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
}); });
}; };

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, type SplitType } from '../../../constants/splitConstants'; import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../components/tools/split/SplitSettings'; import { SplitParameters } from '../../../components/tools/split/SplitSettings';
export interface SplitParametersHook { export interface SplitParametersHook {

View File

@ -4,6 +4,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api"; import ApiIcon from "@mui/icons-material/Api";
import CleaningServicesIcon from "@mui/icons-material/CleaningServices";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
@ -75,6 +76,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
description: "Extract text from images using OCR", description: "Extract text from images using OCR",
endpoints: ["ocr-pdf"] endpoints: ["ocr-pdf"]
}, },
sanitize: {
id: "sanitize",
icon: <CleaningServicesIcon />,
component: React.lazy(() => import("../tools/Sanitize")),
maxFiles: -1,
category: "security",
description: "Remove potentially harmful elements from PDF files",
endpoints: ["sanitize-pdf"]
},
}; };

View File

@ -122,7 +122,11 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isVisible={true} isVisible={true}
isCollapsed={filesCollapsed} isCollapsed={filesCollapsed}
isCompleted={filesCollapsed} isCompleted={filesCollapsed}
completedMessage={hasFiles ? `${selectedFiles.length} ${t("filesSelected", "files selected")}` : undefined} completedMessage={hasFiles ?
selectedFiles.length === 1
? t('fileSelected', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
: t('filesSelected', '{{count}} files selected', { count: selectedFiles.length })
: undefined}
> >
<FileStatusIndicator <FileStatusIndicator
selectedFiles={selectedFiles} selectedFiles={selectedFiles}

View File

@ -0,0 +1,166 @@
import { useEffect } 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 { 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 SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
import { BaseToolProps } from "../types/tool";
import { useFileContext } from "../contexts/FileContext";
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useToolFileSelection();
const { setCurrentMode } = useFileContext();
const sanitizeParams = useSanitizeParameters();
const sanitizeOperation = useSanitizeOperation();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
sanitizeParams.getEndpointName()
);
useEffect(() => {
sanitizeOperation.resetResults();
onPreviewFile?.(null);
}, [sanitizeParams.parameters, selectedFiles]);
const handleSanitize = async () => {
try {
await sanitizeOperation.executeOperation(
sanitizeParams.parameters,
selectedFiles,
);
if (sanitizeOperation.files && onComplete) {
onComplete(sanitizeOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'));
}
}
};
const handleSettingsReset = () => {
sanitizeOperation.resetResults();
onPreviewFile?.(null);
};
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem('previousMode', 'sanitize');
setCurrentMode('viewer');
};
const hasFiles = selectedFiles.length > 0;
const hasResults = sanitizeOperation.files.length > 0;
const filesCollapsed = hasFiles;
const settingsCollapsed = hasResults;
return (
<ToolStepContainer>
<Stack gap="sm" p="sm" style={{ height: '100vh', overflow: 'auto' }}>
{/* Files Step */}
<ToolStep
title={t('sanitize.steps.files', 'Files')}
isVisible={true}
isCollapsed={filesCollapsed}
isCompleted={filesCollapsed}
completedMessage={hasFiles ?
selectedFiles.length === 1
? t('fileSelected', 'Selected: {{filename}}', { filename: selectedFiles[0].name })
: t('filesSelected', 'Selected: {{count}} files', { count: selectedFiles.length })
: undefined}
>
<FileStatusIndicator
selectedFiles={selectedFiles}
placeholder={t('sanitize.files.placeholder', 'Select a PDF file in the main view to get started')}
/>
</ToolStep>
{/* Settings Step */}
<ToolStep
title={t('sanitize.steps.settings', 'Settings')}
isVisible={hasFiles}
isCollapsed={settingsCollapsed}
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? t('sanitize.completed', 'Sanitization completed') : undefined}
>
<Stack gap="sm">
<SanitizeSettings
parameters={sanitizeParams.parameters}
onParameterChange={sanitizeParams.updateParameter}
disabled={endpointLoading}
/>
<OperationButton
onClick={handleSanitize}
isLoading={sanitizeOperation.isLoading}
disabled={!sanitizeParams.validateParameters() || !hasFiles || !endpointEnabled}
loadingText={t("loading")}
submitText={t("sanitize.submit", "Sanitize PDF")}
/>
</Stack>
</ToolStep>
{/* Results Step */}
<ToolStep
title={t('sanitize.steps.results', 'Results')}
isVisible={hasResults}
>
<Stack gap="sm">
{sanitizeOperation.status && (
<Text size="sm" c="dimmed">{sanitizeOperation.status}</Text>
)}
<ErrorNotification
error={sanitizeOperation.errorMessage}
onClose={sanitizeOperation.clearError}
/>
{sanitizeOperation.downloadUrl && (
<Button
component="a"
href={sanitizeOperation.downloadUrl}
download={sanitizeOperation.downloadFilename}
leftSection={<DownloadIcon />}
color="green"
fullWidth
mb="md"
>
{sanitizeOperation.files.length === 1
? t("download", "Download")
: t("downloadZip", "Download ZIP")
}
</Button>
)}
<ResultsPreview
files={sanitizeOperation.files.map((file, index) => ({
file,
thumbnail: sanitizeOperation.thumbnails[index]
}))}
onFileClick={handleThumbnailClick}
isGeneratingThumbnails={sanitizeOperation.isGeneratingThumbnails}
title={t("sanitize.sanitizationResults", "Sanitization Results")}
/>
</Stack>
</ToolStep>
</Stack>
</ToolStepContainer>
);
}
export default Sanitize;

View File

@ -11,7 +11,7 @@ export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
export type ToolType = 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize'; export type ToolType = 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize';
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr'; export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr' | 'sanitize';
export interface FileOperation { export interface FileOperation {
id: string; id: string;