From 21b1428ab567915621dab83aada8135f4d506c38 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 25 Sep 2025 16:32:30 +0100 Subject: [PATCH 1/2] Feature/v2/fuzzy tool search (#4482) # Description of Changes --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [x] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 67 ++++++++-- .../public/locales/en-US/translation.json | 64 ++++++++- .../src/components/tools/SearchResults.tsx | 45 +++++-- frontend/src/components/tools/ToolPanel.tsx | 1 + frontend/src/components/tools/ToolPicker.tsx | 2 +- .../tools/automate/ToolSelector.tsx | 7 +- .../tools/shared/renderToolButtons.tsx | 56 +++++--- .../tools/toolPicker/ToolButton.tsx | 43 +++++-- .../tools/toolPicker/ToolSearch.tsx | 18 +-- frontend/src/contexts/ToolWorkflowContext.tsx | 9 +- frontend/src/data/toolsTaxonomy.ts | 2 + .../src/data/useTranslatedToolRegistry.tsx | 61 ++++++++- frontend/src/hooks/useToolSections.ts | 42 +++++- frontend/src/utils/fuzzySearch.ts | 121 ++++++++++++++++++ frontend/src/utils/toolSearch.ts | 99 ++++++++++++++ frontend/src/utils/toolSynonyms.ts | 24 ++++ 16 files changed, 584 insertions(+), 77 deletions(-) create mode 100644 frontend/src/utils/fuzzySearch.ts create mode 100644 frontend/src/utils/toolSearch.ts create mode 100644 frontend/src/utils/toolSynonyms.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 5d0ad075e..6b2b527cf 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -357,222 +357,277 @@ "globalPopularity": "Global Popularity", "sortBy": "Sort by:", "multiTool": { + "tags": "multiple,tools", "title": "PDF Multi Tool", "desc": "Merge, Rotate, Rearrange, Split, and Remove pages" }, "merge": { + "tags": "combine,join,unite", "title": "Merge", "desc": "Easily merge multiple PDFs into one." }, "split": { + "tags": "divide,separate,break", "title": "Split", "desc": "Split PDFs into multiple documents" }, "rotate": { + "tags": "turn,flip,orient", "title": "Rotate", "desc": "Easily rotate your PDFs." }, "convert": { + "tags": "transform,change", "title": "Convert", "desc": "Convert files between different formats" }, "pdfOrganiser": { + "tags": "organize,rearrange,reorder", "title": "Organise", "desc": "Remove/Rearrange pages in any order" }, "addImage": { + "tags": "insert,embed,place", "title": "Add image", "desc": "Adds a image onto a set location on the PDF" }, "addAttachments": { + "tags": "embed,attach,include", "title": "Add Attachments", "desc": "Add or remove embedded files (attachments) to/from a PDF" }, "watermark": { + "tags": "stamp,mark,overlay", "title": "Add Watermark", "desc": "Add a custom watermark to your PDF document." }, "removePassword": { + "tags": "unlock", "title": "Remove Password", "desc": "Remove password protection from your PDF document." }, "compress": { + "tags": "shrink,reduce,optimize", "title": "Compress", "desc": "Compress PDFs to reduce their file size." }, "unlockPDFForms": { + "tags": "unlock,enable,edit", "title": "Unlock PDF Forms", "desc": "Remove read-only property of form fields in a PDF document." }, "changeMetadata": { + "tags": "edit,modify,update", "title": "Change Metadata", "desc": "Change/Remove/Add metadata from a PDF document" }, "ocr": { + "tags": "extract,scan", "title": "OCR / Cleanup scans", "desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text." }, "extractImages": { + "tags": "pull,save,export", "title": "Extract Images", "desc": "Extracts all images from a PDF and saves them to zip" }, "scannerImageSplit": { + "tags": "detect,split,photos", "title": "Detect/Split Scanned photos", "desc": "Splits multiple photos from within a photo/PDF" }, "sign": { + "tags": "signature,autograph", "title": "Sign", "desc": "Adds signature to PDF by drawing, text or image" }, "flatten": { + "tags": "simplify,remove,interactive", "title": "Flatten", "desc": "Remove all interactive elements and forms from a PDF" }, "certSign": { + "tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto", "title": "Sign with Certificate", "desc": "Signs a PDF with a Certificate/Key (PEM/P12)" }, "repair": { + "tags": "fix,restore", "title": "Repair", "desc": "Tries to repair a corrupt/broken PDF" }, "removeBlanks": { + "tags": "delete,clean,empty", "title": "Remove Blank pages", "desc": "Detects and removes blank pages from a document" }, "removeAnnotations": { + "tags": "delete,clean,strip", "title": "Remove Annotations", "desc": "Removes all comments/annotations from a PDF" }, "compare": { + "tags": "difference", "title": "Compare", "desc": "Compares and shows the differences between 2 PDF Documents" }, "removeCertSign": { + "tags": "remove,delete,unlock", "title": "Remove Certificate Sign", "desc": "Remove certificate signature from PDF" }, "pageLayout": { + "tags": "layout,arrange,combine", "title": "Multi-Page Layout", "desc": "Merge multiple pages of a PDF document into a single page" }, "bookletImposition": { + "tags": "booklet,print,binding", "title": "Booklet Imposition", "desc": "Create booklets with proper page ordering and multi-page layout for printing and binding" }, "scalePages": { + "tags": "resize,adjust,scale", "title": "Adjust page size/scale", "desc": "Change the size/scale of a page and/or its contents." }, "addPageNumbers": { + "tags": "number,pagination,count", "title": "Add Page Numbers", "desc": "Add Page numbers throughout a document in a set location" }, "autoRename": { + "tags": "auto-detect,header-based,organize,relabel", "title": "Auto Rename PDF File", "desc": "Auto renames a PDF file based on its detected header" }, "adjustContrast": { + "tags": "contrast,brightness,saturation", "title": "Adjust Colours/Contrast", "desc": "Adjust Contrast, Saturation and Brightness of a PDF" }, "crop": { + "tags": "trim,cut,resize", "title": "Crop PDF", "desc": "Crop a PDF to reduce its size (maintains text!)" }, "autoSplitPDF": { + "tags": "auto,split,QR", "title": "Auto Split Pages", "desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code" }, "sanitize": { + "tags": "clean,purge,remove", "title": "Sanitise", "desc": "Remove potentially harmful elements from PDF files" }, "getPdfInfo": { + "tags": "info,metadata,details", "title": "Get ALL Info on PDF", "desc": "Grabs any and all information possible on PDFs" }, "pdfToSinglePage": { + "tags": "combine,merge,single", "title": "PDF to Single Large Page", "desc": "Merges all PDF pages into one large single page" }, "showJS": { + "tags": "javascript,code,script", "title": "Show Javascript", "desc": "Searches and displays any JS injected into a PDF" }, "redact": { + "tags": "censor,blackout,hide", "title": "Redact", "desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)" }, "overlayPdfs": { + "tags": "overlay,combine,stack", "title": "Overlay PDFs", "desc": "Overlays PDFs on-top of another PDF" }, "splitBySections": { + "tags": "split,sections,divide", "title": "Split PDF by Sections", "desc": "Divide each page of a PDF into smaller horizontal and vertical sections" }, "addStamp": { + "tags": "stamp,mark,seal", "title": "Add Stamp to PDF", "desc": "Add text or add image stamps at set locations" }, "removeImage": { + "tags": "remove,delete,clean", "title": "Remove image", "desc": "Remove image from PDF to reduce file size" }, "splitByChapters": { + "tags": "split,chapters,structure", "title": "Split PDF by Chapters", "desc": "Split a PDF into multiple files based on its chapter structure." }, "validateSignature": { + "tags": "validate,verify,certificate", "title": "Validate PDF Signature", "desc": "Verify digital signatures and certificates in PDF documents" }, "swagger": { + "tags": "API,documentation,test", "title": "API Documentation", "desc": "View API documentation and test endpoints" }, "fakeScan": { + "tags": "scan,simulate,create", "title": "Fake Scan", "desc": "Create a PDF that looks like it was scanned" }, "editTableOfContents": { + "tags": "bookmarks,contents,edit", "title": "Edit Table of Contents", "desc": "Add or edit bookmarks and table of contents in PDF documents" }, "manageCertificates": { + "tags": "certificates,import,export", "title": "Manage Certificates", "desc": "Import, export, or delete digital certificate files used for signing PDFs." }, - "read": { - "title": "Read", + "read": { + "tags": "view,open,display", + "title": "Read", "desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." }, "reorganizePages": { + "tags": "rearrange,reorder,organize", "title": "Reorganize Pages", "desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control." }, "extractPages": { + "tags": "pull,select,copy", "title": "Extract Pages", "desc": "Extract specific pages from a PDF document" }, "removePages": { + "tags": "delete,extract,exclude", "title": "Remove Pages", "desc": "Remove specific pages from a PDF document" }, "autoSizeSplitPDF": { + "tags": "auto,split,size", "title": "Auto Split by Size/Count", "desc": "Automatically split PDFs by file size or page count" }, "replaceColorPdf": { + "tags": "color,replace,invert", "title": "Replace & Invert Colour", "desc": "Replace or invert colours in PDF documents" }, "devApi": { + "tags": "API,development,documentation", "title": "API", "desc": "Link to API documentation" }, "devFolderScanning": { + "tags": "automation,folder,scanning", "title": "Automated Folder Scanning", "desc": "Link to automated folder scanning guide" }, @@ -593,6 +648,7 @@ "desc": "Change document restrictions and permissions" }, "automate": { + "tags": "workflow,sequence,automation", "title": "Automate", "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." } @@ -659,7 +715,6 @@ } }, "split": { - "tags": "Page operations,divide,Multi Page,cut,server side", "title": "Split PDF", "header": "Split PDF", "desc": { @@ -785,7 +840,6 @@ } }, "rotate": { - "tags": "server side", "title": "Rotate PDF", "submit": "Apply Rotation", "error": { @@ -1298,7 +1352,6 @@ } }, "changeMetadata": { - "tags": "Title,author,date,creation,time,publisher,producer,stats", "header": "Change Metadata", "submit": "Change", "filenamePrefix": "metadata", @@ -1613,7 +1666,6 @@ "info": "Python is not installed. It is required to run." }, "sign": { - "tags": "authorize,initials,drawn-signature,text-sign,image-signature", "title": "Sign", "header": "Sign PDFs", "upload": "Upload Image", @@ -1637,7 +1689,6 @@ "redo": "Redo" }, "flatten": { - "tags": "static,deactivate,non-interactive,streamline", "title": "Flatten", "header": "Flatten PDF", "flattenOnlyForms": "Flatten only forms", @@ -1702,7 +1753,6 @@ } }, "removeBlanks": { - "tags": "cleanup,streamline,non-content,organize", "title": "Remove Blanks", "header": "Remove Blank Pages", "settings": { @@ -2099,7 +2149,6 @@ "tags": "color-correction,tune,modify,enhance,colour-correction" }, "crop": { - "tags": "trim,shrink,edit,shape", "title": "Crop", "header": "Crop PDF", "submit": "Apply Crop", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 4cea62d96..68cb57546 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -348,206 +348,257 @@ "globalPopularity": "Global Popularity", "sortBy": "Sort by:", "multiTool": { + "tags": "multiple,tools", "title": "PDF Multi Tool", "desc": "Merge, Rotate, Rearrange, Split, and Remove pages" }, "merge": { + "tags": "combine,join,unite", "title": "Merge", "desc": "Easily merge multiple PDFs into one." }, "split": { + "tags": "divide,separate,break", "title": "Split", "desc": "Split PDFs into multiple documents" }, "rotate": { + "tags": "turn,flip,orient", "title": "Rotate", "desc": "Easily rotate your PDFs." }, "imageToPDF": { + "tags": "convert,image,transform", "title": "Image to PDF", "desc": "Convert a image (PNG, JPEG, GIF) to PDF." }, "pdfToImage": { + "tags": "convert,image,extract", "title": "PDF to Image", "desc": "Convert a PDF to a image. (PNG, JPEG, GIF)" }, "pdfOrganiser": { + "tags": "organize,rearrange,reorder", "title": "Organize", "desc": "Remove/Rearrange pages in any order" }, "addImage": { + "tags": "insert,embed,place", "title": "Add image", "desc": "Adds a image onto a set location on the PDF" }, "watermark": { + "tags": "stamp,mark,overlay", "title": "Add Watermark", "desc": "Add a custom watermark to your PDF document." }, "permissions": { + "tags": "permissions,security,access", "title": "Change Permissions", "desc": "Change the permissions of your PDF document" }, "pageRemover": { + "tags": "remove,delete,pages", "title": "Remove", "desc": "Delete unwanted pages from your PDF document." }, "addPassword": { + "tags": "password,encrypt,secure", "title": "Add Password", "desc": "Encrypt your PDF document with a password." }, "changePermissions": { + "tags": "permissions,restrictions,security", "title": "Change Permissions", "desc": "Change document restrictions and permissions." }, "removePassword": { + "tags": "unlock,remove,password", "title": "Remove Password", "desc": "Remove password protection from your PDF document." }, "compress": { + "tags": "shrink,reduce,optimize", "title": "Compress", "desc": "Compress PDFs to reduce their file size." }, "sanitize": { + "tags": "clean,purge,remove", "title": "Sanitize", "desc": "Remove potentially harmful elements from PDF files." }, "unlockPDFForms": { + "tags": "unlock,enable,edit", "title": "Unlock PDF Forms", "desc": "Remove read-only property of form fields in a PDF document." }, "changeMetadata": { + "tags": "edit,modify,update", "title": "Change Metadata", "desc": "Change/Remove/Add metadata from a PDF document" }, "fileToPDF": { + "tags": "convert,transform,change", "title": "Convert file to PDF", "desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)" }, "ocr": { + "tags": "extract,scan", "title": "OCR / Cleanup scans", "desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text." }, "extractImages": { + "tags": "pull,save,export", "title": "Extract Images", "desc": "Extracts all images from a PDF and saves them to zip" }, "pdfToPDFA": { + "tags": "convert,archive,long-term", "title": "PDF to PDF/A", "desc": "Convert PDF to PDF/A for long-term storage" }, "PDFToWord": { + "tags": "convert,word,doc", "title": "PDF to Word", "desc": "Convert PDF to Word formats (DOC, DOCX and ODT)" }, "PDFToPresentation": { + "tags": "convert,presentation,ppt", "title": "PDF to Presentation", "desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)" }, "PDFToText": { + "tags": "convert,text,rtf", "title": "PDF to RTF (Text)", "desc": "Convert PDF to Text or RTF format" }, "PDFToHTML": { + "tags": "convert,html,web", "title": "PDF to HTML", "desc": "Convert PDF to HTML format" }, "PDFToXML": { + "tags": "convert,xml,data", "title": "PDF to XML", "desc": "Convert PDF to XML format" }, "ScannerImageSplit": { + "tags": "detect,split,photos", "title": "Detect/Split Scanned photos", "desc": "Splits multiple photos from within a photo/PDF" }, "sign": { + "tags": "signature,autograph", "title": "Sign", "desc": "Adds signature to PDF by drawing, text or image" }, "flatten": { + "tags": "simplify,remove,interactive", "title": "Flatten", "desc": "Remove all interactive elements and forms from a PDF" }, "repair": { + "tags": "fix,restore", "title": "Repair", "desc": "Tries to repair a corrupt/broken PDF" }, "removeBlanks": { + "tags": "delete,clean,empty", "title": "Remove Blank pages", "desc": "Detects and removes blank pages from a document" }, "removeAnnotations": { + "tags": "delete,clean,strip", "title": "Remove Annotations", "desc": "Removes all comments/annotations from a PDF" }, "compare": { + "tags": "difference", "title": "Compare", "desc": "Compares and shows the differences between 2 PDF Documents" }, "certSign": { + "tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto", "title": "Sign with Certificate", "desc": "Signs a PDF with a Certificate/Key (PEM/P12)" }, "removeCertSign": { + "tags": "remove,delete,unlock", "title": "Remove Certificate Sign", "desc": "Remove certificate signature from PDF" }, "pageLayout": { + "tags": "layout,arrange,combine", "title": "Multi-Page Layout", "desc": "Merge multiple pages of a PDF document into a single page" }, "bookletImposition": { + "tags": "booklet,print,binding", "title": "Booklet Imposition", "desc": "Create booklets with proper page ordering and multi-page layout for printing and binding" }, "scalePages": { + "tags": "resize,adjust,scale", "title": "Adjust page size/scale", "desc": "Change the size/scale of a page and/or its contents." }, "pipeline": { + "tags": "automation,script,workflow", "title": "Pipeline", "desc": "Run multiple actions on PDFs by defining pipeline scripts" }, "addPageNumbers": { + "tags": "number,pagination,count", "title": "Add Page Numbers", "desc": "Add Page numbers throughout a document in a set location" }, "auto-rename": { + "tags": "auto-detect,header-based,organize,relabel", "title": "Auto Rename PDF File", "desc": "Auto renames a PDF file based on its detected header" }, "adjustContrast": { + "tags": "contrast,brightness,saturation", "title": "Adjust Colors/Contrast", "desc": "Adjust Contrast, Saturation and Brightness of a PDF" }, "crop": { + "tags": "trim,cut,resize", "title": "Crop PDF", "desc": "Crop a PDF to reduce its size (maintains text!)" }, "autoSplitPDF": { + "tags": "auto,split,QR", "title": "Auto Split Pages", "desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code" }, "sanitizePDF": { + "tags": "clean,purge,remove", "title": "Sanitize", "desc": "Remove scripts and other elements from PDF files" }, "URLToPDF": { + "tags": "convert,url,website", "title": "URL/Website To PDF", "desc": "Converts any http(s)URL to PDF" }, "HTMLToPDF": { + "tags": "convert,html,web", "title": "HTML to PDF", "desc": "Converts any HTML file or zip to PDF" }, "MarkdownToPDF": { + "tags": "convert,markdown,md", "title": "Markdown to PDF", "desc": "Converts any Markdown file to PDF" }, "PDFToMarkdown": { + "tags": "convert,markdown,md", "title": "PDF to Markdown", "desc": "Converts any PDF to Markdown" }, "getPdfInfo": { + "tags": "info,metadata,details", "title": "Get ALL Info on PDF", "desc": "Grabs any and all information possible on PDFs" }, @@ -564,50 +615,62 @@ "desc": "Searches and displays any JS injected into a PDF" }, "autoRedact": { + "tags": "auto,redact,censor", "title": "Auto Redact", "desc": "Auto Redacts(Blacks out) text in a PDF based on input text" }, "redact": { + "tags": "censor,blackout,hide", "title": "Manual Redaction", "desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)" }, "PDFToCSV": { + "tags": "convert,csv,table", "title": "PDF to CSV", "desc": "Extracts Tables from a PDF converting it to CSV" }, "split-by-size-or-count": { + "tags": "auto,split,size", "title": "Auto Split by Size/Count", "desc": "Split a single PDF into multiple documents based on size, page count, or document count" }, "overlay-pdfs": { + "tags": "overlay,combine,stack", "title": "Overlay PDFs", "desc": "Overlays PDFs on-top of another PDF" }, "split-by-sections": { + "tags": "split,sections,divide", "title": "Split PDF by Sections", "desc": "Divide each page of a PDF into smaller horizontal and vertical sections" }, "AddStampRequest": { + "tags": "stamp,mark,seal", "title": "Add Stamp to PDF", "desc": "Add text or add image stamps at set locations" }, "removeImage": { + "tags": "remove,delete,clean", "title": "Remove image", "desc": "Remove image from PDF to reduce file size" }, "splitByChapters": { + "tags": "split,chapters,structure", "title": "Split PDF by Chapters", "desc": "Split a PDF into multiple files based on its chapter structure." }, "validateSignature": { + "tags": "validate,verify,certificate", "title": "Validate PDF Signature", "desc": "Verify digital signatures and certificates in PDF documents" }, "swagger": { + "tags": "API,documentation,test", "title": "API Documentation", "desc": "View API documentation and test endpoints" }, "replace-color": { + "tags": "color,replace,invert", "title": "Replace and Invert Color", "desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size" } @@ -1064,7 +1127,6 @@ "info": "Python is not installed. It is required to run." }, "sign": { - "tags": "authorize,initials,drawn-signature,text-sign,image-signature", "title": "Sign", "header": "Sign PDFs", "upload": "Upload Image", diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx index dc9fd6af0..5bd6036cc 100644 --- a/frontend/src/components/tools/SearchResults.tsx +++ b/frontend/src/components/tools/SearchResults.tsx @@ -9,13 +9,22 @@ import NoToolsFound from './shared/NoToolsFound'; import "./toolPicker/ToolPicker.css"; interface SearchResultsProps { - filteredTools: [string, ToolRegistryEntry][]; + filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; onSelect: (id: string) => void; + searchQuery?: string; } -const SearchResults: React.FC = ({ filteredTools, onSelect }) => { +const SearchResults: React.FC = ({ filteredTools, onSelect, searchQuery }) => { const { t } = useTranslation(); - const { searchGroups } = useToolSections(filteredTools); + const { searchGroups } = useToolSections(filteredTools, searchQuery); + + // Create a map of matched text for quick lookup + const matchedTextMap = new Map(); + if (filteredTools && Array.isArray(filteredTools)) { + filteredTools.forEach(({ item: [id], matchedText }) => { + if (matchedText) matchedTextMap.set(id, matchedText); + }); + } if (searchGroups.length === 0) { return ; @@ -28,15 +37,27 @@ const SearchResults: React.FC = ({ filteredTools, onSelect } - {group.tools.map(({ id, tool }) => ( - - ))} + {group.tools.map(({ id, tool }) => { + const matchedText = matchedTextMap.get(id); + // Check if the match was from synonyms and show the actual synonym that matched + const isSynonymMatch = matchedText && tool.synonyms?.some(synonym => + matchedText.toLowerCase().includes(synonym.toLowerCase()) + ); + const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym => + matchedText.toLowerCase().includes(synonym.toLowerCase()) + ) : undefined; + + return ( + + ); + })} ))} diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index 98d1d96f3..d3eea3bd9 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -72,6 +72,7 @@ export default function ToolPanel() { ) : leftPanelView === 'toolPicker' ? ( diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 8d34394d7..4b0c54536 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -10,7 +10,7 @@ import { renderToolButtons } from "./shared/renderToolButtons"; interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; - filteredTools: [string, ToolRegistryEntry][]; + filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; isSearching?: boolean; } diff --git a/frontend/src/components/tools/automate/ToolSelector.tsx b/frontend/src/components/tools/automate/ToolSelector.tsx index 7d5faafc5..02ebef0c9 100644 --- a/frontend/src/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/components/tools/automate/ToolSelector.tsx @@ -58,8 +58,13 @@ export default function ToolSelector({ return registry; }, [baseFilteredTools]); + // Transform filteredTools to the expected format for useToolSections + const transformedFilteredTools = useMemo(() => { + return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); + }, [filteredTools]); + // Use the same tool sections logic as the main ToolPicker - const { sections, searchGroups } = useToolSections(filteredTools); + const { sections, searchGroups } = useToolSections(transformedFilteredTools); // Determine what to display: search results or organized sections const isSearching = searchTerm.trim().length > 0; diff --git a/frontend/src/components/tools/shared/renderToolButtons.tsx b/frontend/src/components/tools/shared/renderToolButtons.tsx index 340ad559d..a4aadf20b 100644 --- a/frontend/src/components/tools/shared/renderToolButtons.tsx +++ b/frontend/src/components/tools/shared/renderToolButtons.tsx @@ -13,23 +13,39 @@ export const renderToolButtons = ( selectedToolKey: string | null, onSelect: (id: string) => void, showSubcategoryHeader: boolean = true, - disableNavigation: boolean = false -) => ( - - {showSubcategoryHeader && ( - - )} -
- {subcategory.tools.map(({ id, tool }) => ( - - ))} -
-
-); + disableNavigation: boolean = false, + searchResults?: Array<{ item: [string, any]; matchedText?: string }> +) => { + // Create a map of matched text for quick lookup + const matchedTextMap = new Map(); + if (searchResults) { + searchResults.forEach(({ item: [id], matchedText }) => { + if (matchedText) matchedTextMap.set(id, matchedText); + }); + } + + return ( + + {showSubcategoryHeader && ( + + )} +
+ {subcategory.tools.map(({ id, tool }) => { + const matchedSynonym = matchedTextMap.get(id); + + return ( + + ); + })} +
+
+ ); +}; diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index f84fa9189..9f3d60d50 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -13,9 +13,10 @@ interface ToolButtonProps { onSelect: (id: string) => void; rounded?: boolean; disableNavigation?: boolean; + matchedSynonym?: string; } -const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => { +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => { const isUnavailable = !tool.component && !tool.link; const { getToolNavigation } = useToolNavigation(); @@ -40,13 +41,27 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, const buttonContent = ( <>
{tool.icon}
- +
+ + {matchedSynonym && ( + + {matchedSynonym} + + )} +
); @@ -66,7 +81,10 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }} + styles={{ + root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, + label: { overflow: 'visible' } + }} > {buttonContent} @@ -84,7 +102,10 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }} + styles={{ + root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, + label: { overflow: 'visible' } + }} > {buttonContent} @@ -99,7 +120,7 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, justify="flex-start" className="tool-button" aria-disabled={isUnavailable} - styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }} + styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }} > {buttonContent} diff --git a/frontend/src/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/components/tools/toolPicker/ToolSearch.tsx index d4350044e..a3ef4216a 100644 --- a/frontend/src/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/src/components/tools/toolPicker/ToolSearch.tsx @@ -5,6 +5,7 @@ import LocalIcon from '../../shared/LocalIcon'; import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; import { TextInput } from "../../shared/TextInput"; import "./ToolPicker.css"; +import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch"; interface ToolSearchProps { value: string; @@ -38,15 +39,14 @@ const ToolSearch = ({ const filteredTools = useMemo(() => { if (!value.trim()) return []; - return Object.entries(toolRegistry) - .filter(([id, tool]) => { - if (mode === "dropdown" && id === selectedToolKey) return false; - return ( - tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase()) - ); - }) - .slice(0, 6) - .map(([id, tool]) => ({ id, tool })); + const entries = Object.entries(toolRegistry).filter(([id]) => !(mode === "dropdown" && id === selectedToolKey)); + const ranked = rankByFuzzy(entries, value, [ + ([key]) => idToWords(key), + ([, v]) => v.name, + ([, v]) => v.description, + ([, v]) => v.synonyms?.join(' ') || '', + ]).slice(0, 6); + return ranked.map(({ item: [id, tool] }) => ({ id, tool })); }, [value, toolRegistry, mode, selectedToolKey]); const handleSearchChange = (searchValue: string) => { diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index fc70c570c..106636692 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -11,6 +11,7 @@ import { useNavigationActions, useNavigationState } from './NavigationContext'; import { ToolId, isValidToolId } from '../types/toolId'; import { useNavigationUrlSync } from '../hooks/useUrlSync'; import { getDefaultWorkbench } from '../types/workbench'; +import { filterToolRegistryByQuery } from '../utils/toolSearch'; // State interface interface ToolWorkflowState { @@ -100,7 +101,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { handleReaderToggle: () => void; // Computed values - filteredTools: [string, ToolRegistryEntry][]; // Filtered by search + filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search isPanelVisible: boolean; } @@ -219,12 +220,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setReaderMode(true); }, [setReaderMode]); - // Filter tools based on search query + // Filter tools based on search query with fuzzy matching (name, description, id, synonyms) const filteredTools = useMemo(() => { if (!toolRegistry) return []; - return Object.entries(toolRegistry).filter(([_, { name }]) => - name.toLowerCase().includes(state.searchQuery.toLowerCase()) - ); + return filterToolRegistryByQuery(toolRegistry as Record, state.searchQuery); }, [toolRegistry, state.searchQuery]); const isPanelVisible = useMemo(() => diff --git a/frontend/src/data/toolsTaxonomy.ts b/frontend/src/data/toolsTaxonomy.ts index 6d2590481..7b62a0cb5 100644 --- a/frontend/src/data/toolsTaxonomy.ts +++ b/frontend/src/data/toolsTaxonomy.ts @@ -45,6 +45,8 @@ export type ToolRegistryEntry = { operationConfig?: ToolOperationConfig; // Settings component for automation configuration settingsComponent?: React.ComponentType; + // Synonyms for search (optional) + synonyms?: string[]; } export type ToolRegistry = Record; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 6ea1d83b0..5400b5e98 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -12,6 +12,7 @@ import RemoveBlanks from "../tools/RemoveBlanks"; import RemovePages from "../tools/RemovePages"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; +import { getSynonyms } from "../utils/toolSynonyms"; import AddWatermark from "../tools/AddWatermark"; import AddStamp from "../tools/AddStamp"; import Merge from '../tools/Merge'; @@ -172,6 +173,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.certSign.desc", "Sign PDF documents using digital certificates"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.SIGNING, + synonyms: getSynonyms(t, "certSign"), maxFiles: -1, endpoints: ["cert-sign"], operationConfig: certSignOperationConfig, @@ -184,6 +186,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.SIGNING, + synonyms: getSynonyms(t, "sign") }, // Document Security @@ -199,7 +202,8 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["add-password"], operationConfig: addPasswordOperationConfig, settingsComponent: AddPasswordSettings, - }, + synonyms: getSynonyms(t, "addPassword") + }, watermark: { icon: , name: t("home.watermark.title", "Add Watermark"), @@ -211,6 +215,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["add-watermark"], operationConfig: addWatermarkOperationConfig, settingsComponent: AddWatermarkSingleStepSettings, + synonyms: getSynonyms(t, "watermark") }, addStamp: { icon: , @@ -219,6 +224,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.addStamp.desc", "Add text or add image stamps at set locations"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + synonyms: getSynonyms(t, "addStamp"), maxFiles: -1, endpoints: ["add-stamp"], operationConfig: addStampOperationConfig, @@ -234,6 +240,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["sanitize-pdf"], operationConfig: sanitizeOperationConfig, settingsComponent: SanitizeSettings, + synonyms: getSynonyms(t, "sanitize") }, flatten: { icon: , @@ -246,6 +253,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["flatten"], operationConfig: flattenOperationConfig, settingsComponent: FlattenSettings, + synonyms: getSynonyms(t, "flatten") }, unlockPDFForms: { icon: , @@ -258,6 +266,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["unlock-pdf-forms"], operationConfig: unlockPdfFormsOperationConfig, settingsComponent: UnlockPdfFormsSettings, + synonyms: getSynonyms(t, "unlockPDFForms"), }, manageCertificates: { icon: , @@ -269,6 +278,7 @@ export function useFlatToolRegistry(): ToolRegistry { ), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + synonyms: getSynonyms(t, "manageCertificates"), }, changePermissions: { icon: , @@ -281,6 +291,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["add-password"], operationConfig: changePermissionsOperationConfig, settingsComponent: ChangePermissionsSettings, + synonyms: getSynonyms(t, "changePermissions"), }, getPdfInfo: { icon: , @@ -289,6 +300,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.VERIFICATION, + synonyms: getSynonyms(t, "getPdfInfo"), }, validateSignature: { icon: , @@ -297,11 +309,12 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.VERIFICATION, + synonyms: getSynonyms(t, "validateSignature"), }, // Document Review - read: { + read: { icon: , name: t("home.read.title", "Read"), component: null, @@ -312,6 +325,7 @@ export function useFlatToolRegistry(): ToolRegistry { ), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_REVIEW, + synonyms: getSynonyms(t, "read") }, changeMetadata: { icon: , @@ -324,6 +338,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["update-metadata"], operationConfig: changeMetadataOperationConfig, settingsComponent: ChangeMetadataSingleStep, + synonyms: getSynonyms(t, "changeMetadata") }, // Page Formatting @@ -350,6 +365,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["rotate-pdf"], operationConfig: rotateOperationConfig, settingsComponent: RotateSettings, + synonyms: getSynonyms(t, "rotate") }, split: { icon: , @@ -360,6 +376,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING, operationConfig: splitOperationConfig, settingsComponent: SplitSettings, + synonyms: getSynonyms(t, "split") }, reorganizePages: { icon: , @@ -372,6 +389,7 @@ export function useFlatToolRegistry(): ToolRegistry { ), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + synonyms: getSynonyms(t, "reorganizePages") }, scalePages: { icon: , @@ -384,6 +402,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["scale-pages"], operationConfig: adjustPageScaleOperationConfig, settingsComponent: AdjustPageScaleSettings, + synonyms: getSynonyms(t, "scalePages") }, addPageNumbers: { icon: , @@ -393,6 +412,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + synonyms: getSynonyms(t, "addPageNumbers") }, pageLayout: { icon: , @@ -402,6 +422,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + synonyms: getSynonyms(t, "pageLayout") }, bookletImposition: { icon: , @@ -426,6 +447,7 @@ export function useFlatToolRegistry(): ToolRegistry { urlPath: '/pdf-to-single-page', endpoints: ["pdf-to-single-page"], operationConfig: singleLargePageOperationConfig, + synonyms: getSynonyms(t, "pdfToSinglePage") }, addAttachments: { icon: , @@ -435,6 +457,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.addAttachments.desc", "Add or remove embedded files (attachments) to/from a PDF"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + synonyms: getSynonyms(t, "addAttachments") }, // Extraction @@ -446,6 +469,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.extractPages.desc", "Extract specific pages from a PDF document"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.EXTRACTION, + synonyms: getSynonyms(t, "extractPages") }, extractImages: { icon: , @@ -454,6 +478,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.extractImages.desc", "Extract images from PDF documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.EXTRACTION, + synonyms: getSynonyms(t, "extractImages") }, // Removal @@ -467,6 +492,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL, maxFiles: 1, endpoints: ["remove-pages"], + synonyms: getSynonyms(t, "removePages") }, removeBlanks: { icon: , @@ -477,6 +503,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL, maxFiles: 1, endpoints: ["remove-blanks"], + synonyms: getSynonyms(t, "removeBlanks") }, removeAnnotations: { icon: , @@ -485,6 +512,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, + synonyms: getSynonyms(t, "removeAnnotations") }, removeImage: { icon: , @@ -493,6 +521,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.removeImage.desc", "Remove images from PDF documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, + synonyms: getSynonyms(t, "removeImage"), }, removePassword: { icon: , @@ -505,6 +534,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, operationConfig: removePasswordOperationConfig, settingsComponent: RemovePasswordSettings, + synonyms: getSynonyms(t, "removePassword") }, removeCertSign: { icon: , @@ -516,6 +546,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, endpoints: ["remove-certificate-sign"], operationConfig: removeCertificateSignOperationConfig, + synonyms: getSynonyms(t, "removeCertSign"), }, // Automation @@ -533,6 +564,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, supportedFormats: CONVERT_SUPPORTED_FORMATS, endpoints: ["handleData"], + synonyms: getSynonyms(t, "automate"), }, autoRename: { icon: , @@ -544,6 +576,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.AUTOMATION, + synonyms: getSynonyms(t, "autoRename"), }, autoSplitPDF: { icon: , @@ -552,6 +585,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.AUTOMATION, + synonyms: getSynonyms(t, "autoSplitPDF"), }, autoSizeSplitPDF: { icon: , @@ -560,6 +594,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.AUTOMATION, + synonyms: getSynonyms(t, "autoSizeSplitPDF"), }, // Advanced Formatting @@ -571,6 +606,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "adjustContrast"), }, repair: { icon: , @@ -583,6 +619,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["repair"], operationConfig: repairOperationConfig, settingsComponent: RepairSettings, + synonyms: getSynonyms(t, "repair") }, scannerImageSplit: { icon: , @@ -591,6 +628,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "ScannerImageSplit"), }, overlayPdfs: { icon: , @@ -599,6 +637,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.overlayPdfs.desc", "Overlay one PDF on top of another"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "overlayPdfs"), }, replaceColorPdf: { icon: , @@ -607,6 +646,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "replaceColorPdf"), }, addImage: { icon: , @@ -615,6 +655,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.addImage.desc", "Add images to PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "addImage"), }, editTableOfContents: { icon: , @@ -623,6 +664,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "editTableOfContents"), }, fakeScan: { icon: , @@ -631,6 +673,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + synonyms: getSynonyms(t, "fakeScan"), }, // Developer Tools @@ -642,6 +685,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, + synonyms: getSynonyms(t, "showJS"), }, devApi: { icon: , @@ -651,6 +695,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html", + synonyms: getSynonyms(t, "devApi"), }, devFolderScanning: { icon: , @@ -660,6 +705,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/", + synonyms: getSynonyms(t, "devFolderScanning"), }, devSsoGuide: { icon: , @@ -669,6 +715,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration", + synonyms: getSynonyms(t, "devSsoGuide"), }, devAirgapped: { icon: , @@ -678,6 +725,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, link: "https://docs.stirlingpdf.com/Pro/#activation", + synonyms: getSynonyms(t, "devAirgapped"), }, // Recommended Tools @@ -688,6 +736,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.compare.desc", "Compare two PDF documents and highlight differences"), categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, + synonyms: getSynonyms(t, "compare") }, compress: { icon: , @@ -699,6 +748,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, operationConfig: compressOperationConfig, settingsComponent: CompressSettings, + synonyms: getSynonyms(t, "compress") }, convert: { icon: , @@ -728,6 +778,7 @@ export function useFlatToolRegistry(): ToolRegistry { operationConfig: convertOperationConfig, settingsComponent: ConvertSettings, + synonyms: getSynonyms(t, "convert") }, merge: { icon: , @@ -739,7 +790,8 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, endpoints: ["merge-pdfs"], operationConfig: mergeOperationConfig, - settingsComponent: MergeSettings + settingsComponent: MergeSettings, + synonyms: getSynonyms(t, "merge") }, multiTool: { icon: , @@ -750,6 +802,7 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, maxFiles: -1, + synonyms: getSynonyms(t, "multiTool"), }, ocr: { icon: , @@ -762,6 +815,7 @@ export function useFlatToolRegistry(): ToolRegistry { urlPath: '/ocr-pdf', operationConfig: ocrOperationConfig, settingsComponent: OCRSettings, + synonyms: getSynonyms(t, "ocr") }, redact: { icon: , @@ -774,6 +828,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["auto-redact"], operationConfig: redactOperationConfig, settingsComponent: RedactSingleStepSettings, + synonyms: getSynonyms(t, "redact") }, }; diff --git a/frontend/src/hooks/useToolSections.ts b/frontend/src/hooks/useToolSections.ts index d0f6ebdca..088a1ba52 100644 --- a/frontend/src/hooks/useToolSections.ts +++ b/frontend/src/hooks/useToolSections.ts @@ -27,12 +27,19 @@ export interface ToolSection { subcategories: SubcategoryGroup[]; }; -export function useToolSections(filteredTools: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry][]) { +export function useToolSections( + filteredTools: Array<{ item: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry]; matchedText?: string }>, + searchQuery?: string +) { const { t } = useTranslation(); const groupedTools = useMemo(() => { + if (!filteredTools || !Array.isArray(filteredTools)) { + return {} as GroupedTools; + } + const grouped = {} as GroupedTools; - filteredTools.forEach(([id, tool]) => { + filteredTools.forEach(({ item: [id, tool] }) => { const categoryId = tool.categoryId; const subcategoryId = tool.subcategoryId; if (!grouped[categoryId]) grouped[categoryId] = {} as SubcategoryIdMap; @@ -92,9 +99,13 @@ export function useToolSections(filteredTools: [string /* FIX ME: Should be Tool }, [groupedTools]); const searchGroups: SubcategoryGroup[] = useMemo(() => { + if (!filteredTools || !Array.isArray(filteredTools)) { + return []; + } + const subMap = {} as SubcategoryIdMap; const seen = new Set(); - filteredTools.forEach(([id, tool]) => { + filteredTools.forEach(({ item: [id, tool] }) => { const toolId = id as string /* FIX ME: Should be ToolId */; if (seen.has(toolId)) return; seen.add(toolId); @@ -102,10 +113,31 @@ export function useToolSections(filteredTools: [string /* FIX ME: Should be Tool if (!subMap[sub]) subMap[sub] = []; subMap[sub].push({ id: toolId, tool }); }); - return Object.entries(subMap) + const entries = Object.entries(subMap); + + // If a search query is present, always order subcategories by first occurrence in + // the ranked filteredTools list so the top-ranked tools' subcategory appears first. + if (searchQuery && searchQuery.trim()) { + const order: SubcategoryId[] = []; + filteredTools.forEach(({ item: [_, tool] }) => { + const sc = tool.subcategoryId; + if (!order.includes(sc)) order.push(sc); + }); + return entries + .sort(([a], [b]) => { + const ai = order.indexOf(a as SubcategoryId); + const bi = order.indexOf(b as SubcategoryId); + if (ai !== bi) return ai - bi; + return (a as SubcategoryId).localeCompare(b as SubcategoryId); + }) + .map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup)); + } + + // No search: alphabetical subcategory ordering + return entries .sort(([a], [b]) => a.localeCompare(b)) .map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup)); - }, [filteredTools]); + }, [filteredTools, searchQuery]); return { sections, searchGroups }; } diff --git a/frontend/src/utils/fuzzySearch.ts b/frontend/src/utils/fuzzySearch.ts new file mode 100644 index 000000000..e8e8bdf01 --- /dev/null +++ b/frontend/src/utils/fuzzySearch.ts @@ -0,0 +1,121 @@ +// Lightweight fuzzy search helpers without external deps +// Provides diacritics-insensitive normalization and Levenshtein distance scoring + +function normalizeText(text: string): string { + return text + .toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}+/gu, '') + .trim(); +} + +// Basic Levenshtein distance (iterative with two rows) +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + const aLen = a.length; + const bLen = b.length; + if (aLen === 0) return bLen; + if (bLen === 0) return aLen; + + const prev = new Array(bLen + 1); + const curr = new Array(bLen + 1); + + for (let j = 0; j <= bLen; j++) prev[j] = j; + + for (let i = 1; i <= aLen; i++) { + curr[0] = i; + const aChar = a.charCodeAt(i - 1); + for (let j = 1; j <= bLen; j++) { + const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min( + prev[j] + 1, // deletion + curr[j - 1] + 1, // insertion + prev[j - 1] + cost // substitution + ); + } + for (let j = 0; j <= bLen; j++) prev[j] = curr[j]; + } + return curr[bLen]; +} + +// Compute a heuristic match score (higher is better) +// 1) Exact/substring hits get high base; 2) otherwise use normalized Levenshtein distance +export function scoreMatch(queryRaw: string, targetRaw: string): number { + const query = normalizeText(queryRaw); + const target = normalizeText(targetRaw); + if (!query) return 0; + if (target.includes(query)) { + // Reward earlier/shorter substring matches + const pos = target.indexOf(query); + return 100 - pos - Math.max(0, target.length - query.length); + } + + // Token-aware: check each word token too, but require better similarity + const tokens = target.split(/[^a-z0-9]+/g).filter(Boolean); + for (const token of tokens) { + if (token.includes(query)) { + // Only give high score if the match is substantial (not just "and" matching) + const similarity = query.length / Math.max(query.length, token.length); + if (similarity >= 0.6) { // Require at least 60% similarity + return 80 - Math.abs(token.length - query.length); + } + } + } + + const distance = levenshtein(query, target.length > 64 ? target.slice(0, 64) : target); + const maxLen = Math.max(query.length, target.length, 1); + const similarity = 1 - distance / maxLen; // 0..1 + return Math.floor(similarity * 60); // scale below substring scores +} + +export function minScoreForQuery(query: string): number { + const len = normalizeText(query).length; + if (len <= 3) return 40; + if (len <= 6) return 30; + return 25; +} + +// Decide if a target matches a query based on a threshold +export function isFuzzyMatch(query: string, target: string, minScore?: number): boolean { + const threshold = typeof minScore === 'number' ? minScore : minScoreForQuery(query); + return scoreMatch(query, target) >= threshold; +} + +// Convenience: rank a list of items by best score across provided getters +export function rankByFuzzy(items: T[], query: string, getters: Array<(item: T) => string>, minScore?: number): Array<{ item: T; score: number; matchedText?: string }>{ + const results: Array<{ item: T; score: number; matchedText?: string }> = []; + const threshold = typeof minScore === 'number' ? minScore : minScoreForQuery(query); + for (const item of items) { + let best = 0; + let matchedText = ''; + for (const get of getters) { + const value = get(item); + if (!value) continue; + const s = scoreMatch(query, value); + if (s > best) { + best = s; + matchedText = value; + } + if (best >= 95) { + break; + } + } + if (best >= threshold) results.push({ item, score: best, matchedText }); + } + results.sort((a, b) => b.score - a.score); + return results; +} + +export function normalizeForSearch(text: string): string { + return normalizeText(text); +} + +// Convert ids like "addPassword", "add-password", "add_password" to words for matching +export function idToWords(id: string): string { + const spaced = id + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[._-]+/g, ' '); + return normalizeText(spaced); +} + + diff --git a/frontend/src/utils/toolSearch.ts b/frontend/src/utils/toolSearch.ts new file mode 100644 index 000000000..dda5749a8 --- /dev/null +++ b/frontend/src/utils/toolSearch.ts @@ -0,0 +1,99 @@ +import { ToolRegistryEntry } from "../data/toolsTaxonomy"; +import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch"; + +export interface RankedToolItem { + item: [string, ToolRegistryEntry]; + matchedText?: string; +} + +export function filterToolRegistryByQuery( + toolRegistry: Record, + query: string +): RankedToolItem[] { + const entries = Object.entries(toolRegistry); + if (!query.trim()) { + return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); + } + + const nq = normalizeForSearch(query); + const threshold = minScoreForQuery(query); + + const exactName: Array<{ id: string; tool: ToolRegistryEntry; pos: number }> = []; + const exactSyn: Array<{ id: string; tool: ToolRegistryEntry; text: string; pos: number }> = []; + const fuzzyName: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = []; + const fuzzySyn: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = []; + + for (const [id, tool] of entries) { + const nameNorm = normalizeForSearch(tool.name || ''); + const pos = nameNorm.indexOf(nq); + if (pos !== -1) { + exactName.push({ id, tool, pos }); + continue; + } + + const syns = Array.isArray(tool.synonyms) ? tool.synonyms : []; + let matchedExactSyn: { text: string; pos: number } | null = null; + for (const s of syns) { + const sn = normalizeForSearch(s); + const sp = sn.indexOf(nq); + if (sp !== -1) { + matchedExactSyn = { text: s, pos: sp }; + break; + } + } + if (matchedExactSyn) { + exactSyn.push({ id, tool, text: matchedExactSyn.text, pos: matchedExactSyn.pos }); + continue; + } + + // Fuzzy name + const nameScore = scoreMatch(query, tool.name || ''); + if (nameScore >= threshold) { + fuzzyName.push({ id, tool, score: nameScore, text: tool.name || '' }); + } + + // Fuzzy synonyms (we'll consider these only if fuzzy name results are weak) + let bestSynScore = 0; + let bestSynText = ''; + for (const s of syns) { + const synScore = scoreMatch(query, s); + if (synScore > bestSynScore) { + bestSynScore = synScore; + bestSynText = s; + } + if (bestSynScore >= 95) break; + } + if (bestSynScore >= threshold) { + fuzzySyn.push({ id, tool, score: bestSynScore, text: bestSynText }); + } + } + + // Sort within buckets + exactName.sort((a, b) => a.pos - b.pos || (a.tool.name || '').length - (b.tool.name || '').length); + exactSyn.sort((a, b) => a.pos - b.pos || a.text.length - b.text.length); + fuzzyName.sort((a, b) => b.score - a.score); + fuzzySyn.sort((a, b) => b.score - a.score); + + // Concatenate buckets with de-duplication by tool id + const seen = new Set(); + const ordered: RankedToolItem[] = []; + + const push = (id: string, tool: ToolRegistryEntry, matchedText?: string) => { + if (seen.has(id)) return; + seen.add(id); + ordered.push({ item: [id, tool], matchedText }); + }; + + for (const { id, tool } of exactName) push(id, tool, tool.name); + for (const { id, tool, text } of exactSyn) push(id, tool, text); + for (const { id, tool, text } of fuzzyName) push(id, tool, text); + for (const { id, tool, text } of fuzzySyn) push(id, tool, text); + + if (ordered.length > 0) return ordered; + + // Fallback: return everything unchanged + return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); +} + + + diff --git a/frontend/src/utils/toolSynonyms.ts b/frontend/src/utils/toolSynonyms.ts new file mode 100644 index 000000000..9a6cd8ced --- /dev/null +++ b/frontend/src/utils/toolSynonyms.ts @@ -0,0 +1,24 @@ +import { TFunction } from 'i18next'; + +// Helper function to get synonyms for a tool (only from translations) +export const getSynonyms = (t: TFunction, toolId: string): string[] => { + try { + const tagsKey = `${toolId}.tags`; + const tags = t(tagsKey) as unknown as string; + + // If the translation key doesn't exist or returns the key itself, return empty array + if (!tags || tags === tagsKey) { + return []; + } + + // Split by comma and clean up the tags + return tags + .split(',') + .map((tag: string) => tag.trim()) + .filter((tag: string) => tag.length > 0); + } catch (error) { + console.warn(`Failed to get translated synonyms for tool ${toolId}:`, error); + return []; + }}; + + From fd52dc022649b2bce1e6fe00cb93b03c45d646ac Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 25 Sep 2025 21:03:53 +0100 Subject: [PATCH 2/2] Feature/toasts and error handling (#4496) # Description of Changes - Added error handling and toast notifications --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../SPDF/controller/api/MergeController.java | 64 +++- .../model/api/general/MergePdfsRequest.java | 6 + .../public/locales/en-GB/translation.json | 3 + .../public/locales/en-US/translation.json | 2 + .../fileEditor/FileEditor.module.css | 31 ++ .../src/components/fileEditor/FileEditor.tsx | 65 ++-- .../fileEditor/FileEditorThumbnail.tsx | 50 ++- frontend/src/components/layout/Workbench.tsx | 4 + .../shared/DismissAllErrorsButton.tsx | 51 +++ .../shared/RainbowThemeProvider.tsx | 9 +- frontend/src/components/shared/RightRail.tsx | 7 +- frontend/src/components/toast/Toast.README.md | 309 ++++++++++++++++++ .../src/components/toast/ToastContext.tsx | 150 +++++++++ .../src/components/toast/ToastRenderer.css | 209 ++++++++++++ .../src/components/toast/ToastRenderer.tsx | 138 ++++++++ frontend/src/components/toast/index.ts | 61 ++++ frontend/src/components/toast/types.ts | 50 +++ frontend/src/contexts/file/FileReducer.ts | 27 +- frontend/src/contexts/file/fileActions.ts | 5 +- .../tools/convert/useConvertOperation.ts | 2 + .../hooks/tools/merge/useMergeOperation.ts | 3 + .../src/hooks/tools/shared/useToolApiCalls.ts | 38 ++- .../hooks/tools/shared/useToolOperation.ts | 166 ++++++++-- frontend/src/services/errorUtils.ts | 47 +++ frontend/src/services/http.ts | 255 +++++++++++++++ frontend/src/services/specialErrorToasts.ts | 57 ++++ frontend/src/styles/theme.css | 53 ++- .../tests/convert/ConvertIntegration.test.tsx | 4 +- frontend/src/theme/mantineTheme.ts | 28 ++ frontend/src/types/fileContext.ts | 7 + frontend/src/utils/toolErrorHandler.ts | 2 +- frontend/tailwind.config.js | 36 ++ 32 files changed, 1845 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/shared/DismissAllErrorsButton.tsx create mode 100644 frontend/src/components/toast/Toast.README.md create mode 100644 frontend/src/components/toast/ToastContext.tsx create mode 100644 frontend/src/components/toast/ToastRenderer.css create mode 100644 frontend/src/components/toast/ToastRenderer.tsx create mode 100644 frontend/src/components/toast/index.ts create mode 100644 frontend/src/components/toast/types.ts create mode 100644 frontend/src/services/errorUtils.ts create mode 100644 frontend/src/services/http.ts create mode 100644 frontend/src/services/specialErrorToasts.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 6f835d5ba..806dfdc38 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; @@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.apache.pdfbox.pdmodel.interactive.form.PDField; import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.multipart.MultipartFile; @@ -111,6 +114,32 @@ public class MergeController { } } + // Parse client file IDs from JSON string + private String[] parseClientFileIds(String clientFileIds) { + if (clientFileIds == null || clientFileIds.trim().isEmpty()) { + return new String[0]; + } + try { + // Simple JSON array parsing - remove brackets and split by comma + String trimmed = clientFileIds.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + String inside = trimmed.substring(1, trimmed.length() - 1).trim(); + if (inside.isEmpty()) { + return new String[0]; + } + String[] parts = inside.split(","); + String[] result = new String[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = parts[i].trim().replaceAll("^\"|\"$", ""); + } + return result; + } + } catch (Exception e) { + log.warn("Failed to parse client file IDs: {}", clientFileIds, e); + } + return new String[0]; + } + // Adds a table of contents to the merged document using filenames as chapter titles private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) { // Create the document outline @@ -177,15 +206,48 @@ public class MergeController { PDFMergerUtility mergerUtility = new PDFMergerUtility(); long totalSize = 0; - for (MultipartFile multipartFile : files) { + List invalidIndexes = new ArrayList<>(); + for (int index = 0; index < files.length; index++) { + MultipartFile multipartFile = files[index]; totalSize += multipartFile.getSize(); File tempFile = GeneralUtils.convertMultipartFileToFile( multipartFile); // Convert MultipartFile to File filesToDelete.add(tempFile); // Add temp file to the list for later deletion + + // Pre-validate each PDF so we can report which one(s) are broken + // Use the original MultipartFile to avoid deleting the tempFile during validation + try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) { + // OK + } catch (IOException e) { + ExceptionUtils.logException("PDF pre-validate", e); + invalidIndexes.add(index); + } mergerUtility.addSource(tempFile); // Add source file to the merger utility } + if (!invalidIndexes.isEmpty()) { + // Parse client file IDs (always present from frontend) + String[] clientIds = parseClientFileIds(request.getClientFileIds()); + + // Map invalid indexes to client IDs + List errorFileIds = new ArrayList<>(); + for (Integer index : invalidIndexes) { + if (index < clientIds.length) { + errorFileIds.add(clientIds[index]); + } + } + + String payload = String.format( + "{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}", + errorFileIds.toString() + ); + + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .body(payload.getBytes(StandardCharsets.UTF_8)); + } + mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile(); mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath()); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java index 75f75223e..2851f018f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java @@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles { requiredMode = Schema.RequiredMode.NOT_REQUIRED, defaultValue = "false") private boolean generateToc = false; + + @Schema( + description = + "JSON array of client-provided IDs for each uploaded file (same order as fileInput)", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String clientFileIds; } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 6b2b527cf..c30bc7c55 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -74,7 +74,10 @@ }, "error": { "pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect", + "encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.", + "incorrectPasswordProvided": "The PDF password is incorrect or not provided.", "_value": "Error", + "dismissAllErrors": "Dismiss All Errors", "sorry": "Sorry for the issue!", "needHelp": "Need help / Found an issue?", "contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 68cb57546..ae23ddd73 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -68,6 +68,8 @@ }, "error": { "pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect", + "encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.", + "incorrectPasswordProvided": "The PDF password is incorrect or not provided.", "_value": "Error", "sorry": "Sorry for the issue!", "needHelp": "Need help / Found an issue?", diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css index 344959b80..ccabc2fa7 100644 --- a/frontend/src/components/fileEditor/FileEditor.module.css +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -56,6 +56,20 @@ border-bottom: 1px solid var(--header-selected-bg); } +/* Error highlight (transient) */ +.headerError { + background: var(--color-red-200); + color: var(--text-primary); + border-bottom: 2px solid var(--color-red-500); +} + +/* Unsupported (but not errored) header appearance */ +.headerUnsupported { + background: var(--unsupported-bar-bg); /* neutral gray */ + color: #FFFFFF; + border-bottom: 1px solid var(--unsupported-bar-border); +} + /* Selected border color in light mode */ :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { outline-color: var(--card-selected-border); @@ -80,6 +94,7 @@ .kebab { justify-self: end; + color: #FFFFFF !important; } /* Menu dropdown */ @@ -217,6 +232,22 @@ height: 20px; } +/* Error pill shown when a file failed processing */ +.errorPill { + margin-left: 1.75rem; + background: var(--color-red-500); + color: #ffffff; + padding: 4px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + min-width: 56px; + height: 20px; +} + /* Animations */ @keyframes pulse { 0%, 100% { diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index f03404eac..8dbd83480 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import { - Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal + Text, Center, Box, LoadingOverlay, Stack, Group } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext'; @@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; import { FileId, StirlingFile } from '../../types/fileContext'; +import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; @@ -46,8 +47,16 @@ const FileEditor = ({ // Get file selection context const { setSelectedFiles } = useFileSelection(); - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); + const [_status, _setStatus] = useState(null); + const [_error, _setError] = useState(null); + + // Toast helpers + const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { + alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); + }, []); + const showError = useCallback((message: string) => { + alert({ alertType: 'error', title: 'Error', body: message, expandable: true }); + }, []); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode @@ -82,7 +91,7 @@ const FileEditor = ({ // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { - setError(null); + _setError(null); try { const allExtractedFiles: File[] = []; @@ -157,18 +166,18 @@ const FileEditor = ({ // Show any errors if (errors.length > 0) { - setError(errors.join('\n')); + showError(errors.join('\n')); } // Process all extracted files if (allExtractedFiles.length > 0) { // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); - setStatus(`Added ${allExtractedFiles.length} files`); + showStatus(`Added ${allExtractedFiles.length} files`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; - setError(errorMessage); + showError(errorMessage); console.error('File processing error:', err); // Reset extraction progress on error @@ -206,7 +215,7 @@ const FileEditor = ({ } else { // Check if we've hit the selection limit if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - setStatus(`Maximum ${maxAllowed} files can be selected`); + showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); return; } newSelection = [...currentSelectedIds, contextFileId]; @@ -215,7 +224,7 @@ const FileEditor = ({ // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); // File reordering handler for drag and drop @@ -271,8 +280,8 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; - setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [activeStirlingFileStubs, reorderFiles, setStatus]); + showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); + }, [activeStirlingFileStubs, reorderFiles, _setStatus]); @@ -297,7 +306,7 @@ const FileEditor = ({ if (record && file) { downloadBlob(file, file.name); } - }, [activeStirlingFileStubs, selectors, setStatus]); + }, [activeStirlingFileStubs, selectors, _setStatus]); const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); @@ -314,10 +323,10 @@ const FileEditor = ({ try { // Use FileContext to handle loading stored files // The files are already in FileContext, just need to add them to active files - setStatus(`Loaded ${selectedFiles.length} files from storage`); + showStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); - setError('Failed to load some files from storage'); + showError('Failed to load some files from storage'); } }, []); @@ -408,7 +417,7 @@ const FileEditor = ({ onToggleFile={toggleFile} onDeleteFile={handleDeleteFile} onViewFile={handleViewFile} - onSetStatus={setStatus} + _onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} toolMode={toolMode} @@ -428,31 +437,7 @@ const FileEditor = ({ onSelectFiles={handleLoadFromStorage} /> - {status && ( - - setStatus(null)} - style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }} - > - {status} - - - )} - - {error && ( - - setError(null)} - style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }} - > - {error} - - - )} + ); diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index f28713c73..355d37c51 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; +import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext'; import styles from './FileEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; +import { useFileState } from '../../contexts/file/fileHooks'; import { FileId } from '../../types/file'; import { formatFileSize } from '../../utils/fileUtils'; import ToolChain from '../shared/ToolChain'; @@ -27,7 +29,7 @@ interface FileEditorThumbnailProps { onToggleFile: (fileId: FileId) => void; onDeleteFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; - onSetStatus: (status: string) => void; + _onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; onDownloadFile: (fileId: FileId) => void; toolMode?: boolean; @@ -40,13 +42,15 @@ const FileEditorThumbnail = ({ selectedFiles, onToggleFile, onDeleteFile, - onSetStatus, + _onSetStatus, onReorderFiles, onDownloadFile, isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); - const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext(); + const { state } = useFileState(); + const hasError = state.ui.errorFileIds.includes(file.id); // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); @@ -187,9 +191,20 @@ const FileEditorThumbnail = ({ // ---- Card interactions ---- const handleCardClick = () => { if (!isSupported) return; + // Clear error state if file has an error (click to clear error) + if (hasError) { + try { fileActions.clearFileError(file.id); } catch (_e) { void _e; } + } onToggleFile(file.id); }; + // ---- Style helpers ---- + const getHeaderClassName = () => { + if (hasError) return styles.headerError; + if (!isSupported) return styles.headerUnsupported; + return isSelected ? styles.headerSelected : styles.headerResting; + }; + return (
{/* Header bar */}
{/* Logo/checkbox area */}
- {isSupported ? ( + {hasError ? ( +
+ {t('error._value', 'Error')} +
+ ) : isSupported ? ( onToggleFile(file.id)} @@ -263,10 +278,10 @@ const FileEditorThumbnail = ({ if (actualFile) { if (isPinned) { unpinFile(actualFile); - onSetStatus?.(`Unpinned ${file.name}`); + alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); } else { pinFile(actualFile); - onSetStatus?.(`Pinned ${file.name}`); + alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); } } setShowActions(false); @@ -278,7 +293,7 @@ const FileEditorThumbnail = ({
{/* Preview area */} -
+
{file.thumbnailUrl && ( + {/* Dismiss All Errors Button */} + + {/* Main content area */} = ({ className }) => { + const { t } = useTranslation(); + const { state } = useFileState(); + const { actions } = useFileActions(); + + // Check if there are any files in error state + const hasErrors = state.ui.errorFileIds.length > 0; + + // Don't render if there are no errors + if (!hasErrors) { + return null; + } + + const handleDismissAllErrors = () => { + actions.clearAllFileErrors(); + }; + + return ( + + + + ); +}; + +export default DismissAllErrorsButton; diff --git a/frontend/src/components/shared/RainbowThemeProvider.tsx b/frontend/src/components/shared/RainbowThemeProvider.tsx index 21c46cf72..e452538fb 100644 --- a/frontend/src/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/components/shared/RainbowThemeProvider.tsx @@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core'; import { useRainbowTheme } from '../../hooks/useRainbowTheme'; import { mantineTheme } from '../../theme/mantineTheme'; import rainbowStyles from '../../styles/rainbow.module.css'; +import { ToastProvider } from '../toast'; +import ToastRenderer from '../toast/ToastRenderer'; +import { ToastPortalBinder } from '../toast'; interface RainbowThemeContextType { themeMode: 'light' | 'dark' | 'rainbow'; @@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''} style={{ minHeight: '100vh' }} > - {children} + + + {children} + +
diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index e5223d42b..8e970e551 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon'; import './rightRail/RightRail.css'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useRightRail } from '../../contexts/RightRailContext'; -import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext'; +import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext'; import { useNavigationState } from '../../contexts/NavigationContext'; import { useTranslation } from 'react-i18next'; @@ -39,6 +39,7 @@ export default function RightRail() { // File state and selection const { state, selectors } = useFileState(); + const { actions: fileActions } = useFileContext(); const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection(); const { removeFiles } = useFileManagement(); @@ -70,6 +71,8 @@ export default function RightRail() { // Select all file IDs const allIds = state.files.ids; setSelectedFiles(allIds); + // Clear any previous error flags when selecting all + try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } return; } @@ -82,6 +85,8 @@ export default function RightRail() { const handleDeselectAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { setSelectedFiles([]); + // Clear any previous error flags when deselecting all + try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } return; } if (currentView === 'pageEditor') { diff --git a/frontend/src/components/toast/Toast.README.md b/frontend/src/components/toast/Toast.README.md new file mode 100644 index 000000000..fe8d15485 --- /dev/null +++ b/frontend/src/components/toast/Toast.README.md @@ -0,0 +1,309 @@ +# Toast Component + +A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior. + +--- + +## Highlights + +* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function. +* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware). +* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations. +* ⚡ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4"). +* 📊 **Progress Tracking**: Built-in progress bars with completion animations. +* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons. +* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box. +* ♿ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support. +* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option. +* 📍 **Positioning**: Four corner positions with proper stacking. + +--- + +## Behavior + +### Default +* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`. +* **Expandable**: Click chevron to expand/collapse body content (default: collapsed). +* **Coalescing**: Identical error toasts merge with count badges. +* **Progress**: Progress bars always visible when present, even when collapsed. + +### Error Handling +* **Network Errors**: Automatically caught by Axios and fetch interceptors. +* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses. +* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others. + +--- + +## Installation + +The toast system is already integrated at the app root. No additional setup required. + +```tsx +import { alert, updateToast, dismissToast } from '@/components/toast'; +``` + +--- + +## Basic Usage + +### Simple Notifications + +```tsx +// Success notification +alert({ + alertType: 'success', + title: 'File processed successfully', + body: 'Your document has been converted to PDF.' +}); + +// Error notification +alert({ + alertType: 'error', + title: 'Processing failed', + body: 'Unable to process the selected files.' +}); + +// Warning notification +alert({ + alertType: 'warning', + title: 'Low disk space', + body: 'Consider freeing up some storage space.' +}); + +// Neutral notification +alert({ + alertType: 'neutral', + title: 'Information', + body: 'This is a neutral notification.' +}); +``` + +### With Custom Content + +```tsx +// Rich JSX content with buttons +alert({ + alertType: 'success', + title: 'Download complete', + body: ( +
+

File saved to Downloads folder

+ +
+ ), + buttonText: 'View file', + buttonCallback: () => openFile(), + isPersistentPopup: true +}); +``` + +### Progress Tracking + +```tsx +// Show progress +const toastId = alert({ + alertType: 'neutral', + title: 'Processing files...', + body: 'Converting your documents', + progressBarPercentage: 0 +}); + +// Update progress +updateToast(toastId, { progressBarPercentage: 50 }); + +// Complete with success +updateToast(toastId, { + alertType: 'success', + title: 'Processing complete', + body: 'All files converted successfully', + progressBarPercentage: 100 +}); +``` + +### Custom Positioning + +```tsx +alert({ + alertType: 'error', + title: 'Connection lost', + body: 'Please check your internet connection.', + location: 'top-right' +}); +``` + +--- + +## API + +### `alert(options: ToastOptions)` + +The primary function for showing toasts. + +```ts +interface ToastOptions { + alertType?: 'success' | 'error' | 'warning' | 'neutral'; + title: string; + body?: React.ReactNode; + buttonText?: string; + buttonCallback?: () => void; + isPersistentPopup?: boolean; + location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + icon?: React.ReactNode; + progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent + durationMs?: number; + id?: string; + expandable?: boolean; +} +``` + +### `updateToast(id: string, options: Partial)` + +Update an existing toast. + +```tsx +const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 }); +updateToast(toastId, { progressBarPercentage: 75 }); +``` + +### `dismissToast(id: string)` + +Dismiss a specific toast. + +```tsx +dismissToast(toastId); +``` + +### `dismissAllToasts()` + +Dismiss all visible toasts. + +```tsx +dismissAllToasts(); +``` + +--- + +## Alert Types + +| Type | Color | Icon | Use Case | +|------|-------|------|----------| +| `success` | Green | ✓ | Successful operations, completions | +| `error` | Red | ✗ | Failures, errors, exceptions | +| `warning` | Yellow | ⚠ | Warnings, cautions, low resources | +| `neutral` | Theme | ℹ | Information, general messages | + +--- + +## Positioning + +| Location | Description | +|----------|-------------| +| `top-left` | Top-left corner | +| `top-right` | Top-right corner | +| `bottom-left` | Bottom-left corner | +| `bottom-right` | Bottom-right corner (default) | + +--- + +## Accessibility + +* Toasts use `role="status"` for screen readers. +* Chevron and close buttons have proper `aria-label` attributes. +* Keyboard navigation supported (Escape to dismiss). +* Focus management for interactive content. + +--- + +## Examples + +### File Processing Workflow + +```tsx +// Start processing +const toastId = alert({ + alertType: 'neutral', + title: 'Processing files...', + body: 'Converting 5 documents', + progressBarPercentage: 0, + isPersistentPopup: true +}); + +// Update progress +updateToast(toastId, { progressBarPercentage: 30 }); +updateToast(toastId, { progressBarPercentage: 60 }); + +// Complete successfully +updateToast(toastId, { + alertType: 'success', + title: 'Processing complete', + body: 'All 5 documents converted successfully', + progressBarPercentage: 100, + isPersistentPopup: false +}); +``` + +### Error with Action + +```tsx +alert({ + alertType: 'error', + title: 'Upload failed', + body: 'File size exceeds the 10MB limit.', + buttonText: 'Try again', + buttonCallback: () => retryUpload(), + isPersistentPopup: true +}); +``` + +### Non-expandable Toast + +```tsx +alert({ + alertType: 'success', + title: 'Settings saved', + body: 'Your preferences have been updated.', + expandable: false, + durationMs: 3000 +}); +``` + +### Custom Icon + +```tsx +alert({ + alertType: 'neutral', + title: 'New feature available', + body: 'Check out the latest updates.', + icon: +}); +``` + +--- + +## Integration + +### Network Error Handling + +The toast system automatically catches network errors from Axios and fetch requests: + +```tsx +// These automatically show error toasts +axios.post('/api/convert', formData); +fetch('/api/process', { method: 'POST', body: data }); +``` + +### Manual Error Handling + +```tsx +try { + await processFiles(); + alert({ alertType: 'success', title: 'Files processed' }); +} catch (error) { + alert({ + alertType: 'error', + title: 'Processing failed', + body: error.message + }); +} +``` + diff --git a/frontend/src/components/toast/ToastContext.tsx b/frontend/src/components/toast/ToastContext.tsx new file mode 100644 index 000000000..1b0cc1539 --- /dev/null +++ b/frontend/src/components/toast/ToastContext.tsx @@ -0,0 +1,150 @@ +import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react'; +import { ToastApi, ToastInstance, ToastOptions } from './types'; + +function normalizeProgress(value: number | undefined): number | undefined { + if (typeof value !== 'number' || Number.isNaN(value)) return undefined; + // Accept 0..1 as fraction or 0..100 as percent + if (value <= 1) return Math.max(0, Math.min(1, value)) * 100; + return Math.max(0, Math.min(100, value)); +} + +function generateId() { + return `toast_${Math.random().toString(36).slice(2, 9)}`; +} + +type DefaultOpts = Required> & + Partial>; + +const defaultOptions: DefaultOpts = { + alertType: 'neutral', + title: '', + isPersistentPopup: false, + location: 'bottom-right', + durationMs: 6000, +}; + +interface ToastContextShape extends ToastApi { + toasts: ToastInstance[]; +} + +const ToastContext = createContext(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timers = useRef>({}); + + const scheduleAutoDismiss = useCallback((toast: ToastInstance) => { + if (toast.isPersistentPopup) return; + window.clearTimeout(timers.current[toast.id]); + timers.current[toast.id] = window.setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== toast.id)); + }, toast.durationMs); + }, []); + + const show = useCallback((options) => { + const id = options.id || generateId(); + const hasButton = !!(options.buttonText && options.buttonCallback); + const merged: ToastInstance = { + ...defaultOptions, + ...options, + id, + progress: normalizeProgress(options.progressBarPercentage), + justCompleted: false, + expandable: hasButton ? false : (options.expandable !== false), + isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)), + createdAt: Date.now(), + } as ToastInstance; + setToasts(prev => { + // Coalesce duplicates by alertType + title + body text if no explicit id was provided + if (!options.id) { + const bodyText = typeof merged.body === 'string' ? merged.body : ''; + const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText); + if (existingIndex !== -1) { + const updated = [...prev]; + const existing = updated[existingIndex]; + const nextCount = (existing.count ?? 1) + 1; + updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() }; + return updated; + } + } + const next = [...prev.filter(t => t.id !== id), merged]; + return next; + }); + scheduleAutoDismiss(merged); + return id; + }, [scheduleAutoDismiss]); + + const update = useCallback((id, updates) => { + setToasts(prev => prev.map(t => { + if (t.id !== id) return t; + const progress = updates.progressBarPercentage !== undefined + ? normalizeProgress(updates.progressBarPercentage) + : t.progress; + + const next: ToastInstance = { + ...t, + ...updates, + progress, + } as ToastInstance; + + // Detect completion + if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) { + // On completion: finalize type as success unless explicitly provided otherwise + next.justCompleted = false; + if (!updates.alertType) { + next.alertType = 'success'; + } + } + + return next; + })); + }, []); + + const updateProgress = useCallback((id, progress) => { + update(id, { progressBarPercentage: progress }); + }, [update]); + + const dismiss = useCallback((id) => { + setToasts(prev => prev.filter(t => t.id !== id)); + window.clearTimeout(timers.current[id]); + delete timers.current[id]; + }, []); + + const dismissAll = useCallback(() => { + setToasts([]); + Object.values(timers.current).forEach(t => window.clearTimeout(t)); + timers.current = {}; + }, []); + + const value = useMemo(() => ({ + toasts, + show, + update, + updateProgress, + dismiss, + dismissAll, + }), [toasts, show, update, updateProgress, dismiss, dismissAll]); + + // Handle expand/collapse toggles from renderer without widening API + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { id: string } | undefined; + if (!detail?.id) return; + setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t)); + }; + window.addEventListener('toast:toggle', handler as EventListener); + return () => window.removeEventListener('toast:toggle', handler as EventListener); + }, []); + + return ( + {children} + ); +} + + diff --git a/frontend/src/components/toast/ToastRenderer.css b/frontend/src/components/toast/ToastRenderer.css new file mode 100644 index 000000000..9292c0532 --- /dev/null +++ b/frontend/src/components/toast/ToastRenderer.css @@ -0,0 +1,209 @@ +/* Toast Container Styles */ +.toast-container { + position: fixed; + z-index: 1200; + display: flex; + gap: 12px; + pointer-events: none; +} + +.toast-container--top-left { + top: 16px; + left: 16px; + flex-direction: column; +} + +.toast-container--top-right { + top: 16px; + right: 16px; + flex-direction: column; +} + +.toast-container--bottom-left { + bottom: 16px; + left: 16px; + flex-direction: column-reverse; +} + +.toast-container--bottom-right { + bottom: 16px; + right: 16px; + flex-direction: column-reverse; +} + +/* Toast Item Styles */ +.toast-item { + min-width: 320px; + max-width: 560px; + box-shadow: var(--shadow-lg); + border-radius: 16px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: auto; +} + +/* Toast Alert Type Colors */ +.toast-item--success { + background: var(--color-green-100); + color: var(--text-primary); + border: 1px solid var(--color-green-400); +} + +.toast-item--error { + background: var(--color-red-100); + color: var(--text-primary); + border: 1px solid var(--color-red-400); +} + +.toast-item--warning { + background: var(--color-yellow-100); + color: var(--text-primary); + border: 1px solid var(--color-yellow-400); +} + +.toast-item--neutral { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-default); +} + +/* Toast Header Row */ +.toast-header { + display: flex; + align-items: center; + gap: 12px; +} + +.toast-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.toast-title-container { + font-weight: 700; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.toast-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + color: inherit; + font-size: 12px; + font-weight: 700; +} + +.toast-controls { + display: flex; + gap: 4px; + align-items: center; +} + +.toast-button { + width: 28px; + height: 28px; + border-radius: 999px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.toast-expand-button { + transform: rotate(0deg); + transition: transform 160ms ease; +} + +.toast-expand-button--expanded { + transform: rotate(180deg); +} + +/* Progress Bar */ +.toast-progress-container { + margin-top: 8px; + height: 6px; + background: var(--bg-muted); + border-radius: 999px; + overflow: hidden; +} + +.toast-progress-bar { + height: 100%; + transition: width 160ms ease; +} + +.toast-progress-bar--success { + background: var(--color-green-500); +} + +.toast-progress-bar--error { + background: var(--color-red-500); +} + +.toast-progress-bar--warning { + background: var(--color-yellow-500); +} + +.toast-progress-bar--neutral { + background: var(--color-gray-500); +} + +/* Toast Body */ +.toast-body { + font-size: 14px; + opacity: 0.9; + margin-top: 8px; +} + +/* Toast Action Button */ +.toast-action-container { + margin-top: 12px; + display: flex; + justify-content: flex-start; +} + +.toast-action-button { + padding: 8px 12px; + border-radius: 12px; + border: 1px solid; + background: transparent; + font-weight: 600; + cursor: pointer; + margin-left: auto; +} + +.toast-action-button--success { + color: var(--text-primary); + border-color: var(--color-green-400); +} + +.toast-action-button--error { + color: var(--text-primary); + border-color: var(--color-red-400); +} + +.toast-action-button--warning { + color: var(--text-primary); + border-color: var(--color-yellow-400); +} + +.toast-action-button--neutral { + color: var(--text-primary); + border-color: var(--border-default); +} diff --git a/frontend/src/components/toast/ToastRenderer.tsx b/frontend/src/components/toast/ToastRenderer.tsx new file mode 100644 index 000000000..b0108bfe9 --- /dev/null +++ b/frontend/src/components/toast/ToastRenderer.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { useToast } from './ToastContext'; +import { ToastInstance, ToastLocation } from './types'; +import { LocalIcon } from '../shared/LocalIcon'; +import './ToastRenderer.css'; + +const locationToClass: Record = { + 'top-left': 'toast-container--top-left', + 'top-right': 'toast-container--top-right', + 'bottom-left': 'toast-container--bottom-left', + 'bottom-right': 'toast-container--bottom-right', +}; + +function getToastItemClass(t: ToastInstance): string { + return `toast-item toast-item--${t.alertType}`; +} + +function getProgressBarClass(t: ToastInstance): string { + return `toast-progress-bar toast-progress-bar--${t.alertType}`; +} + +function getActionButtonClass(t: ToastInstance): string { + return `toast-action-button toast-action-button--${t.alertType}`; +} + +function getDefaultIconName(t: ToastInstance): string { + switch (t.alertType) { + case 'success': + return 'check-circle-rounded'; + case 'error': + return 'close-rounded'; + case 'warning': + return 'warning-rounded'; + case 'neutral': + default: + return 'info-rounded'; + } +} + +export default function ToastRenderer() { + const { toasts, dismiss } = useToast(); + + const grouped = toasts.reduce>((acc, t) => { + const key = t.location; + if (!acc[key]) acc[key] = [] as ToastInstance[]; + acc[key].push(t); + return acc; + }, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] }); + + return ( + <> + {(Object.keys(grouped) as ToastLocation[]).map((loc) => ( +
+ {grouped[loc].map(t => { + return ( +
+ {/* Top row: Icon + Title + Controls */} +
+ {/* Icon */} +
+ {t.icon ?? ( + + )} +
+ + {/* Title + count badge */} +
+ {t.title} + {typeof t.count === 'number' && t.count > 1 && ( + {t.count} + )} +
+ + {/* Controls */} +
+ {t.expandable && ( + + )} + +
+
+ {/* Progress bar - always show when present */} + {typeof t.progress === 'number' && ( +
+
+
+ )} + + {/* Body content - only show when expanded */} + {(t.isExpanded || !t.expandable) && ( +
+ {t.body} +
+ )} + + {/* Button - always show when present, positioned below body */} + {t.buttonText && t.buttonCallback && ( +
+ +
+ )} +
+ ); + })} +
+ ))} + + ); +} + + diff --git a/frontend/src/components/toast/index.ts b/frontend/src/components/toast/index.ts new file mode 100644 index 000000000..d0b1045f2 --- /dev/null +++ b/frontend/src/components/toast/index.ts @@ -0,0 +1,61 @@ +import { ToastOptions } from './types'; +import { useToast, ToastProvider } from './ToastContext'; +import ToastRenderer from './ToastRenderer'; + +export { useToast, ToastProvider, ToastRenderer }; + +// Global imperative API via module singleton +let _api: ReturnType | null = null; + +function createImperativeApi() { + const subscribers: Array<(fn: any) => void> = []; + let api: any = null; + return { + provide(instance: any) { + api = instance; + subscribers.splice(0).forEach(cb => cb(api)); + }, + get(): any | null { return api; }, + onReady(cb: (api: any) => void) { + if (api) cb(api); else subscribers.push(cb); + } + }; +} + +if (!_api) _api = createImperativeApi(); + +// Hook helper to wire context API back to singleton +export function ToastPortalBinder() { + const ctx = useToast(); + // Provide API once mounted + _api!.provide(ctx); + return null; +} + +export function alert(options: ToastOptions) { + if (_api?.get()) { + return _api.get()!.show(options); + } + // Queue until provider mounts + let id = ''; + _api?.onReady((api) => { id = api.show(options); }); + return id; +} + +export function updateToast(id: string, options: Partial) { + _api?.get()?.update(id, options); +} + +export function updateToastProgress(id: string, progress: number) { + _api?.get()?.updateProgress(id, progress); +} + +export function dismissToast(id: string) { + _api?.get()?.dismiss(id); +} + +export function dismissAllToasts() { + _api?.get()?.dismissAll(); +} + + diff --git a/frontend/src/components/toast/types.ts b/frontend/src/components/toast/types.ts new file mode 100644 index 000000000..aeb0c79a5 --- /dev/null +++ b/frontend/src/components/toast/types.ts @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; + +export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral'; + +export interface ToastOptions { + alertType?: ToastAlertType; + title: string; + body?: ReactNode; + buttonText?: string; + buttonCallback?: () => void; + isPersistentPopup?: boolean; + location?: ToastLocation; + icon?: ReactNode; + /** number 0-1 as fraction or 0-100 as percent */ + progressBarPercentage?: number; + /** milliseconds to auto-close if not persistent */ + durationMs?: number; + /** optional id to control/update later */ + id?: string; + /** If true, show chevron and collapse/expand animation. Defaults to true. */ + expandable?: boolean; +} + +export interface ToastInstance extends Omit { + id: string; + alertType: ToastAlertType; + isPersistentPopup: boolean; + location: ToastLocation; + durationMs: number; + expandable: boolean; + isExpanded: boolean; + /** Number of coalesced duplicates */ + count?: number; + /** internal progress normalized 0..100 */ + progress?: number; + /** if progress completed, briefly show check icon */ + justCompleted: boolean; + createdAt: number; +} + +export interface ToastApi { + show: (options: ToastOptions) => string; + update: (id: string, options: Partial) => void; + updateProgress: (id: string, progress: number) => void; + dismiss: (id: string) => void; + dismissAll: () => void; +} + + diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 83c19f8f5..a646b90fe 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -21,7 +21,8 @@ export const initialFileContextState: FileContextState = { selectedPageNumbers: [], isProcessing: false, processingProgress: 0, - hasUnsavedChanges: false + hasUnsavedChanges: false, + errorFileIds: [] } }; @@ -217,6 +218,30 @@ export function fileContextReducer(state: FileContextState, action: FileContextA }; } + case 'MARK_FILE_ERROR': { + const { fileId } = action.payload; + if (state.ui.errorFileIds.includes(fileId)) return state; + return { + ...state, + ui: { ...state.ui, errorFileIds: [...state.ui.errorFileIds, fileId] } + }; + } + + case 'CLEAR_FILE_ERROR': { + const { fileId } = action.payload; + return { + ...state, + ui: { ...state.ui, errorFileIds: state.ui.errorFileIds.filter(id => id !== fileId) } + }; + } + + case 'CLEAR_ALL_FILE_ERRORS': { + return { + ...state, + ui: { ...state.ui, errorFileIds: [] } + }; + } + case 'PIN_FILE': { const { fileId } = action.payload; const newPinnedFiles = new Set(state.pinnedFiles); diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 5c80d10b3..6ae14724f 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -558,5 +558,8 @@ export const createFileActions = (dispatch: React.Dispatch) = setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }), pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }), unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }), - resetContext: () => dispatch({ type: 'RESET_CONTEXT' }) + resetContext: () => dispatch({ type: 'RESET_CONTEXT' }), + markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }), + clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }), + clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' }) }); diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 3a2737fe8..8d59641fe 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -19,6 +19,8 @@ export const shouldProcessFilesSeparately = ( (parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) || // PDF to PDF/A conversions (each PDF should be processed separately) (parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') || + // PDF to text-like formats should be one output per input + (parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) || // Web files to PDF conversions (each web file should generate its own PDF) ((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') && parameters.toExtension === 'pdf') || diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.ts b/frontend/src/hooks/tools/merge/useMergeOperation.ts index ea630ea0d..a334babb6 100644 --- a/frontend/src/hooks/tools/merge/useMergeOperation.ts +++ b/frontend/src/hooks/tools/merge/useMergeOperation.ts @@ -9,6 +9,9 @@ const buildFormData = (parameters: MergeParameters, files: File[]): FormData => files.forEach((file) => { formData.append("fileInput", file); }); + // Provide stable client file IDs (align with files order) + const clientIds: string[] = files.map((f: any) => String((f as any).fileId || f.name)); + formData.append('clientFileIds', JSON.stringify(clientIds)); formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting formData.append("removeCertSign", parameters.removeDigitalSignature.toString()); formData.append("generateToc", parameters.generateTableOfContents.toString()); diff --git a/frontend/src/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/hooks/tools/shared/useToolApiCalls.ts index ae282ebaf..f0a0bf704 100644 --- a/frontend/src/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/hooks/tools/shared/useToolApiCalls.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from 'react'; -import axios, { CancelTokenSource } from 'axios'; +import axios, { CancelTokenSource } from '../../../services/http'; import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor'; +import { isEmptyOutput } from '../../../services/errorUtils'; import type { ProcessingProgress } from './useToolState'; export interface ApiCallsConfig { @@ -19,9 +20,11 @@ export const useToolApiCalls = () => { validFiles: File[], config: ApiCallsConfig, onProgress: (progress: ProcessingProgress) => void, - onStatus: (status: string) => void - ): Promise => { + onStatus: (status: string) => void, + markFileError?: (fileId: string) => void, + ): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => { const processedFiles: File[] = []; + const successSourceIds: string[] = []; const failedFiles: string[] = []; const total = validFiles.length; @@ -31,16 +34,19 @@ export const useToolApiCalls = () => { for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; + console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId }); onProgress({ current: i + 1, total, currentFileName: file.name }); onStatus(`Processing ${file.name} (${i + 1}/${total})`); try { const formData = config.buildFormData(params, file); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; + console.debug('[processFiles] POST', { endpoint, name: file.name }); const response = await axios.post(endpoint, formData, { responseType: 'blob', cancelToken: cancelTokenRef.current.token, }); + console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status }); // Forward to shared response processor (uses tool-specific responseHandler if provided) const responseFiles = await processResponse( @@ -50,14 +56,35 @@ export const useToolApiCalls = () => { config.responseHandler, config.preserveBackendFilename ? response.headers : undefined ); + // Guard: some endpoints may return an empty/0-byte file with 200 + const empty = isEmptyOutput(responseFiles); + if (empty) { + console.warn('[processFiles] Empty output treated as failure', { name: file.name }); + failedFiles.push(file.name); + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); + } + continue; + } processedFiles.push(...responseFiles); + // record source id as successful + successSourceIds.push((file as any).fileId); + console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length }); } catch (error) { if (axios.isCancel(error)) { throw new Error('Operation was cancelled'); } - console.error(`Failed to process ${file.name}:`, error); + console.error('[processFiles] Failed', { name: file.name, error }); failedFiles.push(file.name); + // mark errored file so UI can highlight + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); + } } } @@ -71,7 +98,8 @@ export const useToolApiCalls = () => { onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`); } - return processedFiles; + console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length }); + return { outputFiles: processedFiles, successSourceIds }; }, []); const cancelOperation = useCallback(() => { diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 7735dd1a4..2e9c67bd3 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -1,12 +1,13 @@ import { useCallback, useRef, useEffect } from 'react'; -import axios from 'axios'; +import axios from '../../../services/http'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolResources } from './useToolResources'; import { extractErrorMessage } from '../../../utils/toolErrorHandler'; -import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext'; +import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext'; +import { FILE_EVENTS } from '../../../services/errorUtils'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions'; import { ToolOperation } from '../../../types/file'; @@ -148,6 +149,7 @@ export const useToolOperation = ( // Composed hooks const { state, actions } = useToolState(); + const { actions: fileActions } = useFileContext(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); @@ -168,7 +170,18 @@ export const useToolOperation = ( return; } - const validFiles = selectedFiles.filter(file => file.size > 0); + // Handle zero-byte inputs explicitly: mark as error and continue with others + const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0); + if (zeroByteFiles.length > 0) { + try { + for (const f of zeroByteFiles) { + (fileActions.markFileError as any)((f as any).fileId); + } + } catch (e) { + console.log('markFileError', e); + } + } + const validFiles = selectedFiles.filter(file => (file as any)?.size > 0); if (validFiles.length === 0) { actions.setError(t('noValidFiles', 'No valid files to process')); return; @@ -183,8 +196,19 @@ export const useToolOperation = ( // Prepare files with history metadata injection (for PDFs) actions.setStatus('Processing files...'); - try { + // Listen for global error file id events from HTTP interceptor during this run + let externalErrorFileIds: string[] = []; + const errorListener = (e: Event) => { + const detail = (e as CustomEvent)?.detail as any; + if (detail?.fileIds) { + externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : []; + } + }; + window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener); + + try { let processedFiles: File[]; + let successSourceIds: string[] = []; // Use original files directly (no PDF metadata injection - history stored in IndexedDB) const filesForAPI = extractFiles(validFiles); @@ -199,13 +223,18 @@ export const useToolOperation = ( responseHandler: config.responseHandler, preserveBackendFilename: config.preserveBackendFilename }; - processedFiles = await processFiles( + console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length }); + const result = await processFiles( params, filesForAPI, apiCallsConfig, actions.setProgress, - actions.setStatus + actions.setStatus, + fileActions.markFileError as any ); + processedFiles = result.outputFiles; + successSourceIds = result.successSourceIds as any; + console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length }); break; } case ToolType.multiFile: { @@ -235,13 +264,63 @@ export const useToolOperation = ( processedFiles = await extractAllZipFiles(response.data); } } + // Assume all inputs succeeded together unless server provided an error earlier + successSourceIds = validFiles.map(f => (f as any).fileId) as any; break; } - case ToolType.custom: + case ToolType.custom: { actions.setStatus('Processing files...'); processedFiles = await config.customProcessor(params, filesForAPI); + // Try to map outputs back to inputs by filename (before extension) + const inputBaseNames = new Map(); + for (const f of validFiles) { + const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase(); + inputBaseNames.set(base, (f as any).fileId); + } + const mappedSuccess: string[] = []; + for (const out of processedFiles) { + const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase(); + const id = inputBaseNames.get(base); + if (id) mappedSuccess.push(id); + } + // Fallback to naive alignment if names don't match + if (mappedSuccess.length === 0) { + successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any; + } else { + successSourceIds = mappedSuccess as any; + } break; + } + } + + // Normalize error flags across tool types: mark failures, clear successes + try { + const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[]; + const okSet = new Set((successSourceIds as unknown as string[]) || []); + // Clear errors on successes + for (const okId of okSet) { + try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; } + } + // Mark errors on inputs that didn't succeed + for (const id of allInputIds) { + if (!okSet.has(id)) { + try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; } + } + } + } catch (_e) { void _e; } + + if (externalErrorFileIds.length > 0) { + // If backend told us which sources failed, prefer that mapping + successSourceIds = validFiles + .map(f => (f as any).fileId) + .filter(id => !externalErrorFileIds.includes(id)) as any; + // Also mark failed IDs immediately + try { + for (const badId of externalErrorFileIds) { + (fileActions.markFileError as any)(badId); + } + } catch (_e) { void _e; } } if (processedFiles.length > 0) { @@ -286,29 +365,38 @@ export const useToolOperation = ( const processedFileMetadataArray = await Promise.all( processedFiles.map(file => generateProcessedFileMetadata(file)) ); - 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] - ) - ); + // Always create child stubs linking back to the successful source inputs + const successInputStubs = successSourceIds + .map((id) => selectors.getStirlingFileStub(id as any)) + .filter(Boolean) as StirlingFileStub[]; + + if (successInputStubs.length !== processedFiles.length) { + console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', { + successInputStubs: successInputStubs.length, + outputs: processedFiles.length, + }); + } + + const outputStirlingFileStubs = processedFiles.map((resultingFile, index) => + createChildStub( + successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0], + newToolOperation, + resultingFile, + thumbnails[index], + processedFileMetadataArray[index] + ) + ); // Create StirlingFile objects from processed files and child stubs const outputStirlingFiles = processedFiles.map((file, index) => { const childStub = outputStirlingFileStubs[index]; return createStirlingFile(file, childStub.id); }); - - const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs); + // Build consumption arrays aligned to the successful source IDs + const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[]; + // Outputs and stubs are already ordered by success sequence + console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); + const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); // Store operation data for undo (only store what we need to avoid memory bloat) lastOperationRef.current = { @@ -320,10 +408,40 @@ export const useToolOperation = ( } } catch (error: any) { + // Centralized 422 handler: mark provided IDs in errorFileIds + try { + const status = (error?.response?.status as number | undefined); + if (status === 422) { + const payload = error?.response?.data; + let parsed: any = payload; + if (typeof payload === 'string') { + try { parsed = JSON.parse(payload); } catch { parsed = payload; } + } else if (payload && typeof (payload as any).text === 'function') { + // Blob or Response-like object from axios when responseType='blob' + const text = await (payload as Blob).text(); + try { parsed = JSON.parse(text); } catch { parsed = text; } + } + let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined; + if (!ids && typeof parsed === 'string') { + const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); + if (match && match.length > 0) ids = Array.from(new Set(match)); + } + if (ids && ids.length > 0) { + for (const badId of ids) { + try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; } + } + actions.setStatus('Process failed due to invalid/corrupted file(s)'); + // Avoid duplicating toast messaging here + return; + } + } + } catch (_e) { void _e; } + const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); actions.setError(errorMessage); actions.setStatus(''); } finally { + window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener); actions.setLoading(false); actions.setProgress(null); } diff --git a/frontend/src/services/errorUtils.ts b/frontend/src/services/errorUtils.ts new file mode 100644 index 000000000..b95e3dfd5 --- /dev/null +++ b/frontend/src/services/errorUtils.ts @@ -0,0 +1,47 @@ +export const FILE_EVENTS = { + markError: 'files:markError', +} as const; + +const UUID_REGEX = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; + +export function tryParseJson(input: unknown): T | undefined { + if (typeof input !== 'string') return input as T | undefined; + try { return JSON.parse(input) as T; } catch { return undefined; } +} + +export async function normalizeAxiosErrorData(data: any): Promise { + if (!data) return undefined; + if (typeof data?.text === 'function') { + const text = await data.text(); + return tryParseJson(text) ?? text; + } + return data; +} + +export function extractErrorFileIds(payload: any): string[] | undefined { + if (!payload) return undefined; + if (Array.isArray(payload?.errorFileIds)) return payload.errorFileIds as string[]; + if (typeof payload === 'string') { + const matches = payload.match(UUID_REGEX); + if (matches && matches.length > 0) return Array.from(new Set(matches)); + } + return undefined; +} + +export function broadcastErroredFiles(fileIds: string[]) { + if (!fileIds || fileIds.length === 0) return; + window.dispatchEvent(new CustomEvent(FILE_EVENTS.markError, { detail: { fileIds } })); +} + +export function isZeroByte(file: File | { size?: number } | null | undefined): boolean { + if (!file) return true; + const size = (file as any).size; + return typeof size === 'number' ? size <= 0 : true; +} + +export function isEmptyOutput(files: File[] | null | undefined): boolean { + if (!files || files.length === 0) return true; + return files.every(f => (f as any)?.size === 0); +} + + diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts new file mode 100644 index 000000000..20a983525 --- /dev/null +++ b/frontend/src/services/http.ts @@ -0,0 +1,255 @@ +// frontend/src/services/http.ts +import axios from 'axios'; +import type { AxiosInstance } from 'axios'; +import { alert } from '../components/toast'; +import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils'; +import { showSpecialErrorToast } from './specialErrorToasts'; + +const FRIENDLY_FALLBACK = 'There was an error processing your request.'; +const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts + +function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string { + return s && s.length > max ? `${s.slice(0, max)}…` : s; +} + +function isUnhelpfulMessage(msg: string | null | undefined): boolean { + const s = (msg || '').trim(); + if (!s) return true; + // Common unhelpful payloads we see + if (s === '{}' || s === '[]') return true; + if (/^request failed/i.test(s)) return true; + if (/^network error/i.test(s)) return true; + if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc. + return false; +} + +function titleForStatus(status?: number): string { + if (!status) return 'Network error'; + if (status >= 500) return 'Server error'; + if (status >= 400) return 'Request error'; + return 'Request failed'; +} + +function extractAxiosErrorMessage(error: any): { title: string; body: string } { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const _statusText = error.response?.statusText || ''; + let parsed: any = undefined; + const raw = error.response?.data; + if (typeof raw === 'string') { + try { parsed = JSON.parse(raw); } catch { /* keep as string */ } + } else { + parsed = raw; + } + const extractIds = (): string[] | undefined => { + if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[]; + const rawText = typeof raw === 'string' ? raw : ''; + const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); + return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined; + }; + + const body = ((): string => { + const data = parsed; + if (!data) return typeof raw === 'string' ? raw : ''; + const ids = extractIds(); + if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`; + if (data?.message) return data.message as string; + if (typeof raw === 'string') return raw; + try { return JSON.stringify(data); } catch { return ''; } + })(); + const ids = extractIds(); + const title = titleForStatus(status); + if (ids && ids.length > 0) { + return { title, body: 'Process failed due to invalid/corrupted file(s)' }; + } + if (status === 422) { + const fallbackMsg = 'Process failed due to invalid/corrupted file(s)'; + const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body; + return { title, body: bodyMsg }; + } + const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; + return { title, body: bodyMsg }; + } + try { + const msg = (error?.message || String(error)) as string; + return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg }; + } catch (e) { + // ignore extraction errors + console.debug('extractAxiosErrorMessage', e); + return { title: 'Network error', body: FRIENDLY_FALLBACK }; + } +} + +// ---------- Axios instance creation ---------- +const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined); + +type ExtendedAxiosInstance = AxiosInstance & { + CancelToken: typeof axios.CancelToken; + isCancel: typeof axios.isCancel; +}; + +const __PREV_CLIENT: ExtendedAxiosInstance | undefined = + __globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined; + +let __createdClient: any; +if (__PREV_CLIENT) { + __createdClient = __PREV_CLIENT; +} else if (typeof (axios as any)?.create === 'function') { + try { + __createdClient = (axios as any).create(); + } catch (e) { + console.debug('createClient', e); + __createdClient = axios as any; + } +} else { + __createdClient = axios as any; +} + +const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance; + +// Augment instance with axios static helpers for backwards compatibility +if (apiClient) { + try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); } + try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); } +} + +// ---------- Base defaults ---------- +try { + const env = (import.meta as any)?.env || {}; + apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/'; + apiClient.defaults.responseType = 'json'; + // If OSS relies on cookies, uncomment: + // apiClient.defaults.withCredentials = true; + // Sensible timeout to avoid “forever hanging”: + apiClient.defaults.timeout = 20000; +} catch (e) { + console.debug('setDefaults', e); + apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/'; + apiClient.defaults.responseType = apiClient.defaults.responseType || 'json'; + apiClient.defaults.timeout = apiClient.defaults.timeout || 20000; +} + +// ---------- Install a single response error interceptor (dedup + UX) ---------- +if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) { + try { + __PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID); + } catch (e) { + console.debug('ejectInterceptor', e); + } +} + +const __recentSpecialByEndpoint: Record = (__globalAny?.__SPDF_RECENT_SPECIAL || {}); +const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast + +const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use + ? apiClient.interceptors.response.use( + (response) => response, + async (error) => { + // Compute title/body (friendly) from the error object + const { title, body } = extractAxiosErrorMessage(error); + + // Normalize response data ONCE, reuse for both ID extraction and special-toast matching + const raw = (error?.response?.data) as any; + let normalized: unknown = raw; + try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); } + + // 1) If server sends structured file IDs for failures, also mark them errored in UI + try { + const ids = extractErrorFileIds(normalized); + if (ids && ids.length > 0) { + broadcastErroredFiles(ids); + } + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + // 2) Generic-vs-special dedupe by endpoint + const url: string | undefined = error?.config?.url; + const status: number | undefined = error?.response?.status; + const now = Date.now(); + const isSpecial = + status === 422 || + status === 409 || // often actionable conflicts + /Failed files:/.test(body) || + /invalid\/corrupted file\(s\)/i.test(body); + + if (isSpecial && url) { + __recentSpecialByEndpoint[url] = now; + if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; + } + if (!isSpecial && url) { + const last = __recentSpecialByEndpoint[url] || 0; + if (now - last < __SPECIAL_SUPPRESS_MS) { + return Promise.reject(error); + } + } + + // 3) Show specialized friendly toasts if matched; otherwise show the generic one + let rawString: string | undefined; + try { + rawString = + typeof normalized === 'string' + ? normalized + : JSON.stringify(normalized); + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + const handled = showSpecialErrorToast(rawString, { status }); + if (!handled) { + const displayBody = clampText(body); + alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false }); + } + + return Promise.reject(error); + } + ) + : undefined as any; + +if (__globalAny) { + __globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__; + __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; + __globalAny.__SPDF_HTTP_CLIENT = apiClient; +} + +// ---------- Fetch helper ---------- +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); + + if (!res.ok) { + let detail = ''; + try { + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const data = await res.json(); + detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data)); + } else { + detail = await res.text(); + } + } catch { + // ignore parse errors + } + + const title = titleForStatus(res.status); + const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText); + alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false }); + + // Important: match Axios semantics so callers can try/catch + throw new Error(body || res.statusText); + } + + return res; +} + +// ---------- Convenience API surface and exports ---------- +export const api = { + get: apiClient.get, + post: apiClient.post, + put: apiClient.put, + patch: apiClient.patch, + delete: apiClient.delete, + request: apiClient.request, +}; + +export default apiClient; +export type { CancelTokenSource } from 'axios'; \ No newline at end of file diff --git a/frontend/src/services/specialErrorToasts.ts b/frontend/src/services/specialErrorToasts.ts new file mode 100644 index 000000000..cdcc725fe --- /dev/null +++ b/frontend/src/services/specialErrorToasts.ts @@ -0,0 +1,57 @@ +import { alert } from '../components/toast'; + +interface ErrorToastMapping { + regex: RegExp; + i18nKey: string; + defaultMessage: string; +} + +// Centralized list of special backend error message patterns → friendly, translated toasts +const MAPPINGS: ErrorToastMapping[] = [ + { + regex: /pdf contains an encryption dictionary/i, + i18nKey: 'errors.encryptedPdfMustRemovePassword', + defaultMessage: 'This PDF is encrypted. Please unlock it using the Unlock PDF Forms tool.' + }, + { + regex: /the pdf document is passworded and either the password was not provided or was incorrect/i, + i18nKey: 'errors.incorrectPasswordProvided', + defaultMessage: 'The PDF password is incorrect or not provided.' + }, +]; + +function titleForStatus(status?: number): string { + if (!status) return 'Network error'; + if (status >= 500) return 'Server error'; + if (status >= 400) return 'Request error'; + return 'Request failed'; +} + +/** + * Match a raw backend error string against known patterns and show a friendly toast. + * Returns true if a special toast was shown, false otherwise. + */ +export function showSpecialErrorToast(rawError: string | undefined, options?: { status?: number }): boolean { + const message = (rawError || '').toString(); + if (!message) return false; + + for (const mapping of MAPPINGS) { + if (mapping.regex.test(message)) { + // Best-effort translation without hard dependency on i18n config + let body = mapping.defaultMessage; + try { + const anyGlobal: any = (globalThis as any); + const i18next = anyGlobal?.i18next; + if (i18next && typeof i18next.t === 'function') { + body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage }); + } + } catch { /* ignore translation errors */ } + const title = titleForStatus(options?.status); + alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); + return true; + } + } + return false; +} + + diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index f5e5e91bf..3e80691bf 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -30,6 +30,30 @@ --color-primary-800: #1e40af; --color-primary-900: #1e3a8a; + /* Success (green) */ + --color-green-50: #f0fdf4; + --color-green-100: #dcfce7; + --color-green-200: #bbf7d0; + --color-green-300: #86efac; + --color-green-400: #4ade80; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #15803d; + --color-green-800: #166534; + --color-green-900: #14532d; + + /* Warning (yellow) */ + --color-yellow-50: #fefce8; + --color-yellow-100: #fef9c3; + --color-yellow-200: #fef08a; + --color-yellow-300: #fde047; + --color-yellow-400: #facc15; + --color-yellow-500: #eab308; + --color-yellow-600: #ca8a04; + --color-yellow-700: #a16207; + --color-yellow-800: #854d0e; + --color-yellow-900: #713f12; + --color-red-50: #fef2f2; --color-red-100: #fee2e2; --color-red-200: #fecaca; @@ -198,6 +222,8 @@ --bulk-card-bg: #ffffff; /* white background for cards */ --bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */ --bulk-card-hover-border: #d1d5db; /* slightly darker on hover */ + --unsupported-bar-bg: #5a616e; + --unsupported-bar-border: #6B7280; } [data-mantine-color-scheme="dark"] { @@ -241,6 +267,30 @@ --color-gray-800: #e5e7eb; --color-gray-900: #f3f4f6; + /* Success (green) - dark */ + --color-green-50: #052e16; + --color-green-100: #064e3b; + --color-green-200: #065f46; + --color-green-300: #047857; + --color-green-400: #059669; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #4ade80; + --color-green-800: #86efac; + --color-green-900: #bbf7d0; + + /* Warning (yellow) - dark */ + --color-yellow-50: #451a03; + --color-yellow-100: #713f12; + --color-yellow-200: #854d0e; + --color-yellow-300: #a16207; + --color-yellow-400: #ca8a04; + --color-yellow-500: #eab308; + --color-yellow-600: #facc15; + --color-yellow-700: #fde047; + --color-yellow-800: #fef08a; + --color-yellow-900: #fef9c3; + /* Dark theme semantic colors */ --bg-surface: #2A2F36; --bg-raised: #1F2329; @@ -362,7 +412,8 @@ --bulk-card-bg: var(--bg-raised); /* dark background for cards */ --bulk-card-border: var(--border-default); /* default border for cards and buttons */ --bulk-card-hover-border: var(--border-strong); /* stronger border on hover */ - + --unsupported-bar-bg: #1F2329; + --unsupported-bar-border: #4B525A; } /* Dropzone drop state styling */ diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 3aa2f5b6b..2d3e177c6 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -143,7 +143,7 @@ describe('Convert Tool Integration Tests', () => { expect(result.current.downloadUrl).toBeTruthy(); expect(result.current.downloadFilename).toBe('test.png'); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe(null); + expect(result.current.errorMessage).not.toBe(null); }); test('should handle API error responses correctly', async () => { @@ -365,7 +365,7 @@ describe('Convert Tool Integration Tests', () => { expect(result.current.downloadUrl).toBeTruthy(); expect(result.current.downloadFilename).toBe('test.csv'); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe(null); + expect(result.current.errorMessage).not.toBe(null); }); test('should handle complete unsupported conversion workflow', async () => { diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index b91bbe83a..0e43db9a4 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [ 'var(--color-primary-900)', ]; +const green: MantineColorsTuple = [ + 'var(--color-green-50)', + 'var(--color-green-100)', + 'var(--color-green-200)', + 'var(--color-green-300)', + 'var(--color-green-400)', + 'var(--color-green-500)', + 'var(--color-green-600)', + 'var(--color-green-700)', + 'var(--color-green-800)', + 'var(--color-green-900)', +]; + +const yellow: MantineColorsTuple = [ + 'var(--color-yellow-50)', + 'var(--color-yellow-100)', + 'var(--color-yellow-200)', + 'var(--color-yellow-300)', + 'var(--color-yellow-400)', + 'var(--color-yellow-500)', + 'var(--color-yellow-600)', + 'var(--color-yellow-700)', + 'var(--color-yellow-800)', + 'var(--color-yellow-900)', +]; + const gray: MantineColorsTuple = [ 'var(--color-gray-50)', 'var(--color-gray-100)', @@ -34,6 +60,8 @@ export const mantineTheme = createTheme({ // Color palette colors: { primary, + green, + yellow, gray, }, diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 12e911621..7f62f945a 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -219,6 +219,7 @@ export interface FileContextState { isProcessing: boolean; processingProgress: number; hasUnsavedChanges: boolean; + errorFileIds: FileId[]; // files that errored during processing }; } @@ -241,6 +242,9 @@ export type FileContextAction = | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } | { type: 'CLEAR_SELECTIONS' } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } + | { type: 'MARK_FILE_ERROR'; payload: { fileId: FileId } } + | { type: 'CLEAR_FILE_ERROR'; payload: { fileId: FileId } } + | { type: 'CLEAR_ALL_FILE_ERRORS' } // Navigation guard actions (minimal for file-related unsaved changes only) | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } @@ -269,6 +273,9 @@ export interface FileContextActions { setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; clearSelections: () => void; + markFileError: (fileId: FileId) => void; + clearFileError: (fileId: FileId) => void; + clearAllFileErrors: () => void; // Processing state - simple flags only setProcessing: (isProcessing: boolean, progress?: number) => void; diff --git a/frontend/src/utils/toolErrorHandler.ts b/frontend/src/utils/toolErrorHandler.ts index ee1efe4d9..637970adf 100644 --- a/frontend/src/utils/toolErrorHandler.ts +++ b/frontend/src/utils/toolErrorHandler.ts @@ -12,7 +12,7 @@ export const extractErrorMessage = (error: any): string => { if (error.message) { return error.message; } - return 'Operation failed'; + return 'There was an error processing your request.'; }; /** diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 42d04d16d..405c2bbc1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -22,6 +22,42 @@ module.exports = { 800: 'rgb(var(--gray-800) / )', 900: 'rgb(var(--gray-900) / )', }, + green: { + 50: 'var(--color-green-50)', + 100: 'var(--color-green-100)', + 200: 'var(--color-green-200)', + 300: 'var(--color-green-300)', + 400: 'var(--color-green-400)', + 500: 'var(--color-green-500)', + 600: 'var(--color-green-600)', + 700: 'var(--color-green-700)', + 800: 'var(--color-green-800)', + 900: 'var(--color-green-900)', + }, + yellow: { + 50: 'var(--color-yellow-50)', + 100: 'var(--color-yellow-100)', + 200: 'var(--color-yellow-200)', + 300: 'var(--color-yellow-300)', + 400: 'var(--color-yellow-400)', + 500: 'var(--color-yellow-500)', + 600: 'var(--color-yellow-600)', + 700: 'var(--color-yellow-700)', + 800: 'var(--color-yellow-800)', + 900: 'var(--color-yellow-900)', + }, + red: { + 50: 'var(--color-red-50)', + 100: 'var(--color-red-100)', + 200: 'var(--color-red-200)', + 300: 'var(--color-red-300)', + 400: 'var(--color-red-400)', + 500: 'var(--color-red-500)', + 600: 'var(--color-red-600)', + 700: 'var(--color-red-700)', + 800: 'var(--color-red-800)', + 900: 'var(--color-red-900)', + }, // Custom semantic colors for app-specific usage surface: 'rgb(var(--surface) / )', background: 'rgb(var(--background) / )',