From 6db062603b1580eca444461d054e481b0f4c91c8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 17:51:59 -0400 Subject: [PATCH 1/4] ENG-1249 Add a cached relations index and link resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading relations from disk costs a full vault file read plus a JSON parse per call. That is fine for the Discourse Context panel, which asks once per file open, but not for anything that renders per link. RelationsIndex keeps a parsed snapshot grouped by endpoint id so a render path can ask synchronously, rebuilt from vault events so it covers our own writes and edits arriving over sync alike. resolveDiscourseLinkTarget answers, for one link, whether it points at a discourse node and how many relations that node has — from in-memory caches only, avoiding getNodeTypeIdForFile, which polls 500ms waiting on frontmatter. Link parsing moves to internalLinkParsing, which wikilinkDragHandler had its own byte-identical copy of. Nothing consumes this yet; the overlay that does follows. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/index.ts | 6 + .../src/utils/discourseLinkFrontmatter.ts | 40 +++++++ apps/obsidian/src/utils/discourseLinkUtils.ts | 58 +++++++++ .../obsidian/src/utils/internalLinkParsing.ts | 21 ++++ .../src/utils/relationsEndpointIndex.ts | 54 +++++++++ apps/obsidian/src/utils/relationsIndex.ts | 112 ++++++++++++++++++ .../obsidian/src/utils/wikilinkDragHandler.ts | 30 +---- 7 files changed, 293 insertions(+), 28 deletions(-) create mode 100644 apps/obsidian/src/utils/discourseLinkFrontmatter.ts create mode 100644 apps/obsidian/src/utils/discourseLinkUtils.ts create mode 100644 apps/obsidian/src/utils/internalLinkParsing.ts create mode 100644 apps/obsidian/src/utils/relationsEndpointIndex.ts create mode 100644 apps/obsidian/src/utils/relationsIndex.ts diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 06d169332..39d6ad400 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -35,6 +35,7 @@ import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal"; import { InlineNodeTypePicker } from "~/components/InlineNodeTypePicker"; import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase"; import { FileChangeListener } from "~/utils/fileChangeListener"; +import { RelationsIndex } from "~/utils/relationsIndex"; import generateUid from "~/utils/generateUid"; import { migrateFrontmatterRelationsToRelationsJson, @@ -51,6 +52,7 @@ import { export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; + relationsIndex: RelationsIndex = new RelationsIndex(this); private tagNodeHandler: TagNodeHandler | null = null; private fileChangeListener: FileChangeListener | null = null; private activeNodePopover: @@ -98,6 +100,8 @@ export default class DiscourseGraphPlugin extends Plugin { } } + this.relationsIndex.initialize(); + registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); addIcon(DISCOURSE_GRAPH_LOGO_ICON_ID, WHITE_LOGO_SVG); @@ -488,5 +492,7 @@ export default class DiscourseGraphPlugin extends Plugin { this.fileChangeListener.cleanup(); this.fileChangeListener = null; } + + this.relationsIndex.unload(); } } diff --git a/apps/obsidian/src/utils/discourseLinkFrontmatter.ts b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts new file mode 100644 index 000000000..168235760 --- /dev/null +++ b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts @@ -0,0 +1,40 @@ +import type { RelationInstance } from "~/types"; + +const asString = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +export const getNodeTypeIdFromFrontmatter = ( + frontmatter: Record | undefined, +): string | undefined => asString(frontmatter?.nodeTypeId); + +/** An imported node is referenced by both its nodeInstanceId and its importedFromRid. */ +export const getEndpointIdsFromFrontmatter = ( + frontmatter: Record | undefined, +): string[] => { + const endpointIds: string[] = []; + const nodeInstanceId = asString(frontmatter?.nodeInstanceId); + const importedFromRid = asString(frontmatter?.importedFromRid); + + if (nodeInstanceId) endpointIds.push(nodeInstanceId); + if (importedFromRid && importedFromRid !== nodeInstanceId) { + endpointIds.push(importedFromRid); + } + + return endpointIds; +}; + +/** + * Counts what the panel would list. Excludes unaccepted imports and relations + * orphaned by a deleted relation type, both of which the panel hides. + */ +export const countDisplayableRelations = ({ + relations, + isConfiguredType, +}: { + relations: RelationInstance[]; + isConfiguredType: (relationTypeId: string) => boolean; +}): number => + relations.filter( + (relation) => + relation.tentative !== false && isConfiguredType(relation.type), + ).length; diff --git a/apps/obsidian/src/utils/discourseLinkUtils.ts b/apps/obsidian/src/utils/discourseLinkUtils.ts new file mode 100644 index 000000000..28cf5e873 --- /dev/null +++ b/apps/obsidian/src/utils/discourseLinkUtils.ts @@ -0,0 +1,58 @@ +import { parseLinktext, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNode } from "~/types"; +import { getNodeTypeById, getRelationTypeById } from "./typeUtils"; +import { + countDisplayableRelations, + getEndpointIdsFromFrontmatter, + getNodeTypeIdFromFrontmatter, +} from "./discourseLinkFrontmatter"; + +export type DiscourseLinkTarget = { + file: TFile; + nodeType: DiscourseNode; + relationCount: number; +}; + +/** + * Resolves a link to a discourse node and its relation count from in-memory + * caches only; avoids getNodeTypeIdForFile, which polls 500ms for frontmatter. + */ +export const resolveDiscourseLinkTarget = ({ + plugin, + linktext, + sourcePath, +}: { + plugin: DiscourseGraphPlugin; + linktext: string; + sourcePath: string; +}): DiscourseLinkTarget | null => { + // Strips any #heading or #^block subpath. + const { path } = parseLinktext(linktext); + if (!path) return null; + + const file = plugin.app.metadataCache.getFirstLinkpathDest(path, sourcePath); + if (!file) return null; + + const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter; + + const nodeTypeId = getNodeTypeIdFromFrontmatter(frontmatter); + if (!nodeTypeId) return null; + + const nodeType = getNodeTypeById(plugin, nodeTypeId); + if (!nodeType) return null; + + const endpointIds = getEndpointIdsFromFrontmatter(frontmatter); + if (endpointIds.length === 0) return { file, nodeType, relationCount: 0 }; + + const relations = + plugin.relationsIndex.getRelationsForEndpointIds(endpointIds); + + const relationCount = countDisplayableRelations({ + relations, + isConfiguredType: (relationTypeId) => + !!getRelationTypeById(plugin, relationTypeId), + }); + + return { file, nodeType, relationCount }; +}; diff --git a/apps/obsidian/src/utils/internalLinkParsing.ts b/apps/obsidian/src/utils/internalLinkParsing.ts new file mode 100644 index 000000000..d95edb4f3 --- /dev/null +++ b/apps/obsidian/src/utils/internalLinkParsing.ts @@ -0,0 +1,21 @@ +// Shared by the CM6 extensions that scan raw markdown for internal links. + +/** Embeds are not matched: the leading `!` sits outside, so callers check it. */ +export const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; + +/** Target of a wikilink or markdown link; any `#subpath` is left for parseLinktext. */ +export const extractLinktext = (match: string): string => { + if (match.startsWith("[[")) { + const inner = match.slice(2, -2); + const pipeIndex = inner.indexOf("|"); + return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; + } + + const parenOpen = match.lastIndexOf("("); + const rawPath = match.slice(parenOpen + 1, -1); + try { + return decodeURIComponent(rawPath); + } catch { + return rawPath; + } +}; diff --git a/apps/obsidian/src/utils/relationsEndpointIndex.ts b/apps/obsidian/src/utils/relationsEndpointIndex.ts new file mode 100644 index 000000000..f200a392e --- /dev/null +++ b/apps/obsidian/src/utils/relationsEndpointIndex.ts @@ -0,0 +1,54 @@ +import type { RelationInstance } from "~/types"; + +/** + * Groups relations by the ids at either end, so a lookup is a Map hit rather + * than a scan. Self-relations are filed once, not twice. + */ +export const buildEndpointIndex = ( + relations: Record, +): Map => { + const index = new Map(); + + const fileUnder = (endpointId: string, relation: RelationInstance): void => { + const existing = index.get(endpointId); + if (existing) { + existing.push(relation); + return; + } + index.set(endpointId, [relation]); + }; + + for (const relation of Object.values(relations)) { + if (!relation) continue; + if (relation.source) fileUnder(relation.source, relation); + if (relation.destination && relation.destination !== relation.source) { + fileUnder(relation.destination, relation); + } + } + + return index; +}; + +/** Relations touching any of `endpointIds`, deduped: an imported node matches on two ids. */ +export const collectRelations = ({ + index, + endpointIds, +}: { + index: Map; + endpointIds: Iterable; +}): RelationInstance[] => { + const seen = new Set(); + const collected: RelationInstance[] = []; + + for (const endpointId of endpointIds) { + const relations = index.get(endpointId); + if (!relations) continue; + for (const relation of relations) { + if (seen.has(relation.id)) continue; + seen.add(relation.id); + collected.push(relation); + } + } + + return collected; +}; diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts new file mode 100644 index 000000000..64b154087 --- /dev/null +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -0,0 +1,112 @@ +import { TAbstractFile, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { RelationInstance } from "~/types"; +import { getRelationsFilePath, loadRelations } from "./relationsStore"; +import { buildEndpointIndex, collectRelations } from "./relationsEndpointIndex"; + +/** + * Parsed snapshot of relations.json so a render path can ask synchronously, + * rebuilt from vault events (which covers our own writes and sync alike). + */ +export class RelationsIndex { + private plugin: DiscourseGraphPlugin; + private index: Map | null = null; + private inFlight: Promise | null = null; + private stale = false; + private unloaded = false; + /** Lets a ViewPlugin, which only sees transactions, detect a changed snapshot. */ + private version = 0; + private subscribers = new Set<() => void>(); + /** Guards against a load that started before an invalidation overwriting a newer one. */ + private generation = 0; + + constructor(plugin: DiscourseGraphPlugin) { + this.plugin = plugin; + } + + initialize(): void { + const invalidateIfRelationsFile = (file: TAbstractFile): void => { + if (!(file instanceof TFile)) return; + if (file.path !== getRelationsFilePath()) return; + this.invalidate(); + }; + + const { vault } = this.plugin.app; + this.plugin.registerEvent(vault.on("modify", invalidateIfRelationsFile)); + this.plugin.registerEvent(vault.on("create", invalidateIfRelationsFile)); + this.plugin.registerEvent(vault.on("delete", invalidateIfRelationsFile)); + + void this.ensureLoaded(); + } + + unload(): void { + this.unloaded = true; + this.subscribers.clear(); + this.index = null; + this.inFlight = null; + this.generation += 1; + } + + /** Changes whenever the snapshot is replaced; see the field comment. */ + getVersion(): number { + return this.version; + } + + /** Fires when the snapshot changes. Returns an unsubscribe function. */ + onChange(subscriber: () => void): () => void { + this.subscribers.add(subscriber); + return () => this.subscribers.delete(subscriber); + } + + async ensureLoaded(): Promise { + if (this.unloaded) return; + if (this.index !== null && !this.stale) return; + if (this.inFlight) return this.inFlight; + + const generation = this.generation; + this.inFlight = (async () => { + try { + const relationsFile = await loadRelations(this.plugin); + // Superseded mid-read; the invalidation already scheduled a reload. + if (generation !== this.generation || this.unloaded) return; + this.index = buildEndpointIndex(relationsFile.relations ?? {}); + this.stale = false; + this.version += 1; + } finally { + // Every path, or ensureLoaded hands out a settled promise forever. + this.inFlight = null; + } + // The skipped invalidation above still needs a load of its own. + if (this.stale && !this.unloaded) { + void this.ensureLoaded(); + return; + } + this.notify(); + })(); + + return this.inFlight; + } + + /** + * Empty while cold, so treat that as "not loaded yet", not "no relations". + * Never schedules a load: that would make notify -> re-render -> read loop. + */ + getRelationsForEndpointIds( + endpointIds: Iterable, + ): RelationInstance[] { + if (this.index === null) return []; + return collectRelations({ index: this.index, endpointIds }); + } + + /** Keeps the old snapshot while reloading, so badges do not flash to 0. */ + private invalidate(): void { + this.generation += 1; + this.inFlight = null; + this.stale = true; + void this.ensureLoaded(); + } + + private notify(): void { + for (const subscriber of this.subscribers) subscriber(); + } +} diff --git a/apps/obsidian/src/utils/wikilinkDragHandler.ts b/apps/obsidian/src/utils/wikilinkDragHandler.ts index 12872979f..a1cde6245 100644 --- a/apps/obsidian/src/utils/wikilinkDragHandler.ts +++ b/apps/obsidian/src/utils/wikilinkDragHandler.ts @@ -10,6 +10,7 @@ import { import { TFile, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; import type DiscourseGraphPlugin from "~/index"; +import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing"; const buildObsidianUrl = (vaultName: string, filePath: string): string => { return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}`; @@ -42,29 +43,6 @@ const setDragData = ( // --- Live Preview --- -/** - * Extract the file path from a link match. - * Handles wikilinks (`[[path]]`, `[[path|alias]]`) and - * markdown links (`[text](path.md)`), decoding URL-encoded paths. - */ -const extractLinkPath = (match: string): string => { - // Wikilink: [[path]] or [[path|alias]] - if (match.startsWith("[[")) { - const inner = match.slice(2, -2); - const pipeIndex = inner.indexOf("|"); - return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; - } - - // Markdown link: [text](path) - const parenOpen = match.lastIndexOf("("); - const rawPath = match.slice(parenOpen + 1, -1); - try { - return decodeURIComponent(rawPath); - } catch (error) { - return rawPath; - } -}; - /** * Widget that renders a small drag handle next to an internal link. * CM6 widgets get `ignoreEvent() → true` by default, which means @@ -103,10 +81,6 @@ class WikilinkDragHandleWidget extends WidgetType { } } -// Matches wikilinks [[...]] and markdown links [text](path.md). -// Embed exclusion (![[...]] and ![text](...)) is handled in the loop. -const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; - const hasVisibleCanvasLeaf = (plugin: DiscourseGraphPlugin): boolean => plugin.app.workspace .getLeavesOfType(VIEW_TYPE_TLDRAW_DG_PREVIEW) @@ -133,7 +107,7 @@ const buildWidgetDecorations = ( view.state.doc.sliceString(checkPos, checkPos + 1) === "!"; if (isEmbed) continue; const matchEnd = from + match.index + match[0].length; - const linkPath = extractLinkPath(match[0]); + const linkPath = extractLinktext(match[0]); const widget = new WikilinkDragHandleWidget(linkPath, plugin); widgets.push(Decoration.widget({ widget, side: 1 }).range(matchEnd)); } From 49211047a1779546d8b0b0eb9254731af01b11eb Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:08:11 -0400 Subject: [PATCH 2/4] ENG-1249 Handle relations.json renames and markdown subpath links Two review findings: Renaming relations.json out of the root, or another file into it, left the snapshot stale; only modify/create/delete were watched. Markdown links with a #heading or #^block subpath never matched, since the pattern required .md immediately before the closing paren. Wikilinks with subpaths already worked, so the two forms behaved differently. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/internalLinkParsing.ts | 3 ++- apps/obsidian/src/utils/relationsIndex.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/internalLinkParsing.ts b/apps/obsidian/src/utils/internalLinkParsing.ts index d95edb4f3..e23c38757 100644 --- a/apps/obsidian/src/utils/internalLinkParsing.ts +++ b/apps/obsidian/src/utils/internalLinkParsing.ts @@ -1,7 +1,8 @@ // Shared by the CM6 extensions that scan raw markdown for internal links. /** Embeds are not matched: the leading `!` sits outside, so callers check it. */ -export const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g; +export const INTERNAL_LINK_RE = + /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g; /** Target of a wikilink or markdown link; any `#subpath` is left for parseLinktext. */ export const extractLinktext = (match: string): string => { diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts index 64b154087..b5310bd01 100644 --- a/apps/obsidian/src/utils/relationsIndex.ts +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -35,6 +35,13 @@ export class RelationsIndex { this.plugin.registerEvent(vault.on("modify", invalidateIfRelationsFile)); this.plugin.registerEvent(vault.on("create", invalidateIfRelationsFile)); this.plugin.registerEvent(vault.on("delete", invalidateIfRelationsFile)); + // Both directions: the file moving out of the root, and one moving in. + this.plugin.registerEvent( + vault.on("rename", (file, oldPath) => { + if (oldPath === getRelationsFilePath()) this.invalidate(); + else invalidateIfRelationsFile(file); + }), + ); void this.ensureLoaded(); } From 9bce55c7e2aaa675b0ace9c86553356f77c341a7 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 21:00:02 -0400 Subject: [PATCH 3/4] ENG-1249 Only clear inFlight when still the current load Review finding: an invalidation arriving mid-read starts a newer load, but the superseded load's finally cleared inFlight unconditionally, discarding the newer load's tracking. Later callers then saw no load in flight and started redundant ones. Clearing is now gated on the generation still matching, which is the same signal that decides whether the load's result is worth keeping. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/relationsIndex.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts index b5310bd01..5d087c06d 100644 --- a/apps/obsidian/src/utils/relationsIndex.ts +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -80,8 +80,9 @@ export class RelationsIndex { this.stale = false; this.version += 1; } finally { - // Every path, or ensureLoaded hands out a settled promise forever. - this.inFlight = null; + // Only if still the current load: an invalidation mid-read starts a + // newer one, and clearing unconditionally would discard its tracking. + if (generation === this.generation) this.inFlight = null; } // The skipped invalidation above still needs a load of its own. if (this.stale && !this.unloaded) { From 509543fb4c0d459dadb8db4575870e9e751b9aab Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 8 Sep 2026 17:28:36 -0400 Subject: [PATCH 4/4] ENG-1249 Trim a comment that restated its function name Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/discourseLinkUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/discourseLinkUtils.ts b/apps/obsidian/src/utils/discourseLinkUtils.ts index 28cf5e873..189523fca 100644 --- a/apps/obsidian/src/utils/discourseLinkUtils.ts +++ b/apps/obsidian/src/utils/discourseLinkUtils.ts @@ -15,8 +15,8 @@ export type DiscourseLinkTarget = { }; /** - * Resolves a link to a discourse node and its relation count from in-memory - * caches only; avoids getNodeTypeIdForFile, which polls 500ms for frontmatter. + * In-memory caches only, so this can run per link on a render path. Avoids + * getNodeTypeIdForFile, which polls 500ms waiting on frontmatter. */ export const resolveDiscourseLinkTarget = ({ plugin,