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:
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user