From ccf3bb458f31c3c7f8dacfa4cc016a20d8cc5ff3 Mon Sep 17 00:00:00 2001 From: ayushsingh82 Date: Wed, 12 Aug 2026 02:14:41 +0530 Subject: [PATCH] fix: tokenize plugin search queries so multi-word terms match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchCatalogPlugins tested the query as one literal substring, so "hacker news" missed the hackernews plugin even though "hackernews" and "hacker" alone both matched. Split the query into tokens, strip non-alphanumerics from tokens and haystack, and require every token to match — so "hacker news", "hacker-news", and "hackernews" all hit. Fixes #282 --- src/plugin-catalog.test.ts | 24 ++++++++++++++++++++++++ src/plugin-catalog.ts | 21 ++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/plugin-catalog.test.ts b/src/plugin-catalog.test.ts index eedb6dff..8ee62a6e 100644 --- a/src/plugin-catalog.test.ts +++ b/src/plugin-catalog.test.ts @@ -119,4 +119,28 @@ describe('plugin catalog', () => { expect(result.plugins.map((plugin) => plugin.name)).toEqual(['flights']); expect(result.errors).toEqual([{ sourceId: 'bad', manifestUrl: 'https://bad.test/webcmd-plugin.json', message: 'network failed' }]); }); + + it('matches multi-word queries against a single-word plugin name, ignoring separators', async () => { + const catalog: PluginCatalog = { + version: 1, + sources: [{ id: 'ok', source: 'github:ok/repo', manifestUrl: 'https://ok.test/webcmd-plugin.json' }], + }; + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Browse Hacker News stories and comments' } } }); + + for (const query of ['hacker news', 'hacker-news', 'hackernews', 'hacker']) { + const result = await searchCatalogPlugins(catalog, { query, fetchJson }); + expect(result.plugins.map((plugin) => plugin.name)).toEqual(['hackernews']); + } + }); + + it('requires every query token to match, not just one', async () => { + const catalog: PluginCatalog = { + version: 1, + sources: [{ id: 'ok', source: 'github:ok/repo', manifestUrl: 'https://ok.test/webcmd-plugin.json' }], + }; + const fetchJson = async () => ({ plugins: { hackernews: { path: 'plugins/hackernews', description: 'Browse Hacker News stories and comments' } } }); + + const result = await searchCatalogPlugins(catalog, { query: 'hacker weather', fetchJson }); + expect(result.plugins).toEqual([]); + }); }); diff --git a/src/plugin-catalog.ts b/src/plugin-catalog.ts index c8b009f3..1a32e9ac 100644 --- a/src/plugin-catalog.ts +++ b/src/plugin-catalog.ts @@ -133,6 +133,21 @@ export function flattenPluginManifest(source: PluginCatalogSource, manifest: Plu }]; } +function normalizeSearchText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +function tokenizeSearchQuery(query: string | undefined): string[] { + return (query ?? '').trim().toLowerCase().split(/\s+/).map(normalizeSearchText).filter(Boolean); +} + +// Ignoring separators (spaces, hyphens) lets "hacker news", "hacker-news", and "hackernews" +// all match a plugin named "hackernews" — every token must appear somewhere in the haystack. +function matchesSearchTokens(plugin: PluginSearchRow, tokens: string[]): boolean { + const haystack = normalizeSearchText(`${plugin.name} ${plugin.description ?? ''}`); + return tokens.every((token) => haystack.includes(token)); +} + export async function searchCatalogPlugins( catalog: PluginCatalog, options: { query?: string; fetchJson?: FetchJson } = {}, @@ -150,9 +165,9 @@ export async function searchCatalogPlugins( } })); - const query = options.query?.trim().toLowerCase(); - const filtered = query - ? plugins.filter((plugin) => `${plugin.name} ${plugin.description ?? ''}`.toLowerCase().includes(query)) + const queryTokens = tokenizeSearchQuery(options.query); + const filtered = queryTokens.length + ? plugins.filter((plugin) => matchesSearchTokens(plugin, queryTokens)) : plugins; filtered.sort((a, b) => a.name.localeCompare(b.name));