mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-21 19:59:24 +00:00
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:
parent
adf6feea27
commit
8eeb4c148c
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
194
frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx
Normal file
194
frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
51
frontend/src/components/tools/sanitize/SanitizeSettings.tsx
Normal file
51
frontend/src/components/tools/sanitize/SanitizeSettings.tsx
Normal 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;
|
@ -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.'))
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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.'
|
||||
|
32
frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts
Normal file
32
frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts
Normal 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.'))
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
53
frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts
Normal file
53
frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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<TParams = void> {
|
||||
*/
|
||||
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 */
|
||||
getErrorMessage?: (error: any) => string;
|
||||
}
|
||||
@ -129,14 +121,6 @@ export const useToolOperation = <TParams = void>(
|
||||
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 = <TParams = void>(
|
||||
// Individual file processing - separate API call per file
|
||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||
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
|
||||
};
|
||||
|
@ -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.'))
|
||||
});
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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<string, ToolDefinition> = {
|
||||
description: "Extract text from images using OCR",
|
||||
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"]
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
>
|
||||
<FileStatusIndicator
|
||||
selectedFiles={selectedFiles}
|
||||
|
166
frontend/src/tools/Sanitize.tsx
Normal file
166
frontend/src/tools/Sanitize.tsx
Normal 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;
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user