fix: cache inspector link lookups

This commit is contained in:
lucaronin
2026-04-19 17:35:08 +02:00
parent 8581a1b1e6
commit 039f9e447f
2 changed files with 214 additions and 46 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { makeEntry } from '../../test-utils/noteListTestUtils'
import { buildInspectorLinkIndex, getInspectorLinkIndex } from './useInspectorData'
describe('buildInspectorLinkIndex', () => {
it('indexes referenced-by and backlinks once per matching source entry', () => {
const target = makeEntry({
path: '/vault/work/responsibility/grow-newsletter.md',
filename: 'grow-newsletter.md',
title: 'Grow Newsletter',
aliases: ['Newsletter'],
isA: 'Responsibility',
})
const relationshipSource = makeEntry({
path: '/vault/essay/on-writing.md',
filename: 'on-writing.md',
title: 'On Writing Well',
isA: 'Essay',
relationships: {
'Belongs to': ['[[responsibility/grow-newsletter]]', '[[Newsletter]]'],
Type: ['[[essay]]'],
},
outgoingLinks: ['grow-newsletter', 'responsibility/grow-newsletter'],
})
const typeSource = makeEntry({
path: '/vault/type/responsibility.md',
filename: 'responsibility.md',
title: 'Responsibility',
isA: 'Type',
relationships: {
Type: ['[[grow-newsletter]]'],
},
})
const index = buildInspectorLinkIndex([target, relationshipSource, typeSource])
expect(index.referencedBy.get(target.path)).toEqual([
{ entry: relationshipSource, viaKey: 'Belongs to' },
])
expect(index.backlinks.get(target.path)).toEqual([
{ entry: relationshipSource, context: null },
])
})
it('reuses the cached index for the same entries array', () => {
const entries = [
makeEntry({
path: '/vault/target.md',
filename: 'target.md',
title: 'Target',
}),
]
expect(getInspectorLinkIndex(entries)).toBe(getInspectorLinkIndex(entries))
expect(getInspectorLinkIndex([...entries])).not.toBe(getInspectorLinkIndex(entries))
})
})

View File

@@ -3,63 +3,174 @@ import type { VaultEntry } from '../../types'
import { wikilinkTarget } from '../../utils/wikilink'
import type { ReferencedByItem, BacklinkItem } from '../InspectorPanels'
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
if (target.includes('/') && entryPath.toLowerCase().endsWith('/' + target.toLowerCase() + '.md')) return true
return false
interface InspectorLinkIndex {
referencedBy: Map<string, ReferencedByItem[]>
backlinks: Map<string, BacklinkItem[]>
}
function refsMatchTargets(refs: string[], entryPath: string, targets: Set<string>): boolean {
return refs.some((ref) => {
const target = wikilinkTarget(ref)
return targetMatchesEntry(target, entryPath, targets)
})
interface EntryLookup {
exactTargetEntries: Map<string, VaultEntry[]>
pathSuffixEntries: Map<string, VaultEntry[]>
}
const inspectorLinkIndexCache = new WeakMap<VaultEntry[], InspectorLinkIndex>()
function pushToEntryLookup(map: Map<string, VaultEntry[]>, key: string, entry: VaultEntry): void {
const existing = map.get(key)
if (existing) {
existing.push(entry)
return
}
map.set(key, [entry])
}
function pushToResultMap<T>(map: Map<string, T[]>, key: string, item: T): void {
const existing = map.get(key)
if (existing) {
existing.push(item)
return
}
map.set(key, [item])
}
function getEntryPathSuffixes(entryPath: string): string[] {
const pathWithoutExtension = entryPath.replace(/\.md$/, '').replace(/^\/+/, '')
const segments = pathWithoutExtension.split('/')
const suffixes: string[] = []
for (let index = 0; index < segments.length; index += 1) {
suffixes.push(segments.slice(index).join('/').toLowerCase())
}
return suffixes
}
function buildEntryLookup(entries: VaultEntry[]): EntryLookup {
const exactTargetEntries = new Map<string, VaultEntry[]>()
const pathSuffixEntries = new Map<string, VaultEntry[]>()
for (const entry of entries) {
const exactTargets = new Set([
entry.filename.replace(/\.md$/, ''),
entry.title,
...entry.aliases,
])
const pathSuffixes = new Set(getEntryPathSuffixes(entry.path))
for (const target of exactTargets) {
pushToEntryLookup(exactTargetEntries, target, entry)
}
for (const suffix of pathSuffixes) {
pushToEntryLookup(pathSuffixEntries, suffix, entry)
}
}
return { exactTargetEntries, pathSuffixEntries }
}
function findMatchedEntries(target: string, lookup: EntryLookup): VaultEntry[] {
const matches = new Map<string, VaultEntry>()
const lastSegment = target.split('/').pop() ?? ''
const pathMatches = target.includes('/') ? lookup.pathSuffixEntries.get(target.toLowerCase()) : undefined
for (const candidate of lookup.exactTargetEntries.get(target) ?? []) {
matches.set(candidate.path, candidate)
}
for (const candidate of lookup.exactTargetEntries.get(lastSegment) ?? []) {
matches.set(candidate.path, candidate)
}
for (const candidate of pathMatches ?? []) {
matches.set(candidate.path, candidate)
}
return [...matches.values()]
}
function collectMatchedPaths(
targets: string[],
lookup: EntryLookup,
sourcePath: string,
resolveTarget: (target: string) => string,
): string[] {
const matchedPaths = new Set<string>()
for (const rawTarget of targets) {
const target = resolveTarget(rawTarget)
for (const matchedEntry of findMatchedEntries(target, lookup)) {
if (matchedEntry.path !== sourcePath) {
matchedPaths.add(matchedEntry.path)
}
}
}
return [...matchedPaths]
}
function indexReferencedByEntries(
sourceEntry: VaultEntry,
lookup: EntryLookup,
referencedBy: Map<string, ReferencedByItem[]>,
): void {
for (const [viaKey, refs] of Object.entries(sourceEntry.relationships)) {
if (viaKey === 'Type') continue
for (const matchedPath of collectMatchedPaths(refs, lookup, sourceEntry.path, wikilinkTarget)) {
pushToResultMap(referencedBy, matchedPath, { entry: sourceEntry, viaKey })
}
}
}
function indexBacklinkEntries(
sourceEntry: VaultEntry,
lookup: EntryLookup,
backlinks: Map<string, BacklinkItem[]>,
): void {
for (const matchedPath of collectMatchedPaths(sourceEntry.outgoingLinks, lookup, sourceEntry.path, (target) => target)) {
pushToResultMap(backlinks, matchedPath, { entry: sourceEntry, context: null })
}
}
export function buildInspectorLinkIndex(entries: VaultEntry[]): InspectorLinkIndex {
const lookup = buildEntryLookup(entries)
const referencedBy = new Map<string, ReferencedByItem[]>()
const backlinks = new Map<string, BacklinkItem[]>()
for (const sourceEntry of entries) {
indexReferencedByEntries(sourceEntry, lookup, referencedBy)
indexBacklinkEntries(sourceEntry, lookup, backlinks)
}
return { referencedBy, backlinks }
}
export function getInspectorLinkIndex(entries: VaultEntry[]): InspectorLinkIndex {
const cached = inspectorLinkIndexCache.get(entries)
if (cached) return cached
const built = buildInspectorLinkIndex(entries)
inspectorLinkIndexCache.set(entries, built)
return built
}
export function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): ReferencedByItem[] {
const linkIndex = useMemo(() => getInspectorLinkIndex(entries), [entries])
return useMemo(() => {
if (!entry) return []
const filenameStem = entry.filename.replace(/\.md$/, '')
const matchTargets = new Set([filenameStem, entry.title, ...entry.aliases])
const results: ReferencedByItem[] = []
for (const other of entries) {
if (other.path === entry.path) continue
for (const [key, refs] of Object.entries(other.relationships)) {
if (key !== 'Type' && refsMatchTargets(refs, entry.path, matchTargets)) {
results.push({ entry: other, viaKey: key })
}
}
}
return results
}, [entry, entries])
return linkIndex.referencedBy.get(entry.path) ?? []
}, [entry, linkIndex])
}
export function useBacklinks(
entry: VaultEntry | null,
entries: VaultEntry[],
referencedBy: ReferencedByItem[],
): BacklinkItem[] {
export function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): BacklinkItem[] {
const linkIndex = useMemo(() => getInspectorLinkIndex(entries), [entries])
return useMemo(() => {
if (!entry) return []
const matchTargets = new Set([
entry.title,
...entry.aliases,
entry.filename.replace(/\.md$/, ''),
])
const backlinks = linkIndex.backlinks.get(entry.path) ?? []
if (referencedBy.length === 0) return backlinks
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
return entries
.filter((other) => {
if (other.path === entry.path) return false
if (referencedByPaths.has(other.path)) return false
return other.outgoingLinks.some((target) => targetMatchesEntry(target, entry.path, matchTargets))
})
.map((other) => ({ entry: other, context: null }))
}, [entry, entries, referencedBy])
return backlinks.filter((item) => !referencedByPaths.has(item.entry.path))
}, [entry, linkIndex, referencedBy])
}