From 8eeb4c148c30c35629392cd19f2e4c9986f8785b Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 12 Aug 2025 16:05:59 +0100 Subject: [PATCH] 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 Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 47 ++++- .../public/locales/en-US/translation.json | 43 +++- .../tools/sanitize/SanitizeSettings.test.tsx | 194 ++++++++++++++++++ .../tools/sanitize/SanitizeSettings.tsx | 51 +++++ .../tools/compress/useCompressOperation.ts | 6 - .../tools/convert/useConvertOperation.ts | 3 - .../src/hooks/tools/ocr/useOCROperation.ts | 4 - .../tools/sanitize/useSanitizeOperation.ts | 32 +++ .../sanitize/useSanitizeParameters.test.ts | 90 ++++++++ .../tools/sanitize/useSanitizeParameters.ts | 53 +++++ .../hooks/tools/shared/useToolOperation.ts | 18 +- .../hooks/tools/split/useSplitOperation.ts | 15 +- .../hooks/tools/split/useSplitParameters.ts | 4 +- frontend/src/hooks/useToolManagement.tsx | 10 + frontend/src/tools/Convert.tsx | 6 +- frontend/src/tools/Sanitize.tsx | 166 +++++++++++++++ frontend/src/types/fileContext.ts | 2 +- 17 files changed, 688 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx create mode 100644 frontend/src/components/tools/sanitize/SanitizeSettings.tsx create mode 100644 frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts create mode 100644 frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts create mode 100644 frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts create mode 100644 frontend/src/tools/Sanitize.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ed3942172..6e5dd6179 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -38,7 +38,8 @@ "save": "Save", "saveToBrowser": "Save to Browser", "close": "Close", - "filesSelected": "files selected", + "fileSelected": "Selected: {{filename}}", + "filesSelected": "{{count}} files selected", "noFavourites": "No favourites added", "downloadComplete": "Download Complete", "bored": "Bored Waiting?", @@ -391,6 +392,10 @@ "title": "Compress", "desc": "Compress PDFs to reduce their file size." }, + "sanitize": { + "title": "Sanitise", + "desc": "Remove potentially harmful elements from PDF files." + }, "unlockPDFForms": { "title": "Unlock PDF Forms", "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" }, "sanitizePdf": { - "title": "Sanitize", + "title": "Sanitise", "desc": "Remove scripts and other elements from PDF files" }, "URLToPDF": { @@ -1425,8 +1430,8 @@ "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" }, "sanitizePDF": { - "title": "Sanitize PDF", - "header": "Sanitize a PDF file", + "title": "Sanitise PDF", + "header": "Sanitise a PDF file", "selectText": { "1": "Remove JavaScript actions", "2": "Remove embedded files", @@ -1761,5 +1766,37 @@ "fileTooLarge": "File too large. Maximum size per file is", "storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.", "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" + } } -} \ No newline at end of file +} diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 56279f8b4..6ca67480b 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -38,7 +38,8 @@ "save": "Save", "saveToBrowser": "Save to Browser", "close": "Close", - "filesSelected": "files selected", + "fileSelected": "Selected: {{filename}}", + "filesSelected": "{{count}} files selected", "noFavourites": "No favorites added", "downloadComplete": "Download Complete", "bored": "Bored Waiting?", @@ -387,6 +388,10 @@ "title": "Compress", "desc": "Compress PDFs to reduce their file size." }, + "sanitize": { + "title": "Sanitize", + "desc": "Remove potentially harmful elements from PDF files." + }, "unlockPDFForms": { "title": "Unlock PDF Forms", "desc": "Remove read-only property of form fields in a PDF document." @@ -1612,6 +1617,38 @@ "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." + "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" + } + } } -} \ No newline at end of file +} diff --git a/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx b/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx new file mode 100644 index 000000000..055de2466 --- /dev/null +++ b/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx @@ -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 }) => ( + {children} +); + +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( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach(checkbox => { + expect(checkbox).toBeDisabled(); + }); + }); + + test('should enable all checkboxes when disabled prop is false or undefined', () => { + render( + + + + ); + + 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( + + + + ); + + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach(checkbox => { + expect(checkbox).toBeChecked(); + }); + }); + + test('should call translation function with correct keys', () => { + render( + + + + ); + + // 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( + + + + ); + + 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); + }); +}); diff --git a/frontend/src/components/tools/sanitize/SanitizeSettings.tsx b/frontend/src/components/tools/sanitize/SanitizeSettings.tsx new file mode 100644 index 000000000..fb5304431 --- /dev/null +++ b/frontend/src/components/tools/sanitize/SanitizeSettings.tsx @@ -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).map((key) => ({ + key: key, + label: t(`sanitize.options.${key}`, key), + description: t(`sanitize.options.${key}.desc`, `${key} from the PDF`), + default: defaultParameters[key], + })); + + return ( + + + {t('sanitize.options.title', 'Sanitization Options')} + + + + {options.map((option) => ( + onParameterChange(option.key, event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {option.label} + {option.description} +
+ } + /> + ))} +
+ + + {t('sanitize.options.note', 'Select the elements you want to remove from the PDF. At least one option must be selected.')} + +
+ ); +}; + +export default SanitizeSettings; diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index e254cbe1c..ebcac8b66 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -38,12 +38,6 @@ export const useCompressOperation = () => { buildFormData, filePrefix: 'compressed_', 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.')) }); }; diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 49b55d0d1..f14302aaa 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -134,9 +134,6 @@ export const useConvertOperation = () => { buildFormData, // Not used with customProcessor but required filePrefix: 'converted_', customProcessor: customConvertProcessor, // Convert handles its own routing - validateParams: (params) => { - return { valid: true }; - }, getErrorMessage: (error) => { if (error.response?.data && typeof error.response.data === 'string') { return error.response.data; diff --git a/frontend/src/hooks/tools/ocr/useOCROperation.ts b/frontend/src/hooks/tools/ocr/useOCROperation.ts index b684657ad..eeebb8747 100644 --- a/frontend/src/hooks/tools/ocr/useOCROperation.ts +++ b/frontend/src/hooks/tools/ocr/useOCROperation.ts @@ -103,10 +103,6 @@ export const useOCROperation = () => { filePrefix: 'ocr_', multiFileEndpoint: false, // Process files individually 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) => 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.' diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts new file mode 100644 index 000000000..c83086121 --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -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({ + 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.')) + }); +}; diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts new file mode 100644 index 000000000..f86a6c81a --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts new file mode 100644 index 000000000..ea3068c2d --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts @@ -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(defaultParameters); + + const updateParameter = useCallback(( + 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, + }; +}; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index cebdc5a23..9fa883490 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -9,11 +9,6 @@ import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { createOperation } from '../../../utils/toolOperationTracker'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; -export interface ValidationResult { - valid: boolean; - errors?: string[]; -} - // Re-export for backwards compatibility export type { ProcessingProgress, ResponseHandler }; @@ -64,9 +59,6 @@ export interface ToolOperationConfig { */ customProcessor?: (params: TParams, files: File[]) => Promise; - /** Validate parameters before execution. Return validation errors if invalid. */ - validateParams?: (params: TParams) => ValidationResult; - /** Extract user-friendly error messages from API errors */ getErrorMessage?: (error: any) => string; } @@ -129,14 +121,6 @@ export const useToolOperation = ( 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); if (validFiles.length === 0) { actions.setError(t('noValidFiles', 'No valid files to process')); @@ -186,7 +170,7 @@ export const useToolOperation = ( // Individual file processing - separate API call per file const apiCallsConfig: ApiCallsConfig = { 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, responseHandler: config.responseHandler }; diff --git a/frontend/src/hooks/tools/split/useSplitOperation.ts b/frontend/src/hooks/tools/split/useSplitOperation.ts index 4979d0ea0..184289e18 100644 --- a/frontend/src/hooks/tools/split/useSplitOperation.ts +++ b/frontend/src/hooks/tools/split/useSplitOperation.ts @@ -1,7 +1,5 @@ -import { useCallback } from 'react'; -import axios from 'axios'; import { useTranslation } from 'react-i18next'; -import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { useToolOperation } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SplitParameters } from '../../../components/tools/split/SplitSettings'; import { SPLIT_MODES } from '../../../constants/splitConstants'; @@ -66,17 +64,6 @@ export const useSplitOperation = () => { buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData filePrefix: 'split_', 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.')) }); }; diff --git a/frontend/src/hooks/tools/split/useSplitParameters.ts b/frontend/src/hooks/tools/split/useSplitParameters.ts index 72d479311..54003e79c 100644 --- a/frontend/src/hooks/tools/split/useSplitParameters.ts +++ b/frontend/src/hooks/tools/split/useSplitParameters.ts @@ -1,5 +1,5 @@ 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'; export interface SplitParametersHook { @@ -63,4 +63,4 @@ export const useSplitParameters = (): SplitParametersHook => { validateParameters, getEndpointName, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index debd3f5b1..1de26814d 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -4,6 +4,7 @@ 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 CleaningServicesIcon from "@mui/icons-material/CleaningServices"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; @@ -75,6 +76,15 @@ const toolDefinitions: Record = { description: "Extract text from images using OCR", endpoints: ["ocr-pdf"] }, + sanitize: { + id: "sanitize", + icon: , + component: React.lazy(() => import("../tools/Sanitize")), + maxFiles: -1, + category: "security", + description: "Remove potentially harmful elements from PDF files", + endpoints: ["sanitize-pdf"] + }, }; diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 3512ca8eb..88e50df23 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -122,7 +122,11 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { isVisible={true} isCollapsed={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} > { + 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 ( + + + {/* Files Step */} + + + + + {/* Settings Step */} + + + + + + + + + {/* Results Step */} + + + {sanitizeOperation.status && ( + {sanitizeOperation.status} + )} + + + + {sanitizeOperation.downloadUrl && ( + + )} + + ({ + file, + thumbnail: sanitizeOperation.thumbnails[index] + }))} + onFileClick={handleThumbnailClick} + isGeneratingThumbnails={sanitizeOperation.isGeneratingThumbnails} + title={t("sanitize.sanitizationResults", "Sanitization Results")} + /> + + + + + ); +} + +export default Sanitize; diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 6ced5a35c..555abdc4c 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -11,7 +11,7 @@ export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; 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 { id: string;