From 13ee46745b1e53c6b6c3e776f7fd46db7bfc0c2a Mon Sep 17 00:00:00 2001 From: James Date: Tue, 5 Aug 2025 15:22:56 +0100 Subject: [PATCH 01/16] Initial commit of Sanitize UI --- .../public/locales/en-US/translation.json | 38 ++++- .../tools/sanitize/SanitizeSettings.tsx | 83 ++++++++++ .../tools/sanitize/useSanitizeOperation.ts | 82 ++++++++++ .../tools/sanitize/useSanitizeParameters.ts | 52 ++++++ frontend/src/hooks/useToolManagement.tsx | 10 ++ frontend/src/tools/Sanitize.tsx | 154 ++++++++++++++++++ 6 files changed, 418 insertions(+), 1 deletion(-) 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.ts create mode 100644 frontend/src/tools/Sanitize.tsx diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 56279f8b4..776213dbe 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -387,6 +387,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 +1616,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", + "processing": "Sanitizing PDF...", + "completed": "Sanitization completed successfully", + "error": "Sanitization failed: {{error}}", + "error.generic": "Sanitization failed", + "steps": { + "files": "Files", + "settings": "Settings", + "results": "Results" + }, + "files": { + "selected": "Selected: {{filename}}", + "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.tsx b/frontend/src/components/tools/sanitize/SanitizeSettings.tsx new file mode 100644 index 000000000..f849d14f0 --- /dev/null +++ b/frontend/src/components/tools/sanitize/SanitizeSettings.tsx @@ -0,0 +1,83 @@ +import { Stack, Text, Checkbox } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { SanitizeParameters } 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 = [ + { + key: 'removeJavaScript' as const, + label: t('sanitize.options.removeJavaScript', 'Remove JavaScript'), + description: t('sanitize.options.removeJavaScript.desc', 'Remove JavaScript actions and scripts from the PDF'), + default: true, + }, + { + key: 'removeEmbeddedFiles' as const, + label: t('sanitize.options.removeEmbeddedFiles', 'Remove Embedded Files'), + description: t('sanitize.options.removeEmbeddedFiles.desc', 'Remove any files embedded within the PDF'), + default: true, + }, + { + key: 'removeXMPMetadata' as const, + label: t('sanitize.options.removeXMPMetadata', 'Remove XMP Metadata'), + description: t('sanitize.options.removeXMPMetadata.desc', 'Remove XMP metadata from the PDF'), + default: false, + }, + { + key: 'removeMetadata' as const, + label: t('sanitize.options.removeMetadata', 'Remove Document Metadata'), + description: t('sanitize.options.removeMetadata.desc', 'Remove document information metadata (title, author, etc.)'), + default: false, + }, + { + key: 'removeLinks' as const, + label: t('sanitize.options.removeLinks', 'Remove Links'), + description: t('sanitize.options.removeLinks.desc', 'Remove external links and launch actions from the PDF'), + default: false, + }, + { + key: 'removeFonts' as const, + label: t('sanitize.options.removeFonts', 'Remove Fonts'), + description: t('sanitize.options.removeFonts.desc', 'Remove embedded fonts from the PDF'), + default: false, + }, + ]; + + 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/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts new file mode 100644 index 000000000..0d92f4c07 --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -0,0 +1,82 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SanitizeParameters } from './useSanitizeParameters'; + +export const useSanitizeOperation = () => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [downloadUrl, setDownloadUrl] = useState(null); + const [status, setStatus] = useState(null); + + const executeOperation = useCallback(async ( + parameters: SanitizeParameters, + selectedFiles: File[] + ) => { + if (selectedFiles.length === 0) { + throw new Error(t('error.noFilesSelected', 'No files selected')); + } + + setIsLoading(true); + setErrorMessage(null); + setStatus(t('sanitize.processing', 'Sanitizing PDF...')); + + try { + const formData = new FormData(); + formData.append('fileInput', selectedFiles[0]); + + // 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()); + + const response = await fetch('/api/v1/security/sanitize-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(t('sanitize.error', 'Sanitization failed: {{error}}', { error: errorText })); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + setDownloadUrl(url); + setStatus(t('sanitize.completed', 'Sanitization completed successfully')); + } catch (error) { + const message = error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'); + setErrorMessage(message); + setStatus(null); + throw error; + } finally { + setIsLoading(false); + } + }, [t]); + + const resetResults = useCallback(() => { + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + } + setDownloadUrl(null); + setErrorMessage(null); + setStatus(null); + }, [downloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + return { + isLoading, + errorMessage, + downloadUrl, + status, + executeOperation, + resetResults, + clearError, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts new file mode 100644 index 000000000..0e7defb7d --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts @@ -0,0 +1,52 @@ +import { useState, useCallback } from 'react'; + +export interface SanitizeParameters { + removeJavaScript: boolean; + removeEmbeddedFiles: boolean; + removeXMPMetadata: boolean; + removeMetadata: boolean; + removeLinks: boolean; + removeFonts: boolean; +} + +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 getEndpointName = useCallback(() => 'sanitize-pdf', []); + + const validateParameters = useCallback(() => { + // At least one sanitization option must be selected + return Object.values(parameters).some(value => value === true); + }, [parameters]); + + return { + parameters, + updateParameter, + resetParameters, + getEndpointName, + validateParameters, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index debd3f5b1..f4b68fe88 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/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx new file mode 100644 index 000000000..233a417db --- /dev/null +++ b/frontend/src/tools/Sanitize.tsx @@ -0,0 +1,154 @@ +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 { useFileContext } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileSelectionContext"; + +import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; +import OperationButton from "../components/tools/shared/OperationButton"; +import ErrorNotification from "../components/tools/shared/ErrorNotification"; +import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator"; +import 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"; + +const generateSanitizedFileName = (originalFileName?: string): string => { + const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; + return `${baseName}_sanitized.pdf`; +}; + +const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); + + 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.downloadUrl && onComplete) { + // Create a File object from the download URL for completion callback + const response = await fetch(sanitizeOperation.downloadUrl); + const blob = await response.blob(); + const sanitizedFileName = generateSanitizedFileName(selectedFiles[0]?.name); + const file = new File([blob], sanitizedFileName, { + type: 'application/pdf' + }); + onComplete([file]); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed')); + } + } + }; + + const handleSettingsReset = () => { + sanitizeOperation.resetResults(); + onPreviewFile?.(null); + // JB: Does this need setCurrentMode()? + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = sanitizeOperation.downloadUrl !== null; + const filesCollapsed = hasFiles; + const settingsCollapsed = hasResults; + + return ( + + + {/* Files Step */} + + + + + {/* Settings Step */} + + + + + + + + + {/* Results Step */} + + + {sanitizeOperation.status && ( + {sanitizeOperation.status} + )} + + + + {sanitizeOperation.downloadUrl && ( + + )} + + + + + ); +} + +export default Sanitize; From 4834e1b7242bb0ddb5d78a59abfdba8f99a8ae62 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 5 Aug 2025 15:53:24 +0100 Subject: [PATCH 02/16] Change Sanitize UI to be scrollable --- frontend/src/tools/Sanitize.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 233a417db..4d5988e29 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -75,7 +75,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return ( - + {/* Files Step */} Date: Tue, 5 Aug 2025 16:07:08 +0100 Subject: [PATCH 03/16] Add English GB translation for sanitisation --- .../public/locales/en-GB/translation.json | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 089562ed6..ced982ea6 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -391,6 +391,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." @@ -1743,5 +1747,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", + "processing": "Sanitising PDF...", + "completed": "Sanitisation completed successfully", + "error": "Sanitisation failed: {{error}}", + "error.generic": "Sanitisation failed", + "steps": { + "files": "Files", + "settings": "Settings", + "results": "Results" + }, + "files": { + "selected": "Selected: {{filename}}", + "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 From 25a0252b09f43ccbdd8354222495f1aaa2279692 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 5 Aug 2025 17:22:03 +0100 Subject: [PATCH 04/16] Add tests for sanitize --- .../tools/sanitize/SanitizeSettings.test.tsx | 194 ++++++++++++++ .../sanitize/useSanitizeOperation.test.ts | 236 ++++++++++++++++++ .../sanitize/useSanitizeParameters.test.ts | 110 ++++++++ 3 files changed, 540 insertions(+) create mode 100644 frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx create mode 100644 frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts create mode 100644 frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts 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..cf8e0c487 --- /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', 'Sanitization Options'); + expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript', 'Remove JavaScript'); + expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles', 'Remove Embedded Files'); + 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); + }); +}); \ No newline at end of file diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts new file mode 100644 index 000000000..d95fcb854 --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSanitizeOperation } from './useSanitizeOperation'; + +// Mock useTranslation +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string, options?: any) => { + if (key === 'sanitize.error' && options?.error) { + return `Sanitization failed: ${options.error}`; + } + if (key === 'error.noFilesSelected') { + return 'No files selected'; + } + if (key === 'sanitize.error.generic') { + return 'Sanitization failed'; + } + return fallback || key; + } + }) +})); + +// Mock fetch +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +// Mock URL.createObjectURL and revokeObjectURL +globalThis.URL.createObjectURL = vi.fn(() => 'mock-blob-url'); +globalThis.URL.revokeObjectURL = vi.fn(); + +describe('useSanitizeOperation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('should initialize with default state', () => { + const { result } = renderHook(() => useSanitizeOperation()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.errorMessage).toBe(null); + expect(result.current.downloadUrl).toBe(null); + expect(result.current.status).toBe(null); + }); + + test('should execute sanitization operation successfully', async () => { + const mockBlob = new Blob(['test'], { type: 'application/pdf' }); + const mockResponse = { + ok: true, + blob: () => Promise.resolve(mockBlob) + }; + mockFetch.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useSanitizeOperation()); + + const parameters = { + removeJavaScript: true, + removeEmbeddedFiles: false, + removeXMPMetadata: true, + removeMetadata: false, + removeLinks: false, + removeFonts: false + }; + + const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + expect(mockFetch).toHaveBeenCalledWith('/api/v1/security/sanitize-pdf', { + method: 'POST', + body: expect.any(FormData) + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.downloadUrl).toBe('mock-blob-url'); + expect(result.current.status).toBe('Sanitization completed successfully'); + expect(result.current.errorMessage).toBe(null); + }); + + test('should handle API errors correctly', async () => { + const mockResponse = { + ok: false, + text: () => Promise.resolve('Server error') + }; + mockFetch.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useSanitizeOperation()); + + const parameters = { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false + }; + + const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); + + await act(async () => { + try { + await result.current.executeOperation(parameters, [testFile]); + } catch (error) { + // Expected to throw + } + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.errorMessage).toBe('Sanitization failed: Server error'); + expect(result.current.downloadUrl).toBe(null); + expect(result.current.status).toBe(null); + }); + + test('should handle no files selected error', async () => { + const { result } = renderHook(() => useSanitizeOperation()); + + const parameters = { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false + }; + + let thrownError: Error | null = null; + await act(async () => { + try { + await result.current.executeOperation(parameters, []); + } catch (error) { + thrownError = error as Error; + } + }); + + // The error should be thrown + expect(thrownError).toBeInstanceOf(Error); + expect(thrownError!.message).toBe('No files selected'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('should send correct form data to API', async () => { + const mockBlob = new Blob(['test'], { type: 'application/pdf' }); + const mockResponse = { + ok: true, + blob: () => Promise.resolve(mockBlob) + }; + mockFetch.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useSanitizeOperation()); + + const parameters = { + removeJavaScript: true, + removeEmbeddedFiles: false, + removeXMPMetadata: true, + removeMetadata: false, + removeLinks: true, + removeFonts: false + }; + + const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); + + await act(async () => { + await result.current.executeOperation(parameters, [testFile]); + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/security/sanitize-pdf'); + expect(options.method).toBe('POST'); + + const formData = options.body as FormData; + expect(formData.get('removeJavaScript')).toBe('true'); + expect(formData.get('removeEmbeddedFiles')).toBe('false'); + expect(formData.get('removeXMPMetadata')).toBe('true'); + expect(formData.get('removeMetadata')).toBe('false'); + expect(formData.get('removeLinks')).toBe('true'); + expect(formData.get('removeFonts')).toBe('false'); + expect(formData.get('fileInput')).toBe(testFile); + }); + + test('should reset results correctly', () => { + const { result } = renderHook(() => useSanitizeOperation()); + + act(() => { + result.current.resetResults(); + }); + + expect(result.current.downloadUrl).toBe(null); + expect(result.current.errorMessage).toBe(null); + expect(result.current.status).toBe(null); + expect(globalThis.URL.revokeObjectURL).not.toHaveBeenCalled(); // No URL to revoke initially + }); + + test('should clear error message', async () => { + // Mock a failed API response + const mockResponse = { + ok: false, + text: () => Promise.resolve('API Error') + }; + mockFetch.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useSanitizeOperation()); + + const parameters = { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false + }; + + const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); + + // Trigger an API error + await act(async () => { + try { + await result.current.executeOperation(parameters, [testFile]); + } catch (error) { + // Expected to throw + } + }); + + expect(result.current.errorMessage).toBeTruthy(); + + act(() => { + result.current.clearError(); + }); + + expect(result.current.errorMessage).toBe(null); + }); +}); 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..9d09ea8bf --- /dev/null +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSanitizeParameters } from './useSanitizeParameters'; + +describe('useSanitizeParameters', () => { + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useSanitizeParameters()); + + expect(result.current.parameters).toEqual({ + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false, + }); + }); + + test('should update individual parameters', () => { + const { result } = renderHook(() => useSanitizeParameters()); + + act(() => { + result.current.updateParameter('removeXMPMetadata', true); + }); + + expect(result.current.parameters.removeXMPMetadata).toBe(true); + expect(result.current.parameters.removeJavaScript).toBe(true); // Other params unchanged + expect(result.current.parameters.removeLinks).toBe(false); // Other params unchanged + }); + + 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).toEqual({ + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false, + }); + }); + + 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 = [ + 'removeJavaScript', + 'removeEmbeddedFiles', + 'removeXMPMetadata', + 'removeMetadata', + 'removeLinks', + 'removeFonts' + ] as const; + + 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); + }); + }); +}); From e88ea34f44b53f15d30885a4c34f2c8473987e7e Mon Sep 17 00:00:00 2001 From: James Date: Tue, 5 Aug 2025 17:23:07 +0100 Subject: [PATCH 05/16] Remove dead code --- frontend/src/tools/Sanitize.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 4d5988e29..394f6c5f3 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -3,7 +3,6 @@ import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; import { useToolFileSelection } from "../contexts/FileSelectionContext"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; @@ -23,7 +22,6 @@ const generateSanitizedFileName = (originalFileName?: string): string => { const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); const { selectedFiles } = useToolFileSelection(); const sanitizeParams = useSanitizeParameters(); @@ -65,7 +63,6 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleSettingsReset = () => { sanitizeOperation.resetResults(); onPreviewFile?.(null); - // JB: Does this need setCurrentMode()? }; const hasFiles = selectedFiles.length > 0; From 243638d51362fc4ea06bb249d24f0b07689a0d30 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 Aug 2025 14:44:41 +0100 Subject: [PATCH 06/16] Put sanitized files in workspace instead of download --- .../tools/sanitize/useSanitizeOperation.ts | 109 +++++++++++++++++- frontend/src/tools/Sanitize.tsx | 37 ++++-- 2 files changed, 129 insertions(+), 17 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index 0d92f4c07..8dc003939 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -1,22 +1,99 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useFileContext } from '../../../contexts/FileContext'; +import { FileOperation } from '../../../types/fileContext'; +import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; import { SanitizeParameters } from './useSanitizeParameters'; export const useSanitizeOperation = () => { const { t } = useTranslation(); + const { + recordOperation, + markOperationApplied, + markOperationFailed, + addFiles + } = useFileContext(); + const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [downloadUrl, setDownloadUrl] = useState(null); const [status, setStatus] = useState(null); + const [files, setFiles] = useState([]); + const [thumbnails, setThumbnails] = useState([]); + const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false); + + const createOperation = useCallback(( + parameters: SanitizeParameters, + selectedFiles: File[] + ): { operation: FileOperation; operationId: string; fileId: string } => { + const operationId = `sanitize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const fileId = selectedFiles[0].name; + + const operation: FileOperation = { + id: operationId, + type: 'sanitize', + timestamp: Date.now(), + fileIds: selectedFiles.map(f => f.name), + status: 'pending', + metadata: { + originalFileName: selectedFiles[0].name, + parameters: { + removeJavaScript: parameters.removeJavaScript, + removeEmbeddedFiles: parameters.removeEmbeddedFiles, + removeXMPMetadata: parameters.removeXMPMetadata, + removeMetadata: parameters.removeMetadata, + removeLinks: parameters.removeLinks, + removeFonts: parameters.removeFonts, + }, + fileSize: selectedFiles[0].size + } + }; + + return { operation, operationId, fileId }; + }, []); + + const processResults = useCallback(async (blob: Blob, filename: string) => { + try { + // Create sanitized file + const sanitizedFile = new File([blob], filename, { type: blob.type }); + + // Set local state for preview + setFiles([sanitizedFile]); + setThumbnails([]); + setIsGeneratingThumbnails(true); + + // Add sanitized file to FileContext for future use + await addFiles([sanitizedFile]); + + // Generate thumbnail for preview + try { + const thumbnail = await generateThumbnailForFile(sanitizedFile); + if (thumbnail) { + setThumbnails([thumbnail]); + } + } catch (error) { + console.warn(`Failed to generate thumbnail for ${filename}:`, error); + setThumbnails(['']); + } + + setIsGeneratingThumbnails(false); + } catch (error) { + console.warn('Failed to process sanitization result:', error); + } + }, [addFiles]); const executeOperation = useCallback(async ( parameters: SanitizeParameters, - selectedFiles: File[] + selectedFiles: File[], + generateSanitizedFileName: (originalFileName?: string) => string ) => { if (selectedFiles.length === 0) { throw new Error(t('error.noFilesSelected', 'No files selected')); } + const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); + recordOperation(fileId, operation); + setIsLoading(true); setErrorMessage(null); setStatus(t('sanitize.processing', 'Sanitizing PDF...')); @@ -24,7 +101,7 @@ export const useSanitizeOperation = () => { try { const formData = new FormData(); formData.append('fileInput', selectedFiles[0]); - + // Add parameters formData.append('removeJavaScript', parameters.removeJavaScript.toString()); formData.append('removeEmbeddedFiles', parameters.removeEmbeddedFiles.toString()); @@ -40,13 +117,20 @@ export const useSanitizeOperation = () => { if (!response.ok) { const errorText = await response.text(); + markOperationFailed(fileId, operationId, errorText); throw new Error(t('sanitize.error', 'Sanitization failed: {{error}}', { error: errorText })); } const blob = await response.blob(); + const sanitizedFileName = generateSanitizedFileName(selectedFiles[0].name); + const url = URL.createObjectURL(blob); setDownloadUrl(url); setStatus(t('sanitize.completed', 'Sanitization completed successfully')); + + // Process results and add to workbench + await processResults(blob, sanitizedFileName); + markOperationApplied(fileId, operationId); } catch (error) { const message = error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'); setErrorMessage(message); @@ -55,12 +139,15 @@ export const useSanitizeOperation = () => { } finally { setIsLoading(false); } - }, [t]); + }, [t, createOperation, recordOperation, markOperationApplied, markOperationFailed, processResults]); const resetResults = useCallback(() => { if (downloadUrl) { URL.revokeObjectURL(downloadUrl); } + setFiles([]); + setThumbnails([]); + setIsGeneratingThumbnails(false); setDownloadUrl(null); setErrorMessage(null); setStatus(null); @@ -70,13 +157,25 @@ export const useSanitizeOperation = () => { setErrorMessage(null); }, []); + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + } + }; + }, [downloadUrl]); + return { isLoading, errorMessage, downloadUrl, status, + files, + thumbnails, + isGeneratingThumbnails, executeOperation, resetResults, clearError, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 394f6c5f3..c5068ae02 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -9,20 +9,23 @@ 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 generateSanitizedFileName = (originalFileName?: string): string => { const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; - return `${baseName}_sanitized.pdf`; + return `sanitized_${baseName}.pdf`; }; const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useToolFileSelection(); + const { setCurrentMode } = useFileContext(); const sanitizeParams = useSanitizeParameters(); const sanitizeOperation = useSanitizeOperation(); @@ -41,17 +44,11 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { try { await sanitizeOperation.executeOperation( sanitizeParams.parameters, - selectedFiles + selectedFiles, + generateSanitizedFileName ); - if (sanitizeOperation.downloadUrl && onComplete) { - // Create a File object from the download URL for completion callback - const response = await fetch(sanitizeOperation.downloadUrl); - const blob = await response.blob(); - const sanitizedFileName = generateSanitizedFileName(selectedFiles[0]?.name); - const file = new File([blob], sanitizedFileName, { - type: 'application/pdf' - }); - onComplete([file]); + if (sanitizeOperation.files && onComplete) { + onComplete(sanitizeOperation.files); } } catch (error) { if (onError) { @@ -65,8 +62,14 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { onPreviewFile?.(null); }; + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem('previousMode', 'sanitize'); + setCurrentMode('viewer'); + }; + const hasFiles = selectedFiles.length > 0; - const hasResults = sanitizeOperation.downloadUrl !== null; + const hasResults = sanitizeOperation.files.length > 0; const filesCollapsed = hasFiles; const settingsCollapsed = hasResults; @@ -141,6 +144,16 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { {t("download", "Download")} )} + + ({ + file, + thumbnail: sanitizeOperation.thumbnails[index] + }))} + onFileClick={handleThumbnailClick} + isGeneratingThumbnails={sanitizeOperation.isGeneratingThumbnails} + title={t("sanitize.sanitizationResults", "Sanitization Results")} + /> From df9d99b8cb2fc0ed5595babd1abb16745f2f70ef Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 Aug 2025 16:44:26 +0100 Subject: [PATCH 07/16] Support multiple-selections for Sanitize --- .../public/locales/en-GB/translation.json | 3 +- .../public/locales/en-US/translation.json | 3 +- .../tools/sanitize/useSanitizeOperation.ts | 163 +++++++++++------- frontend/src/hooks/useToolManagement.tsx | 2 +- frontend/src/tools/Convert.tsx | 6 +- frontend/src/tools/Sanitize.tsx | 16 +- 6 files changed, 126 insertions(+), 67 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ced982ea6..b3e923fb5 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?", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 776213dbe..89d603f8a 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?", diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index 8dc003939..18db1d3bb 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { FileOperation } from '../../../types/fileContext'; import { generateThumbnailForFile } from '../../../utils/thumbnailUtils'; +import { zipFileService } from '../../../services/zipFileService'; import { SanitizeParameters } from './useSanitizeParameters'; export const useSanitizeOperation = () => { @@ -52,36 +53,6 @@ export const useSanitizeOperation = () => { return { operation, operationId, fileId }; }, []); - const processResults = useCallback(async (blob: Blob, filename: string) => { - try { - // Create sanitized file - const sanitizedFile = new File([blob], filename, { type: blob.type }); - - // Set local state for preview - setFiles([sanitizedFile]); - setThumbnails([]); - setIsGeneratingThumbnails(true); - - // Add sanitized file to FileContext for future use - await addFiles([sanitizedFile]); - - // Generate thumbnail for preview - try { - const thumbnail = await generateThumbnailForFile(sanitizedFile); - if (thumbnail) { - setThumbnails([thumbnail]); - } - } catch (error) { - console.warn(`Failed to generate thumbnail for ${filename}:`, error); - setThumbnails(['']); - } - - setIsGeneratingThumbnails(false); - } catch (error) { - console.warn('Failed to process sanitization result:', error); - } - }, [addFiles]); - const executeOperation = useCallback(async ( parameters: SanitizeParameters, selectedFiles: File[], @@ -91,47 +62,119 @@ export const useSanitizeOperation = () => { throw new Error(t('error.noFilesSelected', 'No files selected')); } - const { operation, operationId, fileId } = createOperation(parameters, selectedFiles); - recordOperation(fileId, operation); - setIsLoading(true); setErrorMessage(null); - setStatus(t('sanitize.processing', 'Sanitizing PDF...')); + setStatus(selectedFiles.length === 1 + ? t('sanitize.processing', 'Sanitizing PDF...') + : t('sanitize.processingMultiple', 'Sanitizing {{count}} PDFs...', { count: selectedFiles.length }) + ); + + const results: File[] = []; + const failedFiles: string[] = []; try { - const formData = new FormData(); - formData.append('fileInput', selectedFiles[0]); + // Process each file separately + for (let i = 0; i < selectedFiles.length; i++) { + const file = selectedFiles[i]; + const { operation, operationId, fileId } = createOperation(parameters, [file]); + recordOperation(fileId, operation); - // 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()); + setStatus(selectedFiles.length === 1 + ? t('sanitize.processing', 'Sanitizing PDF...') + : t('sanitize.processingFile', 'Processing file {{current}} of {{total}}: {{filename}}', { + current: i + 1, + total: selectedFiles.length, + filename: file.name + }) + ); - const response = await fetch('/api/v1/security/sanitize-pdf', { - method: 'POST', - body: formData, - }); + try { + 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()); - if (!response.ok) { - const errorText = await response.text(); - markOperationFailed(fileId, operationId, errorText); - throw new Error(t('sanitize.error', 'Sanitization failed: {{error}}', { error: errorText })); + const response = await fetch('/api/v1/security/sanitize-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + markOperationFailed(fileId, operationId, errorText); + console.error(`Error sanitizing file ${file.name}:`, errorText); + failedFiles.push(file.name); + continue; + } + + const blob = await response.blob(); + const sanitizedFileName = generateSanitizedFileName(file.name); + const sanitizedFile = new File([blob], sanitizedFileName, { type: blob.type }); + + results.push(sanitizedFile); + markOperationApplied(fileId, operationId); + } catch (error) { + console.error(`Error sanitizing file ${file.name}:`, error); + markOperationFailed(fileId, operationId, error instanceof Error ? error.message : 'Unknown error'); + failedFiles.push(file.name); + } } - const blob = await response.blob(); - const sanitizedFileName = generateSanitizedFileName(selectedFiles[0].name); + if (failedFiles.length > 0 && results.length === 0) { + throw new Error(`Failed to sanitize all files: ${failedFiles.join(', ')}`); + } - const url = URL.createObjectURL(blob); - setDownloadUrl(url); - setStatus(t('sanitize.completed', 'Sanitization completed successfully')); + if (failedFiles.length > 0) { + setStatus(`Sanitized ${results.length}/${selectedFiles.length} files. Failed: ${failedFiles.join(', ')}`); + } - // Process results and add to workbench - await processResults(blob, sanitizedFileName); - markOperationApplied(fileId, operationId); + if (results.length > 0) { + setFiles(results); + setIsGeneratingThumbnails(true); + + // Add sanitized files to FileContext for future use + await addFiles(results); + + // Create download info - single file or ZIP + if (results.length === 1) { + const url = window.URL.createObjectURL(results[0]); + setDownloadUrl(url); + } else { + const { zipFile } = await zipFileService.createZipFromFiles(results, 'sanitized_files.zip'); + const url = window.URL.createObjectURL(zipFile); + setDownloadUrl(url); + } + + // Generate thumbnails + const thumbnails = await Promise.all( + results.map(async (file) => { + try { + const thumbnail = await generateThumbnailForFile(file); + return thumbnail || ''; + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + return ''; + } + }) + ); + + setThumbnails(thumbnails); + setIsGeneratingThumbnails(false); + setStatus(results.length === 1 + ? t('sanitize.completed', 'Sanitization completed successfully') + : t('sanitize.completedMultiple', 'Sanitized {{count}} files successfully', { count: results.length }) + ); + } else { + setErrorMessage(t('sanitize.errorAllFilesFailed', 'All files failed to sanitize')); + } } catch (error) { + console.error('Error in sanitization operation:', error); const message = error instanceof Error ? error.message : t('sanitize.error.generic', 'Sanitization failed'); setErrorMessage(message); setStatus(null); @@ -139,7 +182,7 @@ export const useSanitizeOperation = () => { } finally { setIsLoading(false); } - }, [t, createOperation, recordOperation, markOperationApplied, markOperationFailed, processResults]); + }, [t, createOperation, recordOperation, markOperationApplied, markOperationFailed, addFiles]); const resetResults = useCallback(() => { if (downloadUrl) { diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index f4b68fe88..1de26814d 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -80,7 +80,7 @@ const toolDefinitions: Record = { id: "sanitize", icon: , component: React.lazy(() => import("../tools/Sanitize")), - maxFiles: 1, + 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} > { isVisible={true} isCollapsed={filesCollapsed} isCompleted={filesCollapsed} - completedMessage={hasFiles ? t('sanitize.files.selected', 'Selected: {{filename}}', { filename: selectedFiles[0]?.name }) : undefined} + completedMessage={hasFiles ? + selectedFiles.length === 1 + ? t('fileSelected', 'Selected: {{filename}}', { filename: selectedFiles[0].name }) + : t('filesSelected', 'Selected: {{count}} files', { count: selectedFiles.length }) + : undefined} > { )} From addaf6f751322c1787b1b0ca48e9b8afff91155b Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 Aug 2025 16:52:48 +0100 Subject: [PATCH 08/16] Refactor into multiple functions --- .../tools/sanitize/useSanitizeOperation.ts | 188 +++++++++++------- frontend/src/types/fileContext.ts | 34 ++-- 2 files changed, 128 insertions(+), 94 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index 18db1d3bb..f10f46ff7 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -53,6 +53,103 @@ export const useSanitizeOperation = () => { return { operation, operationId, fileId }; }, []); + const buildFormData = useCallback((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; + }, []); + + const sanitizeFile = useCallback(async ( + file: File, + parameters: SanitizeParameters, + generateSanitizedFileName: (originalFileName?: string) => string, + operationId: string, + fileId: string + ): Promise => { + try { + const formData = buildFormData(parameters, file); + + const response = await fetch('/api/v1/security/sanitize-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + markOperationFailed(fileId, operationId, errorText); + console.error(`Error sanitizing file ${file.name}:`, errorText); + return null; + } + + const blob = await response.blob(); + const sanitizedFileName = generateSanitizedFileName(file.name); + const sanitizedFile = new File([blob], sanitizedFileName, { type: blob.type }); + + markOperationApplied(fileId, operationId); + return sanitizedFile; + } catch (error) { + console.error(`Error sanitizing file ${file.name}:`, error); + markOperationFailed(fileId, operationId, error instanceof Error ? error.message : 'Unknown error'); + return null; + } + }, [buildFormData, markOperationApplied, markOperationFailed]); + + const createDownloadInfo = useCallback(async (results: File[]): Promise => { + if (results.length === 1) { + const url = window.URL.createObjectURL(results[0]); + setDownloadUrl(url); + } else { + const { zipFile } = await zipFileService.createZipFromFiles(results, 'sanitized_files.zip'); + const url = window.URL.createObjectURL(zipFile); + setDownloadUrl(url); + } + }, []); + + const generateThumbnailsForResults = useCallback(async (results: File[]): Promise => { + const thumbnails = await Promise.all( + results.map(async (file) => { + try { + const thumbnail = await generateThumbnailForFile(file); + return thumbnail || ''; + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + return ''; + } + }) + ); + + setThumbnails(thumbnails); + }, []); + + const processResults = useCallback(async (results: File[]): Promise => { + setFiles(results); + setIsGeneratingThumbnails(true); + + // Add sanitized files to FileContext for future use + await addFiles(results); + + // Create download info - single file or ZIP + await createDownloadInfo(results); + + // Generate thumbnails + await generateThumbnailsForResults(results); + + setIsGeneratingThumbnails(false); + setStatus(results.length === 1 + ? t('sanitize.completed', 'Sanitization completed successfully') + : t('sanitize.completedMultiple', 'Sanitized {{count}} files successfully', { count: results.length }) + ); + }, [addFiles, createDownloadInfo, generateThumbnailsForResults, t]); + const executeOperation = useCallback(async ( parameters: SanitizeParameters, selectedFiles: File[], @@ -64,8 +161,8 @@ export const useSanitizeOperation = () => { setIsLoading(true); setErrorMessage(null); - setStatus(selectedFiles.length === 1 - ? t('sanitize.processing', 'Sanitizing PDF...') + setStatus(selectedFiles.length === 1 + ? t('sanitize.processing', 'Sanitizing PDF...') : t('sanitize.processingMultiple', 'Sanitizing {{count}} PDFs...', { count: selectedFiles.length }) ); @@ -79,49 +176,20 @@ export const useSanitizeOperation = () => { const { operation, operationId, fileId } = createOperation(parameters, [file]); recordOperation(fileId, operation); - setStatus(selectedFiles.length === 1 - ? t('sanitize.processing', 'Sanitizing PDF...') - : t('sanitize.processingFile', 'Processing file {{current}} of {{total}}: {{filename}}', { - current: i + 1, - total: selectedFiles.length, - filename: file.name + setStatus(selectedFiles.length === 1 + ? t('sanitize.processing', 'Sanitizing PDF...') + : t('sanitize.processingFile', 'Processing file {{current}} of {{total}}: {{filename}}', { + current: i + 1, + total: selectedFiles.length, + filename: file.name }) ); - try { - 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()); - - const response = await fetch('/api/v1/security/sanitize-pdf', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - markOperationFailed(fileId, operationId, errorText); - console.error(`Error sanitizing file ${file.name}:`, errorText); - failedFiles.push(file.name); - continue; - } - - const blob = await response.blob(); - const sanitizedFileName = generateSanitizedFileName(file.name); - const sanitizedFile = new File([blob], sanitizedFileName, { type: blob.type }); - + const sanitizedFile = await sanitizeFile(file, parameters, generateSanitizedFileName, operationId, fileId); + + if (sanitizedFile) { results.push(sanitizedFile); - markOperationApplied(fileId, operationId); - } catch (error) { - console.error(`Error sanitizing file ${file.name}:`, error); - markOperationFailed(fileId, operationId, error instanceof Error ? error.message : 'Unknown error'); + } else { failedFiles.push(file.name); } } @@ -135,41 +203,7 @@ export const useSanitizeOperation = () => { } if (results.length > 0) { - setFiles(results); - setIsGeneratingThumbnails(true); - - // Add sanitized files to FileContext for future use - await addFiles(results); - - // Create download info - single file or ZIP - if (results.length === 1) { - const url = window.URL.createObjectURL(results[0]); - setDownloadUrl(url); - } else { - const { zipFile } = await zipFileService.createZipFromFiles(results, 'sanitized_files.zip'); - const url = window.URL.createObjectURL(zipFile); - setDownloadUrl(url); - } - - // Generate thumbnails - const thumbnails = await Promise.all( - results.map(async (file) => { - try { - const thumbnail = await generateThumbnailForFile(file); - return thumbnail || ''; - } catch (error) { - console.warn(`Failed to generate thumbnail for ${file.name}:`, error); - return ''; - } - }) - ); - - setThumbnails(thumbnails); - setIsGeneratingThumbnails(false); - setStatus(results.length === 1 - ? t('sanitize.completed', 'Sanitization completed successfully') - : t('sanitize.completedMultiple', 'Sanitized {{count}} files successfully', { count: results.length }) - ); + await processResults(results); } else { setErrorMessage(t('sanitize.errorAllFilesFailed', 'All files failed to sanitize')); } @@ -182,7 +216,7 @@ export const useSanitizeOperation = () => { } finally { setIsLoading(false); } - }, [t, createOperation, recordOperation, markOperationApplied, markOperationFailed, addFiles]); + }, [t, createOperation, recordOperation, sanitizeFile, processResults]); const resetResults = useCallback(() => { if (downloadUrl) { diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index d9c049ae7..292454065 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -7,7 +7,7 @@ import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr'; -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; @@ -51,25 +51,25 @@ export interface FileContextState { // Core file management activeFiles: File[]; processedFiles: Map; - + // Current navigation state currentMode: ModeType; - + // Edit history and state fileEditHistory: Map; globalFileOperations: FileOperation[]; // New comprehensive operation history fileOperationHistory: Map; - + // UI state that persists across views selectedFileIds: string[]; selectedPageNumbers: number[]; viewerConfig: ViewerConfig; - + // Processing state isProcessing: boolean; processingProgress: number; - + // Export state lastExportConfig?: { filename: string; @@ -89,7 +89,7 @@ export interface FileContextActions { removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; replaceFile: (oldFileId: string, newFile: File) => Promise; clearAllFiles: () => void; - + // Navigation setCurrentMode: (mode: ModeType) => void; // Selection management @@ -97,12 +97,12 @@ export interface FileContextActions { setSelectedPages: (pageNumbers: number[]) => void; updateProcessedFile: (file: File, processedFile: ProcessedFile) => void; clearSelections: () => void; - + // Edit operations applyPageOperations: (fileId: string, operations: PageOperation[]) => void; applyFileOperation: (operation: FileOperation) => void; undoLastOperation: (fileId?: string) => void; - + // Operation history management recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void; markOperationApplied: (fileId: string, operationId: string) => void; @@ -110,31 +110,31 @@ export interface FileContextActions { getFileHistory: (fileId: string) => FileOperationHistory | undefined; getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[]; clearFileHistory: (fileId: string) => void; - + // Viewer state updateViewerConfig: (config: Partial) => void; - + // Export configuration setExportConfig: (config: FileContextState['lastExportConfig']) => void; - - + + // Utility getFileById: (fileId: string) => File | undefined; getProcessedFileById: (fileId: string) => ProcessedFile | undefined; getCurrentFile: () => File | undefined; getCurrentProcessedFile: () => ProcessedFile | undefined; - + // Context persistence saveContext: () => Promise; loadContext: () => Promise; resetContext: () => void; - + // Navigation guard system setHasUnsavedChanges: (hasChanges: boolean) => void; requestNavigation: (navigationFn: () => void) => boolean; confirmNavigation: () => void; cancelNavigation: () => void; - + // Memory management trackBlobUrl: (url: string) => void; trackPdfDocument: (fileId: string, pdfDoc: any) => void; @@ -163,4 +163,4 @@ export interface FileContextUrlParams { pageIds?: string[]; zoom?: number; page?: number; -} \ No newline at end of file +} From 201f6cb6ab6578d96f406746b80d4783b907665e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 6 Aug 2025 17:00:48 +0100 Subject: [PATCH 09/16] Fix tests --- .../sanitize/useSanitizeOperation.test.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts index d95fcb854..26c924dcf 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts @@ -20,6 +20,16 @@ vi.mock('react-i18next', () => ({ }) })); +// Mock FileContext +vi.mock('../../../contexts/FileContext', () => ({ + useFileContext: () => ({ + recordOperation: vi.fn(), + markOperationApplied: vi.fn(), + markOperationFailed: vi.fn(), + addFiles: vi.fn() + }) +})); + // Mock fetch const mockFetch = vi.fn(); globalThis.fetch = mockFetch; @@ -29,6 +39,11 @@ globalThis.URL.createObjectURL = vi.fn(() => 'mock-blob-url'); globalThis.URL.revokeObjectURL = vi.fn(); describe('useSanitizeOperation', () => { + const mockGenerateSanitizedFileName = (originalFileName?: string): string => { + const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; + return `sanitized_${baseName}.pdf`; + }; + beforeEach(() => { vi.clearAllMocks(); }); @@ -68,7 +83,7 @@ describe('useSanitizeOperation', () => { const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); await act(async () => { - await result.current.executeOperation(parameters, [testFile]); + await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); }); expect(mockFetch).toHaveBeenCalledWith('/api/v1/security/sanitize-pdf', { @@ -104,14 +119,14 @@ describe('useSanitizeOperation', () => { await act(async () => { try { - await result.current.executeOperation(parameters, [testFile]); + await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); } catch (error) { // Expected to throw } }); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe('Sanitization failed: Server error'); + expect(result.current.errorMessage).toBe('Failed to sanitize all files: test.pdf'); expect(result.current.downloadUrl).toBe(null); expect(result.current.status).toBe(null); }); @@ -131,7 +146,7 @@ describe('useSanitizeOperation', () => { let thrownError: Error | null = null; await act(async () => { try { - await result.current.executeOperation(parameters, []); + await result.current.executeOperation(parameters, [], mockGenerateSanitizedFileName); } catch (error) { thrownError = error as Error; } @@ -165,7 +180,7 @@ describe('useSanitizeOperation', () => { const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); await act(async () => { - await result.current.executeOperation(parameters, [testFile]); + await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); }); const [url, options] = mockFetch.mock.calls[0]; @@ -219,13 +234,13 @@ describe('useSanitizeOperation', () => { // Trigger an API error await act(async () => { try { - await result.current.executeOperation(parameters, [testFile]); + await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); } catch (error) { // Expected to throw } }); - expect(result.current.errorMessage).toBeTruthy(); + expect(result.current.errorMessage).toBe('Failed to sanitize all files: test.pdf'); act(() => { result.current.clearError(); From 8c4a646fe39282085bb4c878ae19604680842f08 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 Aug 2025 09:00:14 +0100 Subject: [PATCH 10/16] Localise filenames --- frontend/public/locales/en-GB/translation.json | 1 + frontend/public/locales/en-US/translation.json | 1 + .../hooks/tools/sanitize/useSanitizeOperation.ts | 5 +++-- frontend/src/tools/Sanitize.tsx | 13 +++++++------ 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b3e923fb5..73e63ad3f 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1755,6 +1755,7 @@ "completed": "Sanitisation completed successfully", "error": "Sanitisation failed: {{error}}", "error.generic": "Sanitisation failed", + "filenamePrefix": "sanitised", "steps": { "files": "Files", "settings": "Settings", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 89d603f8a..a797073f3 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1624,6 +1624,7 @@ "completed": "Sanitization completed successfully", "error": "Sanitization failed: {{error}}", "error.generic": "Sanitization failed", + "filenamePrefix": "sanitized", "steps": { "files": "Files", "settings": "Settings", diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index f10f46ff7..03d65430f 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -108,11 +108,12 @@ export const useSanitizeOperation = () => { const url = window.URL.createObjectURL(results[0]); setDownloadUrl(url); } else { - const { zipFile } = await zipFileService.createZipFromFiles(results, 'sanitized_files.zip'); + const zipFilename = `${t('sanitize.filenamePrefix', 'sanitized')}_files.zip`; + const { zipFile } = await zipFileService.createZipFromFiles(results, zipFilename); const url = window.URL.createObjectURL(zipFile); setDownloadUrl(url); } - }, []); + }, [t]); const generateThumbnailsForResults = useCallback(async (results: File[]): Promise => { const thumbnails = await Promise.all( diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index e2f700ed5..6062a360d 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -17,13 +17,14 @@ import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperati import { BaseToolProps } from "../types/tool"; import { useFileContext } from "../contexts/FileContext"; -const generateSanitizedFileName = (originalFileName?: string): string => { - const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; - return `sanitized_${baseName}.pdf`; -}; - const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); + + const generateSanitizedFileName = (originalFileName?: string): string => { + const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; + const prefix = t('sanitize.filenamePrefix', 'sanitized'); + return `${prefix}_${baseName}.pdf`; + }; const { selectedFiles } = useToolFileSelection(); const { setCurrentMode } = useFileContext(); @@ -141,7 +142,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { href={sanitizeOperation.downloadUrl} download={sanitizeOperation.files.length === 1 ? generateSanitizedFileName(selectedFiles[0]?.name) - : 'sanitized_files.zip' + : `${t('sanitize.filenamePrefix', 'sanitized')}_files.zip` } leftSection={} color="green" From e7f6fd5e918109ba4c7091f8fa43b33b7222231c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 Aug 2025 09:36:36 +0100 Subject: [PATCH 11/16] Tidy testing --- .../tools/sanitize/SanitizeSettings.test.tsx | 16 ++++++++-------- .../tools/sanitize/useSanitizeOperation.test.ts | 10 +++++++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx b/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx index cf8e0c487..055de2466 100644 --- a/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx +++ b/frontend/src/components/tools/sanitize/SanitizeSettings.test.tsx @@ -59,7 +59,7 @@ describe('SanitizeSettings', () => { const checkboxes = screen.getAllByRole('checkbox'); const parameterValues = Object.values(defaultParameters); - + parameterValues.forEach((value, index) => { if (value) { expect(checkboxes[index]).toBeChecked(); @@ -80,7 +80,7 @@ describe('SanitizeSettings', () => { ); 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); @@ -160,9 +160,9 @@ describe('SanitizeSettings', () => { ); // Verify that translation keys are being called (just check that it was called, not specific order) - expect(mockT).toHaveBeenCalledWith('sanitize.options.title', 'Sanitization Options'); - expect(mockT).toHaveBeenCalledWith('sanitize.options.removeJavaScript', 'Remove JavaScript'); - expect(mockT).toHaveBeenCalledWith('sanitize.options.removeEmbeddedFiles', 'Remove Embedded Files'); + 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)); }); @@ -178,12 +178,12 @@ describe('SanitizeSettings', () => { ); 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; @@ -191,4 +191,4 @@ describe('SanitizeSettings', () => { fireEvent.click(firstCheckbox); expect(firstCheckbox.checked).toBe(initialChecked); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts index 26c924dcf..f6e267fec 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts @@ -32,11 +32,15 @@ vi.mock('../../../contexts/FileContext', () => ({ // Mock fetch const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; +vi.stubGlobal('fetch', mockFetch); // Mock URL.createObjectURL and revokeObjectURL -globalThis.URL.createObjectURL = vi.fn(() => 'mock-blob-url'); -globalThis.URL.revokeObjectURL = vi.fn(); +const mockCreateObjectURL = vi.fn(() => 'mock-blob-url'); +const mockRevokeObjectURL = vi.fn(); +vi.stubGlobal('URL', { + createObjectURL: mockCreateObjectURL, + revokeObjectURL: mockRevokeObjectURL +}); describe('useSanitizeOperation', () => { const mockGenerateSanitizedFileName = (originalFileName?: string): string => { From 274b545fe4052224e69f9d8e214a3dd0c9eda1d2 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 Aug 2025 09:46:28 +0100 Subject: [PATCH 12/16] More test tidying --- .../sanitize/useSanitizeOperation.test.ts | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts index f6e267fec..4e4791e74 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts @@ -122,11 +122,8 @@ describe('useSanitizeOperation', () => { const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); await act(async () => { - try { - await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); - } catch (error) { - // Expected to throw - } + await expect(result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName)) + .rejects.toThrow('Failed to sanitize all files: test.pdf'); }); expect(result.current.isLoading).toBe(false); @@ -147,18 +144,11 @@ describe('useSanitizeOperation', () => { removeFonts: false }; - let thrownError: Error | null = null; await act(async () => { - try { - await result.current.executeOperation(parameters, [], mockGenerateSanitizedFileName); - } catch (error) { - thrownError = error as Error; - } + await expect(result.current.executeOperation(parameters, [], mockGenerateSanitizedFileName)) + .rejects.toThrow('No files selected'); }); - // The error should be thrown - expect(thrownError).toBeInstanceOf(Error); - expect(thrownError!.message).toBe('No files selected'); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -211,7 +201,7 @@ describe('useSanitizeOperation', () => { expect(result.current.downloadUrl).toBe(null); expect(result.current.errorMessage).toBe(null); expect(result.current.status).toBe(null); - expect(globalThis.URL.revokeObjectURL).not.toHaveBeenCalled(); // No URL to revoke initially + expect(mockRevokeObjectURL).not.toHaveBeenCalled(); // No URL to revoke initially }); test('should clear error message', async () => { @@ -237,11 +227,8 @@ describe('useSanitizeOperation', () => { // Trigger an API error await act(async () => { - try { - await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); - } catch (error) { - // Expected to throw - } + await expect(result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName)) + .rejects.toThrow('Failed to sanitize all files: test.pdf'); }); expect(result.current.errorMessage).toBe('Failed to sanitize all files: test.pdf'); From 6ac0994adf1197ebffba0bcf762519cbd7efcf65 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 Aug 2025 10:40:15 +0100 Subject: [PATCH 13/16] Neaten testing --- .../sanitize/useSanitizeParameters.test.ts | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts index 9d09ea8bf..e7875ad4e 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts @@ -2,18 +2,20 @@ import { describe, expect, test } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useSanitizeParameters } from './useSanitizeParameters'; +const defaultParameters = { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false, +} + describe('useSanitizeParameters', () => { test('should initialize with default parameters', () => { const { result } = renderHook(() => useSanitizeParameters()); - expect(result.current.parameters).toEqual({ - removeJavaScript: true, - removeEmbeddedFiles: true, - removeXMPMetadata: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false, - }); + expect(result.current.parameters).toStrictEqual(defaultParameters); }); test('should update individual parameters', () => { @@ -23,9 +25,10 @@ describe('useSanitizeParameters', () => { result.current.updateParameter('removeXMPMetadata', true); }); - expect(result.current.parameters.removeXMPMetadata).toBe(true); - expect(result.current.parameters.removeJavaScript).toBe(true); // Other params unchanged - expect(result.current.parameters.removeLinks).toBe(false); // Other params unchanged + expect(result.current.parameters).toStrictEqual({ + ...defaultParameters, // Other params unchanged + removeXMPMetadata: true, + }); }); test('should reset parameters to defaults', () => { @@ -45,14 +48,7 @@ describe('useSanitizeParameters', () => { result.current.resetParameters(); }); - expect(result.current.parameters).toEqual({ - removeJavaScript: true, - removeEmbeddedFiles: true, - removeXMPMetadata: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false, - }); + expect(result.current.parameters).toStrictEqual(defaultParameters); }); test('should return correct endpoint name', () => { @@ -86,14 +82,7 @@ describe('useSanitizeParameters', () => { test('should handle all parameter types correctly', () => { const { result } = renderHook(() => useSanitizeParameters()); - const allParameters = [ - 'removeJavaScript', - 'removeEmbeddedFiles', - 'removeXMPMetadata', - 'removeMetadata', - 'removeLinks', - 'removeFonts' - ] as const; + const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[]; allParameters.forEach(param => { act(() => { From 45b51edb8b3c22b2b9b2eda8dd486572492c564f Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 Aug 2025 11:14:07 +0100 Subject: [PATCH 14/16] Tweaks from self-review --- .../tools/sanitize/useSanitizeParameters.test.ts | 11 +---------- .../src/hooks/tools/sanitize/useSanitizeParameters.ts | 4 ++-- frontend/src/tools/Sanitize.tsx | 3 ++- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts index e7875ad4e..f86a6c81a 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.test.ts @@ -1,15 +1,6 @@ import { describe, expect, test } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { useSanitizeParameters } from './useSanitizeParameters'; - -const defaultParameters = { - removeJavaScript: true, - removeEmbeddedFiles: true, - removeXMPMetadata: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false, -} +import { defaultParameters, useSanitizeParameters } from './useSanitizeParameters'; describe('useSanitizeParameters', () => { test('should initialize with default parameters', () => { diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts index 0e7defb7d..1697657e9 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeParameters.ts @@ -9,7 +9,7 @@ export interface SanitizeParameters { removeFonts: boolean; } -const defaultParameters: SanitizeParameters = { +export const defaultParameters: SanitizeParameters = { removeJavaScript: true, removeEmbeddedFiles: true, removeXMPMetadata: false, @@ -49,4 +49,4 @@ export const useSanitizeParameters = () => { getEndpointName, validateParameters, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 6062a360d..0821670da 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -25,6 +25,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const prefix = t('sanitize.filenamePrefix', 'sanitized'); return `${prefix}_${baseName}.pdf`; }; + const { selectedFiles } = useToolFileSelection(); const { setCurrentMode } = useFileContext(); @@ -76,7 +77,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { return ( - + {/* Files Step */} Date: Thu, 7 Aug 2025 14:05:11 +0100 Subject: [PATCH 15/16] Simplify filename generation --- .../sanitize/useSanitizeOperation.test.ts | 15 ++++------- .../tools/sanitize/useSanitizeOperation.ts | 27 ++++++++++++------- frontend/src/tools/Sanitize.tsx | 12 +-------- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts index 4e4791e74..cac27cf99 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.test.ts @@ -43,11 +43,6 @@ vi.stubGlobal('URL', { }); describe('useSanitizeOperation', () => { - const mockGenerateSanitizedFileName = (originalFileName?: string): string => { - const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; - return `sanitized_${baseName}.pdf`; - }; - beforeEach(() => { vi.clearAllMocks(); }); @@ -87,7 +82,7 @@ describe('useSanitizeOperation', () => { const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); await act(async () => { - await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); + await result.current.executeOperation(parameters, [testFile]); }); expect(mockFetch).toHaveBeenCalledWith('/api/v1/security/sanitize-pdf', { @@ -122,7 +117,7 @@ describe('useSanitizeOperation', () => { const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); await act(async () => { - await expect(result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName)) + await expect(result.current.executeOperation(parameters, [testFile])) .rejects.toThrow('Failed to sanitize all files: test.pdf'); }); @@ -145,7 +140,7 @@ describe('useSanitizeOperation', () => { }; await act(async () => { - await expect(result.current.executeOperation(parameters, [], mockGenerateSanitizedFileName)) + await expect(result.current.executeOperation(parameters, [])) .rejects.toThrow('No files selected'); }); @@ -174,7 +169,7 @@ describe('useSanitizeOperation', () => { const testFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); await act(async () => { - await result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName); + await result.current.executeOperation(parameters, [testFile]); }); const [url, options] = mockFetch.mock.calls[0]; @@ -227,7 +222,7 @@ describe('useSanitizeOperation', () => { // Trigger an API error await act(async () => { - await expect(result.current.executeOperation(parameters, [testFile], mockGenerateSanitizedFileName)) + await expect(result.current.executeOperation(parameters, [testFile])) .rejects.toThrow('Failed to sanitize all files: test.pdf'); }); diff --git a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts index 03d65430f..dab8ff04d 100644 --- a/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/src/hooks/tools/sanitize/useSanitizeOperation.ts @@ -18,6 +18,7 @@ export const useSanitizeOperation = () => { const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); const [status, setStatus] = useState(null); const [files, setFiles] = useState([]); const [thumbnails, setThumbnails] = useState([]); @@ -56,7 +57,7 @@ export const useSanitizeOperation = () => { const buildFormData = useCallback((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()); @@ -64,14 +65,19 @@ export const useSanitizeOperation = () => { formData.append('removeMetadata', parameters.removeMetadata.toString()); formData.append('removeLinks', parameters.removeLinks.toString()); formData.append('removeFonts', parameters.removeFonts.toString()); - + return formData; }, []); + const generateSanitizedFileName = (originalFileName: string): string => { + const baseName = originalFileName.replace(/\.[^/.]+$/, ''); + const prefix = t('sanitize.filenamePrefix', 'sanitized'); + return `${prefix}_${baseName}.pdf`; + }; + const sanitizeFile = useCallback(async ( file: File, parameters: SanitizeParameters, - generateSanitizedFileName: (originalFileName?: string) => string, operationId: string, fileId: string ): Promise => { @@ -93,7 +99,7 @@ export const useSanitizeOperation = () => { const blob = await response.blob(); const sanitizedFileName = generateSanitizedFileName(file.name); const sanitizedFile = new File([blob], sanitizedFileName, { type: blob.type }); - + markOperationApplied(fileId, operationId); return sanitizedFile; } catch (error) { @@ -107,11 +113,13 @@ export const useSanitizeOperation = () => { if (results.length === 1) { const url = window.URL.createObjectURL(results[0]); setDownloadUrl(url); + setDownloadFilename(results[0].name); } else { const zipFilename = `${t('sanitize.filenamePrefix', 'sanitized')}_files.zip`; const { zipFile } = await zipFileService.createZipFromFiles(results, zipFilename); const url = window.URL.createObjectURL(zipFile); setDownloadUrl(url); + setDownloadFilename(zipFilename); } }, [t]); @@ -145,8 +153,8 @@ export const useSanitizeOperation = () => { await generateThumbnailsForResults(results); setIsGeneratingThumbnails(false); - setStatus(results.length === 1 - ? t('sanitize.completed', 'Sanitization completed successfully') + setStatus(results.length === 1 + ? t('sanitize.completed', 'Sanitization completed successfully') : t('sanitize.completedMultiple', 'Sanitized {{count}} files successfully', { count: results.length }) ); }, [addFiles, createDownloadInfo, generateThumbnailsForResults, t]); @@ -154,7 +162,6 @@ export const useSanitizeOperation = () => { const executeOperation = useCallback(async ( parameters: SanitizeParameters, selectedFiles: File[], - generateSanitizedFileName: (originalFileName?: string) => string ) => { if (selectedFiles.length === 0) { throw new Error(t('error.noFilesSelected', 'No files selected')); @@ -186,8 +193,8 @@ export const useSanitizeOperation = () => { }) ); - const sanitizedFile = await sanitizeFile(file, parameters, generateSanitizedFileName, operationId, fileId); - + const sanitizedFile = await sanitizeFile(file, parameters, operationId, fileId); + if (sanitizedFile) { results.push(sanitizedFile); } else { @@ -227,6 +234,7 @@ export const useSanitizeOperation = () => { setThumbnails([]); setIsGeneratingThumbnails(false); setDownloadUrl(null); + setDownloadFilename(''); setErrorMessage(null); setStatus(null); }, [downloadUrl]); @@ -248,6 +256,7 @@ export const useSanitizeOperation = () => { isLoading, errorMessage, downloadUrl, + downloadFilename, status, files, thumbnails, diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index 0821670da..8eea5568a 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -20,12 +20,6 @@ import { useFileContext } from "../contexts/FileContext"; const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const generateSanitizedFileName = (originalFileName?: string): string => { - const baseName = originalFileName?.replace(/\.[^/.]+$/, '') || 'document'; - const prefix = t('sanitize.filenamePrefix', 'sanitized'); - return `${prefix}_${baseName}.pdf`; - }; - const { selectedFiles } = useToolFileSelection(); const { setCurrentMode } = useFileContext(); @@ -47,7 +41,6 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { await sanitizeOperation.executeOperation( sanitizeParams.parameters, selectedFiles, - generateSanitizedFileName ); if (sanitizeOperation.files && onComplete) { onComplete(sanitizeOperation.files); @@ -141,10 +134,7 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {