diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index d8488577d..12f66ad2a 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -39,6 +39,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, @@ -55,6 +56,7 @@ import { export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; + relationsIndex: RelationsIndex = new RelationsIndex(this); private tagStyleManager: DiscourseTagStyleManager | null = null; private fileChangeListener: FileChangeListener | null = null; private activeNodePopover: @@ -102,6 +104,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); @@ -496,5 +500,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..189523fca --- /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; +}; + +/** + * 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, + 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..e23c38757 --- /dev/null +++ b/apps/obsidian/src/utils/internalLinkParsing.ts @@ -0,0 +1,22 @@ +// 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..5d087c06d --- /dev/null +++ b/apps/obsidian/src/utils/relationsIndex.ts @@ -0,0 +1,120 @@ +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)); + // 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(); + } + + 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 { + // 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) { + 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)); }