fix: ensure exact title match always ranks first in search

Title exact match gets exclusive tier 0 — alias exact match is capped
at tier 1, so a note titled "Refactoring" always appears above notes
with "Refactoring" as an alias or prefix. The 5-tier ranking is:
0=title exact, 1=alias exact, 2=title prefix, 3=alias prefix, 4=fuzzy.

Also adds ranking to editor wikilink autocomplete (enrichSuggestionItems)
and trims whitespace in searchRank comparisons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-08 22:03:54 +01:00
parent e40c09a2ef
commit 60f3139b3e
5 changed files with 95 additions and 25 deletions

View File

@@ -175,18 +175,31 @@ describe('useNoteSearch', () => {
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it('ranks exact title match first, prefix second, fuzzy third', () => {
it('ranks exact title match first even with many prefix competitors', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000010 }),
makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000009 }),
makeEntry({ path: '/vault/rp.md', title: 'Refactoring Patterns', modifiedAt: 1700000008 }),
makeEntry({ path: '/vault/rs.md', title: 'Refactoring Strategy', modifiedAt: 1700000007 }),
makeEntry({ path: '/vault/rt.md', title: 'Refactoring Techniques', modifiedAt: 1700000006 }),
makeEntry({ path: '/vault/rb.md', title: 'Refactoring Best Practices', modifiedAt: 1700000005 }),
makeEntry({ path: '/vault/rg.md', title: 'Refactoring Guide', modifiedAt: 1700000004 }),
makeEntry({ path: '/vault/rw.md', title: 'Refactoring Workflows', modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/rc.md', title: 'Refactoring Checklist', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/r.md', title: 'Refactoring', isA: 'Area', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring'))
expect(result.current.results[0].title).toBe('Refactoring')
})
it('ranks exact title match above note with alias exact match', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', aliases: ['Refactoring'], 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',
])
expect(result.current.results[0].title).toBe('Refactoring')
})
it('ranks case-insensitive exact match first', () => {

View File

@@ -68,19 +68,42 @@ describe('searchRank', () => {
})
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', () => {
it('returns 0 for title exact match', () => {
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 0 for title exact match with aliases present', () => {
expect(bestSearchRank('Refactoring', 'Refactoring', ['refactor', 'cleanup'])).toBe(0)
})
it('returns 2 when nothing matches as exact or prefix', () => {
expect(bestSearchRank('ideas', 'Refactoring Ideas', ['thoughts'])).toBe(2)
it('returns 1 for alias exact match (never 0)', () => {
expect(bestSearchRank('ref', 'Refactoring Notes', ['ref'])).toBe(1)
})
it('ranks alias exact match above title prefix match', () => {
expect(bestSearchRank('ref', 'Reference Manual', ['ref'])).toBe(1)
// title prefix would be rank 2, alias exact is rank 1
})
it('returns 2 for title prefix match (no alias boost)', () => {
expect(bestSearchRank('Refactoring', 'Refactoring Ideas', [])).toBe(2)
})
it('returns 3 for alias prefix match only', () => {
expect(bestSearchRank('ref', 'Something Else', ['refactor'])).toBe(3)
})
it('returns 4 when nothing matches as exact or prefix', () => {
expect(bestSearchRank('ideas', 'Refactoring Ideas', ['thoughts'])).toBe(4)
})
it('title exact match always beats alias exact match', () => {
const titleExact = bestSearchRank('Refactoring', 'Refactoring', ['refactor'])
const aliasExact = bestSearchRank('Refactoring', 'Refactoring Ideas', ['Refactoring'])
expect(titleExact).toBeLessThan(aliasExact)
})
it('handles trimmed whitespace in title and query', () => {
expect(bestSearchRank('Refactoring ', ' Refactoring', [])).toBe(0)
})
})

View File

@@ -1,20 +1,39 @@
/** 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()
const q = query.trim().toLowerCase()
const t = target.trim().toLowerCase()
if (t === q) return 0
if (t.startsWith(q)) return 1
return 2
}
/** Best rank across a title and its aliases. */
/**
* Rank a note by how well its title/aliases match the query.
* Tiers: 0 = title exact, 1 = alias exact, 2 = title prefix,
* 3 = alias prefix, 4 = fuzzy only. Lower is better.
*
* Title exact match (tier 0) is exclusive to the title — an alias
* exact match only reaches tier 1, ensuring the note whose title
* matches exactly always sorts first.
*/
export function bestSearchRank(query: string, title: string, aliases: string[]): number {
let rank = searchRank(query, title)
const q = query.trim().toLowerCase()
const t = title.trim().toLowerCase()
if (t === q) return 0
let aliasExact = false
let aliasPrefix = false
for (const alias of aliases) {
rank = Math.min(rank, searchRank(query, alias))
if (rank === 0) break
const a = alias.trim().toLowerCase()
if (a === q) { aliasExact = true; break }
if (a.startsWith(q)) aliasPrefix = true
}
return rank
if (aliasExact) return 1
if (t.startsWith(q)) return 2
if (aliasPrefix) return 3
return 4
}
/** Fuzzy match: all query chars must appear in order in the target. */

View File

@@ -90,4 +90,14 @@ describe('enrichSuggestionItems', () => {
const result = enrichSuggestionItems(items, '', {})
expect(result.length).toBeLessThanOrEqual(20)
})
it('ranks exact title match first among prefix competitors', () => {
const items = [
makeItem('Refactoring Ideas', 'Note', '/ri.md'),
makeItem('Refactoring Key Ideas', 'Note', '/rk.md'),
makeItem('Refactoring', 'Area', '/r.md'),
]
const result = enrichSuggestionItems(items, 'Refactoring', {})
expect(result[0].title).toBe('Refactoring')
})
})

View File

@@ -2,6 +2,7 @@ import type { VaultEntry } from '../types'
import { getTypeColor, getTypeLightColor } from './typeColors'
import { getTypeIcon } from '../components/NoteItem'
import { deduplicateByPath, disambiguateTitles } from './wikilinkSuggestions'
import { bestSearchRank } from './fuzzyMatch'
import { filterSuggestionItems } from '@blocknote/core/extensions'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
@@ -32,8 +33,12 @@ export function enrichSuggestionItems(
query: string,
typeEntryMap: Record<string, VaultEntry>,
): WikilinkSuggestionItem[] {
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(filtered))
const filtered = filterSuggestionItems(items, query)
filtered.sort((a, b) =>
bestSearchRank(query, a.entryTitle, a.aliases) - bestSearchRank(query, b.entryTitle, b.aliases),
)
const sliced = filtered.slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(sliced))
return final.map(({ group, ...rest }) => {
const noteType = group !== 'Note' ? group : undefined
const te = typeEntryMap[group]