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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-11 19:12:05 +01:00
parent 1486bae4bd
commit ee94ecb731
9 changed files with 187 additions and 42 deletions

View File

@@ -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())

View File

@@ -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)
})
})

View File

@@ -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<string, unknown>): Promise<string> {
@@ -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. */

View File

@@ -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. */

View File

@@ -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', () => {

View File

@@ -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<string, number>()
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)
},
}))
}

View File

@@ -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>): 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)
})
})

View File

@@ -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
})
}

View File

@@ -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 */