diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 3f8bf3ae..742df6ed 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -29,6 +29,15 @@ interface InspectorProps { onToggleRawEditor?: () => void } +function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set): boolean { + if (matchTargets.has(target)) return true + const lastSegment = target.split('/').pop() ?? '' + if (matchTargets.has(lastSegment)) return true + // Path-suffix match: "projects/alpha" matches entry path ending with "/projects/alpha.md" + if (target.includes('/') && entryPath.toLowerCase().endsWith('/' + target.toLowerCase() + '.md')) return true + return false +} + function useBacklinks( entry: VaultEntry | null, entries: VaultEntry[], @@ -39,7 +48,6 @@ function useBacklinks( const matchTargets = new Set([ entry.title, ...entry.aliases, entry.filename.replace(/\.md$/, ''), - entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, ''), ]) const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path)) @@ -48,9 +56,7 @@ function useBacklinks( .filter((e) => { if (e.path === entry.path) return false if (referencedByPaths.has(e.path)) return false - return e.outgoingLinks.some((target) => - matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '') - ) + return e.outgoingLinks.some((target) => targetMatchesEntry(target, entry.path, matchTargets)) }) .map((e) => ({ entry: e, @@ -70,9 +76,8 @@ function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): Refer return useMemo(() => { if (!entry) return [] - const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') const filenameStem = entry.filename.replace(/\.md$/, '') - const matchTargets = new Set([pathStem, filenameStem, entry.title, ...entry.aliases]) + const matchTargets = new Set([filenameStem, entry.title, ...entry.aliases]) const results: ReferencedByItem[] = [] diff --git a/src/utils/wikilink.test.ts b/src/utils/wikilink.test.ts index bb2ee122..1faf3bb8 100644 --- a/src/utils/wikilink.test.ts +++ b/src/utils/wikilink.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import type { VaultEntry } from '../types' -import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink' +import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink' function makeEntry(overrides: Partial): VaultEntry { return { @@ -96,4 +96,31 @@ describe('resolveEntry', () => { // Searching for "bar" should match barEntry (by filename stem) not fooEntry (by title) expect(resolveEntry(ambiguous, 'bar')).toBe(barEntry) }) + + it('resolves path-style target by matching path suffix', () => { + const adr = makeEntry({ path: '/vault/docs/adr/0031-foo.md', filename: '0031-foo.md', title: '0031 Foo' }) + const flat = makeEntry({ path: '/vault/hello.md', filename: 'hello.md', title: 'Hello' }) + expect(resolveEntry([adr, flat], 'docs/adr/0031-foo')).toBe(adr) + }) + + it('disambiguates same-name files in different subfolders via path', () => { + const alpha = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' }) + const alphaArchived = makeEntry({ path: '/vault/archive/alpha.md', filename: 'alpha.md', title: 'Alpha' }) + expect(resolveEntry([alpha, alphaArchived], 'projects/alpha')).toBe(alpha) + expect(resolveEntry([alpha, alphaArchived], 'archive/alpha')).toBe(alphaArchived) + }) +}) + +describe('relativePathStem', () => { + it('extracts relative path stem from absolute path and vault path', () => { + expect(relativePathStem('/Users/luca/Vault/note.md', '/Users/luca/Vault')).toBe('note') + }) + + it('preserves subdirectory structure', () => { + expect(relativePathStem('/Users/luca/Vault/docs/adr/0031.md', '/Users/luca/Vault')).toBe('docs/adr/0031') + }) + + it('falls back to filename stem when vault path does not match', () => { + expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note') + }) }) diff --git a/src/utils/wikilink.ts b/src/utils/wikilink.ts index 0951ac08..beab91af 100644 --- a/src/utils/wikilink.ts +++ b/src/utils/wikilink.ts @@ -18,39 +18,53 @@ export function wikilinkDisplay(ref: string): string { return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } +/** Extract the vault-relative path stem (no leading slash, no .md extension). */ +export function relativePathStem(absolutePath: string, vaultPath: string): string { + const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/' + if (absolutePath.startsWith(prefix)) return absolutePath.slice(prefix.length).replace(/\.md$/, '') + // Fallback: just the filename stem + const filename = absolutePath.split('/').pop() ?? absolutePath + return filename.replace(/\.md$/, '') +} + /** * Unified wikilink resolution: find the VaultEntry matching a wikilink target. * Handles pipe syntax, case-insensitive matching. * Resolution order (multi-pass, global priority): - * 1. Filename stem match (strongest — filename IS the identity in flat vault) - * 2. Alias match - * 3. Exact title match - * 4. Humanized title match (kebab-case → words) - * No path-based matching — flat vault uses title/filename only. - * Legacy path-style targets like "person/alice" are handled by extracting the last segment. + * 1. Path-suffix match (for path-style targets like "docs/adr/0031-foo") + * 2. Filename stem match (strongest for flat vaults) + * 3. Alias match + * 4. Exact title match + * 5. Humanized title match (kebab-case → words) */ export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined { const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget const keyLower = key.toLowerCase() - // For legacy path-style targets like "person/alice", extract just the last segment const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key const lastSegmentLower = lastSegment.toLowerCase() const asWords = lastSegmentLower.replace(/-/g, ' ') + const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null - // Pass 1: filename stem (strongest match — filename IS identity in flat vault) + // Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo") + if (pathSuffix) { + for (const e of entries) { + if (e.path.toLowerCase().endsWith(pathSuffix)) return e + } + } + // Pass 2: filename stem (strongest for flat vault) for (const e of entries) { const stem = e.filename.replace(/\.md$/, '').toLowerCase() if (stem === keyLower || stem === lastSegmentLower) return e } - // Pass 2: alias + // Pass 3: alias for (const e of entries) { if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e } - // Pass 3: exact title + // Pass 4: exact title for (const e of entries) { if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e } - // Pass 4: humanized title (kebab-case → words) + // Pass 5: humanized title (kebab-case → words) if (asWords !== keyLower) { for (const e of entries) { if (e.title.toLowerCase() === asWords) return e diff --git a/src/utils/wikilinkSuggestions.ts b/src/utils/wikilinkSuggestions.ts index 5774eee3..e7b3f171 100644 --- a/src/utils/wikilinkSuggestions.ts +++ b/src/utils/wikilinkSuggestions.ts @@ -25,7 +25,8 @@ export function preFilterWikilinks( return items.filter(item => item.title.toLowerCase().includes(lowerQuery) || item.aliases.some(a => a.toLowerCase().includes(lowerQuery)) || - item.group.toLowerCase().includes(lowerQuery) + item.group.toLowerCase().includes(lowerQuery) || + item.path.toLowerCase().includes(lowerQuery) ) }