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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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})` }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user