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:
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
88
src/utils/wikilink.test.ts
Normal file
88
src/utils/wikilink.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user