From 60f3139b3efef8bba827f75ab77367c72612ab8e Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 8 Mar 2026 22:03:54 +0100 Subject: [PATCH] fix: ensure exact title match always ranks first in search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/useNoteSearch.test.ts | 27 ++++++++++++----- src/utils/fuzzyMatch.test.ts | 41 ++++++++++++++++++++------ src/utils/fuzzyMatch.ts | 33 ++++++++++++++++----- src/utils/suggestionEnrichment.test.ts | 10 +++++++ src/utils/suggestionEnrichment.ts | 9 ++++-- 5 files changed, 95 insertions(+), 25 deletions(-) diff --git a/src/hooks/useNoteSearch.test.ts b/src/hooks/useNoteSearch.test.ts index ccaef793..4057f975 100644 --- a/src/hooks/useNoteSearch.test.ts +++ b/src/hooks/useNoteSearch.test.ts @@ -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', () => { diff --git a/src/utils/fuzzyMatch.test.ts b/src/utils/fuzzyMatch.test.ts index b8b65764..24fb3586 100644 --- a/src/utils/fuzzyMatch.test.ts +++ b/src/utils/fuzzyMatch.test.ts @@ -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) }) }) diff --git a/src/utils/fuzzyMatch.ts b/src/utils/fuzzyMatch.ts index 6be47fa4..3706be00 100644 --- a/src/utils/fuzzyMatch.ts +++ b/src/utils/fuzzyMatch.ts @@ -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. */ diff --git a/src/utils/suggestionEnrichment.test.ts b/src/utils/suggestionEnrichment.test.ts index c0c51d54..4297d5b1 100644 --- a/src/utils/suggestionEnrichment.test.ts +++ b/src/utils/suggestionEnrichment.test.ts @@ -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') + }) }) diff --git a/src/utils/suggestionEnrichment.ts b/src/utils/suggestionEnrichment.ts index 3ec9a81d..cb0e536a 100644 --- a/src/utils/suggestionEnrichment.ts +++ b/src/utils/suggestionEnrichment.ts @@ -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, ): 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]