Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -488,5 +492,7 @@ export default class DiscourseGraphPlugin extends Plugin {
this.fileChangeListener.cleanup();
this.fileChangeListener = null;
}

this.relationsIndex.unload();
}
}
40 changes: 40 additions & 0 deletions apps/obsidian/src/utils/discourseLinkFrontmatter.ts
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);
Comment on lines +15 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Imported local relations disappear

Accepted imported relations vanish from local-node counts because getEndpointIdsFromFrontmatter omits the local node's canonical RID. The importer stores local endpoints under that RID in getLocalNodeKeyToEndpointId.

Prompt for agents
The link resolver must query every endpoint representation used by relations.json. apps/obsidian/src/utils/importRelations.ts stores existing local-node endpoints using the canonical local RID produced by getLocalNodeKeyToEndpointId, while getEndpointIdsFromFrontmatter currently returns only nodeInstanceId and importedFromRid. Update the resolution flow so local discourse nodes also query the canonical RID built from getLocalSpaceUri(plugin.app), nodeInstanceId, and spaceUriAndLocalIdToRid(..., "note"). Keep deduplication across representations and add coverage for accepted imported relations connected to a local node.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing here, deliberately — flagging why rather than resolving silently.

You're right that importRelationsForImportedNodes can store a local node's endpoint as the canonical RID from getLocalNodeKeyToEndpointId, and that getEndpointIdsFromFrontmatter doesn't query it.

But the same gap already exists in getRelationsForFile (relationsStore.ts), which is what the Discourse Context panel uses — it also matches only nodeInstanceId and importedFromRid. So this is pre-existing in the data layer, not introduced here.

That matters because the overlay's contract is that its count equals what the panel will list; an earlier revision of this work had the badge over-count and it was confusing precisely because the popover then showed fewer rows. Adding the canonical RID only on the badge side would recreate that inconsistency in the other direction.

It should be fixed in getRelationsForFile so both surfaces gain it together, which is a separate change with its own test surface. I checked this vault: no relation endpoint currently resolves to a local non-imported node this way, so nothing is being dropped today.

}

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;
58 changes: 58 additions & 0 deletions apps/obsidian/src/utils/discourseLinkUtils.ts
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 };
};
22 changes: 22 additions & 0 deletions apps/obsidian/src/utils/internalLinkParsing.ts
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;

/** 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;
}
};
54 changes: 54 additions & 0 deletions apps/obsidian/src/utils/relationsEndpointIndex.ts
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;
};
120 changes: 120 additions & 0 deletions apps/obsidian/src/utils/relationsIndex.ts
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));
Comment thread
trangdoan982 marked this conversation as resolved.
// 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;

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;
}
Comment thread
trangdoan982 marked this conversation as resolved.
// 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();
}
}
30 changes: 2 additions & 28 deletions apps/obsidian/src/utils/wikilinkDragHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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));
}
Expand Down