fix: deduplicate autocomplete suggestions and disambiguate same-title notes (#54)

BlockNote's SuggestionMenu uses item.title as React key, causing duplicate
rendering and broken arrow-key navigation when multiple notes share the
same title. Fix by:
- Adding path-based deduplication to filter out duplicate entries
- Disambiguating same-title notes with parent folder name (e.g. "Standup (work)")
- Deduplicating aliases within each item (filename vs entry.aliases overlap)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-24 23:12:35 +01:00
committed by GitHub
parent 2e2224865c
commit 3ea175eec5
4 changed files with 162 additions and 9 deletions

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest'
import { preFilterWikilinks, MIN_QUERY_LENGTH, MAX_RESULTS, type WikilinkBaseItem } from './wikilinkSuggestions'
import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MIN_QUERY_LENGTH, MAX_RESULTS, type WikilinkBaseItem } from './wikilinkSuggestions'
function makeItem(title: string, aliases: string[] = [], group = 'Note'): WikilinkBaseItem {
return { title, aliases, group, entryTitle: title }
let pathCounter = 0
function makeItem(title: string, aliases: string[] = [], group = 'Note', path?: string): WikilinkBaseItem {
return { title, aliases, group, entryTitle: title, path: path ?? `/vault/${title.toLowerCase().replace(/\s/g, '-')}-${pathCounter++}.md` }
}
describe('preFilterWikilinks', () => {
@@ -83,7 +84,7 @@ describe('constants', () => {
describe('preFilterWikilinks with large dataset', () => {
const largeItems: WikilinkBaseItem[] = Array.from({ length: 10000 }, (_, i) =>
makeItem(`Note ${i}`, [`alias-${i}`], i % 3 === 0 ? 'Project' : 'Note')
makeItem(`Note ${i}`, [`alias-${i}`], i % 3 === 0 ? 'Project' : 'Note', `/vault/note-${i}.md`)
)
it('handles 10000+ items without throwing', () => {
@@ -96,3 +97,77 @@ describe('preFilterWikilinks with large dataset', () => {
expect(preFilterWikilinks(largeItems, 'N')).toEqual([])
})
})
describe('deduplicateByPath', () => {
it('removes items with duplicate paths, keeping the first occurrence', () => {
const items = [
makeItem('Alpha', [], 'Note', '/vault/alpha.md'),
makeItem('Beta', [], 'Note', '/vault/beta.md'),
makeItem('Alpha Dup', [], 'Note', '/vault/alpha.md'),
]
const result = deduplicateByPath(items)
expect(result).toHaveLength(2)
expect(result[0].title).toBe('Alpha')
expect(result[1].title).toBe('Beta')
})
it('returns all items when paths are unique', () => {
const items = [
makeItem('A', [], 'Note', '/vault/a.md'),
makeItem('B', [], 'Note', '/vault/b.md'),
makeItem('C', [], 'Note', '/vault/c.md'),
]
expect(deduplicateByPath(items)).toHaveLength(3)
})
it('returns empty array for empty input', () => {
expect(deduplicateByPath([])).toEqual([])
})
})
describe('disambiguateTitles', () => {
it('appends parent folder when titles collide', () => {
const items = [
makeItem('Meeting Notes', [], 'Note', '/vault/project/meeting-notes.md'),
makeItem('Meeting Notes', [], 'Note', '/vault/personal/meeting-notes.md'),
]
const result = disambiguateTitles(items)
expect(result).toHaveLength(2)
expect(result[0].title).toBe('Meeting Notes (project)')
expect(result[1].title).toBe('Meeting Notes (personal)')
})
it('leaves unique titles unchanged', () => {
const items = [
makeItem('Alpha', [], 'Note', '/vault/alpha.md'),
makeItem('Beta', [], 'Note', '/vault/beta.md'),
]
const result = disambiguateTitles(items)
expect(result[0].title).toBe('Alpha')
expect(result[1].title).toBe('Beta')
})
it('preserves entryTitle even when title is disambiguated', () => {
const items = [
makeItem('Standup', [], 'Note', '/vault/work/standup.md'),
makeItem('Standup', [], 'Note', '/vault/personal/standup.md'),
]
const result = disambiguateTitles(items)
expect(result[0].entryTitle).toBe('Standup')
expect(result[1].entryTitle).toBe('Standup')
})
it('handles three-way title collision', () => {
const items = [
makeItem('TODO', [], 'Note', '/vault/work/todo.md'),
makeItem('TODO', [], 'Note', '/vault/personal/todo.md'),
makeItem('TODO', [], 'Note', '/vault/archive/todo.md'),
]
const result = disambiguateTitles(items)
expect(new Set(result.map(r => r.title)).size).toBe(3)
})
it('returns empty array for empty input', () => {
expect(disambiguateTitles([])).toEqual([])
})
})

View File

@@ -6,6 +6,7 @@ export interface WikilinkBaseItem {
aliases: string[]
group: string
entryTitle: string
path: string
}
/**
@@ -27,3 +28,32 @@ export function preFilterWikilinks<T extends WikilinkBaseItem>(
item.group.toLowerCase().includes(lowerQuery)
)
}
/** Remove duplicate items by path, keeping the first occurrence. */
export function deduplicateByPath<T extends { path: string }>(items: T[]): T[] {
const seen = new Set<string>()
return items.filter(item => {
if (seen.has(item.path)) return false
seen.add(item.path)
return true
})
}
/**
* When multiple items share the same title, append the parent folder name
* so the user can distinguish them and BlockNote gets unique React keys.
*/
export function disambiguateTitles<T extends { title: string; path: string }>(
items: T[],
): T[] {
const titleCounts = new Map<string, number>()
for (const item of items) {
titleCounts.set(item.title, (titleCounts.get(item.title) ?? 0) + 1)
}
return items.map(item => {
if ((titleCounts.get(item.title) ?? 0) <= 1) return item
const parts = item.path.split('/')
const folder = parts.length >= 2 ? parts[parts.length - 2] : ''
return { ...item, title: `${item.title} (${folder})` }
})
}