mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-24 04:26:14 +00:00
Compare commits
No commits in common. "777c54dbe4ee2f95fbfedb869ed85d2ed4dd8f1d" and "65e31417602b1d670d4a9cded81cbba3892ef831" have entirely different histories.
777c54dbe4
...
65e3141760
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -139,8 +139,5 @@
|
|||||||
"app/core/src/main/java",
|
"app/core/src/main/java",
|
||||||
"app/common/src/main/java",
|
"app/common/src/main/java",
|
||||||
"app/proprietary/src/main/java"
|
"app/proprietary/src/main/java"
|
||||||
],
|
]
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -38,18 +38,16 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npm run generate-icons",
|
"predev": "npm run generate-icons",
|
||||||
"dev": "npm run typecheck && vite",
|
"dev": "npx tsc --noEmit && vite",
|
||||||
"prebuild": "npm run generate-icons",
|
"prebuild": "npm run generate-icons",
|
||||||
"lint": "eslint",
|
"lint": "npx eslint",
|
||||||
"build": "npm run typecheck && vite build",
|
"build": "npx tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"check": "npm run typecheck && npm run lint && npm run test:run",
|
|
||||||
"generate-licenses": "node scripts/generate-licenses.js",
|
"generate-licenses": "node scripts/generate-licenses.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js",
|
"generate-icons": "node scripts/generate-icons.js",
|
||||||
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
@ -51,11 +51,11 @@
|
|||||||
"filesSelected": "{{count}} files selected",
|
"filesSelected": "{{count}} files selected",
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Files",
|
"title": "Files",
|
||||||
|
"placeholder": "Select a PDF file in the main view to get started",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploadFiles": "Upload Files",
|
"uploadFiles": "Upload Files",
|
||||||
"addFiles": "Add 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",
|
"noFavourites": "No favourites added",
|
||||||
"downloadComplete": "Download Complete",
|
"downloadComplete": "Download Complete",
|
||||||
@ -498,9 +498,13 @@
|
|||||||
"title": "Show Javascript",
|
"title": "Show Javascript",
|
||||||
"desc": "Searches and displays any JS injected into a PDF"
|
"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": {
|
"redact": {
|
||||||
"title": "Redact",
|
"title": "Manual Redaction",
|
||||||
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||||
},
|
},
|
||||||
"overlay-pdfs": {
|
"overlay-pdfs": {
|
||||||
"title": "Overlay PDFs",
|
"title": "Overlay PDFs",
|
||||||
@ -644,29 +648,11 @@
|
|||||||
"merge": {
|
"merge": {
|
||||||
"tags": "merge,Page operations,Back end,server side",
|
"tags": "merge,Page operations,Back end,server side",
|
||||||
"title": "Merge",
|
"title": "Merge",
|
||||||
"removeDigitalSignature": "Remove digital signature in the merged file?",
|
"header": "Merge multiple PDFs (2+)",
|
||||||
"generateTableOfContents": "Generate table of contents in the merged file?",
|
"sortByName": "Sort by name",
|
||||||
"removeDigitalSignature.tooltip": {
|
"sortByDate": "Sort by date",
|
||||||
"title": "Remove Digital Signature",
|
"removeCertSign": "Remove digital signature in the merged file?",
|
||||||
"description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF."
|
"submit": "Merge"
|
||||||
},
|
|
||||||
"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": {
|
"split": {
|
||||||
"tags": "Page operations,divide,Multi Page,cut,server side",
|
"tags": "Page operations,divide,Multi Page,cut,server side",
|
||||||
@ -1483,29 +1469,7 @@
|
|||||||
"tags": "auto-detect,header-based,organize,relabel",
|
"tags": "auto-detect,header-based,organize,relabel",
|
||||||
"title": "Auto Rename",
|
"title": "Auto Rename",
|
||||||
"header": "Auto Rename PDF",
|
"header": "Auto Rename PDF",
|
||||||
"description": "Automatically finds the title from your PDF content and uses it as the filename.",
|
"submit": "Auto Rename"
|
||||||
"submit": "Auto Rename",
|
|
||||||
"files": {
|
|
||||||
"placeholder": "Select a PDF file in the main view to get started"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"failed": "An error occurred whilst auto-renaming the PDF."
|
|
||||||
},
|
|
||||||
"results": {
|
|
||||||
"title": "Auto-Rename Results"
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"header": {
|
|
||||||
"title": "How Auto-Rename Works"
|
|
||||||
},
|
|
||||||
"howItWorks": {
|
|
||||||
"title": "Smart Renaming",
|
|
||||||
"text": "Automatically finds the title from your PDF content and uses it as the filename.",
|
|
||||||
"bullet1": "Looks for text that appears to be a title or heading",
|
|
||||||
"bullet2": "Creates a clean, valid filename from the detected title",
|
|
||||||
"bullet3": "Keeps the original name if no suitable title is found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"adjust-contrast": {
|
"adjust-contrast": {
|
||||||
"tags": "color-correction,tune,modify,enhance,colour-correction"
|
"tags": "color-correction,tune,modify,enhance,colour-correction"
|
||||||
@ -1597,123 +1561,50 @@
|
|||||||
"downloadJS": "Download Javascript",
|
"downloadJS": "Download Javascript",
|
||||||
"submit": "Show"
|
"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": {
|
"redact": {
|
||||||
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
|
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
|
||||||
"title": "Redact",
|
"title": "Manual Redaction",
|
||||||
|
"header": "Manual Redaction",
|
||||||
"submit": "Redact",
|
"submit": "Redact",
|
||||||
"error": {
|
"textBasedRedaction": "Text based Redaction",
|
||||||
"failed": "An error occurred while redacting the PDF."
|
"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)"
|
||||||
},
|
},
|
||||||
"modeSelector": {
|
"redactionColor": {
|
||||||
"title": "Redaction Method",
|
"title": "Redaction Color"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"auto": {
|
"export": "Export",
|
||||||
"header": "Auto Redact",
|
"upload": "Upload",
|
||||||
"settings": {
|
"boxRedaction": "Box draw redaction",
|
||||||
"title": "Redaction Settings",
|
"zoom": "Zoom",
|
||||||
"advancedTitle": "Advanced"
|
"zoomIn": "Zoom in",
|
||||||
},
|
"zoomOut": "Zoom out",
|
||||||
"colorLabel": "Box Colour",
|
"nextPage": "Next Page",
|
||||||
"wordsToRedact": {
|
"previousPage": "Previous Page",
|
||||||
"title": "Words to Redact",
|
"toggleSidebar": "Toggle Sidebar",
|
||||||
"placeholder": "Enter a word",
|
"showThumbnails": "Show Thumbnails",
|
||||||
"add": "Add",
|
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
|
||||||
"examples": "Examples: Confidential, Top-Secret"
|
"showAttatchments": "Show Attachments",
|
||||||
},
|
"showLayers": "Show Layers (double-click to reset all layers to the default state)",
|
||||||
"useRegexLabel": "Use Regex",
|
"colourPicker": "Colour Picker",
|
||||||
"wholeWordSearchLabel": "Whole Word Search",
|
"findCurrentOutlineItem": "Find current outline item",
|
||||||
"customPaddingLabel": "Custom Extra Padding",
|
"applyChanges": "Apply Changes"
|
||||||
"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": {
|
"tableExtraxt": {
|
||||||
"tags": "CSV,Table Extraction,extract,convert"
|
"tags": "CSV,Table Extraction,extract,convert"
|
||||||
@ -1924,11 +1815,6 @@
|
|||||||
"title": "Compress",
|
"title": "Compress",
|
||||||
"desc": "Compress PDFs to reduce their file size.",
|
"desc": "Compress PDFs to reduce their file size.",
|
||||||
"header": "Compress PDF",
|
"header": "Compress PDF",
|
||||||
"method": {
|
|
||||||
"title": "Compression Method",
|
|
||||||
"quality": "Quality",
|
|
||||||
"filesize": "File Size"
|
|
||||||
},
|
|
||||||
"credit": "This service uses qpdf for PDF Compress/Optimisation.",
|
"credit": "This service uses qpdf for PDF Compress/Optimisation.",
|
||||||
"grayscale": {
|
"grayscale": {
|
||||||
"label": "Apply Grayscale for Compression"
|
"label": "Apply Grayscale for Compression"
|
||||||
|
@ -1113,28 +1113,7 @@
|
|||||||
"tags": "auto-detect,header-based,organize,relabel",
|
"tags": "auto-detect,header-based,organize,relabel",
|
||||||
"title": "Auto Rename",
|
"title": "Auto Rename",
|
||||||
"header": "Auto Rename PDF",
|
"header": "Auto Rename PDF",
|
||||||
"submit": "Auto Rename",
|
"submit": "Auto Rename"
|
||||||
"files": {
|
|
||||||
"placeholder": "Select a PDF file in the main view to get started"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"failed": "An error occurred while auto-renaming the PDF."
|
|
||||||
},
|
|
||||||
"results": {
|
|
||||||
"title": "Auto-Rename Results"
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"header": {
|
|
||||||
"title": "How Auto-Rename Works"
|
|
||||||
},
|
|
||||||
"howItWorks": {
|
|
||||||
"title": "Smart Renaming",
|
|
||||||
"text": "Automatically finds the best title from your PDF content and uses it as the filename.",
|
|
||||||
"bullet1": "Looks for text that appears to be a title or heading",
|
|
||||||
"bullet2": "Creates a clean, valid filename from the detected title",
|
|
||||||
"bullet3": "Keeps the original name if no suitable title is found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"adjust-contrast": {
|
"adjust-contrast": {
|
||||||
"tags": "color-correction,tune,modify,enhance"
|
"tags": "color-correction,tune,modify,enhance"
|
||||||
|
@ -385,13 +385,6 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/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",
|
"moduleName": "@tailwindcss/node",
|
||||||
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
|
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
|
||||||
@ -749,13 +742,6 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/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",
|
"moduleName": "core-util-is",
|
||||||
"moduleUrl": "https://github.com/isaacs/core-util-is",
|
"moduleUrl": "https://github.com/isaacs/core-util-is",
|
||||||
@ -938,13 +924,6 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/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",
|
"moduleName": "file-selector",
|
||||||
"moduleUrl": "https://github.com/react-dropzone/file-selector",
|
"moduleUrl": "https://github.com/react-dropzone/file-selector",
|
||||||
@ -1554,20 +1533,6 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/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",
|
"moduleName": "pretty-format",
|
||||||
"moduleUrl": "https://github.com/facebook/jest",
|
"moduleUrl": "https://github.com/facebook/jest",
|
||||||
@ -1963,7 +1928,7 @@
|
|||||||
{
|
{
|
||||||
"moduleName": "typescript",
|
"moduleName": "typescript",
|
||||||
"moduleUrl": "https://github.com/microsoft/TypeScript",
|
"moduleUrl": "https://github.com/microsoft/TypeScript",
|
||||||
"moduleVersion": "5.9.2",
|
"moduleVersion": "5.8.3",
|
||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||||
},
|
},
|
||||||
@ -2030,13 +1995,6 @@
|
|||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-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",
|
"moduleName": "webidl-conversions",
|
||||||
"moduleUrl": "https://github.com/jsdom/webidl-conversions",
|
"moduleUrl": "https://github.com/jsdom/webidl-conversions",
|
||||||
|
@ -78,6 +78,23 @@ const FileEditor = ({
|
|||||||
// Use activeStirlingFileStubs directly - no conversion needed
|
// Use activeStirlingFileStubs directly - no conversion needed
|
||||||
const localSelectedIds = contextSelectedIds;
|
const localSelectedIds = contextSelectedIds;
|
||||||
|
|
||||||
|
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||||
|
const recordToFileItem = useCallback((record: any) => {
|
||||||
|
const file = selectors.getFile(record.id);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: file.name,
|
||||||
|
pageCount: record.processedFile?.totalPages || 1,
|
||||||
|
thumbnail: record.thumbnailUrl || '',
|
||||||
|
size: file.size,
|
||||||
|
modifiedAt: file.lastModified,
|
||||||
|
file: file
|
||||||
|
};
|
||||||
|
}, [selectors]);
|
||||||
|
|
||||||
|
|
||||||
// Process uploaded files using context
|
// Process uploaded files using context
|
||||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -388,10 +405,13 @@ const FileEditor = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeStirlingFileStubs.map((record, index) => {
|
{activeStirlingFileStubs.map((record, index) => {
|
||||||
|
const fileItem = recordToFileItem(record);
|
||||||
|
if (!fileItem) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileEditorThumbnail
|
<FileEditorThumbnail
|
||||||
key={record.id}
|
key={record.id}
|
||||||
file={record}
|
file={fileItem}
|
||||||
index={index}
|
index={index}
|
||||||
totalFiles={activeStirlingFileStubs.length}
|
totalFiles={activeStirlingFileStubs.length}
|
||||||
selectedFiles={localSelectedIds}
|
selectedFiles={localSelectedIds}
|
||||||
@ -402,7 +422,7 @@ const FileEditor = ({
|
|||||||
onSetStatus={setStatus}
|
onSetStatus={setStatus}
|
||||||
onReorderFiles={handleReorderFiles}
|
onReorderFiles={handleReorderFiles}
|
||||||
toolMode={toolMode}
|
toolMode={toolMode}
|
||||||
isSupported={isFileSupported(record.name)}
|
isSupported={isFileSupported(fileItem.name)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -8,17 +8,23 @@ import PushPinIcon from '@mui/icons-material/PushPin';
|
|||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import { StirlingFileStub } from '../../types/fileContext';
|
|
||||||
|
|
||||||
import styles from './FileEditor.module.css';
|
import styles from './FileEditor.module.css';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
import ToolChain from '../shared/ToolChain';
|
import ToolChain from '../shared/ToolChain';
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
id: FileId;
|
||||||
|
name: string;
|
||||||
|
pageCount: number;
|
||||||
|
thumbnail: string | null;
|
||||||
|
size: number;
|
||||||
|
modifiedAt?: number | string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
interface FileEditorThumbnailProps {
|
interface FileEditorThumbnailProps {
|
||||||
file: StirlingFileStub;
|
file: FileItem;
|
||||||
index: number;
|
index: number;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
selectedFiles: FileId[];
|
selectedFiles: FileId[];
|
||||||
@ -45,7 +51,7 @@ const FileEditorThumbnail = ({
|
|||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileEditorThumbnailProps) => {
|
}: FileEditorThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
const { pinFile, unpinFile, isFilePinned, activeFiles, selectors } = useFileContext();
|
||||||
|
|
||||||
// ---- Drag state ----
|
// ---- Drag state ----
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@ -59,7 +65,12 @@ const FileEditorThumbnail = ({
|
|||||||
}, [activeFiles, file.id]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
const pageCount = file.processedFile?.totalPages || 0;
|
// Get file record to access tool history
|
||||||
|
const fileRecord = selectors.getStirlingFileStub(file.id);
|
||||||
|
const toolHistory = fileRecord?.toolHistory || [];
|
||||||
|
const hasToolHistory = toolHistory.length > 0;
|
||||||
|
const versionNumber = fileRecord?.versionNumber || 1;
|
||||||
|
|
||||||
|
|
||||||
const downloadSelectedFile = useCallback(() => {
|
const downloadSelectedFile = useCallback(() => {
|
||||||
// Prefer parent-provided handler if available
|
// Prefer parent-provided handler if available
|
||||||
@ -106,21 +117,22 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
const pageLabel = useMemo(
|
const pageLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pageCount > 0
|
file.pageCount > 0
|
||||||
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
|
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
|
||||||
: '',
|
: '',
|
||||||
[pageCount]
|
[file.pageCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dateLabel = useMemo(() => {
|
const dateLabel = useMemo(() => {
|
||||||
const d = new Date(file.lastModified);
|
const d =
|
||||||
|
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}).format(d);
|
}).format(d);
|
||||||
}, [file.lastModified]);
|
}, [file.modifiedAt]);
|
||||||
|
|
||||||
// ---- Drag & drop wiring ----
|
// ---- Drag & drop wiring ----
|
||||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
@ -347,7 +359,7 @@ const FileEditorThumbnail = ({
|
|||||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||||
>
|
>
|
||||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||||
{`v${file.versionNumber} - `}
|
{hasToolHistory ? ` v${versionNumber} - ` : ''}
|
||||||
{dateLabel}
|
{dateLabel}
|
||||||
{extUpper ? ` - ${extUpper} file` : ''}
|
{extUpper ? ` - ${extUpper} file` : ''}
|
||||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||||
@ -357,9 +369,9 @@ const FileEditorThumbnail = ({
|
|||||||
{/* Preview area */}
|
{/* Preview area */}
|
||||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||||
<div className={styles.previewPaper}>
|
<div className={styles.previewPaper}>
|
||||||
{file.thumbnailUrl && (
|
{file.thumbnail && (
|
||||||
<img
|
<img
|
||||||
src={file.thumbnailUrl}
|
src={file.thumbnail}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@ -398,7 +410,7 @@ const FileEditorThumbnail = ({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Tool chain display at bottom */}
|
{/* Tool chain display at bottom */}
|
||||||
{file.toolHistory && (
|
{hasToolHistory && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '4px',
|
bottom: '4px',
|
||||||
@ -411,7 +423,7 @@ const FileEditorThumbnail = ({
|
|||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}>
|
}}>
|
||||||
<ToolChain
|
<ToolChain
|
||||||
toolChain={file.toolHistory}
|
toolChain={toolHistory}
|
||||||
displayStyle="text"
|
displayStyle="text"
|
||||||
size="xs"
|
size="xs"
|
||||||
maxWidth={'100%'}
|
maxWidth={'100%'}
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
import { Group, Text, ActionIcon, Tooltip, Switch } from "@mantine/core";
|
||||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||||
|
|
||||||
const FileActions: React.FC = () => {
|
const FileActions: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
|
const {
|
||||||
useFileManagerContext();
|
recentFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
filteredFiles,
|
||||||
|
onSelectAll,
|
||||||
|
onDeleteSelected,
|
||||||
|
onDownloadSelected
|
||||||
|
} = useFileManagerContext();
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onSelectAll();
|
onSelectAll();
|
||||||
|
@ -73,9 +73,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
|
<Stack gap="md" h={`calc(${modalHeight} - 2rem)`}>
|
||||||
{/* Section 1: Thumbnail Preview */}
|
{/* Section 1: Thumbnail Preview */}
|
||||||
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.5 - 2rem)`, textAlign: 'center', padding: 'xs' }}>
|
<Box style={{ width: '100%', height: `calc(${modalHeight} * 0.42 - 1rem)`, textAlign: 'center', padding: 'xs' }}>
|
||||||
<FilePreview
|
<FilePreview
|
||||||
file={currentFile}
|
file={currentFile}
|
||||||
thumbnail={getCurrentThumbnail()}
|
thumbnail={getCurrentThumbnail()}
|
||||||
|
@ -17,7 +17,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
<Card withBorder p={0} h={`calc(${modalHeight} * 0.38 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
||||||
<Text size="sm" fw={500} ta="center" c="white">
|
<Text size="sm" fw={500} ta="center" c="white">
|
||||||
{t('fileManager.details', 'File Details')}
|
{t('fileManager.details', 'File Details')}
|
||||||
@ -129,6 +129,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
toolChain={currentFile.toolHistory}
|
toolChain={currentFile.toolHistory}
|
||||||
displayStyle="badges"
|
displayStyle="badges"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
maxWidth={'180px'}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge, Loader } from '@mantine/core';
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
import RestoreIcon from '@mui/icons-material/Restore';
|
import RestoreIcon from '@mui/icons-material/Restore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
import { StirlingFileStub } from '../../types/fileContext';
|
||||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
import ToolChain from '../shared/ToolChain';
|
import ToolChain from '../shared/ToolChain';
|
||||||
|
|
||||||
@ -44,7 +45,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const shouldShowHovered = isHovered || isMenuOpen;
|
const shouldShowHovered = isHovered || isMenuOpen;
|
||||||
|
|
||||||
// Get version information for this file
|
// Get version information for this file
|
||||||
const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId;
|
const leafFileId = isLatestVersion ? file.id : (file.originalFileId || file.id);
|
||||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||||
const isExpanded = expandedFileIds.has(leafFileId);
|
const isExpanded = expandedFileIds.has(leafFileId);
|
||||||
|
@ -1,216 +0,0 @@
|
|||||||
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 }) => (
|
|
||||||
<MantineProvider>{children}</MantineProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
label="Test Label"
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
label="Selection Label"
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value={undefined}
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
disabled={globalDisabled}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
fullWidth={false}
|
|
||||||
label="Layout Label"
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<ButtonSelector
|
|
||||||
value="option1"
|
|
||||||
onChange={mockOnChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,59 +0,0 @@
|
|||||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
|
||||||
|
|
||||||
export interface ButtonOption<T> {
|
|
||||||
value: T;
|
|
||||||
label: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ButtonSelectorProps<T> {
|
|
||||||
value: T | undefined;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: ButtonOption<T>[];
|
|
||||||
label?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonSelector = <T extends string>({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
label = undefined,
|
|
||||||
disabled = false,
|
|
||||||
fullWidth = true,
|
|
||||||
}: ButtonSelectorProps<T>) => {
|
|
||||||
return (
|
|
||||||
<Stack gap='var(--mantine-spacing-sm)'>
|
|
||||||
{/* Label (if it exists) */}
|
|
||||||
{label && <Text style={{
|
|
||||||
fontSize: "var(--mantine-font-size-sm)",
|
|
||||||
lineHeight: "var(--mantine-line-height-sm)",
|
|
||||||
fontWeight: "var(--font-weight-medium)",
|
|
||||||
}}>{label}</Text>}
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<Group gap='4px'>
|
|
||||||
{options.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
variant={value === option.value ? 'filled' : 'outline'}
|
|
||||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
disabled={disabled || option.disabled}
|
|
||||||
style={{
|
|
||||||
flex: fullWidth ? 1 : undefined,
|
|
||||||
height: 'auto',
|
|
||||||
minHeight: '2.5rem',
|
|
||||||
fontSize: 'var(--mantine-font-size-sm)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ButtonSelector;
|
|
@ -12,7 +12,7 @@ import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
|||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: File;
|
file: File;
|
||||||
fileStub?: StirlingFileStub;
|
record?: StirlingFileStub;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onView?: () => void;
|
onView?: () => void;
|
||||||
@ -22,11 +22,12 @@ interface FileCardProps {
|
|||||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub);
|
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||||
|
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -176,7 +177,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
|
|||||||
<Badge color="blue" variant="light" size="sm">
|
<Badge color="blue" variant="light" size="sm">
|
||||||
{getFileDate(file)}
|
{getFileDate(file)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{fileStub?.id && (
|
{record?.id && (
|
||||||
<Badge
|
<Badge
|
||||||
color="green"
|
color="green"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
@ -139,7 +139,7 @@ const FileGrid = ({
|
|||||||
<FileCard
|
<FileCard
|
||||||
key={fileId + idx}
|
key={fileId + idx}
|
||||||
file={item.file}
|
file={item.file}
|
||||||
fileStub={item.record}
|
record={item.record}
|
||||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
||||||
onView={onView && supported ? () => onView(item) : undefined}
|
onView={onView && supported ? () => onView(item) : undefined}
|
||||||
|
@ -135,7 +135,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: `${maxWidth}`,
|
maxWidth: `${maxWidth}px`,
|
||||||
cursor: isTruncated ? 'help' : 'default'
|
cursor: isTruncated ? 'help' : 'default'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -4,7 +4,7 @@ import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPa
|
|||||||
|
|
||||||
interface AddPasswordSettingsProps {
|
interface AddPasswordSettingsProps {
|
||||||
parameters: AddPasswordParameters;
|
parameters: AddPasswordParameters;
|
||||||
onParameterChange: <K extends keyof AddPasswordParameters>(key: K, value: AddPasswordParameters[K]) => void;
|
onParameterChange: (key: keyof AddPasswordParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ButtonSelector from "../../shared/ButtonSelector";
|
|
||||||
|
|
||||||
interface WatermarkTypeSettingsProps {
|
interface WatermarkTypeSettingsProps {
|
||||||
watermarkType?: 'text' | 'image';
|
watermarkType?: 'text' | 'image';
|
||||||
@ -11,21 +11,32 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonSelector
|
<Stack gap="sm">
|
||||||
value={watermarkType}
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
onChange={onWatermarkTypeChange}
|
<Button
|
||||||
options={[
|
variant={watermarkType === 'text' ? 'filled' : 'outline'}
|
||||||
{
|
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
|
||||||
value: 'text',
|
onClick={() => onWatermarkTypeChange('text')}
|
||||||
label: t('watermark.watermarkType.text', 'Text'),
|
disabled={disabled}
|
||||||
},
|
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||||
{
|
>
|
||||||
value: 'image',
|
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||||
label: t('watermark.watermarkType.image', 'Image'),
|
{t('watermark.watermarkType.text', 'Text')}
|
||||||
},
|
</div>
|
||||||
]}
|
</Button>
|
||||||
disabled={disabled}
|
<Button
|
||||||
/>
|
variant={watermarkType === 'image' ? 'filled' : 'outline'}
|
||||||
|
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
|
||||||
|
onClick={() => onWatermarkTypeChange('image')}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||||
|
{t('watermark.watermarkType.image', 'Image')}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutoRenameParameters } from '../../../hooks/tools/autoRename/useAutoRenameParameters';
|
|
||||||
|
|
||||||
interface AutoRenameSettingsProps {
|
|
||||||
parameters: AutoRenameParameters;
|
|
||||||
onParameterChange: <K extends keyof AutoRenameParameters>(parameter: K, value: AutoRenameParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AutoRenameSettings: React.FC<AutoRenameSettingsProps> = (
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="auto-rename-settings">
|
|
||||||
<p className="text-muted">
|
|
||||||
{t('autoRename.description', 'This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AutoRenameSettings;
|
|
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Stack, Text, ScrollArea } from '@mantine/core';
|
import { Stack, Text, ScrollArea } from '@mantine/core';
|
||||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||||
@ -93,7 +93,7 @@ export default function ToolSelector({
|
|||||||
|
|
||||||
const renderedTools = useMemo(() =>
|
const renderedTools = useMemo(() =>
|
||||||
displayGroups.map((subcategory) =>
|
displayGroups.map((subcategory) =>
|
||||||
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true)
|
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
|
||||||
), [displayGroups, handleToolSelect, isSearching, t]
|
), [displayGroups, handleToolSelect, isSearching, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -150,7 +150,7 @@ export default function ToolSelector({
|
|||||||
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
|
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
|
||||||
borderRadius: "var(--mantine-radius-lg)" }}>
|
borderRadius: "var(--mantine-radius-lg)" }}>
|
||||||
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
|
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
|
||||||
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
|
onSelect={()=>{}} rounded={true}></ToolButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Show search input when no tool selected OR when dropdown is opened
|
// Show search input when no tool selected OR when dropdown is opened
|
||||||
|
@ -4,7 +4,7 @@ import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissi
|
|||||||
|
|
||||||
interface ChangePermissionsSettingsProps {
|
interface ChangePermissionsSettingsProps {
|
||||||
parameters: ChangePermissionsParameters;
|
parameters: ChangePermissionsParameters;
|
||||||
onParameterChange: <K extends keyof ChangePermissionsParameters>(key: K, value: ChangePermissionsParameters[K]) => void;
|
onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
|
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
|
||||||
import ButtonSelector from "../../shared/ButtonSelector";
|
|
||||||
|
|
||||||
interface CompressSettingsProps {
|
interface CompressSettingsProps {
|
||||||
parameters: CompressParameters;
|
parameters: CompressParameters;
|
||||||
onParameterChange: <K extends keyof CompressParameters>(key: K, value: CompressParameters[K]) => void;
|
onParameterChange: (key: keyof CompressParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,16 +18,33 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
|||||||
|
|
||||||
<Divider ml='-md'></Divider>
|
<Divider ml='-md'></Divider>
|
||||||
{/* Compression Method */}
|
{/* Compression Method */}
|
||||||
<ButtonSelector
|
<Stack gap="sm">
|
||||||
label={t('compress.method.title', 'Compression Method')}
|
<Text size="sm" fw={500}>Compression Method</Text>
|
||||||
value={parameters.compressionMethod}
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
onChange={(value) => onParameterChange('compressionMethod', value)}
|
<Button
|
||||||
options={[
|
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
|
||||||
{ value: 'quality', label: t('compress.method.quality', 'Quality') },
|
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
|
||||||
{ value: 'filesize', label: t('compress.method.filesize', 'File Size') },
|
onClick={() => onParameterChange('compressionMethod', 'quality')}
|
||||||
]}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||||
/>
|
>
|
||||||
|
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||||
|
Quality
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
|
||||||
|
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
|
||||||
|
onClick={() => onParameterChange('compressionMethod', 'filesize')}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||||
|
File Size
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{/* Quality Adjustment */}
|
{/* Quality Adjustment */}
|
||||||
{parameters.compressionMethod === 'quality' && (
|
{parameters.compressionMethod === 'quality' && (
|
||||||
|
@ -5,7 +5,7 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
|
|||||||
|
|
||||||
interface ConvertFromEmailSettingsProps {
|
interface ConvertFromEmailSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
|
|||||||
|
|
||||||
interface ConvertFromImageSettingsProps {
|
interface ConvertFromImageSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
|
|||||||
|
|
||||||
interface ConvertFromWebSettingsProps {
|
interface ConvertFromWebSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ import { StirlingFile } from "../../../types/fileContext";
|
|||||||
|
|
||||||
interface ConvertSettingsProps {
|
interface ConvertSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||||
selectedFiles: StirlingFile[];
|
selectedFiles: StirlingFile[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
|
|||||||
|
|
||||||
interface ConvertToImageSettingsProps {
|
interface ConvertToImageSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import { StirlingFile } from '../../../types/fileContext';
|
|||||||
|
|
||||||
interface ConvertToPdfaSettingsProps {
|
interface ConvertToPdfaSettingsProps {
|
||||||
parameters: ConvertParameters;
|
parameters: ConvertParameters;
|
||||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||||
selectedFiles: StirlingFile[];
|
selectedFiles: StirlingFile[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,182 +0,0 @@
|
|||||||
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 }) => (
|
|
||||||
<MantineProvider>{children}</MantineProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('MergeFileSorter', () => {
|
|
||||||
const mockOnSortFiles = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render sort options dropdown, direction toggle, and sort button', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have filename selected by default', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = screen.getByRole('textbox');
|
|
||||||
expect(select).toHaveValue('mock-merge.sortBy.filename');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show ascending direction by default', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,77 +0,0 @@
|
|||||||
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<MergeFileSorterProps> = ({
|
|
||||||
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 (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
|
|
||||||
</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group gap="xs" align="end" justify="space-between">
|
|
||||||
<Select
|
|
||||||
data={sortOptions}
|
|
||||||
value={sortType}
|
|
||||||
onChange={(value) => setSortType(value as 'filename' | 'dateModified')}
|
|
||||||
disabled={disabled}
|
|
||||||
label={t('merge.sortBy.label', 'Sort By')}
|
|
||||||
size='xs'
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
size="md"
|
|
||||||
onClick={handleDirectionToggle}
|
|
||||||
disabled={disabled}
|
|
||||||
title={ascending ? t('merge.sortBy.ascending', 'Ascending') : t('merge.sortBy.descending', 'Descending')}
|
|
||||||
>
|
|
||||||
{ascending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="xs"
|
|
||||||
leftSection={<SortIcon />}
|
|
||||||
onClick={handleSort}
|
|
||||||
disabled={disabled}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t('merge.sortBy.sort', 'Sort')}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MergeFileSorter;
|
|
@ -1,100 +0,0 @@
|
|||||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import { MantineProvider } from '@mantine/core';
|
|
||||||
import MergeSettings from './MergeSettings';
|
|
||||||
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
|
|
||||||
|
|
||||||
// Mock useTranslation with predictable return values
|
|
||||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
|
||||||
vi.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({ t: mockT })
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Wrapper component to provide Mantine context
|
|
||||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<MantineProvider>{children}</MantineProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('MergeSettings', () => {
|
|
||||||
const defaultParameters: MergeParameters = {
|
|
||||||
removeDigitalSignature: false,
|
|
||||||
generateTableOfContents: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOnParameterChange = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render both merge option checkboxes', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should render one checkbox for each parameter
|
|
||||||
const expectedCheckboxCount = Object.keys(defaultParameters).length;
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox');
|
|
||||||
expect(checkboxes).toHaveLength(expectedCheckboxCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show correct initial checkbox states based on parameters', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox');
|
|
||||||
|
|
||||||
// Both checkboxes should be unchecked initially
|
|
||||||
checkboxes.forEach(checkbox => {
|
|
||||||
expect(checkbox).not.toBeChecked();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox');
|
|
||||||
|
|
||||||
// Click the first checkbox (removeDigitalSignature - should toggle from false to true)
|
|
||||||
fireEvent.click(checkboxes[0]);
|
|
||||||
expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
|
|
||||||
|
|
||||||
// Click the second checkbox (generateTableOfContents - should toggle from false to true)
|
|
||||||
fireEvent.click(checkboxes[1]);
|
|
||||||
expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should call translation function with correct keys', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<MergeSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify that translation keys are being called
|
|
||||||
expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
|
|
||||||
expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
@ -1,38 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Stack, Checkbox } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
|
|
||||||
|
|
||||||
interface MergeSettingsProps {
|
|
||||||
parameters: MergeParameters;
|
|
||||||
onParameterChange: <K extends keyof MergeParameters>(key: K, value: MergeParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MergeSettings: React.FC<MergeSettingsProps> = ({
|
|
||||||
parameters,
|
|
||||||
onParameterChange,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Checkbox
|
|
||||||
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
|
|
||||||
checked={parameters.removeDigitalSignature}
|
|
||||||
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')}
|
|
||||||
checked={parameters.generateTableOfContents}
|
|
||||||
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MergeSettings;
|
|
@ -16,7 +16,7 @@ interface AdvancedOption {
|
|||||||
interface AdvancedOCRSettingsProps {
|
interface AdvancedOCRSettingsProps {
|
||||||
advancedOptions: string[];
|
advancedOptions: string[];
|
||||||
ocrRenderType?: string;
|
ocrRenderType?: string;
|
||||||
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
|
onParameterChange: (key: keyof OCRParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
|
|||||||
|
|
||||||
interface OCRSettingsProps {
|
interface OCRSettingsProps {
|
||||||
parameters: OCRParameters;
|
parameters: OCRParameters;
|
||||||
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
|
onParameterChange: (key: keyof OCRParameters, value: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,211 +0,0 @@
|
|||||||
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 }) => (
|
|
||||||
<MantineProvider>{children}</MantineProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('RedactAdvancedSettings', () => {
|
|
||||||
const mockOnParameterChange = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render all advanced settings controls', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={customParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={customParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
const control = getValue();
|
|
||||||
expect(control).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have correct padding input constraints', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,69 +0,0 @@
|
|||||||
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: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RedactAdvancedSettings = ({ parameters, onParameterChange, disabled = false }: RedactAdvancedSettingsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{/* Box Color */}
|
|
||||||
<ColorInput
|
|
||||||
label={t('redact.auto.colorLabel', 'Box Colour')}
|
|
||||||
value={parameters.redactColor}
|
|
||||||
onChange={(value) => onParameterChange('redactColor', value)}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
format="hex"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Box Padding */}
|
|
||||||
<NumberInput
|
|
||||||
label={t('redact.auto.customPaddingLabel', 'Custom Extra Padding')}
|
|
||||||
value={parameters.customPadding}
|
|
||||||
onChange={(value) => onParameterChange('customPadding', typeof value === 'number' ? value : 0.1)}
|
|
||||||
min={0}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
placeholder="0.1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Use Regex */}
|
|
||||||
<Checkbox
|
|
||||||
label={t('redact.auto.useRegexLabel', 'Use Regex')}
|
|
||||||
checked={parameters.useRegex}
|
|
||||||
onChange={(e) => onParameterChange('useRegex', e.currentTarget.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Whole Word Search */}
|
|
||||||
<Checkbox
|
|
||||||
label={t('redact.auto.wholeWordSearchLabel', 'Whole Word Search')}
|
|
||||||
checked={parameters.wholeWordSearch}
|
|
||||||
onChange={(e) => onParameterChange('wholeWordSearch', e.currentTarget.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Convert PDF to PDF-Image */}
|
|
||||||
<Checkbox
|
|
||||||
label={t('redact.auto.convertPDFToImageLabel', 'Convert PDF to PDF-Image (Used to remove text behind the box)')}
|
|
||||||
checked={parameters.convertPDFToImage}
|
|
||||||
onChange={(e) => onParameterChange('convertPDFToImage', e.currentTarget.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RedactAdvancedSettings;
|
|
@ -1,33 +0,0 @@
|
|||||||
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 (
|
|
||||||
<ButtonSelector
|
|
||||||
label={t('redact.modeSelector.mode', 'Mode')}
|
|
||||||
value={mode}
|
|
||||||
onChange={onModeChange}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: 'automatic' as const,
|
|
||||||
label: t('redact.modeSelector.automatic', 'Automatic'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'manual' as const,
|
|
||||||
label: t('redact.modeSelector.manual', 'Manual'),
|
|
||||||
disabled: true, // Keep manual mode disabled until implemented
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,183 +0,0 @@
|
|||||||
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 }) => (
|
|
||||||
<MantineProvider>{children}</MantineProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('RedactSingleStepSettings', () => {
|
|
||||||
const mockOnParameterChange = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render mode selector', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={manualParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={customParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<TestWrapper>
|
|
||||||
<RedactSingleStepSettings
|
|
||||||
parameters={defaultParameters}
|
|
||||||
onParameterChange={mockOnParameterChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that the Stack container exists
|
|
||||||
const container = screen.getByText('Mode').closest('.mantine-Stack-root');
|
|
||||||
expect(container).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,61 +0,0 @@
|
|||||||
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: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RedactSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: RedactSingleStepSettingsProps) => {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{/* Mode Selection */}
|
|
||||||
<RedactModeSelector
|
|
||||||
mode={parameters.mode}
|
|
||||||
onModeChange={(mode) => onParameterChange('mode', mode)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Automatic Mode Settings */}
|
|
||||||
{parameters.mode === 'automatic' && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Words to Redact */}
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={parameters.wordsToRedact}
|
|
||||||
onWordsChange={(words) => onParameterChange('wordsToRedact', words)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Advanced Settings */}
|
|
||||||
<RedactAdvancedSettings
|
|
||||||
parameters={parameters}
|
|
||||||
onParameterChange={onParameterChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Manual Mode Placeholder */}
|
|
||||||
{parameters.mode === 'manual' && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Stack gap="md">
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
|
||||||
Manual redaction interface will be available here when implemented.
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RedactSingleStepSettings;
|
|
@ -1,191 +0,0 @@
|
|||||||
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 }) => (
|
|
||||||
<MantineProvider>{children}</MantineProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('WordsToRedactInput', () => {
|
|
||||||
const mockOnWordsChange = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render with title and input field', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={[]}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={[]}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={[]}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
|
||||||
|
|
||||||
fireEvent.click(addButton);
|
|
||||||
|
|
||||||
expect(mockOnWordsChange).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not add duplicate word', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={['Existing']}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={[]}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={['Word1', 'Word2']}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeButtons = screen.getAllByText('×');
|
|
||||||
fireEvent.click(removeButtons[0]);
|
|
||||||
|
|
||||||
expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should clear input after adding word', () => {
|
|
||||||
render(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={[]}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={[]}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
|
||||||
<TestWrapper>
|
|
||||||
<WordsToRedactInput
|
|
||||||
wordsToRedact={['Word1']}
|
|
||||||
onWordsChange={mockOnWordsChange}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</TestWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,99 +0,0 @@
|
|||||||
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 (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{t('redact.auto.wordsToRedact.title', 'Words to Redact')}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Current words */}
|
|
||||||
{wordsToRedact.map((word, index) => (
|
|
||||||
<Group key={index} justify="space-between" p="sm" style={{
|
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
|
||||||
border: `1px solid var(--mantine-color-gray-3)`,
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
|
||||||
}}>
|
|
||||||
<Text
|
|
||||||
size="sm"
|
|
||||||
style={{
|
|
||||||
maxWidth: '80%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
title={word}
|
|
||||||
>
|
|
||||||
{word}
|
|
||||||
</Text>
|
|
||||||
<ActionIcon
|
|
||||||
size="sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={() => removeWord(index)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add new word input */}
|
|
||||||
<Group gap="sm" align="end">
|
|
||||||
<TextInput
|
|
||||||
placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')}
|
|
||||||
value={currentWord}
|
|
||||||
onChange={(e) => setCurrentWord(e.target.value)}
|
|
||||||
onKeyDown={handleKeyPress}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onClick={addWord}
|
|
||||||
disabled={disabled || !currentWord.trim()}
|
|
||||||
>
|
|
||||||
+ {t('redact.auto.wordsToRedact.add', 'Add')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Examples */}
|
|
||||||
{wordsToRedact.length === 0 && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
@ -4,7 +4,7 @@ import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/us
|
|||||||
|
|
||||||
interface RemovePasswordSettingsProps {
|
interface RemovePasswordSettingsProps {
|
||||||
parameters: RemovePasswordParameters;
|
parameters: RemovePasswordParameters;
|
||||||
onParameterChange: <K extends keyof RemovePasswordParameters>(key: K, value: RemovePasswordParameters[K]) => void;
|
onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sani
|
|||||||
|
|
||||||
interface SanitizeSettingsProps {
|
interface SanitizeSettingsProps {
|
||||||
parameters: SanitizeParameters;
|
parameters: SanitizeParameters;
|
||||||
onParameterChange: <K extends keyof SanitizeParameters>(key: K, value: SanitizeParameters[K]) => void;
|
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,12 +10,11 @@ import { StirlingFile } from "../../../types/fileContext";
|
|||||||
|
|
||||||
export interface FileStatusIndicatorProps {
|
export interface FileStatusIndicatorProps {
|
||||||
selectedFiles?: StirlingFile[];
|
selectedFiles?: StirlingFile[];
|
||||||
minFiles?: number;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileStatusIndicator = ({
|
const FileStatusIndicator = ({
|
||||||
selectedFiles = [],
|
selectedFiles = [],
|
||||||
minFiles = 1,
|
|
||||||
}: FileStatusIndicatorProps) => {
|
}: FileStatusIndicatorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { openFilesModal, onFileUpload } = useFilesModalContext();
|
const { openFilesModal, onFileUpload } = useFilesModalContext();
|
||||||
@ -56,14 +55,6 @@ const FileStatusIndicator = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
|
||||||
if (minFiles === undefined || minFiles === 1) {
|
|
||||||
return t("files.selectFromWorkbench", "Select files from the workbench or ");
|
|
||||||
} else {
|
|
||||||
return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if there are no files in the workbench
|
// Check if there are no files in the workbench
|
||||||
if (stirlingFileStubs.length === 0) {
|
if (stirlingFileStubs.length === 0) {
|
||||||
// If no recent files, show upload button
|
// If no recent files, show upload button
|
||||||
@ -98,12 +89,12 @@ const FileStatusIndicator = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show selection status when there are files in workbench
|
// Show selection status when there are files in workbench
|
||||||
if (selectedFiles.length < minFiles) {
|
if (selectedFiles.length === 0) {
|
||||||
// If no recent files, show upload option
|
// If no recent files, show upload option
|
||||||
if (!hasRecentFiles) {
|
if (!hasRecentFiles) {
|
||||||
return (
|
return (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{getPlaceholder() + " "}
|
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleNativeUpload}
|
onClick={handleNativeUpload}
|
||||||
@ -118,7 +109,7 @@ const FileStatusIndicator = ({
|
|||||||
// If there are recent files, show add files option
|
// If there are recent files, show add files option
|
||||||
return (
|
return (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{getPlaceholder() + " "}
|
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openFilesModal()}
|
onClick={() => openFilesModal()}
|
||||||
@ -134,7 +125,7 @@ const FileStatusIndicator = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -7,7 +7,7 @@ export interface FilesToolStepProps {
|
|||||||
selectedFiles: StirlingFile[];
|
selectedFiles: StirlingFile[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
minFiles?: number;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFilesToolStep(
|
export function createFilesToolStep(
|
||||||
@ -23,7 +23,7 @@ export function createFilesToolStep(
|
|||||||
}, (
|
}, (
|
||||||
<FileStatusIndicator
|
<FileStatusIndicator
|
||||||
selectedFiles={props.selectedFiles}
|
selectedFiles={props.selectedFiles}
|
||||||
minFiles={props.minFiles}
|
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -53,7 +53,7 @@ const renderTooltipTitle = (
|
|||||||
<Text fw={400} size="sm">
|
<Text fw={400} size="sm">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
@ -5,7 +5,6 @@ import { Tooltip } from '../../shared/Tooltip';
|
|||||||
|
|
||||||
export interface ToolWorkflowTitleProps {
|
export interface ToolWorkflowTitleProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
|
||||||
tooltip?: {
|
tooltip?: {
|
||||||
content?: React.ReactNode;
|
content?: React.ReactNode;
|
||||||
tips?: any[];
|
tips?: any[];
|
||||||
@ -16,19 +15,10 @@ export interface ToolWorkflowTitleProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowTitleProps) {
|
export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
|
||||||
const titleContent = (
|
if (tooltip) {
|
||||||
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
return (
|
||||||
<Text fw={500} size="lg" p="xs">
|
<>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{tooltip && <LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{tooltip ? (
|
|
||||||
<Flex justify="center" w="100%">
|
<Flex justify="center" w="100%">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={tooltip.content}
|
content={tooltip.content}
|
||||||
@ -36,17 +26,27 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT
|
|||||||
header={tooltip.header}
|
header={tooltip.header}
|
||||||
sidebarTooltip={true}
|
sidebarTooltip={true}
|
||||||
>
|
>
|
||||||
{titleContent}
|
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Text fw={500} size="xl" p="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||||
|
</Flex>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
) : (
|
<Divider />
|
||||||
titleContent
|
</>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Text size="sm" mb="md" p="sm" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
|
return (
|
||||||
{description}
|
<>
|
||||||
</Text>
|
<Flex justify="center" w="100%">
|
||||||
<Divider mb="sm" />
|
<Text fw={500} size="xl" p="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Divider />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
|
|||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: StirlingFile[];
|
selectedFiles: StirlingFile[];
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
minFiles?: number;
|
placeholder?: string;
|
||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
}
|
}
|
||||||
@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
{config.files.isVisible !== false && steps.createFilesStep({
|
{config.files.isVisible !== false && steps.createFilesStep({
|
||||||
selectedFiles: config.files.selectedFiles,
|
selectedFiles: config.files.selectedFiles,
|
||||||
isCollapsed: config.files.isCollapsed,
|
isCollapsed: config.files.isCollapsed,
|
||||||
minFiles: config.files.minFiles,
|
placeholder: config.files.placeholder,
|
||||||
onCollapsedClick: config.files.onCollapsedClick
|
onCollapsedClick: config.files.onCollapsedClick
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
@ -12,8 +12,7 @@ export const renderToolButtons = (
|
|||||||
subcategory: SubcategoryGroup,
|
subcategory: SubcategoryGroup,
|
||||||
selectedToolKey: string | null,
|
selectedToolKey: string | null,
|
||||||
onSelect: (id: string) => void,
|
onSelect: (id: string) => void,
|
||||||
showSubcategoryHeader: boolean = true,
|
showSubcategoryHeader: boolean = true
|
||||||
disableNavigation: boolean = false
|
|
||||||
) => (
|
) => (
|
||||||
<Box key={subcategory.subcategoryId} w="100%">
|
<Box key={subcategory.subcategoryId} w="100%">
|
||||||
{showSubcategoryHeader && (
|
{showSubcategoryHeader && (
|
||||||
@ -27,7 +26,6 @@ export const renderToolButtons = (
|
|||||||
tool={tool}
|
tool={tool}
|
||||||
isSelected={selectedToolKey === id}
|
isSelected={selectedToolKey === id}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
disableNavigation={disableNavigation}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
|
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { isSplitMode, isSplitType, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
|
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
|
||||||
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
|
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
|
||||||
|
|
||||||
export interface SplitSettingsProps {
|
export interface SplitSettingsProps {
|
||||||
parameters: SplitParameters;
|
parameters: SplitParameters;
|
||||||
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
|
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ const SplitSettings = ({
|
|||||||
<Select
|
<Select
|
||||||
label={t("split-by-size-or-count.type.label", "Split Type")}
|
label={t("split-by-size-or-count.type.label", "Split Type")}
|
||||||
value={parameters.splitType}
|
value={parameters.splitType}
|
||||||
onChange={(v) => isSplitType(v) && onParameterChange('splitType', v)}
|
onChange={(v) => v && onParameterChange('splitType', v)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data={[
|
data={[
|
||||||
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
|
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
|
||||||
|
@ -12,10 +12,9 @@ interface ToolButtonProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
rounded?: boolean;
|
rounded?: boolean;
|
||||||
disableNavigation?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
|
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
||||||
const isUnavailable = !tool.component && !tool.link;
|
const isUnavailable = !tool.component && !tool.link;
|
||||||
const { getToolNavigation } = useToolNavigation();
|
const { getToolNavigation } = useToolNavigation();
|
||||||
|
|
||||||
@ -30,8 +29,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
onSelect(id);
|
onSelect(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation props for URL support (only if navigation is not disabled)
|
// Get navigation props for URL support
|
||||||
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
|
const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null;
|
||||||
|
|
||||||
const tooltipContent = isUnavailable
|
const tooltipContent = isUnavailable
|
||||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { TooltipContent } from '../../types/tips';
|
|
||||||
|
|
||||||
export const useAutoRenameTips = (): TooltipContent => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return {
|
|
||||||
header: {
|
|
||||||
title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works")
|
|
||||||
},
|
|
||||||
tips: [
|
|
||||||
{
|
|
||||||
title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"),
|
|
||||||
bullets: [
|
|
||||||
t("auto-rename.tooltip.howItWorks.bullet1", "Looks for text that appears to be a title or heading"),
|
|
||||||
t("auto-rename.tooltip.howItWorks.bullet2", "Creates a clean, valid filename from the detected title"),
|
|
||||||
t("auto-rename.tooltip.howItWorks.bullet3", "Keeps the original name if no suitable title is found")
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,19 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { TooltipContent } from '../../types/tips';
|
|
||||||
|
|
||||||
export const useMergeTips = (): TooltipContent => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return {
|
|
||||||
tips: [
|
|
||||||
{
|
|
||||||
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
|
|
||||||
description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'),
|
|
||||||
description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,79 +0,0 @@
|
|||||||
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.")
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
};
|
|
@ -147,17 +147,12 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
|
|
||||||
// Validate that all IDs exist in current state
|
// Validate that all IDs exist in current state
|
||||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||||
// Reorder selected files by passed order
|
|
||||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: {
|
files: {
|
||||||
...state.files,
|
...state.files,
|
||||||
ids: validIds
|
ids: validIds
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
...state.ui,
|
|
||||||
selectedFileIds,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -241,14 +236,11 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
|
|
||||||
case 'CONSUME_FILES': {
|
case 'CONSUME_FILES': {
|
||||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||||
|
|
||||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
case 'UNDO_CONSUME_FILES': {
|
case 'UNDO_CONSUME_FILES': {
|
||||||
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||||
|
|
||||||
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,11 +6,10 @@ import {
|
|||||||
StirlingFileStub,
|
StirlingFileStub,
|
||||||
FileContextAction,
|
FileContextAction,
|
||||||
FileContextState,
|
FileContextState,
|
||||||
createNewStirlingFileStub,
|
toStirlingFileStub,
|
||||||
createFileId,
|
createFileId,
|
||||||
createQuickKey,
|
createQuickKey,
|
||||||
createStirlingFile,
|
createStirlingFile,
|
||||||
ProcessedFileMetadata,
|
|
||||||
} from '../../types/fileContext';
|
} from '../../types/fileContext';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||||
@ -71,43 +70,19 @@ export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate fresh ProcessedFileMetadata for a file
|
|
||||||
* Used when tools process files to ensure metadata matches actual file content
|
|
||||||
*/
|
|
||||||
export async function generateProcessedFileMetadata(file: File): Promise<ProcessedFileMetadata | undefined> {
|
|
||||||
// Only generate metadata for PDF files
|
|
||||||
if (!file.type.startsWith('application/pdf')) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await generateThumbnailWithMetadata(file);
|
|
||||||
return createProcessedFile(result.pageCount, result.thumbnail);
|
|
||||||
} catch (error) {
|
|
||||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a child StirlingFileStub from a parent stub with proper history management.
|
* Create a child StirlingFileStub from a parent stub with proper history management.
|
||||||
* Used when a tool processes an existing file to create a new version with incremented history.
|
* Used when a tool processes an existing file to create a new version with incremented history.
|
||||||
*
|
*
|
||||||
* @param parentStub - The parent StirlingFileStub to create a child from
|
* @param parentStub - The parent StirlingFileStub to create a child from
|
||||||
* @param operation - Tool operation information (toolName, timestamp)
|
* @param operation - Tool operation information (toolName, timestamp)
|
||||||
* @param resultingFile - The processed File object
|
|
||||||
* @param thumbnail - Optional thumbnail for the child
|
|
||||||
* @param processedFileMetadata - Optional fresh metadata for the processed file
|
|
||||||
* @returns New child StirlingFileStub with proper version history
|
* @returns New child StirlingFileStub with proper version history
|
||||||
*/
|
*/
|
||||||
export function createChildStub(
|
export function createChildStub(
|
||||||
parentStub: StirlingFileStub,
|
parentStub: StirlingFileStub,
|
||||||
operation: { toolName: string; timestamp: number },
|
operation: { toolName: string; timestamp: number },
|
||||||
resultingFile: File,
|
resultingFile: File,
|
||||||
thumbnail?: string,
|
thumbnail?: string
|
||||||
processedFileMetadata?: ProcessedFileMetadata
|
|
||||||
): StirlingFileStub {
|
): StirlingFileStub {
|
||||||
const newFileId = createFileId();
|
const newFileId = createFileId();
|
||||||
|
|
||||||
@ -121,12 +96,10 @@ export function createChildStub(
|
|||||||
// Determine original file ID (root of the version chain)
|
// Determine original file ID (root of the version chain)
|
||||||
const originalFileId = parentStub.originalFileId || parentStub.id;
|
const originalFileId = parentStub.originalFileId || parentStub.id;
|
||||||
|
|
||||||
// Copy parent metadata but exclude processedFile to prevent stale data
|
// Update the child stub's name to match the processed file
|
||||||
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Copy parent metadata (excluding processedFile)
|
// Copy all parent metadata
|
||||||
...parentMetadata,
|
...parentStub,
|
||||||
|
|
||||||
// Update identity and version info
|
// Update identity and version info
|
||||||
id: newFileId,
|
id: newFileId,
|
||||||
@ -140,10 +113,10 @@ export function createChildStub(
|
|||||||
size: resultingFile.size,
|
size: resultingFile.size,
|
||||||
type: resultingFile.type,
|
type: resultingFile.type,
|
||||||
lastModified: resultingFile.lastModified,
|
lastModified: resultingFile.lastModified,
|
||||||
thumbnailUrl: thumbnail,
|
thumbnailUrl: thumbnail
|
||||||
|
|
||||||
// Set fresh processedFile metadata (no inheritance from parent)
|
// Preserve thumbnails and processing metadata from parent
|
||||||
processedFile: processedFileMetadata
|
// These will be updated if the child has new thumbnails, but fallback to parent
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,29 +170,36 @@ export async function addFiles(
|
|||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
// Generate processedFile metadata using centralized function
|
// Generate thumbnail and page count immediately
|
||||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
|
||||||
|
|
||||||
// Extract thumbnail for non-PDF files or use from processedFile for PDFs
|
|
||||||
let thumbnail: string | undefined;
|
let thumbnail: string | undefined;
|
||||||
if (processedFileMetadata) {
|
let pageCount: number = 1;
|
||||||
// PDF file - use thumbnail from processedFile metadata
|
|
||||||
thumbnail = processedFileMetadata.thumbnailUrl;
|
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${processedFileMetadata.totalPages} pages, thumbnail: SUCCESS`);
|
if (file.type.startsWith('application/pdf')) {
|
||||||
} else if (!file.type.startsWith('application/pdf')) {
|
try {
|
||||||
// Non-PDF files: simple thumbnail generation, no processedFile metadata
|
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
|
||||||
|
const result = await generateThumbnailWithMetadata(file);
|
||||||
|
thumbnail = result.thumbnail;
|
||||||
|
pageCount = result.pageCount;
|
||||||
|
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-PDF files: simple thumbnail generation, no page count
|
||||||
try {
|
try {
|
||||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||||
thumbnail = await generateThumbnailForFile(file);
|
thumbnail = await generateThumbnailForFile(file);
|
||||||
|
pageCount = 0; // Non-PDFs have no page count
|
||||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new filestub with processedFile metadata
|
// Create record with immediate thumbnail and page metadata
|
||||||
const fileStub = createNewStirlingFileStub(file, fileId, thumbnail, processedFileMetadata);
|
const record = toStirlingFileStub(file, fileId, thumbnail);
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
if (thumbnail.startsWith('blob:')) {
|
if (thumbnail.startsWith('blob:')) {
|
||||||
@ -229,11 +209,17 @@ export async function addFiles(
|
|||||||
|
|
||||||
// Store insertion position if provided
|
// Store insertion position if provided
|
||||||
if (options.insertAfterPageId !== undefined) {
|
if (options.insertAfterPageId !== undefined) {
|
||||||
fileStub.insertAfterPageId = options.insertAfterPageId;
|
record.insertAfterPageId = options.insertAfterPageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial processedFile metadata with page count
|
||||||
|
if (pageCount > 0) {
|
||||||
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
existingQuickKeys.add(quickKey);
|
existingQuickKeys.add(quickKey);
|
||||||
stirlingFileStubs.push(fileStub);
|
stirlingFileStubs.push(record);
|
||||||
|
|
||||||
// Create StirlingFile directly
|
// Create StirlingFile directly
|
||||||
const stirlingFile = createStirlingFile(file, fileId);
|
const stirlingFile = createStirlingFile(file, fileId);
|
||||||
@ -303,18 +289,16 @@ export async function consumeFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark input files as processed in storage (no longer leaf nodes)
|
// Mark input files as processed in storage (no longer leaf nodes)
|
||||||
if(!outputStirlingFileStubs.reduce((areAllV1, stub) => areAllV1 && (stub.versionNumber == 1), true)) {
|
await Promise.all(
|
||||||
await Promise.all(
|
inputFileIds.map(async (fileId) => {
|
||||||
inputFileIds.map(async (fileId) => {
|
try {
|
||||||
try {
|
await fileStorage.markFileAsProcessed(fileId);
|
||||||
await fileStorage.markFileAsProcessed(fileId);
|
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
|
||||||
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
|
} catch (error) {
|
||||||
} catch (error) {
|
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
|
||||||
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
|
}
|
||||||
}
|
})
|
||||||
})
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save output files directly to fileStorage with complete metadata
|
// Save output files directly to fileStorage with complete metadata
|
||||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||||
@ -516,16 +500,15 @@ export async function addStirlingFileStubs(
|
|||||||
|
|
||||||
if (needsProcessing) {
|
if (needsProcessing) {
|
||||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
|
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
|
||||||
|
try {
|
||||||
// Use centralized metadata generation function
|
// Generate basic processedFile structure with page count
|
||||||
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
|
const result = await generateThumbnailWithMetadata(stirlingFile);
|
||||||
if (processedFileMetadata) {
|
record.processedFile = createProcessedFile(result.pageCount, result.thumbnail);
|
||||||
record.processedFile = processedFileMetadata;
|
record.thumbnailUrl = result.thumbnail; // Update thumbnail if needed
|
||||||
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
|
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${result.pageCount} pages`);
|
||||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
|
} catch (error) {
|
||||||
} else {
|
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}:`, error);
|
||||||
// Fallback for files that couldn't be processed
|
// Ensure we have at least basic structure
|
||||||
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
|
|
||||||
if (!record.processedFile) {
|
if (!record.processedFile) {
|
||||||
record.processedFile = createProcessedFile(1); // Fallback to 1 page
|
record.processedFile = createProcessedFile(1); // Fallback to 1 page
|
||||||
}
|
}
|
||||||
|
@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
|
|||||||
/**
|
/**
|
||||||
* Hook for selected files (optimized for selection-based UI)
|
* Hook for selected files (optimized for selection-based UI)
|
||||||
*/
|
*/
|
||||||
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
|
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
selectedFiles: selectors.getSelectedFiles(),
|
files: selectors.getSelectedFiles(),
|
||||||
selectedRecords: selectors.getSelectedStirlingFileStubs(),
|
records: selectors.getSelectedStirlingFileStubs(),
|
||||||
selectedFileIds: state.ui.selectedFileIds
|
fileIds: state.ui.selectedFileIds
|
||||||
}), [state.ui.selectedFileIds, selectors]);
|
}), [state.ui.selectedFileIds, selectors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +169,7 @@ export function useFileContext() {
|
|||||||
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
||||||
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
||||||
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
|
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
|
||||||
|
|
||||||
// File ID lookup
|
// File ID lookup
|
||||||
findFileId: (file: File) => {
|
findFileId: (file: File) => {
|
||||||
return state.files.ids.find(id => {
|
return state.files.ids.find(id => {
|
||||||
|
@ -11,9 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
|
|||||||
import RemovePassword from "../tools/RemovePassword";
|
import RemovePassword from "../tools/RemovePassword";
|
||||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||||
import AddWatermark from "../tools/AddWatermark";
|
import AddWatermark from "../tools/AddWatermark";
|
||||||
import Merge from '../tools/Merge';
|
|
||||||
import Repair from "../tools/Repair";
|
import Repair from "../tools/Repair";
|
||||||
import AutoRename from "../tools/AutoRename";
|
|
||||||
import SingleLargePage from "../tools/SingleLargePage";
|
import SingleLargePage from "../tools/SingleLargePage";
|
||||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||||
@ -31,10 +29,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
|
|||||||
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
|
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
|
||||||
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||||
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
|
|
||||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
|
||||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
|
||||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||||
@ -47,10 +42,7 @@ import OCRSettings from "../components/tools/ocr/OCRSettings";
|
|||||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
|
||||||
import Redact from "../tools/Redact";
|
|
||||||
import { ToolId } from "../types/toolId";
|
import { ToolId } from "../types/toolId";
|
||||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
|
||||||
|
|
||||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||||
|
|
||||||
@ -480,10 +472,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
"auto-rename-pdf-file": {
|
"auto-rename-pdf-file": {
|
||||||
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.auto-rename.title", "Auto Rename PDF File"),
|
name: t("home.auto-rename.title", "Auto Rename PDF File"),
|
||||||
component: AutoRename,
|
component: null,
|
||||||
maxFiles: -1,
|
|
||||||
endpoints: ["remove-certificate-sign"],
|
|
||||||
operationConfig: autoRenameOperationConfig,
|
|
||||||
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
|
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
|
||||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||||
subcategoryId: SubcategoryId.AUTOMATION,
|
subcategoryId: SubcategoryId.AUTOMATION,
|
||||||
@ -675,14 +664,12 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
mergePdfs: {
|
mergePdfs: {
|
||||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.merge.title", "Merge"),
|
name: t("home.merge.title", "Merge"),
|
||||||
component: Merge,
|
component: null,
|
||||||
|
|
||||||
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
||||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||||
subcategoryId: SubcategoryId.GENERAL,
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
maxFiles: -1,
|
maxFiles: -1,
|
||||||
endpoints: ["merge-pdfs"],
|
|
||||||
operationConfig: mergeOperationConfig,
|
|
||||||
settingsComponent: MergeSettings
|
|
||||||
},
|
},
|
||||||
"multi-tool": {
|
"multi-tool": {
|
||||||
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
@ -709,14 +696,10 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
redact: {
|
redact: {
|
||||||
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.redact.title", "Redact"),
|
name: t("home.redact.title", "Redact"),
|
||||||
component: Redact,
|
component: null,
|
||||||
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
|
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
|
||||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||||
subcategoryId: SubcategoryId.GENERAL,
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
maxFiles: -1,
|
|
||||||
endpoints: ["auto-redact"],
|
|
||||||
operationConfig: redactOperationConfig,
|
|
||||||
settingsComponent: RedactSingleStepSettings,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -119,7 +119,7 @@ describe('useAddPasswordOperation', () => {
|
|||||||
test.each([
|
test.each([
|
||||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||||
{ property: 'filePrefix' as const, expectedValue: undefined },
|
{ property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' },
|
||||||
{ property: 'operationType' as const, expectedValue: 'addPassword' }
|
{ property: 'operationType' as const, expectedValue: 'addPassword' }
|
||||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||||
renderHook(() => useAddPasswordOperation());
|
renderHook(() => useAddPasswordOperation());
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
|
||||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
|
||||||
import { AutoRenameParameters, defaultParameters } from './useAutoRenameParameters';
|
|
||||||
|
|
||||||
export const getFormData = ((parameters: AutoRenameParameters) =>
|
|
||||||
Object.entries(parameters).map(([key, value]) =>
|
|
||||||
[key, value.toString()]
|
|
||||||
) as string[][]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Static function that can be used by both the hook and automation executor
|
|
||||||
export const buildAutoRenameFormData = (parameters: AutoRenameParameters, file: File): FormData => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("fileInput", file);
|
|
||||||
|
|
||||||
// Add all permission parameters
|
|
||||||
getFormData(parameters).forEach(([key, value]) => {
|
|
||||||
formData.append(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Static configuration object
|
|
||||||
export const autoRenameOperationConfig = {
|
|
||||||
toolType: ToolType.singleFile,
|
|
||||||
buildFormData: buildAutoRenameFormData,
|
|
||||||
operationType: 'autoRename',
|
|
||||||
endpoint: '/api/v1/misc/auto-rename',
|
|
||||||
filePrefix: 'autoRename_',
|
|
||||||
preserveBackendFilename: true, // Use filename from backend response headers
|
|
||||||
defaultParameters,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const useAutoRenameOperation = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useToolOperation({
|
|
||||||
...autoRenameOperationConfig,
|
|
||||||
getErrorMessage: createStandardErrorHandler(t('auto-rename.error.failed', 'An error occurred while auto-renaming the PDF.'))
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,19 +0,0 @@
|
|||||||
import { BaseParameters } from '../../../types/parameters';
|
|
||||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
|
||||||
|
|
||||||
export interface AutoRenameParameters extends BaseParameters {
|
|
||||||
useFirstTextAsFallback: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultParameters: AutoRenameParameters = {
|
|
||||||
useFirstTextAsFallback: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutoRenameParametersHook = BaseParametersHook<AutoRenameParameters>;
|
|
||||||
|
|
||||||
export const useAutoRenameParameters = (): AutoRenameParametersHook => {
|
|
||||||
return useBaseParameters({
|
|
||||||
defaultParameters,
|
|
||||||
endpointName: 'auto-rename',
|
|
||||||
});
|
|
||||||
};
|
|
@ -113,7 +113,7 @@ describe('useChangePermissionsOperation', () => {
|
|||||||
test.each([
|
test.each([
|
||||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||||
{ property: 'filePrefix' as const, expectedValue: undefined },
|
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
|
||||||
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
|
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
|
||||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||||
renderHook(() => useChangePermissionsOperation());
|
renderHook(() => useChangePermissionsOperation());
|
||||||
|
@ -1,138 +0,0 @@
|
|||||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
|
||||||
import { renderHook } from '@testing-library/react';
|
|
||||||
import { useMergeOperation } from './useMergeOperation';
|
|
||||||
import type { MergeParameters } from './useMergeParameters';
|
|
||||||
|
|
||||||
// Mock the useToolOperation hook
|
|
||||||
vi.mock('../shared/useToolOperation', async () => {
|
|
||||||
const actual = await vi.importActual('../shared/useToolOperation');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useToolOperation: vi.fn()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the translation hook
|
|
||||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
|
||||||
vi.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({ t: mockT })
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the error handler
|
|
||||||
vi.mock('../../../utils/toolErrorHandler', () => ({
|
|
||||||
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import the mocked function
|
|
||||||
import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
|
|
||||||
|
|
||||||
describe('useMergeOperation', () => {
|
|
||||||
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
|
|
||||||
|
|
||||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
|
|
||||||
|
|
||||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
|
||||||
files: [],
|
|
||||||
thumbnails: [],
|
|
||||||
downloadUrl: null,
|
|
||||||
downloadFilename: '',
|
|
||||||
isLoading: false,
|
|
||||||
errorMessage: null,
|
|
||||||
status: '',
|
|
||||||
isGeneratingThumbnails: false,
|
|
||||||
progress: null,
|
|
||||||
executeOperation: vi.fn(),
|
|
||||||
resetResults: vi.fn(),
|
|
||||||
clearError: vi.fn(),
|
|
||||||
cancelOperation: vi.fn(),
|
|
||||||
undoOperation: function (): Promise<void> {
|
|
||||||
throw new Error('Function not implemented.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should build FormData correctly', () => {
|
|
||||||
renderHook(() => useMergeOperation());
|
|
||||||
|
|
||||||
const config = getToolConfig();
|
|
||||||
const mockFiles = [
|
|
||||||
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
|
|
||||||
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
|
|
||||||
];
|
|
||||||
const parameters: MergeParameters = {
|
|
||||||
removeDigitalSignature: true,
|
|
||||||
generateTableOfContents: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = config.buildFormData(parameters, mockFiles);
|
|
||||||
|
|
||||||
// Verify files are appended
|
|
||||||
expect(formData.getAll('fileInput')).toHaveLength(2);
|
|
||||||
expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
|
|
||||||
expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
|
|
||||||
|
|
||||||
// Verify parameters are appended correctly
|
|
||||||
expect(formData.get('sortType')).toBe('orderProvided');
|
|
||||||
expect(formData.get('removeCertSign')).toBe('true');
|
|
||||||
expect(formData.get('generateToc')).toBe('false');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle response correctly', () => {
|
|
||||||
renderHook(() => useMergeOperation());
|
|
||||||
|
|
||||||
const config = getToolConfig();
|
|
||||||
const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
|
|
||||||
const mockFiles = [
|
|
||||||
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
|
|
||||||
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = config.responseHandler!(mockBlob, mockFiles) as File[];
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].name).toBe('merged_file1.pdf');
|
|
||||||
expect(result[0].type).toBe('application/pdf');
|
|
||||||
expect(result[0].size).toBe(mockBlob.size);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return the hook result from useToolOperation', () => {
|
|
||||||
const { result } = renderHook(() => useMergeOperation());
|
|
||||||
|
|
||||||
expect(result.current).toBe(mockToolOperationReturn);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should use correct translation keys for error handling', () => {
|
|
||||||
renderHook(() => useMergeOperation());
|
|
||||||
|
|
||||||
expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should build FormData with different parameter combinations', () => {
|
|
||||||
renderHook(() => useMergeOperation());
|
|
||||||
|
|
||||||
const config = getToolConfig();
|
|
||||||
const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
|
|
||||||
|
|
||||||
// Test case 1: All options disabled
|
|
||||||
const params1: MergeParameters = {
|
|
||||||
removeDigitalSignature: false,
|
|
||||||
generateTableOfContents: false
|
|
||||||
};
|
|
||||||
const formData1 = config.buildFormData(params1, mockFiles);
|
|
||||||
expect(formData1.get('removeCertSign')).toBe('false');
|
|
||||||
expect(formData1.get('generateToc')).toBe('false');
|
|
||||||
|
|
||||||
// Test case 2: All options enabled
|
|
||||||
const params2: MergeParameters = {
|
|
||||||
removeDigitalSignature: true,
|
|
||||||
generateTableOfContents: true
|
|
||||||
};
|
|
||||||
const formData2 = config.buildFormData(params2, mockFiles);
|
|
||||||
expect(formData2.get('removeCertSign')).toBe('true');
|
|
||||||
expect(formData2.get('generateToc')).toBe('true');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,41 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
|
||||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
|
||||||
import { MergeParameters } from './useMergeParameters';
|
|
||||||
|
|
||||||
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
formData.append("fileInput", file);
|
|
||||||
});
|
|
||||||
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
|
|
||||||
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
|
||||||
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => {
|
|
||||||
const filename = `merged_${originalFiles[0].name}`
|
|
||||||
return [new File([blob], filename, { type: 'application/pdf' })];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Operation configuration for automation
|
|
||||||
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
|
|
||||||
toolType: ToolType.multiFile,
|
|
||||||
buildFormData,
|
|
||||||
operationType: 'merge',
|
|
||||||
endpoint: '/api/v1/general/merge-pdfs',
|
|
||||||
filePrefix: 'merged_',
|
|
||||||
responseHandler: mergeResponseHandler,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMergeOperation = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useToolOperation<MergeParameters>({
|
|
||||||
...mergeOperationConfig,
|
|
||||||
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,68 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { useMergeParameters, defaultParameters } from './useMergeParameters';
|
|
||||||
|
|
||||||
describe('useMergeParameters', () => {
|
|
||||||
test('should initialize with default parameters', () => {
|
|
||||||
const { result } = renderHook(() => useMergeParameters());
|
|
||||||
|
|
||||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
{ paramName: 'removeDigitalSignature' as const, value: true },
|
|
||||||
{ paramName: 'removeDigitalSignature' as const, value: false },
|
|
||||||
{ paramName: 'generateTableOfContents' as const, value: true },
|
|
||||||
{ paramName: 'generateTableOfContents' as const, value: false }
|
|
||||||
])('should update parameter $paramName to $value', ({ paramName, value }) => {
|
|
||||||
const { result } = renderHook(() => useMergeParameters());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateParameter(paramName, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.parameters[paramName]).toBe(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reset parameters to defaults', () => {
|
|
||||||
const { result } = renderHook(() => useMergeParameters());
|
|
||||||
|
|
||||||
// First, change some parameters
|
|
||||||
act(() => {
|
|
||||||
result.current.updateParameter('removeDigitalSignature', true);
|
|
||||||
result.current.updateParameter('generateTableOfContents', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.parameters.removeDigitalSignature).toBe(true);
|
|
||||||
expect(result.current.parameters.generateTableOfContents).toBe(true);
|
|
||||||
|
|
||||||
// Then reset
|
|
||||||
act(() => {
|
|
||||||
result.current.resetParameters();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate parameters correctly - always returns true', () => {
|
|
||||||
const { result } = renderHook(() => useMergeParameters());
|
|
||||||
|
|
||||||
// Default state should be valid
|
|
||||||
expect(result.current.validateParameters()).toBe(true);
|
|
||||||
|
|
||||||
// Change parameters and validate again
|
|
||||||
act(() => {
|
|
||||||
result.current.updateParameter('removeDigitalSignature', true);
|
|
||||||
result.current.updateParameter('generateTableOfContents', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.validateParameters()).toBe(true);
|
|
||||||
|
|
||||||
// Reset and validate again
|
|
||||||
act(() => {
|
|
||||||
result.current.resetParameters();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.validateParameters()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,21 +0,0 @@
|
|||||||
import { BaseParameters } from '../../../types/parameters';
|
|
||||||
import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
|
|
||||||
|
|
||||||
export interface MergeParameters extends BaseParameters {
|
|
||||||
removeDigitalSignature: boolean;
|
|
||||||
generateTableOfContents: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultParameters: MergeParameters = {
|
|
||||||
removeDigitalSignature: false,
|
|
||||||
generateTableOfContents: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
|
|
||||||
|
|
||||||
export const useMergeParameters = (): MergeParametersHook => {
|
|
||||||
return useBaseParameters({
|
|
||||||
defaultParameters,
|
|
||||||
endpointName: "merge-pdfs",
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,142 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,51 +0,0 @@
|
|||||||
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<RedactParameters>({
|
|
||||||
...redactOperationConfig,
|
|
||||||
getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.'))
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,134 +0,0 @@
|
|||||||
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']);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,48 +0,0 @@
|
|||||||
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<RedactParameters>;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
@ -97,7 +97,7 @@ describe('useRemovePasswordOperation', () => {
|
|||||||
test.each([
|
test.each([
|
||||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' },
|
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' },
|
||||||
{ property: 'filePrefix' as const, expectedValue: undefined },
|
{ property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' },
|
||||||
{ property: 'operationType' as const, expectedValue: 'removePassword' }
|
{ property: 'operationType' as const, expectedValue: 'removePassword' }
|
||||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||||
renderHook(() => useRemovePasswordOperation());
|
renderHook(() => useRemovePasswordOperation());
|
||||||
|
@ -38,9 +38,7 @@ export function useBaseTool<TParams>(
|
|||||||
useParams: () => BaseParametersHook<TParams>,
|
useParams: () => BaseParametersHook<TParams>,
|
||||||
useOperation: () => ToolOperationHook<TParams>,
|
useOperation: () => ToolOperationHook<TParams>,
|
||||||
props: BaseToolProps,
|
props: BaseToolProps,
|
||||||
options?: { minFiles?: number }
|
|
||||||
): BaseToolReturn<TParams> {
|
): BaseToolReturn<TParams> {
|
||||||
const minFiles = options?.minFiles ?? 1;
|
|
||||||
const { onPreviewFile, onComplete, onError } = props;
|
const { onPreviewFile, onComplete, onError } = props;
|
||||||
|
|
||||||
// File selection
|
// File selection
|
||||||
@ -98,7 +96,7 @@ export function useBaseTool<TParams>(
|
|||||||
}, [operation, onPreviewFile]);
|
}, [operation, onPreviewFile]);
|
||||||
|
|
||||||
// Standard computed state
|
// Standard computed state
|
||||||
const hasFiles = selectedFiles.length >= minFiles;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||||
const settingsCollapsed = !hasFiles || hasResults;
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
|
|
||||||
|
@ -8,7 +8,6 @@ export interface ApiCallsConfig<TParams = void> {
|
|||||||
buildFormData: (params: TParams, file: File) => FormData;
|
buildFormData: (params: TParams, file: File) => FormData;
|
||||||
filePrefix?: string;
|
filePrefix?: string;
|
||||||
responseHandler?: ResponseHandler;
|
responseHandler?: ResponseHandler;
|
||||||
preserveBackendFilename?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useToolApiCalls = <TParams = void>() => {
|
export const useToolApiCalls = <TParams = void>() => {
|
||||||
@ -47,8 +46,7 @@ export const useToolApiCalls = <TParams = void>() => {
|
|||||||
response.data,
|
response.data,
|
||||||
[file],
|
[file],
|
||||||
config.filePrefix,
|
config.filePrefix,
|
||||||
config.responseHandler,
|
config.responseHandler
|
||||||
config.preserveBackendFilename ? response.headers : undefined
|
|
||||||
);
|
);
|
||||||
processedFiles.push(...responseFiles);
|
processedFiles.push(...responseFiles);
|
||||||
|
|
||||||
|
@ -6,9 +6,9 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
|||||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||||
import { useToolResources } from './useToolResources';
|
import { useToolResources } from './useToolResources';
|
||||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, toStirlingFileStub } from '../../../types/fileContext';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
import { createChildStub } from '../../../contexts/file/fileActions';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { ProcessingProgress, ResponseHandler };
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
@ -34,13 +34,6 @@ interface BaseToolOperationConfig<TParams> {
|
|||||||
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
||||||
filePrefix?: string;
|
filePrefix?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to preserve the filename provided by the backend in response headers.
|
|
||||||
* When true, ignores filePrefix and uses the filename from Content-Disposition header.
|
|
||||||
* Useful for tools like auto-rename where the backend determines the final filename.
|
|
||||||
*/
|
|
||||||
preserveBackendFilename?: boolean;
|
|
||||||
|
|
||||||
/** How to handle API responses (e.g., ZIP extraction, single file response) */
|
/** How to handle API responses (e.g., ZIP extraction, single file response) */
|
||||||
responseHandler?: ResponseHandler;
|
responseHandler?: ResponseHandler;
|
||||||
|
|
||||||
@ -188,8 +181,7 @@ export const useToolOperation = <TParams>(
|
|||||||
endpoint: config.endpoint,
|
endpoint: config.endpoint,
|
||||||
buildFormData: config.buildFormData,
|
buildFormData: config.buildFormData,
|
||||||
filePrefix: config.filePrefix,
|
filePrefix: config.filePrefix,
|
||||||
responseHandler: config.responseHandler,
|
responseHandler: config.responseHandler
|
||||||
preserveBackendFilename: config.preserveBackendFilename
|
|
||||||
};
|
};
|
||||||
processedFiles = await processFiles(
|
processedFiles = await processFiles(
|
||||||
params,
|
params,
|
||||||
@ -272,27 +264,12 @@ export const useToolOperation = <TParams>(
|
|||||||
toolName: config.operationType,
|
toolName: config.operationType,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
console.log("tool complete inputs ")
|
||||||
// Generate fresh processedFileMetadata for all processed files to ensure accuracy
|
const outputStirlingFileStubs = processedFiles.length != inputStirlingFileStubs.length
|
||||||
actions.setStatus('Generating metadata for processed files...');
|
? processedFiles.map((file, index) => toStirlingFileStub(file, undefined, thumbnails[index]))
|
||||||
const processedFileMetadataArray = await Promise.all(
|
: processedFiles.map((resultingFile, index) =>
|
||||||
processedFiles.map(file => generateProcessedFileMetadata(file))
|
createChildStub(inputStirlingFileStubs[index], newToolOperation, resultingFile, thumbnails[index])
|
||||||
);
|
);
|
||||||
const shouldBranchHistory = processedFiles.length != inputStirlingFileStubs.length;
|
|
||||||
// Create output stubs with fresh metadata (no inheritance of stale processedFile data)
|
|
||||||
const outputStirlingFileStubs = shouldBranchHistory
|
|
||||||
? processedFiles.map((file, index) =>
|
|
||||||
createNewStirlingFileStub(file, undefined, thumbnails[index], processedFileMetadataArray[index])
|
|
||||||
)
|
|
||||||
: processedFiles.map((resultingFile, index) =>
|
|
||||||
createChildStub(
|
|
||||||
inputStirlingFileStubs[index],
|
|
||||||
newToolOperation,
|
|
||||||
resultingFile,
|
|
||||||
thumbnails[index],
|
|
||||||
processedFileMetadataArray[index]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create StirlingFile objects from processed files and child stubs
|
// Create StirlingFile objects from processed files and child stubs
|
||||||
const outputStirlingFiles = processedFiles.map((file, index) => {
|
const outputStirlingFiles = processedFiles.map((file, index) => {
|
||||||
|
@ -59,7 +59,6 @@ i18n
|
|||||||
.init({
|
.init({
|
||||||
fallbackLng: 'en-GB',
|
fallbackLng: 'en-GB',
|
||||||
supportedLngs: Object.keys(supportedLanguages),
|
supportedLngs: Object.keys(supportedLanguages),
|
||||||
load: 'currentOnly',
|
|
||||||
nonExplicitSupportedLngs: false,
|
nonExplicitSupportedLngs: false,
|
||||||
debug: process.env.NODE_ENV === 'development',
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
|
@ -141,7 +141,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
// Verify hook state updates
|
// Verify hook state updates
|
||||||
expect(result.current.downloadUrl).toBeTruthy();
|
expect(result.current.downloadUrl).toBeTruthy();
|
||||||
expect(result.current.downloadFilename).toBe('test.png');
|
expect(result.current.downloadFilename).toBe('test_converted.png');
|
||||||
expect(result.current.isLoading).toBe(false);
|
expect(result.current.isLoading).toBe(false);
|
||||||
expect(result.current.errorMessage).toBe(null);
|
expect(result.current.errorMessage).toBe(null);
|
||||||
});
|
});
|
||||||
@ -363,7 +363,7 @@ describe('Convert Tool Integration Tests', () => {
|
|||||||
|
|
||||||
// Verify hook state updates correctly
|
// Verify hook state updates correctly
|
||||||
expect(result.current.downloadUrl).toBeTruthy();
|
expect(result.current.downloadUrl).toBeTruthy();
|
||||||
expect(result.current.downloadFilename).toBe('test.csv');
|
expect(result.current.downloadFilename).toBe('test_converted.csv');
|
||||||
expect(result.current.isLoading).toBe(false);
|
expect(result.current.isLoading).toBe(false);
|
||||||
expect(result.current.errorMessage).toBe(null);
|
expect(result.current.errorMessage).toBe(null);
|
||||||
});
|
});
|
||||||
|
@ -1,44 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
|
||||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
|
||||||
import { BaseToolProps } from "../types/tool";
|
|
||||||
|
|
||||||
import { useAutoRenameParameters } from "../hooks/tools/autoRename/useAutoRenameParameters";
|
|
||||||
import { useAutoRenameOperation } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
|
||||||
import { useAutoRenameTips } from "../components/tooltips/useAutoRenameTips";
|
|
||||||
|
|
||||||
const AutoRename =(props: BaseToolProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const base = useBaseTool(
|
|
||||||
'"auto-rename-pdf-file',
|
|
||||||
useAutoRenameParameters,
|
|
||||||
useAutoRenameOperation,
|
|
||||||
props
|
|
||||||
);
|
|
||||||
|
|
||||||
return createToolFlow({
|
|
||||||
title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()},
|
|
||||||
files: {
|
|
||||||
selectedFiles: base.selectedFiles,
|
|
||||||
isCollapsed: base.hasResults,
|
|
||||||
},
|
|
||||||
steps: [],
|
|
||||||
executeButton: {
|
|
||||||
text: t("auto-rename.submit", "Auto Rename"),
|
|
||||||
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("auto-rename.results.title", "Auto-Rename Results"),
|
|
||||||
onFileClick: base.handleThumbnailClick,
|
|
||||||
onUndo: base.handleUndo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AutoRename;
|
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useNavigationActions } from "../contexts/NavigationContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
@ -161,10 +161,25 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
content
|
content
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dynamic file placeholder based on supported types
|
||||||
|
const filesPlaceholder = useMemo(() => {
|
||||||
|
if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
|
||||||
|
const firstOperation = stepData.automation.operations[0];
|
||||||
|
const toolConfig = toolRegistry[firstOperation.operation as keyof typeof toolRegistry];
|
||||||
|
|
||||||
|
// Check if the tool has supportedFormats that include non-PDF formats
|
||||||
|
if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
|
||||||
|
return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t('automate.files.placeholder', 'Select PDF files to process with this automation');
|
||||||
|
}, [currentStep, stepData.automation, toolRegistry, t]);
|
||||||
|
|
||||||
// Always create files step to avoid conditional hook calls
|
// Always create files step to avoid conditional hook calls
|
||||||
const filesStep = createFilesToolStep(createStep, {
|
const filesStep = createFilesToolStep(createStep, {
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: hasResults,
|
||||||
|
placeholder: filesPlaceholder
|
||||||
});
|
});
|
||||||
|
|
||||||
const automationSteps = [
|
const automationSteps = [
|
||||||
|
@ -100,6 +100,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
isCollapsed: hasResults,
|
isCollapsed: hasResults,
|
||||||
|
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
@ -22,6 +22,7 @@ const Flatten = (props: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: base.hasResults,
|
isCollapsed: base.hasResults,
|
||||||
|
placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
@ -1,98 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
|
||||||
import MergeSettings from "../components/tools/merge/MergeSettings";
|
|
||||||
import MergeFileSorter from "../components/tools/merge/MergeFileSorter";
|
|
||||||
import { useMergeParameters } from "../hooks/tools/merge/useMergeParameters";
|
|
||||||
import { useMergeOperation } from "../hooks/tools/merge/useMergeOperation";
|
|
||||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
|
||||||
import { useMergeTips } from "../components/tooltips/useMergeTips";
|
|
||||||
import { useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext";
|
|
||||||
|
|
||||||
const Merge = (props: BaseToolProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const mergeTips = useMergeTips();
|
|
||||||
|
|
||||||
// File selection hooks for custom sorting
|
|
||||||
const { fileIds } = useAllFiles();
|
|
||||||
const { selectedRecords } = useSelectedFiles();
|
|
||||||
const { reorderFiles } = useFileManagement();
|
|
||||||
|
|
||||||
const base = useBaseTool(
|
|
||||||
'merge',
|
|
||||||
useMergeParameters,
|
|
||||||
useMergeOperation,
|
|
||||||
props,
|
|
||||||
{ minFiles: 2 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom file sorting logic for merge tool
|
|
||||||
const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
|
|
||||||
const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
|
|
||||||
let comparison = 0;
|
|
||||||
switch (sortType) {
|
|
||||||
case 'filename':
|
|
||||||
comparison = recordA.name.localeCompare(recordB.name);
|
|
||||||
break;
|
|
||||||
case 'dateModified':
|
|
||||||
comparison = recordA.lastModified - recordB.lastModified;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return ascending ? comparison : -comparison;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedIds = sortedRecords.map(record => record.id);
|
|
||||||
const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
|
|
||||||
reorderFiles([...selectedIds, ...deselectedIds]);
|
|
||||||
}, [selectedRecords, fileIds, reorderFiles]);
|
|
||||||
|
|
||||||
return createToolFlow({
|
|
||||||
files: {
|
|
||||||
selectedFiles: base.selectedFiles,
|
|
||||||
isCollapsed: base.hasResults,
|
|
||||||
minFiles: 2,
|
|
||||||
},
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
title: "Sort Files",
|
|
||||||
isCollapsed: base.settingsCollapsed,
|
|
||||||
content: (
|
|
||||||
<MergeFileSorter
|
|
||||||
onSortFiles={sortFiles}
|
|
||||||
disabled={!base.hasFiles || base.endpointLoading}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
isCollapsed: base.settingsCollapsed,
|
|
||||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
|
||||||
tooltip: mergeTips,
|
|
||||||
content: (
|
|
||||||
<MergeSettings
|
|
||||||
parameters={base.params.parameters}
|
|
||||||
onParameterChange={base.params.updateParameter}
|
|
||||||
disabled={base.endpointLoading}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
executeButton: {
|
|
||||||
text: t("merge.submit", "Merge PDFs"),
|
|
||||||
isVisible: !base.hasResults,
|
|
||||||
loadingText: t("loading"),
|
|
||||||
onClick: base.handleExecute,
|
|
||||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
isVisible: base.hasResults,
|
|
||||||
operation: base.operation,
|
|
||||||
title: t("merge.title", "Merge Results"),
|
|
||||||
onFileClick: base.handleThumbnailClick,
|
|
||||||
onUndo: base.handleUndo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Merge as ToolComponent;
|
|
@ -1,120 +0,0 @@
|
|||||||
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: (
|
|
||||||
<RedactModeSelector
|
|
||||||
mode={base.params.parameters.mode}
|
|
||||||
onModeChange={(mode) => 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: <WordsToRedactInput
|
|
||||||
wordsToRedact={base.params.parameters.wordsToRedact}
|
|
||||||
onWordsChange={(words) => 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: <RedactAdvancedSettings
|
|
||||||
parameters={base.params.parameters}
|
|
||||||
onParameterChange={base.params.updateParameter}
|
|
||||||
disabled={base.endpointLoading}
|
|
||||||
/>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} 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;
|
|
@ -19,6 +19,7 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: base.hasResults,
|
isCollapsed: base.hasResults,
|
||||||
|
placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
|
@ -19,6 +19,7 @@ const Repair = (props: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: base.hasResults,
|
isCollapsed: base.hasResults,
|
||||||
|
placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
|
@ -20,6 +20,7 @@ const Sanitize = (props: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: base.hasResults,
|
isCollapsed: base.hasResults,
|
||||||
|
placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
@ -19,6 +19,7 @@ const SingleLargePage = (props: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: base.hasResults,
|
isCollapsed: base.hasResults,
|
||||||
|
placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
|
@ -19,6 +19,7 @@ const UnlockPdfForms = (props: BaseToolProps) => {
|
|||||||
files: {
|
files: {
|
||||||
selectedFiles: base.selectedFiles,
|
selectedFiles: base.selectedFiles,
|
||||||
isCollapsed: base.hasFiles || base.hasResults,
|
isCollapsed: base.hasFiles || base.hasResults,
|
||||||
|
placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
|
||||||
},
|
},
|
||||||
steps: [],
|
steps: [],
|
||||||
executeButton: {
|
executeButton: {
|
||||||
|
@ -41,7 +41,7 @@ export interface BaseFileMetadata {
|
|||||||
// File history tracking
|
// File history tracking
|
||||||
isLeaf?: boolean; // True if this file hasn't been processed yet
|
isLeaf?: boolean; // True if this file hasn't been processed yet
|
||||||
originalFileId?: string; // Root file ID for grouping versions
|
originalFileId?: string; // Root file ID for grouping versions
|
||||||
versionNumber: number; // Version number in chain
|
versionNumber?: number; // Version number in chain
|
||||||
parentFileId?: FileId; // Immediate parent file ID
|
parentFileId?: FileId; // Immediate parent file ID
|
||||||
toolHistory?: Array<{
|
toolHistory?: Array<{
|
||||||
toolName: string;
|
toolName: string;
|
||||||
|
@ -25,8 +25,7 @@ export type ModeType =
|
|||||||
| 'single-large-page'
|
| 'single-large-page'
|
||||||
| 'repair'
|
| 'repair'
|
||||||
| 'unlockPdfForms'
|
| 'unlockPdfForms'
|
||||||
| 'removeCertificateSign'
|
| 'removeCertificateSign';
|
||||||
| 'auto-rename-pdf-file';
|
|
||||||
|
|
||||||
// Normalized state types
|
// Normalized state types
|
||||||
export interface ProcessedFilePage {
|
export interface ProcessedFilePage {
|
||||||
@ -40,6 +39,7 @@ export interface ProcessedFilePage {
|
|||||||
export interface ProcessedFileMetadata {
|
export interface ProcessedFileMetadata {
|
||||||
pages: ProcessedFilePage[];
|
pages: ProcessedFilePage[];
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
lastProcessed?: number;
|
lastProcessed?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
@ -156,11 +156,11 @@ export function isFileObject(obj: any): obj is File | StirlingFile {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function createNewStirlingFileStub(
|
export function toStirlingFileStub(
|
||||||
file: File,
|
file: File,
|
||||||
id?: FileId,
|
id?: FileId,
|
||||||
thumbnail?: string,
|
thumbnail?: string
|
||||||
processedFileMetadata?: ProcessedFileMetadata
|
|
||||||
): StirlingFileStub {
|
): StirlingFileStub {
|
||||||
const fileId = id || createFileId();
|
const fileId = id || createFileId();
|
||||||
return {
|
return {
|
||||||
@ -172,9 +172,7 @@ export function createNewStirlingFileStub(
|
|||||||
quickKey: createQuickKey(file),
|
quickKey: createQuickKey(file),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
isLeaf: true, // New files are leaf nodes by default
|
isLeaf: true, // New files are leaf nodes by default
|
||||||
versionNumber: 1, // New files start at version 1
|
thumbnailUrl: thumbnail
|
||||||
thumbnailUrl: thumbnail,
|
|
||||||
processedFile: processedFileMetadata
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,8 +2,8 @@ import axios from 'axios';
|
|||||||
import { ToolRegistry } from '../data/toolsTaxonomy';
|
import { ToolRegistry } from '../data/toolsTaxonomy';
|
||||||
import { AUTOMATION_CONSTANTS } from '../constants/automation';
|
import { AUTOMATION_CONSTANTS } from '../constants/automation';
|
||||||
import { AutomationFileProcessor } from './automationFileProcessor';
|
import { AutomationFileProcessor } from './automationFileProcessor';
|
||||||
|
import { ResourceManager } from './resourceManager';
|
||||||
import { ToolType } from '../hooks/tools/shared/useToolOperation';
|
import { ToolType } from '../hooks/tools/shared/useToolOperation';
|
||||||
import { processResponse } from './toolResponseProcessor';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,17 +68,12 @@ export const executeToolOperationWithPrefix = async (
|
|||||||
let result;
|
let result;
|
||||||
if (response.data.type === 'application/pdf' ||
|
if (response.data.type === 'application/pdf' ||
|
||||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||||
// Single PDF response (e.g. split with merge option) - use processResponse to respect preserveBackendFilename
|
// Single PDF response (e.g. split with merge option) - use original filename
|
||||||
const processedFiles = await processResponse(
|
const originalFileName = files[0]?.name || 'document.pdf';
|
||||||
response.data,
|
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||||
files,
|
|
||||||
filePrefix,
|
|
||||||
undefined,
|
|
||||||
config.preserveBackendFilename ? response.headers : undefined
|
|
||||||
);
|
|
||||||
result = {
|
result = {
|
||||||
success: true,
|
success: true,
|
||||||
files: processedFiles,
|
files: [singleFile],
|
||||||
errors: []
|
errors: []
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -90,8 +85,7 @@ export const executeToolOperationWithPrefix = async (
|
|||||||
console.warn(`⚠️ File processing warnings:`, result.errors);
|
console.warn(`⚠️ File processing warnings:`, result.errors);
|
||||||
}
|
}
|
||||||
// Apply prefix to files, replacing any existing prefix
|
// Apply prefix to files, replacing any existing prefix
|
||||||
// Skip prefixing if preserveBackendFilename is true and backend provided a filename
|
const processedFiles = filePrefix
|
||||||
const processedFiles = filePrefix && !config.preserveBackendFilename
|
|
||||||
? result.files.map(file => {
|
? result.files.map(file => {
|
||||||
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
|
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
|
||||||
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
|
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
|
||||||
@ -123,16 +117,15 @@ export const executeToolOperationWithPrefix = async (
|
|||||||
|
|
||||||
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
|
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
|
||||||
|
|
||||||
// Create result file using processResponse to respect preserveBackendFilename setting
|
// Create result file with automation prefix
|
||||||
const processedFiles = await processResponse(
|
|
||||||
|
const resultFile = ResourceManager.createResultFile(
|
||||||
response.data,
|
response.data,
|
||||||
[file],
|
file.name,
|
||||||
filePrefix,
|
filePrefix
|
||||||
undefined,
|
|
||||||
config.preserveBackendFilename ? response.headers : undefined
|
|
||||||
);
|
);
|
||||||
resultFiles.push(...processedFiles);
|
resultFiles.push(resultFile);
|
||||||
console.log(`✅ Created result file(s): ${processedFiles.map(f => f.name).join(', ')}`);
|
console.log(`✅ Created result file: ${resultFile.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎉 Single-file processing complete: ${resultFiles.length} files`);
|
console.log(`🎉 Single-file processing complete: ${resultFiles.length} files`);
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
// Note: This utility should be used with useToolResources for ZIP operations
|
// Note: This utility should be used with useToolResources for ZIP operations
|
||||||
import { getFilenameFromHeaders } from './fileResponseUtils';
|
|
||||||
|
|
||||||
export type ResponseHandler = (blob: Blob, originalFiles: File[]) => Promise<File[]> | File[];
|
export type ResponseHandler = (blob: Blob, originalFiles: File[]) => Promise<File[]> | File[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a blob response into File(s).
|
* Processes a blob response into File(s).
|
||||||
* - If a tool-specific responseHandler is provided, it is used.
|
* - If a tool-specific responseHandler is provided, it is used.
|
||||||
* - If responseHeaders provided and contains Content-Disposition, uses that filename.
|
|
||||||
* - Otherwise, create a single file using the filePrefix + original name.
|
* - Otherwise, create a single file using the filePrefix + original name.
|
||||||
* - If filePrefix is empty, preserves the original filename.
|
* - If filePrefix is empty, preserves the original filename.
|
||||||
*/
|
*/
|
||||||
@ -14,28 +12,13 @@ export async function processResponse(
|
|||||||
blob: Blob,
|
blob: Blob,
|
||||||
originalFiles: File[],
|
originalFiles: File[],
|
||||||
filePrefix?: string,
|
filePrefix?: string,
|
||||||
responseHandler?: ResponseHandler,
|
responseHandler?: ResponseHandler
|
||||||
responseHeaders?: Record<string, any>
|
|
||||||
): Promise<File[]> {
|
): Promise<File[]> {
|
||||||
if (responseHandler) {
|
if (responseHandler) {
|
||||||
const out = await responseHandler(blob, originalFiles);
|
const out = await responseHandler(blob, originalFiles);
|
||||||
return Array.isArray(out) ? out : [out as unknown as File];
|
return Array.isArray(out) ? out : [out as unknown as File];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should use the backend-provided filename from headers
|
|
||||||
// Only when responseHeaders are explicitly provided (indicating the operation requested this)
|
|
||||||
if (responseHeaders) {
|
|
||||||
const contentDisposition = responseHeaders['content-disposition'];
|
|
||||||
const backendFilename = getFilenameFromHeaders(contentDisposition);
|
|
||||||
if (backendFilename) {
|
|
||||||
const type = blob.type || responseHeaders['content-type'] || 'application/octet-stream';
|
|
||||||
return [new File([blob], backendFilename, { type })];
|
|
||||||
}
|
|
||||||
// If preserveBackendFilename was requested but no Content-Disposition header found,
|
|
||||||
// fall back to default behavior (this handles cases where backend doesn't set the header)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default behavior: use filePrefix + original name
|
|
||||||
const original = originalFiles[0]?.name ?? 'result.pdf';
|
const original = originalFiles[0]?.name ?? 'result.pdf';
|
||||||
// Only add prefix if it's not empty - this preserves original filenames for file history
|
// Only add prefix if it's not empty - this preserves original filenames for file history
|
||||||
const name = filePrefix ? `${filePrefix}${original}` : original;
|
const name = filePrefix ? `${filePrefix}${original}` : original;
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
%PDF-1.3
|
|
||||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
|
||||||
1 0 obj
|
|
||||||
<<
|
|
||||||
/F1 2 0 R /F2 3 0 R
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<<
|
|
||||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
|
||||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
||||||
>> /Rotate 0 /Trans <<
|
|
||||||
|
|
||||||
>>
|
|
||||||
/Type /Page
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
5 0 obj
|
|
||||||
<<
|
|
||||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
6 0 obj
|
|
||||||
<<
|
|
||||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
|
||||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
7 0 obj
|
|
||||||
<<
|
|
||||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
8 0 obj
|
|
||||||
<<
|
|
||||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
|
||||||
>>
|
|
||||||
stream
|
|
||||||
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp2V9^`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngc-TW~>endstream
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 9
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000073 00000 n
|
|
||||||
0000000114 00000 n
|
|
||||||
0000000221 00000 n
|
|
||||||
0000000333 00000 n
|
|
||||||
0000000526 00000 n
|
|
||||||
0000000594 00000 n
|
|
||||||
0000000890 00000 n
|
|
||||||
0000000949 00000 n
|
|
||||||
trailer
|
|
||||||
<<
|
|
||||||
/ID
|
|
||||||
[<cb35d644a26f0c9be3597a7f8189b123><cb35d644a26f0c9be3597a7f8189b123>]
|
|
||||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
|
||||||
|
|
||||||
/Info 6 0 R
|
|
||||||
/Root 5 0 R
|
|
||||||
/Size 9
|
|
||||||
>>
|
|
||||||
startxref
|
|
||||||
1186
|
|
||||||
%%EOF
|
|
@ -1,74 +0,0 @@
|
|||||||
%PDF-1.3
|
|
||||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
|
||||||
1 0 obj
|
|
||||||
<<
|
|
||||||
/F1 2 0 R /F2 3 0 R
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<<
|
|
||||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
|
||||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
||||||
>> /Rotate 0 /Trans <<
|
|
||||||
|
|
||||||
>>
|
|
||||||
/Type /Page
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
5 0 obj
|
|
||||||
<<
|
|
||||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
6 0 obj
|
|
||||||
<<
|
|
||||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
|
||||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
7 0 obj
|
|
||||||
<<
|
|
||||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
8 0 obj
|
|
||||||
<<
|
|
||||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
|
||||||
>>
|
|
||||||
stream
|
|
||||||
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 9
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000073 00000 n
|
|
||||||
0000000114 00000 n
|
|
||||||
0000000221 00000 n
|
|
||||||
0000000333 00000 n
|
|
||||||
0000000526 00000 n
|
|
||||||
0000000594 00000 n
|
|
||||||
0000000890 00000 n
|
|
||||||
0000000949 00000 n
|
|
||||||
trailer
|
|
||||||
<<
|
|
||||||
/ID
|
|
||||||
[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>]
|
|
||||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
|
||||||
|
|
||||||
/Info 6 0 R
|
|
||||||
/Root 5 0 R
|
|
||||||
/Size 9
|
|
||||||
>>
|
|
||||||
startxref
|
|
||||||
1186
|
|
||||||
%%EOF
|
|
@ -1,74 +0,0 @@
|
|||||||
%PDF-1.3
|
|
||||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
|
||||||
1 0 obj
|
|
||||||
<<
|
|
||||||
/F1 2 0 R /F2 3 0 R
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<<
|
|
||||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
|
||||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
||||||
>> /Rotate 0 /Trans <<
|
|
||||||
|
|
||||||
>>
|
|
||||||
/Type /Page
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
5 0 obj
|
|
||||||
<<
|
|
||||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
6 0 obj
|
|
||||||
<<
|
|
||||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
|
||||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
7 0 obj
|
|
||||||
<<
|
|
||||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
8 0 obj
|
|
||||||
<<
|
|
||||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
|
||||||
>>
|
|
||||||
stream
|
|
||||||
GarW0YmS?5&4HDC`<2TCEOpM_A^cO6ZEVtG&1rQ7k5R.W5uPe>'T[Ma*9KfZqZs*-57""%'<u)dPtNs!.p_7Cem+LKojd:CaF,4$g:S_<`9sPL'Dq([aoCSX;_^WU4Wa'KgNd255,.iQh#\m&~>endstream
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 9
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000073 00000 n
|
|
||||||
0000000114 00000 n
|
|
||||||
0000000221 00000 n
|
|
||||||
0000000333 00000 n
|
|
||||||
0000000526 00000 n
|
|
||||||
0000000594 00000 n
|
|
||||||
0000000890 00000 n
|
|
||||||
0000000949 00000 n
|
|
||||||
trailer
|
|
||||||
<<
|
|
||||||
/ID
|
|
||||||
[<8c4eba11c30780ded30147f80c0aa46f><8c4eba11c30780ded30147f80c0aa46f>]
|
|
||||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
|
||||||
|
|
||||||
/Info 6 0 R
|
|
||||||
/Root 5 0 R
|
|
||||||
/Size 9
|
|
||||||
>>
|
|
||||||
startxref
|
|
||||||
1186
|
|
||||||
%%EOF
|
|
@ -1,74 +0,0 @@
|
|||||||
%PDF-1.3
|
|
||||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
|
||||||
1 0 obj
|
|
||||||
<<
|
|
||||||
/F1 2 0 R /F2 3 0 R
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<<
|
|
||||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<<
|
|
||||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
|
||||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
||||||
>> /Rotate 0 /Trans <<
|
|
||||||
|
|
||||||
>>
|
|
||||||
/Type /Page
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
5 0 obj
|
|
||||||
<<
|
|
||||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
6 0 obj
|
|
||||||
<<
|
|
||||||
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
|
||||||
/Subject (unspecified) /Title (untitled) /Trapped /False
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
7 0 obj
|
|
||||||
<<
|
|
||||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
|
||||||
>>
|
|
||||||
endobj
|
|
||||||
8 0 obj
|
|
||||||
<<
|
|
||||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
|
|
||||||
>>
|
|
||||||
stream
|
|
||||||
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3%Qb`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^nh.J$8~>endstream
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 9
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000073 00000 n
|
|
||||||
0000000114 00000 n
|
|
||||||
0000000221 00000 n
|
|
||||||
0000000333 00000 n
|
|
||||||
0000000526 00000 n
|
|
||||||
0000000594 00000 n
|
|
||||||
0000000890 00000 n
|
|
||||||
0000000949 00000 n
|
|
||||||
trailer
|
|
||||||
<<
|
|
||||||
/ID
|
|
||||||
[<ade40b97468692afaf20f74813f90619><ade40b97468692afaf20f74813f90619>]
|
|
||||||
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
|
||||||
|
|
||||||
/Info 6 0 R
|
|
||||||
/Root 5 0 R
|
|
||||||
/Size 9
|
|
||||||
>>
|
|
||||||
startxref
|
|
||||||
1186
|
|
||||||
%%EOF
|
|
Loading…
x
Reference in New Issue
Block a user