-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-2248 Add a cached relations index and link resolution #1433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
36a6689
18d19fb
b23e1cb
bbab339
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown> | undefined, | ||
| ): string | undefined => asString(frontmatter?.nodeTypeId); | ||
|
|
||
| /** An imported node is referenced by both its nodeInstanceId and its importedFromRid. */ | ||
| export const getEndpointIdsFromFrontmatter = ( | ||
| frontmatter: Record<string, unknown> | 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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a newly matched Markdown link such as Useful? React with 👍 / 👎. |
||
|
|
||
| /** 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; | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, RelationInstance>, | ||
| ): Map<string, RelationInstance[]> => { | ||
| const index = new Map<string, RelationInstance[]>(); | ||
|
|
||
| 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<string, RelationInstance[]>; | ||
| endpointIds: Iterable<string>; | ||
| }): RelationInstance[] => { | ||
| const seen = new Set<string>(); | ||
| 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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, RelationInstance[]> | null = null; | ||
| private inFlight: Promise<void> | 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<void> { | ||
| if (this.unloaded) return; | ||
| if (this.index !== null && !this.stale) return; | ||
| if (this.inFlight) return this.inFlight; | ||
|
Comment on lines
+68
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This introduces a generation-based asynchronous invalidation state machine plus new parsing and relation-resolution helpers, but the commit adds no tests and the Obsidian workspace still has no AGENTS.md reference: AGENTS.md:L94-L98 Useful? React with 👍 / 👎. |
||
|
|
||
| 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<string>, | ||
| ): 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(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a relation's other endpoint no longer resolves to a vault file—for example, after its note is deleted—this filter still counts it as long as its type remains configured.
buildGroupedRelationsin the context panel only adds a relation whenresolveEndpointToFilefinds that peer, so the returned count can advertise a relation that the panel contains no entry for, contrary to this helper's stated contract.Useful? React with 👍 / 👎.