From 039f9e447fd0bf352fc4bd535efe3b90877d8ff6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 19 Apr 2026 17:35:08 +0200 Subject: [PATCH] fix: cache inspector link lookups --- .../inspector/useInspectorData.test.ts | 57 +++++ src/components/inspector/useInspectorData.ts | 203 ++++++++++++++---- 2 files changed, 214 insertions(+), 46 deletions(-) create mode 100644 src/components/inspector/useInspectorData.test.ts diff --git a/src/components/inspector/useInspectorData.test.ts b/src/components/inspector/useInspectorData.test.ts new file mode 100644 index 00000000..e8178d86 --- /dev/null +++ b/src/components/inspector/useInspectorData.test.ts @@ -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)) + }) +}) diff --git a/src/components/inspector/useInspectorData.ts b/src/components/inspector/useInspectorData.ts index 786a5c8b..4f30a7b8 100644 --- a/src/components/inspector/useInspectorData.ts +++ b/src/components/inspector/useInspectorData.ts @@ -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): 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 + backlinks: Map } -function refsMatchTargets(refs: string[], entryPath: string, targets: Set): boolean { - return refs.some((ref) => { - const target = wikilinkTarget(ref) - return targetMatchesEntry(target, entryPath, targets) - }) +interface EntryLookup { + exactTargetEntries: Map + pathSuffixEntries: Map +} + +const inspectorLinkIndexCache = new WeakMap() + +function pushToEntryLookup(map: Map, key: string, entry: VaultEntry): void { + const existing = map.get(key) + if (existing) { + existing.push(entry) + return + } + map.set(key, [entry]) +} + +function pushToResultMap(map: Map, 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() + const pathSuffixEntries = new Map() + + 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() + 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() + + 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, +): 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, +): 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() + const backlinks = new Map() + + 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]) }