From 4bdf1c93ae18a8b8e585971f632080d2843e9714 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Tue, 23 Sep 2025 13:05:24 +0100 Subject: [PATCH] fuzzy search + tool name synonyms --- .../src/components/tools/SearchResults.tsx | 45 +++++-- frontend/src/components/tools/ToolPanel.tsx | 1 + .../tools/shared/renderToolButtons.tsx | 56 ++++++--- .../tools/toolPicker/ToolButton.tsx | 41 ++++-- .../tools/toolPicker/ToolSearch.tsx | 18 +-- frontend/src/contexts/ToolWorkflowContext.tsx | 24 +++- frontend/src/data/toolsTaxonomy.ts | 2 + .../src/data/useTranslatedToolRegistry.tsx | 35 +++++- frontend/src/hooks/useToolSections.ts | 49 +++++++- frontend/src/utils/fuzzySearch.ts | 118 ++++++++++++++++++ 10 files changed, 325 insertions(+), 64 deletions(-) create mode 100644 frontend/src/utils/fuzzySearch.ts 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/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..fddade3c6 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} 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 4473dc020..5dca067e7 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 { rankByFuzzy, idToWords } from '../utils/fuzzySearch'; // State interface interface ToolWorkflowState { @@ -99,7 +100,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; } @@ -218,12 +219,25 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { setReaderMode(true); }, [setReaderMode]); - // Filter tools based on search query + // Filter tools based on search query with fuzzy matching (name and description) const filteredTools = useMemo(() => { if (!toolRegistry) return []; - return Object.entries(toolRegistry).filter(([_, { name }]) => - name.toLowerCase().includes(state.searchQuery.toLowerCase()) - ); + const entries = Object.entries(toolRegistry); + if (!state.searchQuery.trim()) { + // Return in the new format even when not searching + return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); + } + const ranked = rankByFuzzy(entries, state.searchQuery, [ + ([key]) => idToWords(key), + ([, v]) => v.name, + ([, v]) => v.description, + ([, v]) => v.synonyms?.join(' ') || '', + ]); + // Keep reasonable number in search view? Return all ranked to preserve grouping downstream + return ranked.map(r => ({ + item: r.item as [string, ToolRegistryEntry], + matchedText: r.matchedText + })); }, [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 8ef194f55..007df8e24 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -169,6 +169,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: ["signature", "autograph"] }, // Document Security @@ -184,7 +185,8 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["add-password"], operationConfig: addPasswordOperationConfig, settingsComponent: AddPasswordSettings, - }, + synonyms: ["lock", "secure"] + }, watermark: { icon: , name: t("home.watermark.title", "Add Watermark"), @@ -196,6 +198,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["add-watermark"], operationConfig: addWatermarkOperationConfig, settingsComponent: AddWatermarkSingleStepSettings, + synonyms: ["brand", "logo", "stamp"] }, addStamp: { icon: , @@ -216,6 +219,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["sanitize-pdf"], operationConfig: sanitizeOperationConfig, settingsComponent: SanitizeSettings, + synonyms: ["clean", "purge", "remove"] }, flatten: { icon: , @@ -228,6 +232,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["flatten"], operationConfig: flattenOperationConfig, settingsComponent: FlattenSettings, + synonyms: ["simplify", "flatten", "static"] }, unlockPDFForms: { icon: , @@ -285,7 +290,7 @@ export function useFlatToolRegistry(): ToolRegistry { // Document Review - read: { + read: { icon: , name: t("home.read.title", "Read"), component: null, @@ -296,6 +301,7 @@ export function useFlatToolRegistry(): ToolRegistry { ), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_REVIEW, + synonyms: ["view", "open", "display"] }, changeMetadata: { icon: , @@ -308,6 +314,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["update-metadata"], operationConfig: changeMetadataOperationConfig, settingsComponent: ChangeMetadataSingleStep, + synonyms: ["edit", "modify", "update"] }, // Page Formatting @@ -318,6 +325,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + synonyms: ["trim", "cut", "resize"] }, rotate: { icon: , @@ -330,6 +338,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["rotate-pdf"], operationConfig: rotateOperationConfig, settingsComponent: RotateSettings, + synonyms: ["turn", "flip", "orient"] }, split: { icon: , @@ -340,6 +349,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.PAGE_FORMATTING, operationConfig: splitOperationConfig, settingsComponent: SplitSettings, + synonyms: ["divide", "separate", "cut"] }, reorganizePages: { icon: , @@ -352,6 +362,7 @@ export function useFlatToolRegistry(): ToolRegistry { ), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + synonyms: ["rearrange", "reorder", "organize"] }, scalePages: { icon: , @@ -364,6 +375,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["scale-pages"], operationConfig: adjustPageScaleOperationConfig, settingsComponent: AdjustPageScaleSettings, + synonyms: ["resize", "adjust", "scale"] }, addPageNumbers: { icon: , @@ -373,6 +385,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: ["number", "pagination", "count"] }, pageLayout: { icon: , @@ -382,6 +395,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: ["layout", "arrange", "combine"] }, pdfToSinglePage: { icon: , @@ -395,6 +409,7 @@ export function useFlatToolRegistry(): ToolRegistry { urlPath: '/pdf-to-single-page', endpoints: ["pdf-to-single-page"], operationConfig: singleLargePageOperationConfig, + synonyms: ["combine", "merge", "single"] }, addAttachments: { icon: , @@ -404,6 +419,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: ["embed", "attach", "include"] }, // Extraction @@ -415,6 +431,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: ["pull", "select", "copy"] }, extractImages: { icon: , @@ -423,6 +440,7 @@ export function useFlatToolRegistry(): ToolRegistry { description: t("home.extractImages.desc", "Extract images from PDF documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.EXTRACTION, + synonyms: ["pull", "save", "export"] }, // Removal @@ -436,6 +454,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL, maxFiles: 1, endpoints: ["remove-pages"], + synonyms: ["delete", "extract", "exclude"] }, removeBlanks: { icon: , @@ -446,6 +465,7 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.REMOVAL, maxFiles: 1, endpoints: ["remove-blanks"], + synonyms: ["delete", "clean", "empty"] }, removeAnnotations: { icon: , @@ -454,6 +474,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: ["delete", "clean", "strip"] }, removeImage: { icon: , @@ -474,6 +495,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, operationConfig: removePasswordOperationConfig, settingsComponent: RemovePasswordSettings, + synonyms: ["unlock"] }, removeCertSign: { icon: , @@ -552,6 +574,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["repair"], operationConfig: repairOperationConfig, settingsComponent: RepairSettings, + synonyms: ["fix", "restore"] }, scannerImageSplit: { icon: , @@ -657,6 +680,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: ["difference"] }, compress: { icon: , @@ -668,6 +692,7 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, operationConfig: compressOperationConfig, settingsComponent: CompressSettings, + synonyms: ["shrink", "reduce", "optimize"] }, convert: { icon: , @@ -697,6 +722,7 @@ export function useFlatToolRegistry(): ToolRegistry { operationConfig: convertOperationConfig, settingsComponent: ConvertSettings, + synonyms: ["transform", "change"] }, merge: { icon: , @@ -708,7 +734,8 @@ export function useFlatToolRegistry(): ToolRegistry { maxFiles: -1, endpoints: ["merge-pdfs"], operationConfig: mergeOperationConfig, - settingsComponent: MergeSettings + settingsComponent: MergeSettings, + synonyms: ["combine", "join", "unite"] }, multiTool: { icon: , @@ -731,6 +758,7 @@ export function useFlatToolRegistry(): ToolRegistry { urlPath: '/ocr-pdf', operationConfig: ocrOperationConfig, settingsComponent: OCRSettings, + synonyms: ["extract", "scan"] }, redact: { icon: , @@ -743,6 +771,7 @@ export function useFlatToolRegistry(): ToolRegistry { endpoints: ["auto-redact"], operationConfig: redactOperationConfig, settingsComponent: RedactSingleStepSettings, + synonyms: ["censor", "blackout", "hide"] }, }; diff --git a/frontend/src/hooks/useToolSections.ts b/frontend/src/hooks/useToolSections.ts index d0f6ebdca..3a4a430a4 100644 --- a/frontend/src/hooks/useToolSections.ts +++ b/frontend/src/hooks/useToolSections.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy'; +import { idToWords, normalizeForSearch } from '../utils/fuzzySearch'; import { useTranslation } from 'react-i18next'; type SubcategoryIdMap = { @@ -27,12 +28,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 +100,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 +114,37 @@ 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 provided, and there are no exact/substring matches across any field, + // preserve the encounter order of subcategories (best matches first) instead of alphabetical. + if (searchQuery && searchQuery.trim()) { + const nq = normalizeForSearch(searchQuery); + const hasExact = filteredTools.some(({ item: [id, tool] }) => { + const idWords = idToWords(id); + return ( + idWords.includes(nq) || + normalizeForSearch(tool.name).includes(nq) || + normalizeForSearch(tool.description).includes(nq) + ); + }); + if (!hasExact) { + // Keep original appearance order of subcategories as they occur in filteredTools + const order: SubcategoryId[] = []; + filteredTools.forEach(({ item: [_, tool] }) => { + const sc = tool.subcategoryId; + if (!order.includes(sc)) order.push(sc); + }); + return entries + .sort(([a], [b]) => order.indexOf(a as SubcategoryId) - order.indexOf(b as SubcategoryId)) + .map(([subcategoryId, tools]) => ({ subcategoryId, tools } as SubcategoryGroup)); + } + } + + 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..40b77c891 --- /dev/null +++ b/frontend/src/utils/fuzzySearch.ts @@ -0,0 +1,118 @@ +// 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 >= 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); +} + +