diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 73660880..996409fd 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -360,4 +360,50 @@ describe('wikilink autocomplete', () => { ' ', ]) }) + + it('deduplicates entries with the same path', async () => { + const dupEntries: VaultEntry[] = [ + { ...mockEntry, title: 'Dup Note', filename: 'dup.md', path: '/vault/dup.md', aliases: [] }, + { ...mockEntry, title: 'Dup Note Copy', filename: 'dup.md', path: '/vault/dup.md', aliases: [] }, + { ...mockEntry, title: 'Other Note', filename: 'other.md', path: '/vault/other.md', aliases: [] }, + ] + capturedGetItems = null + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + render( + + ) + const items = await capturedGetItems!('Note') + const paths = items.map((i: { path: string }) => i.path) + expect(new Set(paths).size).toBe(paths.length) + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + }) + + it('disambiguates entries with the same title by appending folder name', async () => { + const sameTitle: VaultEntry[] = [ + { ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/work/standup.md', aliases: [] }, + { ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/personal/standup.md', aliases: [] }, + ] + capturedGetItems = null + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + render( + + ) + const items = await capturedGetItems!('Standup') + expect(items).toHaveLength(2) + const titles = items.map((i: { title: string }) => i.title) + expect(new Set(titles).size).toBe(2) + expect(titles).toContain('Standup (work)') + expect(titles).toContain('Standup (personal)') + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + }) }) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index e01f32a9..ede88396 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -14,7 +14,7 @@ import { TabBar } from './TabBar' import { BreadcrumbBar } from './BreadcrumbBar' import { useEditorTheme } from '../hooks/useTheme' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks' -import { preFilterWikilinks, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' +import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import './Editor.css' import './EditorTheme.css' @@ -132,12 +132,13 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e }, [editor]) const baseItems = useMemo( - () => entries.map(entry => ({ + () => deduplicateByPath(entries.map(entry => ({ title: entry.title, - aliases: [entry.filename.replace(/\.md$/, ''), ...entry.aliases], + aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])], group: entry.isA || 'Note', entryTitle: entry.title, - })), + path: entry.path, + }))), [entries] ) @@ -157,7 +158,8 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e ]) }, })) - return filterSuggestionItems(items, query).slice(0, MAX_RESULTS) + const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS) + return disambiguateTitles(deduplicateByPath(filtered)) }, [baseItems, editor]) return ( diff --git a/src/utils/wikilinkSuggestions.test.ts b/src/utils/wikilinkSuggestions.test.ts index 3b1ead17..f96263e9 100644 --- a/src/utils/wikilinkSuggestions.test.ts +++ b/src/utils/wikilinkSuggestions.test.ts @@ -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([]) + }) +}) diff --git a/src/utils/wikilinkSuggestions.ts b/src/utils/wikilinkSuggestions.ts index 5acedb85..3d8feeaa 100644 --- a/src/utils/wikilinkSuggestions.ts +++ b/src/utils/wikilinkSuggestions.ts @@ -6,6 +6,7 @@ export interface WikilinkBaseItem { aliases: string[] group: string entryTitle: string + path: string } /** @@ -27,3 +28,32 @@ export function preFilterWikilinks( item.group.toLowerCase().includes(lowerQuery) ) } + +/** Remove duplicate items by path, keeping the first occurrence. */ +export function deduplicateByPath(items: T[]): T[] { + const seen = new Set() + 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( + items: T[], +): T[] { + const titleCounts = new Map() + 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})` } + }) +}