diff --git a/src/components/WikilinkChatInput.tsx b/src/components/WikilinkChatInput.tsx index 183c3509..c87167cc 100644 --- a/src/components/WikilinkChatInput.tsx +++ b/src/components/WikilinkChatInput.tsx @@ -8,6 +8,7 @@ import { useState, useRef, useMemo, useEffect } from 'react' import type { VaultEntry } from '../types' import type { NoteReference } from '../utils/ai-context' import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors' +import { bestSearchRank } from '../utils/fuzzyMatch' const MAX_SUGGESTIONS = 20 const MIN_QUERY_LENGTH = 1 @@ -46,13 +47,16 @@ function matchEntries( ): SuggestionEntry[] { if (query.length < MIN_QUERY_LENGTH) return [] const lower = query.toLowerCase() - const matches = entries.filter(e => - !e.trashed && !e.archived && ( - e.title.toLowerCase().includes(lower) || - e.aliases.some(a => a.toLowerCase().includes(lower)) - ), - ) - return matches.slice(0, MAX_SUGGESTIONS).map(e => { + const matches = entries + .filter(e => + !e.trashed && !e.archived && ( + e.title.toLowerCase().includes(lower) || + e.aliases.some(a => a.toLowerCase().includes(lower)) + ), + ) + .map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) })) + .sort((a, b) => a.rank - b.rank) + return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => { const te = typeEntryMap[e.isA ?? ''] return { title: e.title, diff --git a/src/hooks/useNoteSearch.test.ts b/src/hooks/useNoteSearch.test.ts index c440d4ef..ccaef793 100644 --- a/src/hooks/useNoteSearch.test.ts +++ b/src/hooks/useNoteSearch.test.ts @@ -175,6 +175,38 @@ describe('useNoteSearch', () => { expect(preventDefaultSpy).not.toHaveBeenCalled() }) + it('ranks exact title match first, prefix second, fuzzy third', () => { + const ranked: VaultEntry[] = [ + makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000003 }), + makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000002 }), + makeEntry({ path: '/vault/r.md', title: 'Refactoring', modifiedAt: 1700000001 }), + ] + const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring')) + expect(result.current.results.map(r => r.title)).toEqual([ + 'Refactoring', + 'Refactoring Ideas', + 'Refactoring Key Ideas', + ]) + }) + + it('ranks case-insensitive exact match first', () => { + const ranked: VaultEntry[] = [ + makeEntry({ path: '/vault/qi.md', title: 'Quarter Ideas', modifiedAt: 1700000002 }), + makeEntry({ path: '/vault/q.md', title: 'Quarter', modifiedAt: 1700000001 }), + ] + const { result } = renderHook(() => useNoteSearch(ranked, 'quarter')) + expect(result.current.results[0].title).toBe('Quarter') + }) + + it('boosts note whose alias is an exact match', () => { + const ranked: VaultEntry[] = [ + makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000002 }), + makeEntry({ path: '/vault/rn.md', title: 'Refactoring Notes', aliases: ['ref'], modifiedAt: 1700000001 }), + ] + const { result } = renderHook(() => useNoteSearch(ranked, 'ref')) + expect(result.current.results[0].title).toBe('Refactoring Notes') + }) + it('resolves custom type color from Type entries', () => { const withTypes: VaultEntry[] = [ makeEntry({ path: '/vault/t/recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }), diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts index 6c58c99c..b0559159 100644 --- a/src/hooks/useNoteSearch.ts +++ b/src/hooks/useNoteSearch.ts @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useEffect } from 'react' import type { VaultEntry } from '../types' -import { fuzzyMatch } from '../utils/fuzzyMatch' +import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch' import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors' import { getTypeIcon } from '../components/NoteItem' import type { NoteSearchResultItem } from '../components/NoteSearchList' @@ -45,9 +45,13 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = .map(mapResult) } return searchableEntries - .map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) })) + .map((e) => ({ + entry: e, + ...fuzzyMatch(query, e.title), + rank: bestSearchRank(query, e.title, e.aliases), + })) .filter((r) => r.match) - .sort((a, b) => b.score - a.score) + .sort((a, b) => a.rank - b.rank || b.score - a.score) .slice(0, maxResults) .map((r) => mapResult(r.entry)) }, [searchableEntries, query, maxResults, typeEntryMap]) diff --git a/src/utils/fuzzyMatch.test.ts b/src/utils/fuzzyMatch.test.ts index 59dc41d9..b8b65764 100644 --- a/src/utils/fuzzyMatch.test.ts +++ b/src/utils/fuzzyMatch.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { fuzzyMatch } from './fuzzyMatch' +import { fuzzyMatch, searchRank, bestSearchRank } from './fuzzyMatch' describe('fuzzyMatch', () => { it('matches exact string', () => { @@ -44,3 +44,43 @@ describe('fuzzyMatch', () => { expect(fuzzyMatch('a', '').match).toBe(false) }) }) + +describe('searchRank', () => { + it('returns 0 for exact match', () => { + expect(searchRank('Refactoring', 'Refactoring')).toBe(0) + }) + + it('returns 0 for case-insensitive exact match', () => { + expect(searchRank('refactoring', 'Refactoring')).toBe(0) + }) + + it('returns 1 for prefix match', () => { + expect(searchRank('Refactoring', 'Refactoring Ideas')).toBe(1) + }) + + it('returns 1 for case-insensitive prefix match', () => { + expect(searchRank('quarter', 'Quarter Review')).toBe(1) + }) + + it('returns 2 for non-prefix fuzzy match', () => { + expect(searchRank('Ideas', 'Refactoring Ideas')).toBe(2) + }) +}) + +describe('bestSearchRank', () => { + it('returns best rank across title and aliases', () => { + expect(bestSearchRank('ref', 'Refactoring Notes', ['ref'])).toBe(0) + }) + + it('returns title rank when no aliases match better', () => { + expect(bestSearchRank('Refactoring', 'Refactoring', [])).toBe(0) + }) + + it('prefers alias exact match over title prefix match', () => { + expect(bestSearchRank('ref', 'Reference Manual', ['ref'])).toBe(0) + }) + + it('returns 2 when nothing matches as exact or prefix', () => { + expect(bestSearchRank('ideas', 'Refactoring Ideas', ['thoughts'])).toBe(2) + }) +}) diff --git a/src/utils/fuzzyMatch.ts b/src/utils/fuzzyMatch.ts index d3cfda05..6be47fa4 100644 --- a/src/utils/fuzzyMatch.ts +++ b/src/utils/fuzzyMatch.ts @@ -1,3 +1,22 @@ +/** Search rank tier: 0 = exact match, 1 = prefix match, 2 = fuzzy only. Lower is better. */ +export function searchRank(query: string, target: string): number { + const q = query.toLowerCase() + const t = target.toLowerCase() + if (t === q) return 0 + if (t.startsWith(q)) return 1 + return 2 +} + +/** Best rank across a title and its aliases. */ +export function bestSearchRank(query: string, title: string, aliases: string[]): number { + let rank = searchRank(query, title) + for (const alias of aliases) { + rank = Math.min(rank, searchRank(query, alias)) + if (rank === 0) break + } + return rank +} + /** Fuzzy match: all query chars must appear in order in the target. */ export function fuzzyMatch(query: string, target: string): { match: boolean; score: number } { const q = query.toLowerCase()