From ee94ecb73183277280a2001b779c1ab2907bec27 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 11 Mar 2026 19:12:05 +0100 Subject: [PATCH] fix: unify wikilink resolution and disambiguate duplicate titles - Create resolveEntry() in wikilink.ts: single case-insensitive resolution function that handles title, alias, filename stem, path suffix, and pipe syntax matching - Replace findEntryByTarget (case-sensitive) and entryMatchesTarget (hardcoded /Laputa/ path) with unified resolveEntry - Fix attachClickHandlers to insert path|title pipe syntax when multiple candidates share the same title (disambiguation) - Update ai-context.ts resolveTarget to use unified resolution - Add comprehensive tests for resolveEntry and disambiguation Co-Authored-By: Claude Opus 4.6 --- src/components/editorSchema.tsx | 5 +- src/hooks/useNoteActions.test.ts | 19 ++++-- src/hooks/useNoteActions.ts | 15 ++--- src/utils/ai-context.ts | 14 ++-- src/utils/suggestionEnrichment.test.ts | 28 ++++++++ src/utils/suggestionEnrichment.ts | 21 +++++- src/utils/wikilink.test.ts | 88 ++++++++++++++++++++++++++ src/utils/wikilink.ts | 25 ++++++++ src/utils/wikilinkColors.ts | 14 ++-- 9 files changed, 187 insertions(+), 42 deletions(-) create mode 100644 src/utils/wikilink.test.ts diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index 2a5d545f..77d8bcf0 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -1,7 +1,8 @@ /* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */ import { BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core' import { createReactInlineContentSpec } from '@blocknote/react' -import { resolveWikilinkColor as resolveColor, findEntryByTarget } from '../utils/wikilinkColors' +import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' +import { resolveEntry } from '../utils/wikilink' import type { VaultEntry } from '../types' // Module-level cache so the WikiLink renderer (defined outside React) can access entries @@ -16,7 +17,7 @@ function resolveWikilinkColor(target: string) { function resolveDisplayText(target: string): string { const pipeIdx = target.indexOf('|') if (pipeIdx !== -1) return target.slice(pipeIdx + 1) - const entry = findEntryByTarget(_wikilinkEntriesRef.current, target) + const entry = resolveEntry(_wikilinkEntriesRef.current, target) if (entry) return entry.title const last = target.split('/').pop() ?? target return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index e30cb09a..af6407a8 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -182,32 +182,37 @@ describe('generateUntitledName', () => { describe('entryMatchesTarget', () => { it('matches by exact title (case-insensitive)', () => { const entry = makeEntry({ title: 'My Project' }) - expect(entryMatchesTarget(entry, 'my project', 'my project')).toBe(true) + expect(entryMatchesTarget(entry, 'my project')).toBe(true) }) it('matches by alias', () => { const entry = makeEntry({ aliases: ['MP', 'TheProject'] }) - expect(entryMatchesTarget(entry, 'mp', 'mp')).toBe(true) + expect(entryMatchesTarget(entry, 'mp')).toBe(true) }) - it('matches by path stem (relative to Laputa)', () => { + it('matches by path suffix (type/slug)', () => { const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' }) - expect(entryMatchesTarget(entry, 'project/my-project', 'project/my-project')).toBe(true) + expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true) }) it('matches by filename stem', () => { const entry = makeEntry({ filename: 'my-project.md' }) - expect(entryMatchesTarget(entry, 'my-project', 'my-project')).toBe(true) + expect(entryMatchesTarget(entry, 'my-project')).toBe(true) }) it('matches when target as words matches title', () => { const entry = makeEntry({ title: 'my project' }) - expect(entryMatchesTarget(entry, 'project/my-project', 'my project')).toBe(true) + expect(entryMatchesTarget(entry, 'my-project')).toBe(true) }) it('returns false when nothing matches', () => { const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' }) - expect(entryMatchesTarget(entry, 'nonexistent', 'nonexistent')).toBe(false) + expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false) + }) + + it('handles pipe syntax targets', () => { + const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' }) + expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true) }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 570efad5..ba695638 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -5,6 +5,7 @@ import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' import { useTabManagement } from './useTabManagement' import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers' +import { resolveEntry } from '../utils/wikilink' interface NewEntryParams { path: string @@ -123,14 +124,8 @@ export function generateUntitledName(entries: VaultEntry[], type: string, pendin return title } -export function entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean { - if (e.title.toLowerCase() === targetLower) return true - if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true - const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') - if (pathStem.toLowerCase() === targetLower) return true - const fileStem = e.filename.replace(/\.md$/, '') - if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true - return e.title.toLowerCase() === targetAsWords +export function entryMatchesTarget(e: VaultEntry, target: string): boolean { + return resolveEntry([e], target) === e } async function invokeFrontmatter(command: string, args: Record): Promise { @@ -262,9 +257,7 @@ function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => voi } function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { - const targetLower = target.toLowerCase() - const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower - return entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords)) + return resolveEntry(entries, target) } /** Navigate to a wikilink target, logging a warning if not found. */ diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index a871b76b..2722a625 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -4,7 +4,7 @@ */ import type { VaultEntry } from '../types' -import { wikilinkTarget } from './wikilink' +import { wikilinkTarget, resolveEntry } from './wikilink' import { splitFrontmatter } from './wikilinks' /** Extract only the body text from raw file content (strips YAML frontmatter). */ @@ -13,16 +13,10 @@ function extractBody(rawContent: string): string { return body.trim() } -/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */ +/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. + * Delegates to the unified resolveEntry for consistent matching. */ export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined { - const lower = target.toLowerCase() - return entries.find(e => { - if (e.title.toLowerCase() === lower) return true - if (e.aliases.some(a => a.toLowerCase() === lower)) return true - const stem = e.filename.replace(/\.md$/, '') - if (stem.toLowerCase() === lower) return true - return false - }) + return resolveEntry(entries, target) } /** Collect first-degree linked notes from the active entry. */ diff --git a/src/utils/suggestionEnrichment.test.ts b/src/utils/suggestionEnrichment.test.ts index 4297d5b1..b083f2e6 100644 --- a/src/utils/suggestionEnrichment.test.ts +++ b/src/utils/suggestionEnrichment.test.ts @@ -42,6 +42,34 @@ describe('attachClickHandlers', () => { ) expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' }) }) + + it('uses path|title target when candidates have duplicate titles', () => { + const insertWikilink = vi.fn() + const candidates = [ + { title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/project/status-update.md' }, + { title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/journal/status-update.md' }, + ] + + const result = attachClickHandlers(candidates, insertWikilink) + + result[0].onItemClick() + expect(insertWikilink).toHaveBeenCalledWith('project/status-update|Status Update') + result[1].onItemClick() + expect(insertWikilink).toHaveBeenCalledWith('journal/status-update|Status Update') + }) + + it('uses title-only target when titles are unique', () => { + const insertWikilink = vi.fn() + const candidates = [ + { title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/note/alpha.md' }, + { title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/note/beta.md' }, + ] + + const result = attachClickHandlers(candidates, insertWikilink) + + result[0].onItemClick() + expect(insertWikilink).toHaveBeenCalledWith('Alpha') + }) }) describe('enrichSuggestionItems', () => { diff --git a/src/utils/suggestionEnrichment.ts b/src/utils/suggestionEnrichment.ts index cb0e536a..6fe931d9 100644 --- a/src/utils/suggestionEnrichment.ts +++ b/src/utils/suggestionEnrichment.ts @@ -16,14 +16,31 @@ interface BaseSuggestionItem { path: string } -/** Add onItemClick to raw suggestion candidates */ +/** Build a vault-relative path target with pipe display: "type/slug|Title" */ +function buildPathTarget(item: BaseSuggestionItem): string { + const parts = item.path.split('/') + const vaultRelPath = parts.slice(-2).join('/').replace(/\.md$/, '') + return `${vaultRelPath}|${item.entryTitle}` +} + +/** Add onItemClick to raw suggestion candidates. + * When multiple candidates share the same title, inserts a path-based + * target with pipe syntax so the wikilink uniquely identifies the note. */ export function attachClickHandlers( candidates: BaseSuggestionItem[], insertWikilink: (target: string) => void, ) { + const titleCounts = new Map() + for (const item of candidates) { + titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1) + } + return candidates.map(item => ({ ...item, - onItemClick: () => insertWikilink(item.entryTitle), + onItemClick: () => { + const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1 + insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle) + }, })) } diff --git a/src/utils/wikilink.test.ts b/src/utils/wikilink.test.ts new file mode 100644 index 00000000..b87f83f9 --- /dev/null +++ b/src/utils/wikilink.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import type { VaultEntry } from '../types' +import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink' + +function makeEntry(overrides: Partial): VaultEntry { + return { + path: '/vault/note.md', filename: 'note.md', title: 'Note', isA: null, + aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, + cadence: null, archived: false, trashed: false, trashedAt: null, + modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0, + relationships: {}, icon: null, color: null, order: null, template: null, + sort: null, outgoingLinks: [], sidebarLabel: null, view: null, visible: null, + properties: {}, + ...overrides, + } +} + +describe('wikilinkTarget', () => { + it('strips brackets', () => { + expect(wikilinkTarget('[[foo]]')).toBe('foo') + }) + it('returns target before pipe', () => { + expect(wikilinkTarget('[[path|display]]')).toBe('path') + }) + it('handles bare text without brackets', () => { + expect(wikilinkTarget('just text')).toBe('just text') + }) +}) + +describe('wikilinkDisplay', () => { + it('returns text after pipe', () => { + expect(wikilinkDisplay('[[path|My Title]]')).toBe('My Title') + }) + it('humanises slug when no pipe', () => { + expect(wikilinkDisplay('[[my-note]]')).toBe('My Note') + }) +}) + +describe('resolveEntry', () => { + const alice = makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice', isA: 'Person', aliases: ['Alice Smith'] }) + const bob = makeEntry({ path: '/vault/person/bob.md', filename: 'bob.md', title: 'Bob', isA: 'Person' }) + const project = makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project' }) + const entries = [alice, bob, project] + + it('matches by title (case-insensitive)', () => { + expect(resolveEntry(entries, 'alice')).toBe(alice) + expect(resolveEntry(entries, 'ALICE')).toBe(alice) + expect(resolveEntry(entries, 'Alice')).toBe(alice) + }) + + it('matches by alias (case-insensitive)', () => { + expect(resolveEntry(entries, 'alice smith')).toBe(alice) + expect(resolveEntry(entries, 'Alice Smith')).toBe(alice) + }) + + it('matches by filename stem (case-insensitive)', () => { + expect(resolveEntry(entries, 'my-project')).toBe(project) + expect(resolveEntry(entries, 'My-Project')).toBe(project) + }) + + it('matches by relative path suffix (type/slug)', () => { + expect(resolveEntry(entries, 'person/alice')).toBe(alice) + expect(resolveEntry(entries, 'project/my-project')).toBe(project) + }) + + it('handles pipe syntax: uses target part for lookup', () => { + expect(resolveEntry(entries, 'person/alice|Alice S.')).toBe(alice) + expect(resolveEntry(entries, 'Alice|A')).toBe(alice) + }) + + it('returns undefined for non-existent target', () => { + expect(resolveEntry(entries, 'Does Not Exist')).toBeUndefined() + }) + + it('returns undefined for empty entries', () => { + expect(resolveEntry([], 'Alice')).toBeUndefined() + }) + + it('matches by filename stem from last segment of path target', () => { + // If target is "person/alice", the last segment "alice" should match filename stem + expect(resolveEntry(entries, 'person/alice')).toBe(alice) + }) + + it('matches title-as-words from kebab-case target', () => { + // "my-project" → "my project" should match title "My Project" + expect(resolveEntry(entries, 'my-project')).toBe(project) + }) +}) diff --git a/src/utils/wikilink.ts b/src/utils/wikilink.ts index 2a499ed7..ea6798a8 100644 --- a/src/utils/wikilink.ts +++ b/src/utils/wikilink.ts @@ -1,5 +1,7 @@ /** Utility functions for parsing wikilink syntax: [[target|display]] */ +import type { VaultEntry } from '../types' + /** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */ export function wikilinkTarget(ref: string): string { const inner = ref.replace(/^\[\[|\]\]$/g, '') @@ -15,3 +17,26 @@ export function wikilinkDisplay(ref: string): string { const last = inner.split('/').pop() ?? inner return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } + +/** + * Unified wikilink resolution: find the VaultEntry matching a wikilink target. + * Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup. + */ +export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined { + const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget + const keyLower = key.toLowerCase() + const suffix = '/' + key + '.md' + const lastSegment = key.split('/').pop() ?? key + const asWords = lastSegment.replace(/-/g, ' ').toLowerCase() + + return entries.find(e => { + if (e.title.toLowerCase() === keyLower) return true + if (e.aliases.some(a => a.toLowerCase() === keyLower)) return true + const stem = e.filename.replace(/\.md$/, '') + if (stem.toLowerCase() === keyLower) return true + if (e.path.endsWith(suffix)) return true + if (stem.toLowerCase() === lastSegment.toLowerCase()) return true + if (e.title.toLowerCase() === asWords) return true + return false + }) +} diff --git a/src/utils/wikilinkColors.ts b/src/utils/wikilinkColors.ts index fcc94b65..c58cb334 100644 --- a/src/utils/wikilinkColors.ts +++ b/src/utils/wikilinkColors.ts @@ -4,21 +4,15 @@ */ import type { VaultEntry } from '../types' import { getTypeColor } from './typeColors' +import { resolveEntry } from './wikilink' /** Broken-link color: muted text to signal the target note doesn't exist */ const BROKEN_LINK_COLOR = 'var(--text-muted)' -/** Find a vault entry matching a wikilink target string */ +/** Find a vault entry matching a wikilink target string. + * Delegates to the unified resolveEntry for consistent case-insensitive matching. */ export function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { - // Handle pipe syntax: [[path|display name]] → use path part for matching - const key = target.includes('|') ? target.split('|')[0] : target - const suffix = '/' + key + '.md' - return entries.find(e => - e.title === key || - e.filename.replace(/\.md$/, '') === key || - e.aliases.includes(key) || - e.path.endsWith(suffix), - ) + return resolveEntry(entries, target) } /** Resolve the accent color for a given entry based on its type */