From 316be5eac5b9935c054e922afd0d1d93bb4a6d7b Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 8 Sep 2025 09:55:30 +0100 Subject: [PATCH 01/10] Fix types of onParameterChange methods (#4415) # Description of Changes Fix types of onParameterChange methods --- .../tools/addPassword/AddPasswordSettings.tsx | 2 +- .../ChangePermissionsSettings.tsx | 2 +- .../tools/compress/CompressSettings.tsx | 2 +- .../convert/ConvertFromEmailSettings.tsx | 44 +++++++++---------- .../convert/ConvertFromImageSettings.tsx | 2 +- .../tools/convert/ConvertFromWebSettings.tsx | 26 +++++------ .../tools/convert/ConvertSettings.tsx | 2 +- .../tools/convert/ConvertToImageSettings.tsx | 2 +- .../tools/convert/ConvertToPdfaSettings.tsx | 20 ++++----- .../tools/ocr/AdvancedOCRSettings.tsx | 8 ++-- .../src/components/tools/ocr/OCRSettings.tsx | 2 +- .../removePassword/RemovePasswordSettings.tsx | 2 +- .../tools/sanitize/SanitizeSettings.tsx | 2 +- .../components/tools/split/SplitSettings.tsx | 6 +-- 14 files changed, 61 insertions(+), 61 deletions(-) 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/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..28035bfe3 100644 --- a/frontend/src/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/components/tools/compress/CompressSettings.tsx @@ -5,7 +5,7 @@ import { CompressParameters } from "../../../hooks/tools/compress/useCompressPar interface CompressSettingsProps { parameters: CompressParameters; - onParameterChange: (key: keyof CompressParameters, value: any) => void; + onParameterChange: (key: K, value: CompressParameters[K]) => void; disabled?: boolean; } 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")}: v && onParameterChange('splitType', v)} + onChange={(v) => isSplitType(v) && onParameterChange('splitType', v)} disabled={disabled} data={[ { value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") }, From c25985e49ee51f37d5e0022d699f7657ebd4627a Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:58:22 +0000 Subject: [PATCH 02/10] Update Frontend 3rd Party Licenses (#4319) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- frontend/src/assets/3rdPartyLicenses.json | 44 ++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) 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", From e8af4f6b35d24e0e778b3339a8d7ccb29a701e6f Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 8 Sep 2025 11:05:49 +0200 Subject: [PATCH 03/10] Set i18n to load only current language (#4359) This pull request introduces a minor configuration change to the i18n setup in the frontend. The change improves language loading behavior by ensuring only the current language is loaded, which can help optimize performance and prevent unnecessary resource usage. * Added the `load: 'currentOnly'` option to the i18n initialization in `frontend/src/i18n.ts`, so only the current language is loaded. Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- frontend/src/i18n.ts | 1 + 1 file changed, 1 insertion(+) 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', From 494ef801a2d270d9d951644041c1d2d773ee7301 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 9 Sep 2025 16:18:09 +0100 Subject: [PATCH 04/10] Improve npm scripts (#4424) # Description of Changes Change NPM scripts so they call each other (single source of truth) and add a command to run type checking, linting and tests (to give confidence CI will pass). --- frontend/package.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d73e9ad97..0b14a8ffc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,16 +38,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", From 9d723eae6992f5103ca7119d8c62ffadbe867539 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 10 Sep 2025 14:03:11 +0100 Subject: [PATCH 05/10] Add auto-redact to V2 (#4417) # Description of Changes Adds auto-redact tool to V2, with manual-redact in the UI but explicitly disabled. Also creates a shared component for the large buttons we're using in a couple different tools and uses consistently. --- .../public/locales/en-GB/translation.json | 166 ++++++++++---- .../components/shared/ButtonSelector.test.tsx | 216 ++++++++++++++++++ .../src/components/shared/ButtonSelector.tsx | 59 +++++ .../addWatermark/WatermarkTypeSettings.tsx | 43 ++-- .../tools/compress/CompressSettings.tsx | 40 +--- .../redact/RedactAdvancedSettings.test.tsx | 211 +++++++++++++++++ .../tools/redact/RedactAdvancedSettings.tsx | 69 ++++++ .../tools/redact/RedactModeSelector.tsx | 33 +++ .../redact/RedactSingleStepSettings.test.tsx | 183 +++++++++++++++ .../tools/redact/RedactSingleStepSettings.tsx | 61 +++++ .../tools/redact/WordsToRedactInput.test.tsx | 191 ++++++++++++++++ .../tools/redact/WordsToRedactInput.tsx | 99 ++++++++ .../src/components/tooltips/useRedactTips.ts | 79 +++++++ .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../tools/redact/useRedactOperation.test.ts | 142 ++++++++++++ .../hooks/tools/redact/useRedactOperation.ts | 51 +++++ .../tools/redact/useRedactParameters.test.ts | 134 +++++++++++ .../hooks/tools/redact/useRedactParameters.ts | 48 ++++ frontend/src/tools/Redact.tsx | 120 ++++++++++ 19 files changed, 1852 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/shared/ButtonSelector.test.tsx create mode 100644 frontend/src/components/shared/ButtonSelector.tsx create mode 100644 frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx create mode 100644 frontend/src/components/tools/redact/RedactAdvancedSettings.tsx create mode 100644 frontend/src/components/tools/redact/RedactModeSelector.tsx create mode 100644 frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx create mode 100644 frontend/src/components/tools/redact/RedactSingleStepSettings.tsx create mode 100644 frontend/src/components/tools/redact/WordsToRedactInput.test.tsx create mode 100644 frontend/src/components/tools/redact/WordsToRedactInput.tsx create mode 100644 frontend/src/components/tooltips/useRedactTips.ts create mode 100644 frontend/src/hooks/tools/redact/useRedactOperation.test.ts create mode 100644 frontend/src/hooks/tools/redact/useRedactOperation.ts create mode 100644 frontend/src/hooks/tools/redact/useRedactParameters.test.ts create mode 100644 frontend/src/hooks/tools/redact/useRedactParameters.ts create mode 100644 frontend/src/tools/Redact.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 5c2aeb3c2..690014859 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", @@ -1583,50 +1579,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 +1906,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/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) => ( + + ))} + + + ); +}; + +export default ButtonSelector; 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 ( - -
- - -
-
+ ); }; diff --git a/frontend/src/components/tools/compress/CompressSettings.tsx b/frontend/src/components/tools/compress/CompressSettings.tsx index 28035bfe3..412962704 100644 --- a/frontend/src/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/components/tools/compress/CompressSettings.tsx @@ -1,7 +1,8 @@ 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; @@ -18,33 +19,16 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C {/* Compression Method */} - - Compression Method -
- - -
-
+ 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/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 }} + /> + + + + {/* Examples */} + {wordsToRedact.length === 0 && ( + + {t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')} + + )} + + ); +} 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/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 5ee8490a1..cf1ece3f1 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -32,6 +32,7 @@ import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCerti import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; 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,6 +45,8 @@ 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 { ToolId } from "../types/toolId"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -701,10 +704,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/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/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; From f3fd85d777f880e6fffabe203d1f4a4058991e2c Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 10 Sep 2025 14:06:23 +0100 Subject: [PATCH 06/10] Add Merge UI to V2 (#4235) # Description of Changes Add UI for Merge into V2. --- .../public/locales/en-GB/translation.json | 32 ++- .../tools/merge/MergeFileSorter.test.tsx | 182 ++++++++++++++++++ .../tools/merge/MergeFileSorter.tsx | 77 ++++++++ .../tools/merge/MergeSettings.test.tsx | 100 ++++++++++ .../components/tools/merge/MergeSettings.tsx | 38 ++++ .../tools/shared/FileStatusIndicator.tsx | 19 +- .../components/tools/shared/FilesToolStep.tsx | 4 +- .../tools/shared/createToolFlow.tsx | 4 +- .../src/components/tooltips/useMergeTips.tsx | 19 ++ frontend/src/contexts/file/FileReducer.ts | 10 +- frontend/src/contexts/file/fileHooks.ts | 9 +- .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../tools/merge/useMergeOperation.test.ts | 138 +++++++++++++ .../hooks/tools/merge/useMergeOperation.ts | 41 ++++ .../tools/merge/useMergeParameters.test.ts | 68 +++++++ .../hooks/tools/merge/useMergeParameters.ts | 21 ++ .../src/hooks/tools/shared/useBaseTool.ts | 4 +- frontend/src/tools/Automate.tsx | 17 +- frontend/src/tools/Convert.tsx | 1 - frontend/src/tools/Flatten.tsx | 3 +- frontend/src/tools/Merge.tsx | 98 ++++++++++ frontend/src/tools/RemoveCertificateSign.tsx | 1 - frontend/src/tools/Repair.tsx | 1 - frontend/src/tools/Sanitize.tsx | 1 - frontend/src/tools/SingleLargePage.tsx | 1 - frontend/src/tools/UnlockPdfForms.tsx | 1 - testing/test_pdf_1.pdf | 74 +++++++ testing/test_pdf_2.pdf | 74 +++++++ testing/test_pdf_3.pdf | 74 +++++++ testing/test_pdf_4.pdf | 74 +++++++ 30 files changed, 1146 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/tools/merge/MergeFileSorter.test.tsx create mode 100644 frontend/src/components/tools/merge/MergeFileSorter.tsx create mode 100644 frontend/src/components/tools/merge/MergeSettings.test.tsx create mode 100644 frontend/src/components/tools/merge/MergeSettings.tsx create mode 100644 frontend/src/components/tooltips/useMergeTips.tsx create mode 100644 frontend/src/hooks/tools/merge/useMergeOperation.test.ts create mode 100644 frontend/src/hooks/tools/merge/useMergeOperation.ts create mode 100644 frontend/src/hooks/tools/merge/useMergeParameters.test.ts create mode 100644 frontend/src/hooks/tools/merge/useMergeParameters.ts create mode 100644 frontend/src/tools/Merge.tsx create mode 100644 testing/test_pdf_1.pdf create mode 100644 testing/test_pdf_2.pdf create mode 100644 testing/test_pdf_3.pdf create mode 100644 testing/test_pdf_4.pdf diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 690014859..ff2ee732c 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", @@ -644,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", 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.")} + + + + { + 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/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/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index c88d46fec..f3050ea01 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -49,8 +49,11 @@ import ChangePermissionsSettings from "../components/tools/changePermissions/Cha 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 @@ -337,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: , 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/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; From 7dad484aa7df99f6a43bbe72d8288662ac4c533c Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 15 Sep 2025 14:28:18 +0100 Subject: [PATCH 09/10] Improve type info on param hooks (#4438) # Description of Changes Changes it so that callers of `useBaseTool` know what actual type the parameters hook that they passed in returned, so they can actually make use of any extra methods that that params hook has. --- frontend/src/hooks/tools/shared/useBaseTool.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts index 723f95e44..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,13 +33,13 @@ 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, options?: { minFiles?: number } -): BaseToolReturn { +): BaseToolReturn { const minFiles = options?.minFiles ?? 1; const { onPreviewFile, onComplete, onError } = props; From a57373b9682b767cdb38db9954f6074b1c17da1e Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:11:29 +0100 Subject: [PATCH 10/10] V2 Flatten split options to remove layers of drop downs (#4439) Co-authored-by: Connor Yoh --- .../public/locales/en-GB/translation.json | 71 +++++++++++++++++- .../components/tools/split/SplitSettings.tsx | 75 +++++++++++-------- .../src/components/tooltips/useSplitTips.ts | 59 +++++++++++++++ frontend/src/constants/splitConstants.ts | 33 ++++---- .../hooks/tools/split/useSplitOperation.ts | 41 +++++----- .../hooks/tools/split/useSplitParameters.ts | 26 +++---- frontend/src/theme/mantineTheme.ts | 4 +- frontend/src/tools/Split.tsx | 3 + 8 files changed, 229 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/tooltips/useSplitTips.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 6ba7c83ba..b7875c314 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -683,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", diff --git a/frontend/src/components/tools/split/SplitSettings.tsx b/frontend/src/components/tools/split/SplitSettings.tsx index 936b5a09f..98a612c41 100644 --- a/frontend/src/components/tools/split/SplitSettings.tsx +++ b/frontend/src/components/tools/split/SplitSettings.tsx @@ -1,6 +1,6 @@ import { Stack, TextInput, Select, Checkbox } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { isSplitMode, isSplitType, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants'; +import { isSplitMethod, SPLIT_METHODS } from '../../../constants/splitConstants'; import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters'; export interface SplitSettingsProps { @@ -57,28 +57,37 @@ const SplitSettings = ({ ); - const renderBySizeOrCountForm = () => ( - - 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/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/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/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/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: (