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:
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user