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 []; + }}; + +