Skip to content
Open
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 @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -496,5 +500,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);
}

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));
// 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;
}
// 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