Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/plugin-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
21 changes: 18 additions & 3 deletions src/plugin-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {},
Expand All @@ -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));
Expand Down