feat: subfolder support — path-based wikilink resolution and cross-folder backlinks

- resolveEntry: add path-suffix matching so [[docs/adr/0031-foo]] resolves
  to entries in subdirectories (Pass 1, before filename match)
- Inspector backlinks: replace hardcoded /Laputa/ regex with generic
  path-suffix matching via targetMatchesEntry helper
- Autocomplete preFilter: also match against file path so searching
  subfolder names (e.g. "adr") surfaces nested notes
- Add relativePathStem utility for vault-relative path extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-31 11:47:54 +02:00
parent 4dd30c53b9
commit 3ede96a437
4 changed files with 66 additions and 19 deletions

View File

@@ -29,6 +29,15 @@ interface InspectorProps {
onToggleRawEditor?: () => void
}
function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set<string>): 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[] = []

View File

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

View File

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

View File

@@ -25,7 +25,8 @@ export function preFilterWikilinks<T extends WikilinkBaseItem>(
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)
)
}