feat: exact-match-first ranking in search and wikilink autocomplete
Add searchRank/bestSearchRank utilities that compute a tier (0=exact, 1=prefix, 2=fuzzy-only). Both useNoteSearch and WikilinkChatInput now sort by rank tier first, then by fuzzy score, ensuring notes with exact title or alias matches always surface above partial/fuzzy matches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user