fuzzy search + tool name synonyms

This commit is contained in:
EthanHealy01 2025-09-23 13:05:24 +01:00
parent f6df414425
commit 4bdf1c93ae
10 changed files with 325 additions and 64 deletions

View File

@ -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<SearchResultsProps> = ({ filteredTools, onSelect }) => {
const SearchResults: React.FC<SearchResultsProps> = ({ 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<string, string>();
if (filteredTools && Array.isArray(filteredTools)) {
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) matchedTextMap.set(id, matchedText);
});
}
if (searchGroups.length === 0) {
return <NoToolsFound />;
@ -28,15 +37,27 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
<Box key={group.subcategoryId} w="100%">
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
/>
))}
{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 (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
);
})}
</Stack>
</Box>
))}

View File

@ -72,6 +72,7 @@ export default function ToolPanel() {
<SearchResults
filteredTools={filteredTools}
onSelect={handleToolSelect}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (

View File

@ -13,23 +13,39 @@ export const renderToolButtons = (
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true,
disableNavigation: boolean = false
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<div>
{subcategory.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
/>
))}
</div>
</Box>
);
disableNavigation: boolean = false,
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
) => {
// Create a map of matched text for quick lookup
const matchedTextMap = new Map<string, string>();
if (searchResults) {
searchResults.forEach(({ item: [id], matchedText }) => {
if (matchedText) matchedTextMap.set(id, matchedText);
});
}
return (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<div>
{subcategory.tools.map(({ id, tool }) => {
const matchedSynonym = matchedTextMap.get(id);
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
/>
);
})}
</div>
</Box>
);
};

View File

@ -13,9 +13,10 @@ interface ToolButtonProps {
onSelect: (id: string) => void;
rounded?: boolean;
disableNavigation?: boolean;
matchedSynonym?: string;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
const isUnavailable = !tool.component && !tool.link;
const { getToolNavigation } = useToolNavigation();
@ -40,13 +41,27 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const buttonContent = (
<>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
{matchedSynonym && (
<span style={{
fontSize: '0.75rem',
color: 'var(--mantine-color-dimmed)',
opacity: isUnavailable ? 0.25 : 1,
marginTop: '1px',
overflow: 'visible',
whiteSpace: 'nowrap'
}}>
{matchedSynonym}
</span>
)}
</div>
</>
);
@ -66,7 +81,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ 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}
</Button>
@ -84,7 +102,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ 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}
</Button>

View File

@ -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) => {

View File

@ -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(() =>

View File

@ -45,6 +45,8 @@ export type ToolRegistryEntry = {
operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration
settingsComponent?: React.ComponentType<any>;
// Synonyms for search (optional)
synonyms?: string[];
}
export type ToolRegistry = Record<ToolId, ToolRegistryEntry>;

View File

@ -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: <LocalIcon icon="branding-watermark-outline-rounded" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
@ -216,6 +219,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["sanitize-pdf"],
operationConfig: sanitizeOperationConfig,
settingsComponent: SanitizeSettings,
synonyms: ["clean", "purge", "remove"]
},
flatten: {
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
@ -228,6 +232,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["flatten"],
operationConfig: flattenOperationConfig,
settingsComponent: FlattenSettings,
synonyms: ["simplify", "flatten", "static"]
},
unlockPDFForms: {
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
@ -285,7 +290,7 @@ export function useFlatToolRegistry(): ToolRegistry {
// Document Review
read: {
read: {
icon: <LocalIcon icon="article-rounded" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
@ -330,6 +338,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["rotate-pdf"],
operationConfig: rotateOperationConfig,
settingsComponent: RotateSettings,
synonyms: ["turn", "flip", "orient"]
},
split: {
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
@ -340,6 +349,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
operationConfig: splitOperationConfig,
settingsComponent: SplitSettings,
synonyms: ["divide", "separate", "cut"]
},
reorganizePages: {
icon: <LocalIcon icon="move-down-rounded" width="1.5rem" height="1.5rem" />,
@ -352,6 +362,7 @@ export function useFlatToolRegistry(): ToolRegistry {
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
synonyms: ["rearrange", "reorder", "organize"]
},
scalePages: {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
@ -364,6 +375,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
settingsComponent: AdjustPageScaleSettings,
synonyms: ["resize", "adjust", "scale"]
},
addPageNumbers: {
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="dashboard-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="filter-alt" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
@ -446,6 +465,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-blanks"],
synonyms: ["delete", "clean", "empty"]
},
removeAnnotations: {
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="remove-selection-rounded" width="1.5rem" height="1.5rem" />,
@ -474,6 +495,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
operationConfig: removePasswordOperationConfig,
settingsComponent: RemovePasswordSettings,
synonyms: ["unlock"]
},
removeCertSign: {
icon: <LocalIcon icon="remove-moderator-outline-rounded" width="1.5rem" height="1.5rem" />,
@ -552,6 +574,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["repair"],
operationConfig: repairOperationConfig,
settingsComponent: RepairSettings,
synonyms: ["fix", "restore"]
},
scannerImageSplit: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
@ -668,6 +692,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
operationConfig: compressOperationConfig,
settingsComponent: CompressSettings,
synonyms: ["shrink", "reduce", "optimize"]
},
convert: {
icon: <LocalIcon icon="sync-alt-rounded" width="1.5rem" height="1.5rem" />,
@ -697,6 +722,7 @@ export function useFlatToolRegistry(): ToolRegistry {
operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings,
synonyms: ["transform", "change"]
},
merge: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
@ -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: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
@ -731,6 +758,7 @@ export function useFlatToolRegistry(): ToolRegistry {
urlPath: '/ocr-pdf',
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings,
synonyms: ["extract", "scan"]
},
redact: {
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
@ -743,6 +771,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
settingsComponent: RedactSingleStepSettings,
synonyms: ["censor", "blackout", "hide"]
},
};

View File

@ -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<string /* FIX ME: Should be ToolId */>();
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 };
}

View File

@ -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<T>(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);
}