diff --git a/frontend/package.json b/frontend/package.json index 6bacd4017..eb1b8ed39 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,16 +53,18 @@ }, "scripts": { "predev": "npm run generate-icons", - "dev": "npx tsc --noEmit && vite", + "dev": "npm run typecheck && vite", "prebuild": "npm run generate-icons", - "lint": "npx eslint", - "build": "npx tsc --noEmit && vite build", + "lint": "eslint", + "build": "npm run typecheck && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "check": "npm run typecheck && npm run lint && npm run test:run", "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", "generate-icons:verbose": "node scripts/generate-icons.js --verbose", "test": "vitest", + "test:run": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", "test:e2e": "playwright test", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b8eb7e4c9..d82b23376 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -51,11 +51,11 @@ "filesSelected": "{{count}} files selected", "files": { "title": "Files", - "placeholder": "Select a PDF file in the main view to get started", "upload": "Upload", "uploadFiles": "Upload Files", "addFiles": "Add files", - "selectFromWorkbench": "Select files from the workbench or " + "selectFromWorkbench": "Select files from the workbench or ", + "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or " }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", @@ -498,13 +498,9 @@ "title": "Show Javascript", "desc": "Searches and displays any JS injected into a PDF" }, - "autoRedact": { - "title": "Auto Redact", - "desc": "Auto Redacts(Blacks out) text in a PDF based on input text" - }, "redact": { - "title": "Manual Redaction", - "desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)" + "title": "Redact", + "desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)" }, "overlay-pdfs": { "title": "Overlay PDFs", @@ -648,11 +644,29 @@ "merge": { "tags": "merge,Page operations,Back end,server side", "title": "Merge", - "header": "Merge multiple PDFs (2+)", - "sortByName": "Sort by name", - "sortByDate": "Sort by date", - "removeCertSign": "Remove digital signature in the merged file?", - "submit": "Merge" + "removeDigitalSignature": "Remove digital signature in the merged file?", + "generateTableOfContents": "Generate table of contents in the merged file?", + "removeDigitalSignature.tooltip": { + "title": "Remove Digital Signature", + "description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF." + }, + "generateTableOfContents.tooltip": { + "title": "Generate Table of Contents", + "description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers." + }, + "submit": "Merge", + "sortBy": { + "description": "Files will be merged in the order they're selected. Drag to reorder or sort below.", + "label": "Sort By", + "filename": "File Name", + "dateModified": "Date Modified", + "ascending": "Ascending", + "descending": "Descending", + "sort": "Sort" + }, + "error": { + "failed": "An error occurred while merging the PDFs." + } }, "split": { "tags": "Page operations,divide,Multi Page,cut,server side", @@ -669,7 +683,76 @@ "8": "Document #6: Page 10" }, "splitPages": "Enter pages to split on:", - "submit": "Split" + "submit": "Split", + "error": { + "failed": "An error occurred while splitting the PDF." + }, + "method": { + "label": "Choose split method", + "placeholder": "Select how to split the PDF" + }, + "methods": { + "byPages": "Split at Page Numbers", + "bySections": "Split by Sections", + "bySize": "Split by File Size", + "byPageCount": "Split by Page Count", + "byDocCount": "Split by Document Count", + "byChapters": "Split by Chapters" + }, + "value": { + "fileSize": { + "label": "File Size", + "placeholder": "e.g. 10MB, 500KB" + }, + "pageCount": { + "label": "Pages per File", + "placeholder": "e.g. 5, 10" + }, + "docCount": { + "label": "Number of Files", + "placeholder": "e.g. 3, 5" + } + }, + "tooltip": { + "header": { + "title": "Split Methods Overview" + }, + "byPages": { + "title": "Split at Page Numbers", + "text": "Split your PDF at specific page numbers. Using 'n' splits after page n. Using 'n-m' splits before page n and after page m.", + "bullet1": "Single split points: 3,7 (splits after pages 3 and 7)", + "bullet2": "Range split points: 3-8 (splits before page 3 and after page 8)", + "bullet3": "Mixed: 2,5-10,15 (splits after page 2, before page 5, after page 10, and after page 15)" + }, + "bySections": { + "title": "Split by Grid Sections", + "text": "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas.", + "bullet1": "Horizontal: Number of rows to create", + "bullet2": "Vertical: Number of columns to create", + "bullet3": "Merge: Combine all sections into one PDF" + }, + "bySize": { + "title": "Split by File Size", + "text": "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments.", + "bullet1": "Use MB for larger files (e.g., 10MB)", + "bullet2": "Use KB for smaller files (e.g., 500KB)", + "bullet3": "System will split at page boundaries" + }, + "byCount": { + "title": "Split by Count", + "text": "Create multiple PDFs with a specific number of pages or documents each.", + "bullet1": "Page Count: Fixed number of pages per file", + "bullet2": "Document Count: Fixed number of output files", + "bullet3": "Useful for batch processing workflows" + }, + "byChapters": { + "title": "Split by Chapters", + "text": "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure.", + "bullet1": "Bookmark Level: Which level to split on (1=top level)", + "bullet2": "Include Metadata: Preserve document properties", + "bullet3": "Allow Duplicates: Handle repeated bookmark names" + } + } }, "rotate": { "tags": "server side", @@ -1454,7 +1537,6 @@ "submit": "Submit" }, "scalePages": { - "tags": "resize,modify,dimension,adapt", "title": "Adjust page-scale", "header": "Adjust page-scale", "pageSize": "Size of a page of the document.", @@ -1462,6 +1544,44 @@ "scaleFactor": "Zoom level (crop) of a page.", "submit": "Submit" }, + "adjustPageScale": { + "tags": "resize,modify,dimension,adapt", + "title": "Adjust Page Scale", + "header": "Adjust Page Scale", + "scaleFactor": { + "label": "Scale Factor" + }, + "pageSize": { + "label": "Target Page Size", + "keep": "Keep Original Size", + "letter": "Letter", + "legal": "Legal" + }, + "submit": "Adjust Page Scale", + "error": { + "failed": "An error occurred while adjusting the page scale." + }, + "tooltip": { + "header": { + "title": "Page Scale Settings Overview" + }, + "description": { + "title": "Description", + "text": "Adjust the size of PDF content and change the page dimensions." + }, + "scaleFactor": { + "title": "Scale Factor", + "text": "Controls how large or small the content appears on the page. Content is scaled and centred - if scaled content is larger than the page size, it may be cropped.", + "bullet1": "1.0 = Original size", + "bullet2": "0.5 = Half size (50% smaller)", + "bullet3": "2.0 = Double size (200% larger, may crop)" + }, + "pageSize": { + "title": "Target Page Size", + "text": "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, whilst other options resize to standard paper sizes." + } + } + }, "add-page-numbers": { "tags": "paginate,label,organize,index" }, @@ -1583,50 +1703,123 @@ "downloadJS": "Download Javascript", "submit": "Show" }, - "autoRedact": { - "tags": "Redact,Hide,black out,black,marker,hidden", - "title": "Auto Redact", - "header": "Auto Redact", - "colorLabel": "Colour", - "textsToRedactLabel": "Text to Redact (line-separated)", - "textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-Secret", - "useRegexLabel": "Use Regex", - "wholeWordSearchLabel": "Whole Word Search", - "customPaddingLabel": "Custom Extra Padding", - "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", - "submitButton": "Submit" - }, "redact": { - "tags": "Redact,Hide,black out,black,marker,hidden,manual", - "title": "Manual Redaction", - "header": "Manual Redaction", + "tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact", + "title": "Redact", "submit": "Redact", - "textBasedRedaction": "Text based Redaction", - "pageBasedRedaction": "Page-based Redaction", - "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", - "pageRedactionNumbers": { - "title": "Pages", - "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" + "error": { + "failed": "An error occurred while redacting the PDF." }, - "redactionColor": { - "title": "Redaction Color" + "modeSelector": { + "title": "Redaction Method", + "mode": "Mode", + "automatic": "Automatic", + "automaticDesc": "Redact text based on search terms", + "manual": "Manual", + "manualDesc": "Click and drag to redact specific areas", + "manualComingSoon": "Manual redaction coming soon" }, - "export": "Export", - "upload": "Upload", - "boxRedaction": "Box draw redaction", - "zoom": "Zoom", - "zoomIn": "Zoom in", - "zoomOut": "Zoom out", - "nextPage": "Next Page", - "previousPage": "Previous Page", - "toggleSidebar": "Toggle Sidebar", - "showThumbnails": "Show Thumbnails", - "showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)", - "showAttatchments": "Show Attachments", - "showLayers": "Show Layers (double-click to reset all layers to the default state)", - "colourPicker": "Colour Picker", - "findCurrentOutlineItem": "Find current outline item", - "applyChanges": "Apply Changes" + "auto": { + "header": "Auto Redact", + "settings": { + "title": "Redaction Settings", + "advancedTitle": "Advanced" + }, + "colorLabel": "Box Colour", + "wordsToRedact": { + "title": "Words to Redact", + "placeholder": "Enter a word", + "add": "Add", + "examples": "Examples: Confidential, Top-Secret" + }, + "useRegexLabel": "Use Regex", + "wholeWordSearchLabel": "Whole Word Search", + "customPaddingLabel": "Custom Extra Padding", + "convertPDFToImageLabel": "Convert PDF to PDF-Image" + }, + "tooltip": { + "mode": { + "header": { + "title": "Redaction Method" + }, + "automatic": { + "title": "Automatic Redaction", + "text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers." + }, + "manual": { + "title": "Manual Redaction", + "text": "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)" + } + }, + "words": { + "header": { + "title": "Words to Redact" + }, + "description": { + "title": "Text Matching", + "text": "Enter words or phrases to find and redact in your document. Each word will be searched for separately." + }, + "bullet1": "Add one word at a time", + "bullet2": "Press Enter or click 'Add Another' to add", + "bullet3": "Click × to remove words", + "examples": { + "title": "Common Examples", + "text": "Typical words to redact include: bank details, email addresses, or specific names." + } + }, + "advanced": { + "header": { + "title": "Advanced Redaction Settings" + }, + "color": { + "title": "Box Colour & Padding", + "text": "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text." + }, + "regex": { + "title": "Use Regex", + "text": "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns.", + "bullet1": "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format", + "bullet2": "Use with caution - test thoroughly" + }, + "wholeWord": { + "title": "Whole Word Search", + "text": "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled." + }, + "convert": { + "title": "Convert to PDF-Image", + "text": "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable." + } + } + }, + "manual": { + "header": "Manual Redaction", + "textBasedRedaction": "Text-based Redaction", + "pageBasedRedaction": "Page-based Redaction", + "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", + "pageRedactionNumbers": { + "title": "Pages", + "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" + }, + "redactionColor": { + "title": "Redaction Colour" + }, + "export": "Export", + "upload": "Upload", + "boxRedaction": "Box draw redaction", + "zoom": "Zoom", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "nextPage": "Next Page", + "previousPage": "Previous Page", + "toggleSidebar": "Toggle Sidebar", + "showThumbnails": "Show Thumbnails", + "showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)", + "showAttachments": "Show Attachments", + "showLayers": "Show Layers (double-click to reset all layers to the default state)", + "colourPicker": "Colour Picker", + "findCurrentOutlineItem": "Find current outline item", + "applyChanges": "Apply Changes" + } }, "tableExtraxt": { "tags": "CSV,Table Extraction,extract,convert" @@ -1837,6 +2030,11 @@ "title": "Compress", "desc": "Compress PDFs to reduce their file size.", "header": "Compress PDF", + "method": { + "title": "Compression Method", + "quality": "Quality", + "filesize": "File Size" + }, "credit": "This service uses qpdf for PDF Compress/Optimisation.", "grayscale": { "label": "Apply Grayscale for Compression" diff --git a/frontend/src/assets/3rdPartyLicenses.json b/frontend/src/assets/3rdPartyLicenses.json index 2f19f5db6..70aacd3b2 100644 --- a/frontend/src/assets/3rdPartyLicenses.json +++ b/frontend/src/assets/3rdPartyLicenses.json @@ -385,6 +385,13 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, + { + "moduleName": "@posthog/core", + "moduleUrl": "https://github.com/PostHog/posthog-js", + "moduleVersion": "1.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "@tailwindcss/node", "moduleUrl": "https://github.com/tailwindlabs/tailwindcss", @@ -742,6 +749,13 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, + { + "moduleName": "core-js", + "moduleUrl": "https://github.com/zloirock/core-js", + "moduleVersion": "3.45.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "core-util-is", "moduleUrl": "https://github.com/isaacs/core-util-is", @@ -924,6 +938,13 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, + { + "moduleName": "fflate", + "moduleUrl": "https://github.com/101arrowz/fflate", + "moduleVersion": "0.4.8", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "file-selector", "moduleUrl": "https://github.com/react-dropzone/file-selector", @@ -1533,6 +1554,20 @@ "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, + { + "moduleName": "posthog-js", + "moduleUrl": "https://github.com/PostHog/posthog-js", + "moduleVersion": "1.261.0", + "moduleLicense": "MIT*", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, + { + "moduleName": "preact", + "moduleUrl": "https://github.com/preactjs/preact", + "moduleVersion": "10.27.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://opensource.org/licenses/MIT" + }, { "moduleName": "pretty-format", "moduleUrl": "https://github.com/facebook/jest", @@ -1928,7 +1963,7 @@ { "moduleName": "typescript", "moduleUrl": "https://github.com/microsoft/TypeScript", - "moduleVersion": "5.8.3", + "moduleVersion": "5.9.2", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1995,6 +2030,13 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "web-vitals", + "moduleUrl": "https://github.com/GoogleChrome/web-vitals", + "moduleVersion": "4.2.4", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "webidl-conversions", "moduleUrl": "https://github.com/jsdom/webidl-conversions", diff --git a/frontend/src/components/shared/ButtonSelector.test.tsx b/frontend/src/components/shared/ButtonSelector.test.tsx new file mode 100644 index 000000000..e3adf1def --- /dev/null +++ b/frontend/src/components/shared/ButtonSelector.test.tsx @@ -0,0 +1,216 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import ButtonSelector from './ButtonSelector'; + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('ButtonSelector', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render all options as buttons', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + expect(screen.getByText('Test Label')).toBeInTheDocument(); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + }); + + test('should highlight selected button with filled variant', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + const selectedButton = screen.getByRole('button', { name: 'Option 1' }); + const unselectedButton = screen.getByRole('button', { name: 'Option 2' }); + + // Check data-variant attribute for filled/outline + expect(selectedButton).toHaveAttribute('data-variant', 'filled'); + expect(unselectedButton).toHaveAttribute('data-variant', 'outline'); + expect(screen.getByText('Selection Label')).toBeInTheDocument(); + }); + + test('should call onChange when button is clicked', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + + expect(mockOnChange).toHaveBeenCalledWith('option2'); + }); + + test('should handle undefined value (no selection)', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + // Both buttons should be outlined when no value is selected + const button1 = screen.getByRole('button', { name: 'Option 1' }); + const button2 = screen.getByRole('button', { name: 'Option 2' }); + + expect(button1).toHaveAttribute('data-variant', 'outline'); + expect(button2).toHaveAttribute('data-variant', 'outline'); + }); + + test.each([ + { + description: 'disable buttons when disabled prop is true', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + globalDisabled: true, + expectedStates: [true, true], + }, + { + description: 'disable individual options when option.disabled is true', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2', disabled: true }, + ], + globalDisabled: false, + expectedStates: [false, true], + }, + ])('should $description', ({ options, globalDisabled, expectedStates }) => { + render( + + + + ); + + options.forEach((option, index) => { + const button = screen.getByRole('button', { name: option.label }); + expect(button).toHaveProperty('disabled', expectedStates[index]); + }); + }); + + test('should not call onChange when disabled button is clicked', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2', disabled: true }, + ]; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + test('should not apply fullWidth styling when fullWidth is false', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + const button = screen.getByRole('button', { name: 'Option 1' }); + expect(button).not.toHaveStyle({ flex: '1' }); + expect(screen.getByText('Layout Label')).toBeInTheDocument(); + }); + + test('should not render label element when not provided', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + const { container } = render( + + + + ); + + // Should render buttons + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + + // Stack should only contain the Group (buttons), no Text element for label + const stackElement = container.querySelector('[class*="mantine-Stack-root"]'); + expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text + }); +}); diff --git a/frontend/src/components/shared/ButtonSelector.tsx b/frontend/src/components/shared/ButtonSelector.tsx new file mode 100644 index 000000000..bc95134d6 --- /dev/null +++ b/frontend/src/components/shared/ButtonSelector.tsx @@ -0,0 +1,59 @@ +import { Button, Group, Stack, Text } from "@mantine/core"; + +export interface ButtonOption { + value: T; + label: string; + disabled?: boolean; +} + +interface ButtonSelectorProps { + value: T | undefined; + onChange: (value: T) => void; + options: ButtonOption[]; + label?: string; + disabled?: boolean; + fullWidth?: boolean; +} + +const ButtonSelector = ({ + value, + onChange, + options, + label = undefined, + disabled = false, + fullWidth = true, +}: ButtonSelectorProps) => { + return ( + + {/* Label (if it exists) */} + {label && {label}} + + {/* Buttons */} + + {options.map((option) => ( + onChange(option.value)} + disabled={disabled || option.disabled} + style={{ + flex: fullWidth ? 1 : undefined, + height: 'auto', + minHeight: '2.5rem', + fontSize: 'var(--mantine-font-size-sm)' + }} + > + {option.label} + + ))} + + + ); +}; + +export default ButtonSelector; diff --git a/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx b/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx index 36ef3ce01..beb8c432c 100644 --- a/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx +++ b/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx @@ -4,7 +4,7 @@ import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPa interface AddPasswordSettingsProps { parameters: AddPasswordParameters; - onParameterChange: (key: keyof AddPasswordParameters, value: any) => void; + onParameterChange: (key: K, value: AddPasswordParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx index 04949c27c..2c7548df8 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx @@ -1,5 +1,5 @@ -import { Button, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import ButtonSelector from "../../shared/ButtonSelector"; interface WatermarkTypeSettingsProps { watermarkType?: 'text' | 'image'; @@ -11,32 +11,21 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled const { t } = useTranslation(); return ( - - - onWatermarkTypeChange('text')} - disabled={disabled} - style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} - > - - {t('watermark.watermarkType.text', 'Text')} - - - onWatermarkTypeChange('image')} - disabled={disabled} - style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} - > - - {t('watermark.watermarkType.image', 'Image')} - - - - + ); }; diff --git a/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.test.tsx b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.test.tsx new file mode 100644 index 000000000..d71655cdc --- /dev/null +++ b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import AdjustPageScaleSettings from './AdjustPageScaleSettings'; +import { AdjustPageScaleParameters, PageSize } from '../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters'; + +// Mock useTranslation with predictable return values +const mockT = vi.fn((key: string, fallback?: string) => fallback || `mock-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('AdjustPageScaleSettings', () => { + const defaultParameters: AdjustPageScaleParameters = { + scaleFactor: 1.0, + pageSize: PageSize.KEEP, + }; + + const mockOnParameterChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render without crashing', () => { + render( + + + + ); + + // Basic render test - component renders without throwing + expect(screen.getByText('Scale Factor')).toBeInTheDocument(); + expect(screen.getByText('Target Page Size')).toBeInTheDocument(); + }); + + test('should render with custom parameters', () => { + const customParameters: AdjustPageScaleParameters = { + scaleFactor: 2.5, + pageSize: PageSize.A4, + }; + + render( + + + + ); + + // Component renders successfully with custom parameters + expect(screen.getByText('Scale Factor')).toBeInTheDocument(); + expect(screen.getByText('Target Page Size')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx new file mode 100644 index 000000000..9262bcba4 --- /dev/null +++ b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx @@ -0,0 +1,55 @@ +import { Stack, NumberInput, Select } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { AdjustPageScaleParameters, PageSize } from "../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; + +interface AdjustPageScaleSettingsProps { + parameters: AdjustPageScaleParameters; + onParameterChange: (key: K, value: AdjustPageScaleParameters[K]) => void; + disabled?: boolean; +} + +const AdjustPageScaleSettings = ({ parameters, onParameterChange, disabled = false }: AdjustPageScaleSettingsProps) => { + const { t } = useTranslation(); + + const pageSizeOptions = [ + { value: PageSize.KEEP, label: t('adjustPageScale.pageSize.keep', 'Keep Original Size') }, + { value: PageSize.A0, label: 'A0' }, + { value: PageSize.A1, label: 'A1' }, + { value: PageSize.A2, label: 'A2' }, + { value: PageSize.A3, label: 'A3' }, + { value: PageSize.A4, label: 'A4' }, + { value: PageSize.A5, label: 'A5' }, + { value: PageSize.A6, label: 'A6' }, + { value: PageSize.LETTER, label: t('adjustPageScale.pageSize.letter', 'Letter') }, + { value: PageSize.LEGAL, label: t('adjustPageScale.pageSize.legal', 'Legal') }, + ]; + + return ( + + onParameterChange('scaleFactor', typeof value === 'number' ? value : 1.0)} + min={0.1} + max={10.0} + step={0.1} + decimalScale={2} + disabled={disabled} + /> + + { + if (value && Object.values(PageSize).includes(value as PageSize)) { + onParameterChange('pageSize', value as PageSize); + } + }} + data={pageSizeOptions} + disabled={disabled} + /> + + ); +}; + +export default AdjustPageScaleSettings; diff --git a/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx b/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx index 071e27cfd..06ac6ac69 100644 --- a/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx +++ b/frontend/src/components/tools/changePermissions/ChangePermissionsSettings.tsx @@ -4,7 +4,7 @@ import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissi interface ChangePermissionsSettingsProps { parameters: ChangePermissionsParameters; - onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void; + onParameterChange: (key: K, value: ChangePermissionsParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/compress/CompressSettings.tsx b/frontend/src/components/tools/compress/CompressSettings.tsx index 42d270abb..412962704 100644 --- a/frontend/src/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/components/tools/compress/CompressSettings.tsx @@ -1,11 +1,12 @@ import React, { useState } from "react"; -import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core"; +import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters"; +import ButtonSelector from "../../shared/ButtonSelector"; interface CompressSettingsProps { parameters: CompressParameters; - onParameterChange: (key: keyof CompressParameters, value: any) => void; + onParameterChange: (key: K, value: CompressParameters[K]) => void; disabled?: boolean; } @@ -18,33 +19,16 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C {/* Compression Method */} - - Compression Method - - onParameterChange('compressionMethod', 'quality')} - disabled={disabled} - style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} - > - - Quality - - - onParameterChange('compressionMethod', 'filesize')} - disabled={disabled} - style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }} - > - - File Size - - - - + onParameterChange('compressionMethod', value)} + options={[ + { value: 'quality', label: t('compress.method.quality', 'Quality') }, + { value: 'filesize', label: t('compress.method.filesize', 'File Size') }, + ]} + disabled={disabled} + /> {/* Quality Adjustment */} {parameters.compressionMethod === 'quality' && ( diff --git a/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx b/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx index 59fa824ee..943e0feed 100644 --- a/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertFromEmailSettings.tsx @@ -5,40 +5,40 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame interface ConvertFromEmailSettingsProps { parameters: ConvertParameters; - onParameterChange: (key: keyof ConvertParameters, value: any) => void; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; disabled?: boolean; } -const ConvertFromEmailSettings = ({ - parameters, - onParameterChange, - disabled = false +const ConvertFromEmailSettings = ({ + parameters, + onParameterChange, + disabled = false }: ConvertFromEmailSettingsProps) => { const { t } = useTranslation(); return ( {t("convert.emailOptions", "Email to PDF Options")}: - + onParameterChange('emailOptions', { - ...parameters.emailOptions, - includeAttachments: event.currentTarget.checked + onChange={(event) => onParameterChange('emailOptions', { + ...parameters.emailOptions, + includeAttachments: event.currentTarget.checked })} disabled={disabled} data-testid="include-attachments-checkbox" /> - + {parameters.emailOptions.includeAttachments && ( {t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}: onParameterChange('emailOptions', { - ...parameters.emailOptions, - maxAttachmentSizeMB: Number(value) || 10 + onChange={(value) => onParameterChange('emailOptions', { + ...parameters.emailOptions, + maxAttachmentSizeMB: Number(value) || 10 })} min={1} max={100} @@ -48,24 +48,24 @@ const ConvertFromEmailSettings = ({ /> )} - + onParameterChange('emailOptions', { - ...parameters.emailOptions, - includeAllRecipients: event.currentTarget.checked + onChange={(event) => onParameterChange('emailOptions', { + ...parameters.emailOptions, + includeAllRecipients: event.currentTarget.checked })} disabled={disabled} data-testid="include-all-recipients-checkbox" /> - + onParameterChange('emailOptions', { - ...parameters.emailOptions, - downloadHtml: event.currentTarget.checked + onChange={(event) => onParameterChange('emailOptions', { + ...parameters.emailOptions, + downloadHtml: event.currentTarget.checked })} disabled={disabled} data-testid="download-html-checkbox" @@ -74,4 +74,4 @@ const ConvertFromEmailSettings = ({ ); }; -export default ConvertFromEmailSettings; \ No newline at end of file +export default ConvertFromEmailSettings; diff --git a/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx b/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx index 0681821fd..eb0457f13 100644 --- a/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertFromImageSettings.tsx @@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame interface ConvertFromImageSettingsProps { parameters: ConvertParameters; - onParameterChange: (key: keyof ConvertParameters, value: any) => void; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx b/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx index 270980f82..f6101d1c1 100644 --- a/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertFromWebSettings.tsx @@ -5,28 +5,28 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame interface ConvertFromWebSettingsProps { parameters: ConvertParameters; - onParameterChange: (key: keyof ConvertParameters, value: any) => void; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; disabled?: boolean; } -const ConvertFromWebSettings = ({ - parameters, - onParameterChange, - disabled = false +const ConvertFromWebSettings = ({ + parameters, + onParameterChange, + disabled = false }: ConvertFromWebSettingsProps) => { const { t } = useTranslation(); return ( {t("convert.webOptions", "Web to PDF Options")}: - + {t("convert.zoomLevel", "Zoom Level")}: onParameterChange('htmlOptions', { - ...parameters.htmlOptions, - zoomLevel: Number(value) || 1.0 + onChange={(value) => onParameterChange('htmlOptions', { + ...parameters.htmlOptions, + zoomLevel: Number(value) || 1.0 })} min={0.1} max={3.0} @@ -36,9 +36,9 @@ const ConvertFromWebSettings = ({ /> onParameterChange('htmlOptions', { - ...parameters.htmlOptions, - zoomLevel: value + onChange={(value) => onParameterChange('htmlOptions', { + ...parameters.htmlOptions, + zoomLevel: value })} min={0.1} max={3.0} @@ -51,4 +51,4 @@ const ConvertFromWebSettings = ({ ); }; -export default ConvertFromWebSettings; \ No newline at end of file +export default ConvertFromWebSettings; diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index 3a019f8da..2b1de9302 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -26,7 +26,7 @@ import { StirlingFile } from "../../../types/fileContext"; interface ConvertSettingsProps { parameters: ConvertParameters; - onParameterChange: (key: keyof ConvertParameters, value: any) => void; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; selectedFiles: StirlingFile[]; disabled?: boolean; diff --git a/frontend/src/components/tools/convert/ConvertToImageSettings.tsx b/frontend/src/components/tools/convert/ConvertToImageSettings.tsx index 9d67bfbf6..887685501 100644 --- a/frontend/src/components/tools/convert/ConvertToImageSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertToImageSettings.tsx @@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame interface ConvertToImageSettingsProps { parameters: ConvertParameters; - onParameterChange: (key: keyof ConvertParameters, value: any) => void; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx index 49e057a1c..b9a572b8d 100644 --- a/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertToPdfaSettings.tsx @@ -7,16 +7,16 @@ import { StirlingFile } from '../../../types/fileContext'; interface ConvertToPdfaSettingsProps { parameters: ConvertParameters; - onParameterChange: (key: keyof ConvertParameters, value: any) => void; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; selectedFiles: StirlingFile[]; disabled?: boolean; } -const ConvertToPdfaSettings = ({ - parameters, +const ConvertToPdfaSettings = ({ + parameters, onParameterChange, selectedFiles, - disabled = false + disabled = false }: ConvertToPdfaSettingsProps) => { const { t } = useTranslation(); const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles); @@ -29,7 +29,7 @@ const ConvertToPdfaSettings = ({ return ( {t("convert.pdfaOptions", "PDF/A Options")}: - + {hasDigitalSignatures && ( @@ -37,14 +37,14 @@ const ConvertToPdfaSettings = ({ )} - + {t("convert.outputFormat", "Output Format")}: onParameterChange('pdfaOptions', { - ...parameters.pdfaOptions, - outputFormat: value || 'pdfa-1' + onChange={(value) => onParameterChange('pdfaOptions', { + ...parameters.pdfaOptions, + outputFormat: value || 'pdfa-1' })} data={pdfaFormatOptions} disabled={disabled || isChecking} @@ -58,4 +58,4 @@ const ConvertToPdfaSettings = ({ ); }; -export default ConvertToPdfaSettings; \ No newline at end of file +export default ConvertToPdfaSettings; diff --git a/frontend/src/components/tools/merge/MergeFileSorter.test.tsx b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx new file mode 100644 index 000000000..302777261 --- /dev/null +++ b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx @@ -0,0 +1,182 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import MergeFileSorter from './MergeFileSorter'; + +// 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('MergeFileSorter', () => { + const mockOnSortFiles = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render sort options dropdown, direction toggle, and sort button', () => { + render( + + + + ); + + // Should have a select dropdown (Mantine Select uses textbox role) + expect(screen.getByRole('textbox')).toBeInTheDocument(); + + // Should have direction toggle button + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); // ActionIcon + Sort Button + + // Should have sort button with text + expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument(); + }); + + test('should render description text', () => { + render( + + + + ); + + expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument(); + }); + + test('should have filename selected by default', () => { + render( + + + + ); + + const select = screen.getByRole('textbox'); + expect(select).toHaveValue('mock-merge.sortBy.filename'); + }); + + test('should show ascending direction by default', () => { + render( + + + + ); + + // Should show ascending arrow icon + const directionButton = screen.getAllByRole('button')[0]; + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending'); + }); + + test('should toggle direction when direction button is clicked', () => { + render( + + + + ); + + const directionButton = screen.getAllByRole('button')[0]; + + // Initially ascending + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending'); + + // Click to toggle to descending + fireEvent.click(directionButton); + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending'); + + // Click again to toggle back to ascending + fireEvent.click(directionButton); + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending'); + }); + + test('should call onSortFiles with correct parameters when sort button is clicked', () => { + render( + + + + ); + + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + fireEvent.click(sortButton); + + // Should be called with default values (filename, ascending) + expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true); + }); + + test('should call onSortFiles with dateModified when dropdown is changed', () => { + render( + + + + ); + + // Open the dropdown by clicking on the current selected value + const currentSelection = screen.getByText('mock-merge.sortBy.filename'); + fireEvent.mouseDown(currentSelection); + + // Click on the dateModified option + const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified'); + fireEvent.click(dateModifiedOption); + + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true); + }); + + test('should call onSortFiles with descending direction when toggled', () => { + render( + + + + ); + + const directionButton = screen.getAllByRole('button')[0]; + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + + // Toggle to descending + fireEvent.click(directionButton); + + // Click sort + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false); + }); + + test('should handle complex user interaction sequence', () => { + render( + + + + ); + + const directionButton = screen.getAllByRole('button')[0]; + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + + // 1. Change to dateModified + const currentSelection = screen.getByText('mock-merge.sortBy.filename'); + fireEvent.mouseDown(currentSelection); + const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified'); + fireEvent.click(dateModifiedOption); + + // 2. Toggle to descending + fireEvent.click(directionButton); + + // 3. Click sort + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false); + + // 4. Toggle back to ascending + fireEvent.click(directionButton); + + // 5. Sort again + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true); + }); +}); diff --git a/frontend/src/components/tools/merge/MergeFileSorter.tsx b/frontend/src/components/tools/merge/MergeFileSorter.tsx new file mode 100644 index 000000000..2b21afc64 --- /dev/null +++ b/frontend/src/components/tools/merge/MergeFileSorter.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import SortIcon from '@mui/icons-material/Sort'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; + +interface MergeFileSorterProps { + onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void; + disabled?: boolean; +} + +const MergeFileSorter: React.FC = ({ + onSortFiles, + disabled = false, +}) => { + const { t } = useTranslation(); + const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename'); + const [ascending, setAscending] = useState(true); + + const sortOptions = [ + { value: 'filename', label: t('merge.sortBy.filename', 'File Name') }, + { value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') }, + ]; + + const handleSort = () => { + onSortFiles(sortType, ascending); + }; + + const handleDirectionToggle = () => { + setAscending(!ascending); + }; + + return ( + + + {t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")} + + + + setSortType(value as 'filename' | 'dateModified')} + disabled={disabled} + label={t('merge.sortBy.label', 'Sort By')} + size='xs' + style={{ flex: 1 }} + /> + + + {ascending ? : } + + + + } + onClick={handleSort} + disabled={disabled} + fullWidth + > + {t('merge.sortBy.sort', 'Sort')} + + + + ); +}; + +export default MergeFileSorter; diff --git a/frontend/src/components/tools/merge/MergeSettings.test.tsx b/frontend/src/components/tools/merge/MergeSettings.test.tsx new file mode 100644 index 000000000..7989fa6d0 --- /dev/null +++ b/frontend/src/components/tools/merge/MergeSettings.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import MergeSettings from './MergeSettings'; +import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters'; + +// 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('MergeSettings', () => { + const defaultParameters: MergeParameters = { + removeDigitalSignature: false, + generateTableOfContents: false, + }; + + const mockOnParameterChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render both merge 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'); + + // Both checkboxes should be unchecked initially + checkboxes.forEach(checkbox => { + expect(checkbox).not.toBeChecked(); + }); + }); + + test('should call onParameterChange with correct parameters when checkboxes are clicked', () => { + render( + + + + ); + + const checkboxes = screen.getAllByRole('checkbox'); + + // Click the first checkbox (removeDigitalSignature - should toggle from false to true) + fireEvent.click(checkboxes[0]); + expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true); + + // Click the second checkbox (generateTableOfContents - should toggle from false to true) + fireEvent.click(checkboxes[1]); + expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true); + }); + + test('should call translation function with correct keys', () => { + render( + + + + ); + + // Verify that translation keys are being called + expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?'); + expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?'); + }); + +}); diff --git a/frontend/src/components/tools/merge/MergeSettings.tsx b/frontend/src/components/tools/merge/MergeSettings.tsx new file mode 100644 index 000000000..a12695081 --- /dev/null +++ b/frontend/src/components/tools/merge/MergeSettings.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Stack, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters'; + +interface MergeSettingsProps { + parameters: MergeParameters; + onParameterChange: (key: K, value: MergeParameters[K]) => void; + disabled?: boolean; +} + +const MergeSettings: React.FC = ({ + parameters, + onParameterChange, + disabled = false, +}) => { + const { t } = useTranslation(); + + return ( + + onParameterChange('removeDigitalSignature', event.currentTarget.checked)} + disabled={disabled} + /> + + onParameterChange('generateTableOfContents', event.currentTarget.checked)} + disabled={disabled} + /> + + ); +}; + +export default MergeSettings; diff --git a/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx b/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx index a38926edf..a6965bb09 100644 --- a/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx +++ b/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx @@ -16,7 +16,7 @@ interface AdvancedOption { interface AdvancedOCRSettingsProps { advancedOptions: string[]; ocrRenderType?: string; - onParameterChange: (key: keyof OCRParameters, value: any) => void; + onParameterChange: (key: K, value: OCRParameters[K]) => void; disabled?: boolean; } @@ -40,7 +40,7 @@ const AdvancedOCRSettings: React.FC = ({ // Handle individual checkbox changes const handleCheckboxChange = (optionValue: string, checked: boolean) => { const option = advancedOptionsData.find(opt => opt.value === optionValue); - + if (option?.isSpecial) { // Handle special options (like compatibility mode) differently if (optionValue === 'compatibilityMode') { @@ -69,7 +69,7 @@ const AdvancedOCRSettings: React.FC = ({ {t('ocr.settings.advancedOptions.label', 'Processing Options')} - + {advancedOptionsData.map((option) => ( = ({ ); }; -export default AdvancedOCRSettings; \ No newline at end of file +export default AdvancedOCRSettings; diff --git a/frontend/src/components/tools/ocr/OCRSettings.tsx b/frontend/src/components/tools/ocr/OCRSettings.tsx index 6009888b9..959601166 100644 --- a/frontend/src/components/tools/ocr/OCRSettings.tsx +++ b/frontend/src/components/tools/ocr/OCRSettings.tsx @@ -6,7 +6,7 @@ import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters'; interface OCRSettingsProps { parameters: OCRParameters; - onParameterChange: (key: keyof OCRParameters, value: any) => void; + onParameterChange: (key: K, value: OCRParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx b/frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx new file mode 100644 index 000000000..92f359abd --- /dev/null +++ b/frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx @@ -0,0 +1,211 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import RedactAdvancedSettings from './RedactAdvancedSettings'; +import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters'; + +// Mock useTranslation +const mockT = vi.fn((_key: string, fallback: string) => fallback); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('RedactAdvancedSettings', () => { + const mockOnParameterChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render all advanced settings controls', () => { + render( + + + + ); + + expect(screen.getByText('Box Colour')).toBeInTheDocument(); + expect(screen.getByText('Custom Extra Padding')).toBeInTheDocument(); + expect(screen.getByText('Use Regex')).toBeInTheDocument(); + expect(screen.getByText('Whole Word Search')).toBeInTheDocument(); + expect(screen.getByText('Convert PDF to PDF-Image (Used to remove text behind the box)')).toBeInTheDocument(); + }); + + test('should display current parameter values', () => { + const customParameters = { + ...defaultParameters, + redactColor: '#FF0000', + customPadding: 0.5, + useRegex: true, + wholeWordSearch: true, + convertPDFToImage: false, + }; + + render( + + + + ); + + // Check color input value + const colorInput = screen.getByDisplayValue('#FF0000'); + expect(colorInput).toBeInTheDocument(); + + // Check number input value + const paddingInput = screen.getByDisplayValue('0.5'); + expect(paddingInput).toBeInTheDocument(); + + // Check checkbox states + const useRegexCheckbox = screen.getByLabelText('Use Regex'); + const wholeWordCheckbox = screen.getByLabelText('Whole Word Search'); + const convertCheckbox = screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)'); + + expect(useRegexCheckbox).toBeChecked(); + expect(wholeWordCheckbox).toBeChecked(); + expect(convertCheckbox).not.toBeChecked(); + }); + + test('should call onParameterChange when color is changed', () => { + render( + + + + ); + + const colorInput = screen.getByDisplayValue('#000000'); + fireEvent.change(colorInput, { target: { value: '#FF0000' } }); + + expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000'); + }); + + test('should call onParameterChange when padding is changed', () => { + render( + + + + ); + + const paddingInput = screen.getByDisplayValue('0.1'); + fireEvent.change(paddingInput, { target: { value: '0.5' } }); + + expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.5); + }); + + test('should handle invalid padding values', () => { + render( + + + + ); + + const paddingInput = screen.getByDisplayValue('0.1'); + + // Simulate NumberInput onChange with invalid value (empty string) + const numberInput = paddingInput.closest('.mantine-NumberInput-root'); + if (numberInput) { + // Find the input and trigger change with empty value + fireEvent.change(paddingInput, { target: { value: '' } }); + + // The component should default to 0.1 for invalid values + expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.1); + } + }); + + test.each([ + { + paramName: 'useRegex' as const, + label: 'Use Regex', + initialValue: false, + expectedValue: true, + }, + { + paramName: 'wholeWordSearch' as const, + label: 'Whole Word Search', + initialValue: false, + expectedValue: true, + }, + { + paramName: 'convertPDFToImage' as const, + label: 'Convert PDF to PDF-Image (Used to remove text behind the box)', + initialValue: true, + expectedValue: false, + }, + ])('should call onParameterChange when $paramName checkbox is toggled', ({ paramName, label, initialValue, expectedValue }) => { + const customParameters = { + ...defaultParameters, + [paramName]: initialValue, + }; + + render( + + + + ); + + const checkbox = screen.getByLabelText(label); + fireEvent.click(checkbox); + + expect(mockOnParameterChange).toHaveBeenCalledWith(paramName, expectedValue); + }); + + test.each([ + { controlType: 'color input', getValue: () => screen.getByDisplayValue('#000000') }, + { controlType: 'padding input', getValue: () => screen.getByDisplayValue('0.1') }, + { controlType: 'useRegex checkbox', getValue: () => screen.getByLabelText('Use Regex') }, + { controlType: 'wholeWordSearch checkbox', getValue: () => screen.getByLabelText('Whole Word Search') }, + { controlType: 'convertPDFToImage checkbox', getValue: () => screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)') }, + ])('should disable $controlType when disabled prop is true', ({ getValue }) => { + render( + + + + ); + + const control = getValue(); + expect(control).toBeDisabled(); + }); + + test('should have correct padding input constraints', () => { + render( + + + + ); + + // NumberInput in Mantine might not expose these attributes directly on the input element + // Instead, check that the NumberInput component is rendered with correct placeholder + const paddingInput = screen.getByPlaceholderText('0.1'); + expect(paddingInput).toBeInTheDocument(); + expect(paddingInput).toHaveDisplayValue('0.1'); + }); +}); diff --git a/frontend/src/components/tools/redact/RedactAdvancedSettings.tsx b/frontend/src/components/tools/redact/RedactAdvancedSettings.tsx new file mode 100644 index 000000000..26a96056b --- /dev/null +++ b/frontend/src/components/tools/redact/RedactAdvancedSettings.tsx @@ -0,0 +1,69 @@ +import { Stack, NumberInput, ColorInput, Checkbox } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters"; + +interface RedactAdvancedSettingsProps { + parameters: RedactParameters; + onParameterChange: (key: K, value: RedactParameters[K]) => void; + disabled?: boolean; +} + +const RedactAdvancedSettings = ({ parameters, onParameterChange, disabled = false }: RedactAdvancedSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {/* Box Color */} + onParameterChange('redactColor', value)} + disabled={disabled} + size="sm" + format="hex" + /> + + {/* Box Padding */} + onParameterChange('customPadding', typeof value === 'number' ? value : 0.1)} + min={0} + max={10} + step={0.1} + disabled={disabled} + size="sm" + placeholder="0.1" + /> + + {/* Use Regex */} + onParameterChange('useRegex', e.currentTarget.checked)} + disabled={disabled} + size="sm" + /> + + {/* Whole Word Search */} + onParameterChange('wholeWordSearch', e.currentTarget.checked)} + disabled={disabled} + size="sm" + /> + + {/* Convert PDF to PDF-Image */} + onParameterChange('convertPDFToImage', e.currentTarget.checked)} + disabled={disabled} + size="sm" + /> + + ); +}; + +export default RedactAdvancedSettings; diff --git a/frontend/src/components/tools/redact/RedactModeSelector.tsx b/frontend/src/components/tools/redact/RedactModeSelector.tsx new file mode 100644 index 000000000..c073bc520 --- /dev/null +++ b/frontend/src/components/tools/redact/RedactModeSelector.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; +import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters'; +import ButtonSelector from '../../shared/ButtonSelector'; + +interface RedactModeSelectorProps { + mode: RedactMode; + onModeChange: (mode: RedactMode) => void; + disabled?: boolean; +} + +export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx b/frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx new file mode 100644 index 000000000..2bfe94e06 --- /dev/null +++ b/frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx @@ -0,0 +1,183 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import RedactSingleStepSettings from './RedactSingleStepSettings'; +import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters'; + +// Mock useTranslation +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('RedactSingleStepSettings', () => { + const mockOnParameterChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render mode selector', () => { + render( + + + + ); + + expect(screen.getByText('Mode')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument(); + }); + + test('should render automatic mode settings when mode is automatic', () => { + render( + + + + ); + + // Default mode is automatic, so these should be visible + expect(screen.getByText('Words to Redact')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument(); + expect(screen.getByText('Box Colour')).toBeInTheDocument(); + expect(screen.getByText('Use Regex')).toBeInTheDocument(); + }); + + test('should render manual mode settings when mode is manual', () => { + const manualParameters = { + ...defaultParameters, + mode: 'manual' as const, + }; + + render( + + + + ); + + // Manual mode should show placeholder text + expect(screen.getByText('Manual redaction interface will be available here when implemented.')).toBeInTheDocument(); + + // Automatic mode settings should not be visible + expect(screen.queryByText('Words to Redact')).not.toBeInTheDocument(); + }); + + test('should pass through parameter changes from automatic settings', () => { + render( + + + + ); + + // Test adding a word + const input = screen.getByPlaceholderText('Enter a word'); + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.change(input, { target: { value: 'TestWord' } }); + fireEvent.click(addButton); + + expect(mockOnParameterChange).toHaveBeenCalledWith('wordsToRedact', ['TestWord']); + }); + + test('should pass through parameter changes from advanced settings', () => { + render( + + + + ); + + // Test changing color + const colorInput = screen.getByDisplayValue('#000000'); + fireEvent.change(colorInput, { target: { value: '#FF0000' } }); + + expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000'); + }); + + test('should disable all controls when disabled prop is true', () => { + render( + + + + ); + + // Mode selector buttons should be disabled + expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled(); + + // Automatic settings controls should be disabled + expect(screen.getByPlaceholderText('Enter a word')).toBeDisabled(); + expect(screen.getByRole('button', { name: '+ Add' })).toBeDisabled(); + expect(screen.getByDisplayValue('#000000')).toBeDisabled(); + }); + + test('should show current parameter values in automatic mode', () => { + const customParameters = { + ...defaultParameters, + wordsToRedact: ['Word1', 'Word2'], + redactColor: '#FF0000', + useRegex: true, + customPadding: 0.5, + }; + + render( + + + + ); + + // Check that word tags are displayed + expect(screen.getByText('Word1')).toBeInTheDocument(); + expect(screen.getByText('Word2')).toBeInTheDocument(); + + // Check that color is displayed + expect(screen.getByDisplayValue('#FF0000')).toBeInTheDocument(); + + // Check that regex checkbox is checked + const useRegexCheckbox = screen.getByLabelText('Use Regex'); + expect(useRegexCheckbox).toBeChecked(); + + // Check that padding value is displayed + expect(screen.getByDisplayValue('0.5')).toBeInTheDocument(); + }); + + test('should maintain consistent spacing and layout', () => { + render( + + + + ); + + // Check that the Stack container exists + const container = screen.getByText('Mode').closest('.mantine-Stack-root'); + expect(container).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/tools/redact/RedactSingleStepSettings.tsx b/frontend/src/components/tools/redact/RedactSingleStepSettings.tsx new file mode 100644 index 000000000..71e48596a --- /dev/null +++ b/frontend/src/components/tools/redact/RedactSingleStepSettings.tsx @@ -0,0 +1,61 @@ +import { Stack, Divider } from "@mantine/core"; +import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters"; +import RedactModeSelector from "./RedactModeSelector"; +import WordsToRedactInput from "./WordsToRedactInput"; +import RedactAdvancedSettings from "./RedactAdvancedSettings"; + +interface RedactSingleStepSettingsProps { + parameters: RedactParameters; + onParameterChange: (key: K, value: RedactParameters[K]) => void; + disabled?: boolean; +} + +const RedactSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: RedactSingleStepSettingsProps) => { + return ( + + {/* Mode Selection */} + onParameterChange('mode', mode)} + disabled={disabled} + /> + + {/* Automatic Mode Settings */} + {parameters.mode === 'automatic' && ( + <> + + + {/* Words to Redact */} + onParameterChange('wordsToRedact', words)} + disabled={disabled} + /> + + + + {/* Advanced Settings */} + + > + )} + + {/* Manual Mode Placeholder */} + {parameters.mode === 'manual' && ( + <> + + + + Manual redaction interface will be available here when implemented. + + + > + )} + + ); +}; + +export default RedactSingleStepSettings; diff --git a/frontend/src/components/tools/redact/WordsToRedactInput.test.tsx b/frontend/src/components/tools/redact/WordsToRedactInput.test.tsx new file mode 100644 index 000000000..35bb3dc5d --- /dev/null +++ b/frontend/src/components/tools/redact/WordsToRedactInput.test.tsx @@ -0,0 +1,191 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import WordsToRedactInput from './WordsToRedactInput'; + +// Mock useTranslation +const mockT = vi.fn((_key: string, fallback: string) => fallback); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('WordsToRedactInput', () => { + const mockOnWordsChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render with title and input field', () => { + render( + + + + ); + + expect(screen.getByText('Words to Redact')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '+ Add' })).toBeInTheDocument(); + }); + + test.each([ + { trigger: 'Add button click', action: (_input: HTMLElement, addButton: HTMLElement) => fireEvent.click(addButton) }, + { trigger: 'Enter key press', action: (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) }, + ])('should add word when $trigger', ({ action }) => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter a word'); + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.change(input, { target: { value: 'TestWord' } }); + action(input, addButton); + + expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']); + }); + + test('should not add empty word', () => { + render( + + + + ); + + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.click(addButton); + + expect(mockOnWordsChange).not.toHaveBeenCalled(); + }); + + test('should not add duplicate word', () => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter a word'); + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.change(input, { target: { value: 'Existing' } }); + fireEvent.click(addButton); + + expect(mockOnWordsChange).not.toHaveBeenCalled(); + }); + + test('should trim whitespace when adding word', () => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter a word'); + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.change(input, { target: { value: ' TestWord ' } }); + fireEvent.click(addButton); + + expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']); + }); + + test('should remove word when x button is clicked', () => { + render( + + + + ); + + const removeButtons = screen.getAllByText('×'); + fireEvent.click(removeButtons[0]); + + expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']); + }); + + test('should clear input after adding word', () => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter a word') as HTMLInputElement; + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.change(input, { target: { value: 'TestWord' } }); + fireEvent.click(addButton); + + expect(input.value).toBe(''); + }); + + test.each([ + { description: 'disable Add button when input is empty', inputValue: '', expectedDisabled: true }, + { description: 'enable Add button when input has text', inputValue: 'TestWord', expectedDisabled: false }, + ])('should $description', ({ inputValue, expectedDisabled }) => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter a word'); + const addButton = screen.getByRole('button', { name: '+ Add' }); + + fireEvent.change(input, { target: { value: inputValue } }); + + expect(addButton).toHaveProperty('disabled', expectedDisabled); + }); + + test('should disable all controls when disabled prop is true', () => { + render( + + + + ); + + const input = screen.getByPlaceholderText('Enter a word'); + const addButton = screen.getByRole('button', { name: '+ Add' }); + const removeButton = screen.getByText('×'); + + expect(input).toBeDisabled(); + expect(addButton).toBeDisabled(); + expect(removeButton.closest('button')).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/tools/redact/WordsToRedactInput.tsx b/frontend/src/components/tools/redact/WordsToRedactInput.tsx new file mode 100644 index 000000000..90c97f0e3 --- /dev/null +++ b/frontend/src/components/tools/redact/WordsToRedactInput.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Stack, Text, TextInput, Button, Group, ActionIcon } from '@mantine/core'; + +interface WordsToRedactInputProps { + wordsToRedact: string[]; + onWordsChange: (words: string[]) => void; + disabled?: boolean; +} + +export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disabled }: WordsToRedactInputProps) { + const { t } = useTranslation(); + const [currentWord, setCurrentWord] = useState(''); + + const addWord = () => { + if (currentWord.trim() && !wordsToRedact.includes(currentWord.trim())) { + onWordsChange([...wordsToRedact, currentWord.trim()]); + setCurrentWord(''); + } + }; + + const removeWord = (index: number) => { + onWordsChange(wordsToRedact.filter((_, i) => i !== index)); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + addWord(); + } + }; + + return ( + + + {t('redact.auto.wordsToRedact.title', 'Words to Redact')} + + + {/* Current words */} + {wordsToRedact.map((word, index) => ( + + + {word} + + removeWord(index)} + disabled={disabled} + > + × + + + ))} + + {/* Add new word input */} + + setCurrentWord(e.target.value)} + onKeyDown={handleKeyPress} + disabled={disabled} + size="sm" + style={{ flex: 1 }} + /> + + + {t('redact.auto.wordsToRedact.add', 'Add')} + + + + {/* Examples */} + {wordsToRedact.length === 0 && ( + + {t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')} + + )} + + ); +} diff --git a/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx b/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx index 5e1493036..cd97dbd96 100644 --- a/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx +++ b/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx @@ -4,7 +4,7 @@ import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/us interface RemovePasswordSettingsProps { parameters: RemovePasswordParameters; - onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void; + onParameterChange: (key: K, value: RemovePasswordParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/sanitize/SanitizeSettings.tsx b/frontend/src/components/tools/sanitize/SanitizeSettings.tsx index fb5304431..21ef7c0aa 100644 --- a/frontend/src/components/tools/sanitize/SanitizeSettings.tsx +++ b/frontend/src/components/tools/sanitize/SanitizeSettings.tsx @@ -4,7 +4,7 @@ import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sani interface SanitizeSettingsProps { parameters: SanitizeParameters; - onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void; + onParameterChange: (key: K, value: SanitizeParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index 354613ecb..b989e29b8 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -10,11 +10,12 @@ import { StirlingFile } from "../../../types/fileContext"; export interface FileStatusIndicatorProps { selectedFiles?: StirlingFile[]; - placeholder?: string; + minFiles?: number; } const FileStatusIndicator = ({ selectedFiles = [], + minFiles = 1, }: FileStatusIndicatorProps) => { const { t } = useTranslation(); const { openFilesModal, onFilesSelect } = useFilesModalContext(); @@ -55,6 +56,14 @@ const FileStatusIndicator = ({ return null; } + const getPlaceholder = () => { + if (minFiles === undefined || minFiles === 1) { + return t("files.selectFromWorkbench", "Select files from the workbench or "); + } else { + return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles }); + } + }; + // Check if there are no files in the workbench if (stirlingFileStubs.length === 0) { // If no recent files, show upload button @@ -89,12 +98,12 @@ const FileStatusIndicator = ({ } // Show selection status when there are files in workbench - if (selectedFiles.length === 0) { + if (selectedFiles.length < minFiles) { // If no recent files, show upload option if (!hasRecentFiles) { return ( - {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} + {getPlaceholder() + " "} - {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} + {getPlaceholder() + " "} openFilesModal()} @@ -125,7 +134,7 @@ const FileStatusIndicator = ({ return ( - ✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })} + ✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })} ); }; diff --git a/frontend/src/components/tools/shared/FilesToolStep.tsx b/frontend/src/components/tools/shared/FilesToolStep.tsx index 8c188d4a9..5ce118bbe 100644 --- a/frontend/src/components/tools/shared/FilesToolStep.tsx +++ b/frontend/src/components/tools/shared/FilesToolStep.tsx @@ -7,7 +7,7 @@ export interface FilesToolStepProps { selectedFiles: StirlingFile[]; isCollapsed?: boolean; onCollapsedClick?: () => void; - placeholder?: string; + minFiles?: number; } export function createFilesToolStep( @@ -23,7 +23,7 @@ export function createFilesToolStep( }, ( )); } diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 2fa8eb2da..203c3b5ab 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -53,7 +53,7 @@ const renderTooltipTitle = ( {title} - + ); diff --git a/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx b/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx index c11726fdb..24619387f 100644 --- a/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx +++ b/frontend/src/components/tools/shared/ToolWorkflowTitle.tsx @@ -22,7 +22,7 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT {title} - {tooltip && } + {tooltip && } ); diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 84051f426..9ea94bc4f 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext'; export interface FilesStepConfig { selectedFiles: StirlingFile[]; isCollapsed?: boolean; - placeholder?: string; + minFiles?: number; onCollapsedClick?: () => void; isVisible?: boolean; } @@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) { {config.files.isVisible !== false && steps.createFilesStep({ selectedFiles: config.files.selectedFiles, isCollapsed: config.files.isCollapsed, - placeholder: config.files.placeholder, + minFiles: config.files.minFiles, onCollapsedClick: config.files.onCollapsedClick })} diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx index 2cf988f6d..98a612c41 100644 --- a/frontend/src/components/tools/split/SplitSettings.tsx +++ b/frontend/src/components/tools/split/SplitSettings.tsx @@ -1,11 +1,11 @@ import { Stack, TextInput, Select, Checkbox } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants'; +import { isSplitMethod, SPLIT_METHODS } from '../../../constants/splitConstants'; import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters'; export interface SplitSettingsProps { parameters: SplitParameters; - onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void; + onParameterChange: (key: K, value: SplitParameters[K]) => void; disabled?: boolean; } @@ -57,28 +57,37 @@ const SplitSettings = ({ ); - const renderBySizeOrCountForm = () => ( - - v && onParameterChange('splitType', v)} - disabled={disabled} - data={[ - { value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") }, - { value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") }, - { value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") }, - ]} - /> + const renderSplitValueForm = () => { + let label, placeholder; + + switch (parameters.method) { + case SPLIT_METHODS.BY_SIZE: + label = t("split.value.fileSize.label", "File Size"); + placeholder = t("split.value.fileSize.placeholder", "e.g. 10MB, 500KB"); + break; + case SPLIT_METHODS.BY_PAGE_COUNT: + label = t("split.value.pageCount.label", "Pages per File"); + placeholder = t("split.value.pageCount.placeholder", "e.g. 5, 10"); + break; + case SPLIT_METHODS.BY_DOC_COUNT: + label = t("split.value.docCount.label", "Number of Files"); + placeholder = t("split.value.docCount.placeholder", "e.g. 3, 5"); + break; + default: + label = t("split-by-size-or-count.value.label", "Split Value"); + placeholder = t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages"); + } + + return ( onParameterChange('splitValue', e.target.value)} disabled={disabled} /> - - ); + ); + }; const renderByChaptersForm = () => ( @@ -106,26 +115,30 @@ const SplitSettings = ({ return ( - {/* Mode Selector */} + {/* Method Selector */} isSplitMode(v) && onParameterChange('mode', v)} + label={t("split.method.label", "Choose split method")} + placeholder={t("split.method.placeholder", "Select how to split the PDF")} + value={parameters.method} + onChange={(v) => isSplitMethod(v) && onParameterChange('method', v)} disabled={disabled} data={[ - { value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" }, - { value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") }, - { value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") }, - { value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") }, + { value: SPLIT_METHODS.BY_PAGES, label: t("split.methods.byPages", "Split at Pages Numbers") }, + { value: SPLIT_METHODS.BY_SECTIONS, label: t("split.methods.bySections", "Split by Sections") }, + { value: SPLIT_METHODS.BY_SIZE, label: t("split.methods.bySize", "Split by Size") }, + { value: SPLIT_METHODS.BY_PAGE_COUNT, label: t("split.methods.byPageCount", "Split by Page Count") }, + { value: SPLIT_METHODS.BY_DOC_COUNT, label: t("split.methods.byDocCount", "Split by Document Count") }, + { value: SPLIT_METHODS.BY_CHAPTERS, label: t("split.methods.byChapters", "Split by Chapters") }, ]} /> {/* Parameter Form */} - {parameters.mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()} - {parameters.mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()} - {parameters.mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()} - {parameters.mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()} + {parameters.method === SPLIT_METHODS.BY_PAGES && renderByPagesForm()} + {parameters.method === SPLIT_METHODS.BY_SECTIONS && renderBySectionsForm()} + {(parameters.method === SPLIT_METHODS.BY_SIZE || + parameters.method === SPLIT_METHODS.BY_PAGE_COUNT || + parameters.method === SPLIT_METHODS.BY_DOC_COUNT) && renderSplitValueForm()} + {parameters.method === SPLIT_METHODS.BY_CHAPTERS && renderByChaptersForm()} ); } diff --git a/frontend/src/components/tooltips/useAdjustPageScaleTips.ts b/frontend/src/components/tooltips/useAdjustPageScaleTips.ts new file mode 100644 index 000000000..dbd6bd9d2 --- /dev/null +++ b/frontend/src/components/tooltips/useAdjustPageScaleTips.ts @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useAdjustPageScaleTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("adjustPageScale.tooltip.header.title", "Page Scale Settings Overview") + }, + tips: [ + { + title: t("adjustPageScale.tooltip.description.title", "Description"), + description: t("adjustPageScale.tooltip.description.text", "Adjust the size of PDF content and change the page dimensions.") + }, + { + title: t("adjustPageScale.tooltip.scaleFactor.title", "Scale Factor"), + description: t("adjustPageScale.tooltip.scaleFactor.text", "Controls how large or small the content appears on the page. Content is scaled and centered - if scaled content is larger than the page size, it may be cropped."), + bullets: [ + t("adjustPageScale.tooltip.scaleFactor.bullet1", "1.0 = Original size"), + t("adjustPageScale.tooltip.scaleFactor.bullet2", "0.5 = Half size (50% smaller)"), + t("adjustPageScale.tooltip.scaleFactor.bullet3", "2.0 = Double size (200% larger, may crop)") + ] + }, + { + title: t("adjustPageScale.tooltip.pageSize.title", "Target Page Size"), + description: t("adjustPageScale.tooltip.pageSize.text", "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, while other options resize to standard paper sizes.") + } + ] + }; +}; diff --git a/frontend/src/components/tooltips/useMergeTips.tsx b/frontend/src/components/tooltips/useMergeTips.tsx new file mode 100644 index 000000000..554c71540 --- /dev/null +++ b/frontend/src/components/tooltips/useMergeTips.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useMergeTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + tips: [ + { + title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'), + description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.') + }, + { + title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'), + description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.') + } + ] + }; +}; diff --git a/frontend/src/components/tooltips/useRedactTips.ts b/frontend/src/components/tooltips/useRedactTips.ts new file mode 100644 index 000000000..6c9910299 --- /dev/null +++ b/frontend/src/components/tooltips/useRedactTips.ts @@ -0,0 +1,79 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useRedactModeTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("redact.tooltip.mode.header.title", "Redaction Method") + }, + tips: [ + { + title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"), + description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.") + }, + { + title: t("redact.tooltip.mode.manual.title", "Manual Redaction"), + description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)") + } + ] + }; +}; + +export const useRedactWordsTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("redact.tooltip.words.header.title", "Words to Redact") + }, + tips: [ + { + title: t("redact.tooltip.words.description.title", "Text Matching"), + description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."), + bullets: [ + t("redact.tooltip.words.bullet1", "Add one word at a time"), + t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"), + t("redact.tooltip.words.bullet3", "Click × to remove words") + ] + }, + { + title: t("redact.tooltip.words.examples.title", "Common Examples"), + description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.") + } + ] + }; +}; + +export const useRedactAdvancedTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings") + }, + tips: [ + { + title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"), + description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."), + }, + { + title: t("redact.tooltip.advanced.regex.title", "Use Regex"), + description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."), + bullets: [ + t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"), + t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly") + ] + }, + { + title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"), + description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.") + }, + { + title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"), + description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.") + } + ] + }; +}; diff --git a/frontend/src/components/tooltips/useSplitTips.ts b/frontend/src/components/tooltips/useSplitTips.ts new file mode 100644 index 000000000..ff655aabe --- /dev/null +++ b/frontend/src/components/tooltips/useSplitTips.ts @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useSplitTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("split.tooltip.header.title", "Split Methods Overview") + }, + tips: [ + { + title: t("split.tooltip.byPages.title", "Split at Page Numbers"), + description: t("split.tooltip.byPages.text", "Extract specific pages or ranges from your PDF. Use commas to separate individual pages and hyphens for ranges."), + bullets: [ + t("split.tooltip.byPages.bullet1", "Single pages: 1,3,5"), + t("split.tooltip.byPages.bullet2", "Page ranges: 1-5,10-15"), + t("split.tooltip.byPages.bullet3", "Mixed: 1,3-7,12,15-20") + ] + }, + { + title: t("split.tooltip.bySections.title", "Split by Grid Sections"), + description: t("split.tooltip.bySections.text", "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas."), + bullets: [ + t("split.tooltip.bySections.bullet1", "Horizontal: Number of rows to create"), + t("split.tooltip.bySections.bullet2", "Vertical: Number of columns to create"), + t("split.tooltip.bySections.bullet3", "Merge: Combine all sections into one PDF") + ] + }, + { + title: t("split.tooltip.bySize.title", "Split by File Size"), + description: t("split.tooltip.bySize.text", "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments."), + bullets: [ + t("split.tooltip.bySize.bullet1", "Use MB for larger files (e.g., 10MB)"), + t("split.tooltip.bySize.bullet2", "Use KB for smaller files (e.g., 500KB)"), + t("split.tooltip.bySize.bullet3", "System will split at page boundaries") + ] + }, + { + title: t("split.tooltip.byCount.title", "Split by Count"), + description: t("split.tooltip.byCount.text", "Create multiple PDFs with a specific number of pages or documents each."), + bullets: [ + t("split.tooltip.byCount.bullet1", "Page Count: Fixed number of pages per file"), + t("split.tooltip.byCount.bullet2", "Document Count: Fixed number of output files"), + t("split.tooltip.byCount.bullet3", "Useful for batch processing workflows") + ] + }, + { + title: t("split.tooltip.byChapters.title", "Split by Chapters"), + description: t("split.tooltip.byChapters.text", "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure."), + bullets: [ + t("split.tooltip.byChapters.bullet1", "Bookmark Level: Which level to split on (1=top level)"), + t("split.tooltip.byChapters.bullet2", "Include Metadata: Preserve document properties"), + t("split.tooltip.byChapters.bullet3", "Allow Duplicates: Handle repeated bookmark names") + ] + } + ] + }; +}; diff --git a/frontend/src/constants/splitConstants.ts b/frontend/src/constants/splitConstants.ts index 1e7098669..896e4bc44 100644 --- a/frontend/src/constants/splitConstants.ts +++ b/frontend/src/constants/splitConstants.ts @@ -1,30 +1,25 @@ -export const SPLIT_MODES = { +export const SPLIT_METHODS = { BY_PAGES: 'byPages', BY_SECTIONS: 'bySections', - BY_SIZE_OR_COUNT: 'bySizeOrCount', + BY_SIZE: 'bySize', + BY_PAGE_COUNT: 'byPageCount', + BY_DOC_COUNT: 'byDocCount', BY_CHAPTERS: 'byChapters' } as const; -export const SPLIT_TYPES = { - SIZE: 'size', - PAGES: 'pages', - DOCS: 'docs' -} as const; export const ENDPOINTS = { - [SPLIT_MODES.BY_PAGES]: 'split-pages', - [SPLIT_MODES.BY_SECTIONS]: 'split-pdf-by-sections', - [SPLIT_MODES.BY_SIZE_OR_COUNT]: 'split-by-size-or-count', - [SPLIT_MODES.BY_CHAPTERS]: 'split-pdf-by-chapters' + [SPLIT_METHODS.BY_PAGES]: 'split-pages', + [SPLIT_METHODS.BY_SECTIONS]: 'split-pdf-by-sections', + [SPLIT_METHODS.BY_SIZE]: 'split-by-size-or-count', + [SPLIT_METHODS.BY_PAGE_COUNT]: 'split-by-size-or-count', + [SPLIT_METHODS.BY_DOC_COUNT]: 'split-by-size-or-count', + [SPLIT_METHODS.BY_CHAPTERS]: 'split-pdf-by-chapters' } as const; -export type SplitMode = typeof SPLIT_MODES[keyof typeof SPLIT_MODES]; -export type SplitType = typeof SPLIT_TYPES[keyof typeof SPLIT_TYPES]; - -export const isSplitMode = (value: string | null): value is SplitMode => { - return Object.values(SPLIT_MODES).includes(value as SplitMode); +export type SplitMethod = typeof SPLIT_METHODS[keyof typeof SPLIT_METHODS]; +export const isSplitMethod = (value: string | null): value is SplitMethod => { + return Object.values(SPLIT_METHODS).includes(value as SplitMethod); } -export const isSplitType = (value: string | null): value is SplitType => { - return Object.values(SPLIT_TYPES).includes(value as SplitType); -} + diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 4c4196764..b1a3988e6 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -145,12 +145,17 @@ export function fileContextReducer(state: FileContextState, action: FileContextA // Validate that all IDs exist in current state const validIds = orderedFileIds.filter(id => state.files.byId[id]); - + // Reorder selected files by passed order + const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id)); return { ...state, files: { ...state.files, ids: validIds + }, + ui: { + ...state.ui, + selectedFileIds, } }; } @@ -234,11 +239,14 @@ export function fileContextReducer(state: FileContextState, action: FileContextA case 'CONSUME_FILES': { const { inputFileIds, outputStirlingFileStubs } = action.payload; + return processFileSwap(state, inputFileIds, outputStirlingFileStubs); } + case 'UNDO_CONSUME_FILES': { const { inputStirlingFileStubs, outputFileIds } = action.payload; + return processFileSwap(state, outputFileIds, inputStirlingFileStubs); } diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index 7d7f9b23e..056ed2aa0 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu /** * Hook for selected files (optimized for selection-based UI) */ -export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } { +export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } { const { state, selectors } = useFileState(); return useMemo(() => ({ - files: selectors.getSelectedFiles(), - records: selectors.getSelectedStirlingFileStubs(), - fileIds: state.ui.selectedFileIds + selectedFiles: selectors.getSelectedFiles(), + selectedRecords: selectors.getSelectedStirlingFileStubs(), + selectedFileIds: state.ui.selectedFileIds }), [state.ui.selectedFileIds, selectors]); } @@ -169,7 +169,6 @@ export function useFileContext() { recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented - // File ID lookup findFileId: (file: File) => { return state.files.ids.find(id => { diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 5ee8490a1..f3050ea01 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import AddWatermark from "../tools/AddWatermark"; +import Merge from '../tools/Merge'; import Repair from "../tools/Repair"; import AutoRename from "../tools/AutoRename"; import SingleLargePage from "../tools/SingleLargePage"; @@ -30,8 +31,10 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation"; import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation"; import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; +import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation'; import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation"; import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation"; +import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; @@ -44,7 +47,13 @@ import OCRSettings from "../components/tools/ocr/OCRSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; import FlattenSettings from "../components/tools/flatten/FlattenSettings"; +import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings"; +import Redact from "../tools/Redact"; +import AdjustPageScale from "../tools/AdjustPageScale"; import { ToolId } from "../types/toolId"; +import MergeSettings from '../components/tools/merge/MergeSettings'; +import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; +import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -331,11 +340,14 @@ export function useFlatToolRegistry(): ToolRegistry { "adjust-page-size-scale": { icon: , name: t("home.scalePages.title", "Adjust page size/scale"), - component: null, - + component: AdjustPageScale, description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + maxFiles: -1, + endpoints: ["scale-pages"], + operationConfig: adjustPageScaleOperationConfig, + settingsComponent: AdjustPageScaleSettings, }, addPageNumbers: { icon: , @@ -669,12 +681,14 @@ export function useFlatToolRegistry(): ToolRegistry { mergePdfs: { icon: , name: t("home.merge.title", "Merge"), - component: null, - + component: Merge, description: t("home.merge.desc", "Merge multiple PDFs into a single document"), categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + endpoints: ["merge-pdfs"], + operationConfig: mergeOperationConfig, + settingsComponent: MergeSettings }, "multi-tool": { icon: , @@ -701,10 +715,14 @@ export function useFlatToolRegistry(): ToolRegistry { redact: { icon: , name: t("home.redact.title", "Redact"), - component: null, + component: Redact, description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"), categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, + maxFiles: -1, + endpoints: ["auto-redact"], + operationConfig: redactOperationConfig, + settingsComponent: RedactSingleStepSettings, }, }; diff --git a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts new file mode 100644 index 000000000..1728e5e1d --- /dev/null +++ b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { AdjustPageScaleParameters, defaultParameters } from './useAdjustPageScaleParameters'; + +export const buildAdjustPageScaleFormData = (parameters: AdjustPageScaleParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("scaleFactor", parameters.scaleFactor.toString()); + formData.append("pageSize", parameters.pageSize); + return formData; +}; + +export const adjustPageScaleOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildAdjustPageScaleFormData, + operationType: 'adjustPageScale', + endpoint: '/api/v1/general/scale-pages', + filePrefix: 'scaled_', + defaultParameters, +} as const; + +export const useAdjustPageScaleOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...adjustPageScaleOperationConfig, + getErrorMessage: createStandardErrorHandler(t('adjustPageScale.error.failed', 'An error occurred while adjusting the page scale.')) + }); +}; diff --git a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.test.ts b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.test.ts new file mode 100644 index 000000000..d68cdd861 --- /dev/null +++ b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from './useAdjustPageScaleParameters'; + +describe('useAdjustPageScaleParameters', () => { + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + expect(result.current.parameters.scaleFactor).toBe(1.0); + expect(result.current.parameters.pageSize).toBe(PageSize.KEEP); + }); + + test.each([ + { paramName: 'scaleFactor' as const, value: 0.5 }, + { paramName: 'scaleFactor' as const, value: 2.0 }, + { paramName: 'scaleFactor' as const, value: 10.0 }, + { paramName: 'pageSize' as const, value: PageSize.A4 }, + { paramName: 'pageSize' as const, value: PageSize.LETTER }, + { paramName: 'pageSize' as const, value: PageSize.LEGAL }, + ])('should update parameter $paramName to $value', ({ paramName, value }) => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + act(() => { + result.current.updateParameter(paramName, value); + }); + + expect(result.current.parameters[paramName]).toBe(value); + }); + + test('should reset parameters to defaults', () => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + // First, change some parameters + act(() => { + result.current.updateParameter('scaleFactor', 2.5); + result.current.updateParameter('pageSize', PageSize.A3); + }); + + expect(result.current.parameters.scaleFactor).toBe(2.5); + expect(result.current.parameters.pageSize).toBe(PageSize.A3); + + // Then reset + act(() => { + result.current.resetParameters(); + }); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + test('should return correct endpoint name', () => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + expect(result.current.getEndpointName()).toBe('scale-pages'); + }); + + test.each([ + { + description: 'with default parameters', + setup: () => {}, + expected: true + }, + { + description: 'with valid scale factor 0.1', + setup: (hook: AdjustPageScaleParametersHook) => { + hook.updateParameter('scaleFactor', 0.1); + }, + expected: true + }, + { + description: 'with valid scale factor 10.0', + setup: (hook: AdjustPageScaleParametersHook) => { + hook.updateParameter('scaleFactor', 10.0); + }, + expected: true + }, + { + description: 'with A4 page size', + setup: (hook: AdjustPageScaleParametersHook) => { + hook.updateParameter('pageSize', PageSize.A4); + }, + expected: true + }, + { + description: 'with invalid scale factor 0', + setup: (hook: AdjustPageScaleParametersHook) => { + hook.updateParameter('scaleFactor', 0); + }, + expected: false + }, + { + description: 'with negative scale factor', + setup: (hook: AdjustPageScaleParametersHook) => { + hook.updateParameter('scaleFactor', -0.5); + }, + expected: false + } + ])('should validate parameters correctly $description', ({ setup, expected }) => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + act(() => { + setup(result.current); + }); + + expect(result.current.validateParameters()).toBe(expected); + }); + + test('should handle all PageSize enum values', () => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + Object.values(PageSize).forEach(pageSize => { + act(() => { + result.current.updateParameter('pageSize', pageSize); + }); + + expect(result.current.parameters.pageSize).toBe(pageSize); + expect(result.current.validateParameters()).toBe(true); + }); + }); + + test('should handle scale factor edge cases', () => { + const { result } = renderHook(() => useAdjustPageScaleParameters()); + + // Test very small valid scale factor + act(() => { + result.current.updateParameter('scaleFactor', 0.01); + }); + expect(result.current.validateParameters()).toBe(true); + + // Test scale factor just above zero + act(() => { + result.current.updateParameter('scaleFactor', 0.001); + }); + expect(result.current.validateParameters()).toBe(true); + + // Test exactly zero (invalid) + act(() => { + result.current.updateParameter('scaleFactor', 0); + }); + expect(result.current.validateParameters()).toBe(false); + }); +}); diff --git a/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts new file mode 100644 index 000000000..108d7d3ea --- /dev/null +++ b/frontend/src/hooks/tools/adjustPageScale/useAdjustPageScaleParameters.ts @@ -0,0 +1,37 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export enum PageSize { + KEEP = 'KEEP', + A0 = 'A0', + A1 = 'A1', + A2 = 'A2', + A3 = 'A3', + A4 = 'A4', + A5 = 'A5', + A6 = 'A6', + LETTER = 'LETTER', + LEGAL = 'LEGAL' +} + +export interface AdjustPageScaleParameters extends BaseParameters { + scaleFactor: number; + pageSize: PageSize; +} + +export const defaultParameters: AdjustPageScaleParameters = { + scaleFactor: 1.0, + pageSize: PageSize.KEEP, +}; + +export type AdjustPageScaleParametersHook = BaseParametersHook; + +export const useAdjustPageScaleParameters = (): AdjustPageScaleParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'scale-pages', + validateFn: (params) => { + return params.scaleFactor > 0; + }, + }); +}; diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.test.ts b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts new file mode 100644 index 000000000..f811febcf --- /dev/null +++ b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useMergeOperation } from './useMergeOperation'; +import type { MergeParameters } from './useMergeParameters'; + +// Mock the useToolOperation hook +vi.mock('../shared/useToolOperation', async () => { + const actual = await vi.importActual('../shared/useToolOperation'); + return { + ...actual, + useToolOperation: vi.fn() + }; +}); + +// Mock the translation hook +const mockT = vi.fn((key: string) => `translated-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Mock the error handler +vi.mock('../../../utils/toolErrorHandler', () => ({ + createStandardErrorHandler: vi.fn(() => 'error-handler-function') +})); + +// Import the mocked function +import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; + +describe('useMergeOperation', () => { + const mockUseToolOperation = vi.mocked(useToolOperation); + + const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig; + + const mockToolOperationReturn: ToolOperationHook = { + files: [], + thumbnails: [], + downloadUrl: null, + downloadFilename: '', + isLoading: false, + errorMessage: null, + status: '', + isGeneratingThumbnails: false, + progress: null, + executeOperation: vi.fn(), + resetResults: vi.fn(), + clearError: vi.fn(), + cancelOperation: vi.fn(), + undoOperation: function (): Promise { + throw new Error('Function not implemented.'); + } + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseToolOperation.mockReturnValue(mockToolOperationReturn); + }); + + test('should build FormData correctly', () => { + renderHook(() => useMergeOperation()); + + const config = getToolConfig(); + const mockFiles = [ + new File(['content1'], 'file1.pdf', { type: 'application/pdf' }), + new File(['content2'], 'file2.pdf', { type: 'application/pdf' }) + ]; + const parameters: MergeParameters = { + removeDigitalSignature: true, + generateTableOfContents: false + }; + + const formData = config.buildFormData(parameters, mockFiles); + + // Verify files are appended + expect(formData.getAll('fileInput')).toHaveLength(2); + expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]); + expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]); + + // Verify parameters are appended correctly + expect(formData.get('sortType')).toBe('orderProvided'); + expect(formData.get('removeCertSign')).toBe('true'); + expect(formData.get('generateToc')).toBe('false'); + }); + + test('should handle response correctly', () => { + renderHook(() => useMergeOperation()); + + const config = getToolConfig(); + const mockBlob = new Blob(['merged content'], { type: 'application/pdf' }); + const mockFiles = [ + new File(['content1'], 'file1.pdf', { type: 'application/pdf' }), + new File(['content2'], 'file2.pdf', { type: 'application/pdf' }) + ]; + + const result = config.responseHandler!(mockBlob, mockFiles) as File[]; + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('merged_file1.pdf'); + expect(result[0].type).toBe('application/pdf'); + expect(result[0].size).toBe(mockBlob.size); + }); + + test('should return the hook result from useToolOperation', () => { + const { result } = renderHook(() => useMergeOperation()); + + expect(result.current).toBe(mockToolOperationReturn); + }); + + test('should use correct translation keys for error handling', () => { + renderHook(() => useMergeOperation()); + + expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.'); + }); + + test('should build FormData with different parameter combinations', () => { + renderHook(() => useMergeOperation()); + + const config = getToolConfig(); + const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })]; + + // Test case 1: All options disabled + const params1: MergeParameters = { + removeDigitalSignature: false, + generateTableOfContents: false + }; + const formData1 = config.buildFormData(params1, mockFiles); + expect(formData1.get('removeCertSign')).toBe('false'); + expect(formData1.get('generateToc')).toBe('false'); + + // Test case 2: All options enabled + const params2: MergeParameters = { + removeDigitalSignature: true, + generateTableOfContents: true + }; + const formData2 = config.buildFormData(params2, mockFiles); + expect(formData2.get('removeCertSign')).toBe('true'); + expect(formData2.get('generateToc')).toBe('true'); + }); +}); diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.ts b/frontend/src/hooks/tools/merge/useMergeOperation.ts new file mode 100644 index 000000000..a356be443 --- /dev/null +++ b/frontend/src/hooks/tools/merge/useMergeOperation.ts @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { MergeParameters } from './useMergeParameters'; + +const buildFormData = (parameters: MergeParameters, files: File[]): FormData => { + const formData = new FormData(); + + files.forEach((file) => { + formData.append("fileInput", file); + }); + formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting + formData.append("removeCertSign", parameters.removeDigitalSignature.toString()); + formData.append("generateToc", parameters.generateTableOfContents.toString()); + + return formData; +}; + +const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => { + const filename = `merged_${originalFiles[0].name}` + return [new File([blob], filename, { type: 'application/pdf' })]; +}; + +// Operation configuration for automation +export const mergeOperationConfig: ToolOperationConfig = { + toolType: ToolType.multiFile, + buildFormData, + operationType: 'merge', + endpoint: '/api/v1/general/merge-pdfs', + filePrefix: 'merged_', + responseHandler: mergeResponseHandler, +}; + +export const useMergeOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...mergeOperationConfig, + getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.')) + }); +}; diff --git a/frontend/src/hooks/tools/merge/useMergeParameters.test.ts b/frontend/src/hooks/tools/merge/useMergeParameters.test.ts new file mode 100644 index 000000000..8294cdf6e --- /dev/null +++ b/frontend/src/hooks/tools/merge/useMergeParameters.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMergeParameters, defaultParameters } from './useMergeParameters'; + +describe('useMergeParameters', () => { + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useMergeParameters()); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + test.each([ + { paramName: 'removeDigitalSignature' as const, value: true }, + { paramName: 'removeDigitalSignature' as const, value: false }, + { paramName: 'generateTableOfContents' as const, value: true }, + { paramName: 'generateTableOfContents' as const, value: false } + ])('should update parameter $paramName to $value', ({ paramName, value }) => { + const { result } = renderHook(() => useMergeParameters()); + + act(() => { + result.current.updateParameter(paramName, value); + }); + + expect(result.current.parameters[paramName]).toBe(value); + }); + + test('should reset parameters to defaults', () => { + const { result } = renderHook(() => useMergeParameters()); + + // First, change some parameters + act(() => { + result.current.updateParameter('removeDigitalSignature', true); + result.current.updateParameter('generateTableOfContents', true); + }); + + expect(result.current.parameters.removeDigitalSignature).toBe(true); + expect(result.current.parameters.generateTableOfContents).toBe(true); + + // Then reset + act(() => { + result.current.resetParameters(); + }); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + test('should validate parameters correctly - always returns true', () => { + const { result } = renderHook(() => useMergeParameters()); + + // Default state should be valid + expect(result.current.validateParameters()).toBe(true); + + // Change parameters and validate again + act(() => { + result.current.updateParameter('removeDigitalSignature', true); + result.current.updateParameter('generateTableOfContents', true); + }); + + expect(result.current.validateParameters()).toBe(true); + + // Reset and validate again + act(() => { + result.current.resetParameters(); + }); + + expect(result.current.validateParameters()).toBe(true); + }); +}); diff --git a/frontend/src/hooks/tools/merge/useMergeParameters.ts b/frontend/src/hooks/tools/merge/useMergeParameters.ts new file mode 100644 index 000000000..2abc416ca --- /dev/null +++ b/frontend/src/hooks/tools/merge/useMergeParameters.ts @@ -0,0 +1,21 @@ +import { BaseParameters } from '../../../types/parameters'; +import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters'; + +export interface MergeParameters extends BaseParameters { + removeDigitalSignature: boolean; + generateTableOfContents: boolean; +}; + +export const defaultParameters: MergeParameters = { + removeDigitalSignature: false, + generateTableOfContents: false, +}; + +export type MergeParametersHook = BaseParametersHook; + +export const useMergeParameters = (): MergeParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: "merge-pdfs", + }); +}; diff --git a/frontend/src/hooks/tools/redact/useRedactOperation.test.ts b/frontend/src/hooks/tools/redact/useRedactOperation.test.ts new file mode 100644 index 000000000..8ca6cc84d --- /dev/null +++ b/frontend/src/hooks/tools/redact/useRedactOperation.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { buildRedactFormData, redactOperationConfig, useRedactOperation } from './useRedactOperation'; +import { defaultParameters, RedactParameters } from './useRedactParameters'; + +// Mock the useToolOperation hook +vi.mock('../shared/useToolOperation', async () => { + const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc. + return { + ...actual, + useToolOperation: vi.fn() + }; +}); + +// Mock the translation hook +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) }) +})); + +// Mock the error handler utility +vi.mock('../../../utils/toolErrorHandler', () => ({ + createStandardErrorHandler: vi.fn(() => vi.fn()) +})); + +describe('buildRedactFormData', () => { + const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); + + test('should build form data for automatic mode', () => { + const parameters: RedactParameters = { + ...defaultParameters, + mode: 'automatic', + wordsToRedact: ['Confidential', 'Secret'], + useRegex: true, + wholeWordSearch: true, + redactColor: '#FF0000', + customPadding: 0.5, + convertPDFToImage: false, + }; + + const formData = buildRedactFormData(parameters, mockFile); + + expect(formData.get('fileInput')).toBe(mockFile); + expect(formData.get('listOfText')).toBe('Confidential\nSecret'); + expect(formData.get('useRegex')).toBe('true'); + expect(formData.get('wholeWordSearch')).toBe('true'); + expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed + expect(formData.get('customPadding')).toBe('0.5'); + expect(formData.get('convertPDFToImage')).toBe('false'); + }); + + test('should handle empty words array', () => { + const parameters: RedactParameters = { + ...defaultParameters, + mode: 'automatic', + wordsToRedact: [], + }; + + const formData = buildRedactFormData(parameters, mockFile); + + expect(formData.get('listOfText')).toBe(''); + }); + + test('should join multiple words with newlines', () => { + const parameters: RedactParameters = { + ...defaultParameters, + mode: 'automatic', + wordsToRedact: ['Word1', 'Word2', 'Word3'], + }; + + const formData = buildRedactFormData(parameters, mockFile); + + expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3'); + }); + + test.each([ + { description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' }, + { description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' }, + ])('should $description', ({ redactColor, expected }) => { + const parameters: RedactParameters = { + ...defaultParameters, + mode: 'automatic', + redactColor, + }; + + const formData = buildRedactFormData(parameters, mockFile); + + expect(formData.get('redactColor')).toBe(expected); + }); + + test('should convert boolean parameters to strings', () => { + const parameters: RedactParameters = { + ...defaultParameters, + mode: 'automatic', + useRegex: false, + wholeWordSearch: true, + convertPDFToImage: false, + }; + + const formData = buildRedactFormData(parameters, mockFile); + + expect(formData.get('useRegex')).toBe('false'); + expect(formData.get('wholeWordSearch')).toBe('true'); + expect(formData.get('convertPDFToImage')).toBe('false'); + }); + + test('should throw error for manual mode (not implemented)', () => { + const parameters: RedactParameters = { + ...defaultParameters, + mode: 'manual', + }; + + expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented'); + }); +}); + +describe('useRedactOperation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should call useToolOperation with correct configuration', async () => { + const { useToolOperation } = await import('../shared/useToolOperation'); + const mockUseToolOperation = vi.mocked(useToolOperation); + + renderHook(() => useRedactOperation()); + + expect(mockUseToolOperation).toHaveBeenCalledWith({ + ...redactOperationConfig, + getErrorMessage: expect.any(Function), + }); + }); + + test('should provide error handler to useToolOperation', async () => { + const { useToolOperation } = await import('../shared/useToolOperation'); + const mockUseToolOperation = vi.mocked(useToolOperation); + + renderHook(() => useRedactOperation()); + + const callArgs = mockUseToolOperation.mock.calls[0][0]; + expect(typeof callArgs.getErrorMessage).toBe('function'); + }); +}); diff --git a/frontend/src/hooks/tools/redact/useRedactOperation.ts b/frontend/src/hooks/tools/redact/useRedactOperation.ts new file mode 100644 index 000000000..d4da5530d --- /dev/null +++ b/frontend/src/hooks/tools/redact/useRedactOperation.ts @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RedactParameters, defaultParameters } from './useRedactParameters'; + +// Static configuration that can be used by both the hook and automation executor +export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + + if (parameters.mode === 'automatic') { + // Convert array to newline-separated string as expected by backend + formData.append("listOfText", parameters.wordsToRedact.join('\n')); + formData.append("useRegex", parameters.useRegex.toString()); + formData.append("wholeWordSearch", parameters.wholeWordSearch.toString()); + formData.append("redactColor", parameters.redactColor.replace('#', '')); + formData.append("customPadding", parameters.customPadding.toString()); + formData.append("convertPDFToImage", parameters.convertPDFToImage.toString()); + } else { + // Manual mode parameters would go here when implemented + throw new Error('Manual redaction not yet implemented'); + } + + return formData; +}; + +// Static configuration object +export const redactOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRedactFormData, + operationType: 'redact', + endpoint: (parameters: RedactParameters) => { + if (parameters.mode === 'automatic') { + return '/api/v1/security/auto-redact'; + } else { + // Manual redaction endpoint would go here when implemented + throw new Error('Manual redaction not yet implemented'); + } + }, + filePrefix: 'redacted_', + defaultParameters, +} as const; + +export const useRedactOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...redactOperationConfig, + getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.')) + }); +}; diff --git a/frontend/src/hooks/tools/redact/useRedactParameters.test.ts b/frontend/src/hooks/tools/redact/useRedactParameters.test.ts new file mode 100644 index 000000000..b87719ad9 --- /dev/null +++ b/frontend/src/hooks/tools/redact/useRedactParameters.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRedactParameters, defaultParameters } from './useRedactParameters'; + +describe('useRedactParameters', () => { + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useRedactParameters()); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + test.each([ + { paramName: 'mode' as const, value: 'manual' as const }, + { paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] }, + { paramName: 'useRegex' as const, value: true }, + { paramName: 'wholeWordSearch' as const, value: true }, + { paramName: 'redactColor' as const, value: '#FF0000' }, + { paramName: 'customPadding' as const, value: 0.5 }, + { paramName: 'convertPDFToImage' as const, value: false } + ])('should update parameter $paramName', ({ paramName, value }) => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter(paramName, value); + }); + + expect(result.current.parameters[paramName]).toStrictEqual(value); + }); + + test('should reset parameters to defaults', () => { + const { result } = renderHook(() => useRedactParameters()); + + // Modify some parameters + act(() => { + result.current.updateParameter('mode', 'manual'); + result.current.updateParameter('wordsToRedact', ['test']); + result.current.updateParameter('useRegex', true); + }); + + // Reset parameters + act(() => { + result.current.resetParameters(); + }); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + describe('validation', () => { + test.each([ + { description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true }, + { description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false }, + { description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false }, + { description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true }, + ])('should $description', ({ wordsToRedact, expected }) => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter('mode', 'automatic'); + result.current.updateParameter('wordsToRedact', wordsToRedact); + }); + + expect(result.current.validateParameters()).toBe(expected); + }); + + test('should not validate in manual mode (not implemented)', () => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter('mode', 'manual'); + }); + + expect(result.current.validateParameters()).toBe(false); + }); + }); + + describe('endpoint handling', () => { + test('should return correct endpoint for automatic mode', () => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter('mode', 'automatic'); + }); + + expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact'); + }); + + test('should throw error for manual mode (not implemented)', () => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter('mode', 'manual'); + }); + + expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented'); + }); + }); + + test('should maintain parameter state across updates', () => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter('redactColor', '#FF0000'); + result.current.updateParameter('customPadding', 0.5); + result.current.updateParameter('wordsToRedact', ['word1']); + }); + + // All parameters should be updated + expect(result.current.parameters.redactColor).toBe('#FF0000'); + expect(result.current.parameters.customPadding).toBe(0.5); + expect(result.current.parameters.wordsToRedact).toEqual(['word1']); + + // Other parameters should remain at defaults + expect(result.current.parameters.mode).toBe('automatic'); + expect(result.current.parameters.useRegex).toBe(false); + expect(result.current.parameters.wholeWordSearch).toBe(false); + expect(result.current.parameters.convertPDFToImage).toBe(true); + }); + + test('should handle array parameter updates correctly', () => { + const { result } = renderHook(() => useRedactParameters()); + + act(() => { + result.current.updateParameter('wordsToRedact', ['initial']); + }); + + expect(result.current.parameters.wordsToRedact).toEqual(['initial']); + + act(() => { + result.current.updateParameter('wordsToRedact', ['updated', 'multiple']); + }); + + expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']); + }); +}); diff --git a/frontend/src/hooks/tools/redact/useRedactParameters.ts b/frontend/src/hooks/tools/redact/useRedactParameters.ts new file mode 100644 index 000000000..33e95e93d --- /dev/null +++ b/frontend/src/hooks/tools/redact/useRedactParameters.ts @@ -0,0 +1,48 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export type RedactMode = 'automatic' | 'manual'; + +export interface RedactParameters extends BaseParameters { + mode: RedactMode; + + // Automatic redaction parameters + wordsToRedact: string[]; + useRegex: boolean; + wholeWordSearch: boolean; + redactColor: string; + customPadding: number; + convertPDFToImage: boolean; +} + +export const defaultParameters: RedactParameters = { + mode: 'automatic', + wordsToRedact: [], + useRegex: false, + wholeWordSearch: false, + redactColor: '#000000', + customPadding: 0.1, + convertPDFToImage: true, +}; + +export type RedactParametersHook = BaseParametersHook; + +export const useRedactParameters = (): RedactParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: (params) => { + if (params.mode === 'automatic') { + return '/api/v1/security/auto-redact'; + } + // Manual redaction endpoint would go here when implemented + throw new Error('Manual redaction not yet implemented'); + }, + validateFn: (params) => { + if (params.mode === 'automatic') { + return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0); + } + // Manual mode validation would go here when implemented + return false; + } + }); +}; diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts index 643e9bca5..996fae712 100644 --- a/frontend/src/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/hooks/tools/shared/useBaseTool.ts @@ -6,12 +6,12 @@ import { ToolOperationHook } from './useToolOperation'; import { BaseParametersHook } from './useBaseParameters'; import { StirlingFile } from '../../../types/fileContext'; -interface BaseToolReturn { +interface BaseToolReturn> { // File management selectedFiles: StirlingFile[]; // Tool-specific hooks - params: BaseParametersHook; + params: TParamsHook; operation: ToolOperationHook; // Endpoint validation @@ -33,12 +33,14 @@ interface BaseToolReturn { /** * Base tool hook for tool components. Manages standard behaviour for tools. */ -export function useBaseTool( +export function useBaseTool>( toolName: string, - useParams: () => BaseParametersHook, + useParams: () => TParamsHook, useOperation: () => ToolOperationHook, props: BaseToolProps, -): BaseToolReturn { + options?: { minFiles?: number } +): BaseToolReturn { + const minFiles = options?.minFiles ?? 1; const { onPreviewFile, onComplete, onError } = props; // File selection @@ -96,7 +98,7 @@ export function useBaseTool( }, [operation, onPreviewFile]); // Standard computed state - const hasFiles = selectedFiles.length > 0; + const hasFiles = selectedFiles.length >= minFiles; const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; const settingsCollapsed = !hasFiles || hasResults; diff --git a/frontend/src/hooks/tools/split/useSplitOperation.ts b/frontend/src/hooks/tools/split/useSplitOperation.ts index b18b7c1f5..b7ad93af0 100644 --- a/frontend/src/hooks/tools/split/useSplitOperation.ts +++ b/frontend/src/hooks/tools/split/useSplitOperation.ts @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { SplitParameters, defaultParameters } from './useSplitParameters'; -import { SPLIT_MODES } from '../../../constants/splitConstants'; +import { SPLIT_METHODS } from '../../../constants/splitConstants'; import { useToolResources } from '../shared/useToolResources'; // Static functions that can be used by both the hook and automation executor @@ -12,46 +12,53 @@ export const buildSplitFormData = (parameters: SplitParameters, file: File): For formData.append("fileInput", file); - switch (parameters.mode) { - case SPLIT_MODES.BY_PAGES: + switch (parameters.method) { + case SPLIT_METHODS.BY_PAGES: formData.append("pageNumbers", parameters.pages); break; - case SPLIT_MODES.BY_SECTIONS: + case SPLIT_METHODS.BY_SECTIONS: formData.append("horizontalDivisions", parameters.hDiv); formData.append("verticalDivisions", parameters.vDiv); formData.append("merge", parameters.merge.toString()); break; - case SPLIT_MODES.BY_SIZE_OR_COUNT: - formData.append( - "splitType", - parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2" - ); + case SPLIT_METHODS.BY_SIZE: + formData.append("splitType", "0"); formData.append("splitValue", parameters.splitValue); break; - case SPLIT_MODES.BY_CHAPTERS: + case SPLIT_METHODS.BY_PAGE_COUNT: + formData.append("splitType", "1"); + formData.append("splitValue", parameters.splitValue); + break; + case SPLIT_METHODS.BY_DOC_COUNT: + formData.append("splitType", "2"); + formData.append("splitValue", parameters.splitValue); + break; + case SPLIT_METHODS.BY_CHAPTERS: formData.append("bookmarkLevel", parameters.bookmarkLevel); formData.append("includeMetadata", parameters.includeMetadata.toString()); formData.append("allowDuplicates", parameters.allowDuplicates.toString()); break; default: - throw new Error(`Unknown split mode: ${parameters.mode}`); + throw new Error(`Unknown split method: ${parameters.method}`); } return formData; }; export const getSplitEndpoint = (parameters: SplitParameters): string => { - switch (parameters.mode) { - case SPLIT_MODES.BY_PAGES: + switch (parameters.method) { + case SPLIT_METHODS.BY_PAGES: return "/api/v1/general/split-pages"; - case SPLIT_MODES.BY_SECTIONS: + case SPLIT_METHODS.BY_SECTIONS: return "/api/v1/general/split-pdf-by-sections"; - case SPLIT_MODES.BY_SIZE_OR_COUNT: + case SPLIT_METHODS.BY_SIZE: + case SPLIT_METHODS.BY_PAGE_COUNT: + case SPLIT_METHODS.BY_DOC_COUNT: return "/api/v1/general/split-by-size-or-count"; - case SPLIT_MODES.BY_CHAPTERS: + case SPLIT_METHODS.BY_CHAPTERS: return "/api/v1/general/split-pdf-by-chapters"; default: - throw new Error(`Unknown split mode: ${parameters.mode}`); + throw new Error(`Unknown split method: ${parameters.method}`); } }; diff --git a/frontend/src/hooks/tools/split/useSplitParameters.ts b/frontend/src/hooks/tools/split/useSplitParameters.ts index e48504304..09b1ff1c9 100644 --- a/frontend/src/hooks/tools/split/useSplitParameters.ts +++ b/frontend/src/hooks/tools/split/useSplitParameters.ts @@ -1,14 +1,13 @@ -import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, SplitType } from '../../../constants/splitConstants'; +import { SPLIT_METHODS, ENDPOINTS, type SplitMethod } from '../../../constants/splitConstants'; import { BaseParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; export interface SplitParameters extends BaseParameters { - mode: SplitMode | ''; + method: SplitMethod | ''; pages: string; hDiv: string; vDiv: string; merge: boolean; - splitType: SplitType | ''; splitValue: string; bookmarkLevel: string; includeMetadata: boolean; @@ -18,12 +17,11 @@ export interface SplitParameters extends BaseParameters { export type SplitParametersHook = BaseParametersHook; export const defaultParameters: SplitParameters = { - mode: '', + method: '', pages: '', hDiv: '2', vDiv: '2', merge: false, - splitType: SPLIT_TYPES.SIZE, splitValue: '', bookmarkLevel: '1', includeMetadata: false, @@ -34,20 +32,22 @@ export const useSplitParameters = (): SplitParametersHook => { return useBaseParameters({ defaultParameters, endpointName: (params) => { - if (!params.mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES]; - return ENDPOINTS[params.mode as SplitMode]; + if (!params.method) return ENDPOINTS[SPLIT_METHODS.BY_PAGES]; + return ENDPOINTS[params.method as SplitMethod]; }, validateFn: (params) => { - if (!params.mode) return false; + if (!params.method) return false; - switch (params.mode) { - case SPLIT_MODES.BY_PAGES: + switch (params.method) { + case SPLIT_METHODS.BY_PAGES: return params.pages.trim() !== ""; - case SPLIT_MODES.BY_SECTIONS: + case SPLIT_METHODS.BY_SECTIONS: return params.hDiv !== "" && params.vDiv !== ""; - case SPLIT_MODES.BY_SIZE_OR_COUNT: + case SPLIT_METHODS.BY_SIZE: + case SPLIT_METHODS.BY_PAGE_COUNT: + case SPLIT_METHODS.BY_DOC_COUNT: return params.splitValue.trim() !== ""; - case SPLIT_MODES.BY_CHAPTERS: + case SPLIT_METHODS.BY_CHAPTERS: return params.bookmarkLevel !== ""; default: return false; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 454bb4cbc..b0ce8fdf7 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -59,6 +59,7 @@ i18n .init({ fallbackLng: 'en-GB', supportedLngs: Object.keys(supportedLanguages), + load: 'currentOnly', nonExplicitSupportedLngs: false, debug: process.env.NODE_ENV === 'development', diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index b7cd70a18..47bb1393d 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -183,10 +183,10 @@ export const mantineTheme = createTheme({ }, option: { color: 'var(--text-primary)', - '&[data-hovered]': { + '&[dataHovered]': { backgroundColor: 'var(--hover-bg)', }, - '&[data-selected]': { + '&[dataSelected]': { backgroundColor: 'var(--color-primary-100)', color: 'var(--color-primary-900)', }, diff --git a/frontend/src/tools/AdjustPageScale.tsx b/frontend/src/tools/AdjustPageScale.tsx new file mode 100644 index 000000000..1ae862e6a --- /dev/null +++ b/frontend/src/tools/AdjustPageScale.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings"; +import { useAdjustPageScaleParameters } from "../hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; +import { useAdjustPageScaleOperation } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useAdjustPageScaleTips } from "../components/tooltips/useAdjustPageScaleTips"; + +const AdjustPageScale = (props: BaseToolProps) => { + const { t } = useTranslation(); + const adjustPageScaleTips = useAdjustPageScaleTips(); + + const base = useBaseTool( + 'adjustPageScale', + useAdjustPageScaleParameters, + useAdjustPageScaleOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: "Settings", + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: adjustPageScaleTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("adjustPageScale.submit", "Adjust Page Scale"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("adjustPageScale.title", "Page Scale Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default AdjustPageScale as ToolComponent; diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index db89db162..ba47df4f1 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useFileSelection } from "../contexts/FileContext"; import { useNavigationActions } from "../contexts/NavigationContext"; @@ -161,25 +161,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { content }); - // Dynamic file placeholder based on supported types - const filesPlaceholder = useMemo(() => { - if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) { - const firstOperation = stepData.automation.operations[0]; - const toolConfig = toolRegistry[firstOperation.operation as keyof typeof toolRegistry]; - - // Check if the tool has supportedFormats that include non-PDF formats - if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) { - return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)'); - } - } - return t('automate.files.placeholder', 'Select PDF files to process with this automation'); - }, [currentStep, stepData.automation, toolRegistry, t]); - // Always create files step to avoid conditional hook calls const filesStep = createFilesToolStep(createStep, { selectedFiles, isCollapsed: hasResults, - placeholder: filesPlaceholder }); const automationSteps = [ diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx index 05fd87531..9019e6fc7 100644 --- a/frontend/src/tools/Convert.tsx +++ b/frontend/src/tools/Convert.tsx @@ -100,7 +100,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { files: { selectedFiles, isCollapsed: hasResults, - placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"), }, steps: [ { diff --git a/frontend/src/tools/Flatten.tsx b/frontend/src/tools/Flatten.tsx index 691a733f9..833757ecd 100644 --- a/frontend/src/tools/Flatten.tsx +++ b/frontend/src/tools/Flatten.tsx @@ -22,7 +22,6 @@ const Flatten = (props: BaseToolProps) => { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, - placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [ { @@ -59,4 +58,4 @@ const Flatten = (props: BaseToolProps) => { // Static method to get the operation hook for automation Flatten.tool = () => useFlattenOperation; -export default Flatten as ToolComponent; \ No newline at end of file +export default Flatten as ToolComponent; diff --git a/frontend/src/tools/Merge.tsx b/frontend/src/tools/Merge.tsx new file mode 100644 index 000000000..c0ed04f1b --- /dev/null +++ b/frontend/src/tools/Merge.tsx @@ -0,0 +1,98 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import MergeSettings from "../components/tools/merge/MergeSettings"; +import MergeFileSorter from "../components/tools/merge/MergeFileSorter"; +import { useMergeParameters } from "../hooks/tools/merge/useMergeParameters"; +import { useMergeOperation } from "../hooks/tools/merge/useMergeOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useMergeTips } from "../components/tooltips/useMergeTips"; +import { useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext"; + +const Merge = (props: BaseToolProps) => { + const { t } = useTranslation(); + const mergeTips = useMergeTips(); + + // File selection hooks for custom sorting + const { fileIds } = useAllFiles(); + const { selectedRecords } = useSelectedFiles(); + const { reorderFiles } = useFileManagement(); + + const base = useBaseTool( + 'merge', + useMergeParameters, + useMergeOperation, + props, + { minFiles: 2 } + ); + + // Custom file sorting logic for merge tool + const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => { + const sortedRecords = [...selectedRecords].sort((recordA, recordB) => { + let comparison = 0; + switch (sortType) { + case 'filename': + comparison = recordA.name.localeCompare(recordB.name); + break; + case 'dateModified': + comparison = recordA.lastModified - recordB.lastModified; + break; + } + return ascending ? comparison : -comparison; + }); + + const selectedIds = sortedRecords.map(record => record.id); + const deselectedIds = fileIds.filter(id => !selectedIds.includes(id)); + reorderFiles([...selectedIds, ...deselectedIds]); + }, [selectedRecords, fileIds, reorderFiles]); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + minFiles: 2, + }, + steps: [ + { + title: "Sort Files", + isCollapsed: base.settingsCollapsed, + content: ( + + ), + }, + { + title: "Settings", + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: mergeTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("merge.submit", "Merge PDFs"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("merge.title", "Merge Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default Merge as ToolComponent; diff --git a/frontend/src/tools/Redact.tsx b/frontend/src/tools/Redact.tsx new file mode 100644 index 000000000..3c15938b7 --- /dev/null +++ b/frontend/src/tools/Redact.tsx @@ -0,0 +1,120 @@ +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import RedactModeSelector from "../components/tools/redact/RedactModeSelector"; +import { useRedactParameters } from "../hooks/tools/redact/useRedactParameters"; +import { useRedactOperation } from "../hooks/tools/redact/useRedactOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "../components/tooltips/useRedactTips"; +import RedactAdvancedSettings from "../components/tools/redact/RedactAdvancedSettings"; +import WordsToRedactInput from "../components/tools/redact/WordsToRedactInput"; + +const Redact = (props: BaseToolProps) => { + const { t } = useTranslation(); + + // State for managing step collapse status + const [methodCollapsed, setMethodCollapsed] = useState(false); + const [wordsCollapsed, setWordsCollapsed] = useState(false); + const [advancedCollapsed, setAdvancedCollapsed] = useState(true); + + const base = useBaseTool( + 'redact', + useRedactParameters, + useRedactOperation, + props + ); + + // Tooltips for each step + const modeTips = useRedactModeTips(); + const wordsTips = useRedactWordsTips(); + const advancedTips = useRedactAdvancedTips(); + + const isExecuteDisabled = () => { + if (base.params.parameters.mode === 'manual') { + return true; // Manual mode not implemented yet + } + return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled; + }; + + // Compute actual collapsed state based on results and user state + const getActualCollapsedState = (userCollapsed: boolean) => { + return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown + }; + + // Build conditional steps based on redaction mode + const buildSteps = () => { + const steps = [ + // Method selection step (always present) + { + title: t("redact.modeSelector.title", "Redaction Method"), + isCollapsed: getActualCollapsedState(methodCollapsed), + onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed), + tooltip: modeTips, + content: ( + base.params.updateParameter('mode', mode)} + disabled={base.endpointLoading} + /> + ), + } + ]; + + // Add mode-specific steps + if (base.params.parameters.mode === 'automatic') { + steps.push( + { + title: t("redact.auto.settings.title", "Redaction Settings"), + isCollapsed: getActualCollapsedState(wordsCollapsed), + onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setWordsCollapsed(!wordsCollapsed), + tooltip: wordsTips, + content: base.params.updateParameter('wordsToRedact', words)} + disabled={base.endpointLoading} + />, + }, + { + title: t("redact.auto.settings.advancedTitle", "Advanced Settings"), + isCollapsed: getActualCollapsedState(advancedCollapsed), + onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setAdvancedCollapsed(!advancedCollapsed), + tooltip: advancedTips, + content: , + }, + ); + } else if (base.params.parameters.mode === 'manual') { + // Manual mode steps would go here when implemented + } + + return steps; + }; + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: buildSteps(), + executeButton: { + text: t("redact.submit", "Redact"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: isExecuteDisabled(), + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("redact.title", "Redaction Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default Redact as ToolComponent; diff --git a/frontend/src/tools/RemoveCertificateSign.tsx b/frontend/src/tools/RemoveCertificateSign.tsx index 195fc11f1..13a3068e6 100644 --- a/frontend/src/tools/RemoveCertificateSign.tsx +++ b/frontend/src/tools/RemoveCertificateSign.tsx @@ -19,7 +19,6 @@ const RemoveCertificateSign = (props: BaseToolProps) => { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, - placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [], executeButton: { diff --git a/frontend/src/tools/Repair.tsx b/frontend/src/tools/Repair.tsx index c805592c5..88cf4eb28 100644 --- a/frontend/src/tools/Repair.tsx +++ b/frontend/src/tools/Repair.tsx @@ -19,7 +19,6 @@ const Repair = (props: BaseToolProps) => { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, - placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [], executeButton: { diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx index f37c8fa0b..9e0c46db3 100644 --- a/frontend/src/tools/Sanitize.tsx +++ b/frontend/src/tools/Sanitize.tsx @@ -20,7 +20,6 @@ const Sanitize = (props: BaseToolProps) => { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, - placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [ { diff --git a/frontend/src/tools/SingleLargePage.tsx b/frontend/src/tools/SingleLargePage.tsx index 095428e70..d31836feb 100644 --- a/frontend/src/tools/SingleLargePage.tsx +++ b/frontend/src/tools/SingleLargePage.tsx @@ -19,7 +19,6 @@ const SingleLargePage = (props: BaseToolProps) => { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasResults, - placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [], executeButton: { diff --git a/frontend/src/tools/Split.tsx b/frontend/src/tools/Split.tsx index f22ee9159..9d4570322 100644 --- a/frontend/src/tools/Split.tsx +++ b/frontend/src/tools/Split.tsx @@ -4,10 +4,12 @@ import SplitSettings from "../components/tools/split/SplitSettings"; import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; import { useSplitOperation } from "../hooks/tools/split/useSplitOperation"; import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { useSplitTips } from "../components/tooltips/useSplitTips"; import { BaseToolProps, ToolComponent } from "../types/tool"; const Split = (props: BaseToolProps) => { const { t } = useTranslation(); + const splitTips = useSplitTips(); const base = useBaseTool( 'split', @@ -26,6 +28,7 @@ const Split = (props: BaseToolProps) => { title: "Settings", isCollapsed: base.settingsCollapsed, onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined, + tooltip: splitTips, content: ( { files: { selectedFiles: base.selectedFiles, isCollapsed: base.hasFiles || base.hasResults, - placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"), }, steps: [], executeButton: { diff --git a/testing/test_pdf_1.pdf b/testing/test_pdf_1.pdf new file mode 100644 index 000000000..3d63420a2 --- /dev/null +++ b/testing/test_pdf_1.pdf @@ -0,0 +1,74 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 147 +>> +stream +GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp2V9^`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngc-TW~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000333 00000 n +0000000526 00000 n +0000000594 00000 n +0000000890 00000 n +0000000949 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1186 +%%EOF diff --git a/testing/test_pdf_2.pdf b/testing/test_pdf_2.pdf new file mode 100644 index 000000000..67f895ebf --- /dev/null +++ b/testing/test_pdf_2.pdf @@ -0,0 +1,74 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 147 +>> +stream +GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000333 00000 n +0000000526 00000 n +0000000594 00000 n +0000000890 00000 n +0000000949 00000 n +trailer +<< +/ID +[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1186 +%%EOF diff --git a/testing/test_pdf_3.pdf b/testing/test_pdf_3.pdf new file mode 100644 index 000000000..fb7e26930 --- /dev/null +++ b/testing/test_pdf_3.pdf @@ -0,0 +1,74 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 147 +>> +stream +GarW0YmS?5&4HDC`<2TCEOpM_A^cO6ZEVtG&1rQ7k5R.W5uPe>'T[Ma*9KfZqZs*-57""%'endstream +endobj +xref +0 9 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000333 00000 n +0000000526 00000 n +0000000594 00000 n +0000000890 00000 n +0000000949 00000 n +trailer +<< +/ID +[<8c4eba11c30780ded30147f80c0aa46f><8c4eba11c30780ded30147f80c0aa46f>] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1186 +%%EOF diff --git a/testing/test_pdf_4.pdf b/testing/test_pdf_4.pdf new file mode 100644 index 000000000..be7bc520d --- /dev/null +++ b/testing/test_pdf_4.pdf @@ -0,0 +1,74 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 147 +>> +stream +GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3%Qb`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^nh.J$8~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000333 00000 n +0000000526 00000 n +0000000594 00000 n +0000000890 00000 n +0000000949 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1186 +%%EOF