Skip to content

Commit 6adfcd0

Browse files
authored
[ENG-1391] Migrate relations into vault relations.json as objects (#748)
* migration complete * address PR comments * light cleanup * fix small bug * address PR comments
1 parent a24bfad commit 6adfcd0

11 files changed

Lines changed: 739 additions & 325 deletions

apps/obsidian/src/components/RelationshipSection.tsx

Lines changed: 160 additions & 162 deletions
Large diffs are not rendered by default.

apps/obsidian/src/components/canvas/TldrawViewComponent.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import {
4444
} from "~/components/canvas/shapes/DiscourseRelationBinding";
4545
import ToastListener from "./ToastListener";
4646
import { RelationsOverlay } from "./overlays/RelationOverlay";
47-
import { showToast } from "./utils/toastUtils";
4847
import { WHITE_LOGO_SVG } from "~/icons";
4948
import { CustomContextMenu } from "./CustomContextMenu";
5049
import {
@@ -53,7 +52,6 @@ import {
5352
openFileInNewLeaf,
5453
resolveDiscourseNodeFile,
5554
} from "./utils/openFileUtils";
56-
5755
type TldrawPreviewProps = {
5856
store: TLStore;
5957
file: TFile;

apps/obsidian/src/components/canvas/overlays/RelationPanel.tsx

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { getFrontmatterForFile } from "~/components/canvas/shapes/discourseNodeS
1717
import { getRelationTypeById } from "~/utils/typeUtils";
1818
import { showToast } from "~/components/canvas/utils/toastUtils";
1919
import { DEFAULT_TLDRAW_COLOR } from "~/utils/tldrawColors";
20+
import {
21+
getNodeInstanceIdForFile,
22+
getRelationsForNodeInstanceId,
23+
getFileForNodeInstanceId,
24+
addRelation,
25+
} from "~/utils/relationsStore";
2026

2127
type GroupedRelation = {
2228
key: string;
@@ -184,7 +190,7 @@ export const RelationsPanel = ({
184190
setError("Linked file not found.");
185191
return;
186192
}
187-
const g = computeRelations(plugin, file);
193+
const g = await computeRelations(plugin, file);
188194
setGroups(g);
189195
} catch (e) {
190196
showToast({
@@ -346,10 +352,33 @@ export const RelationsPanel = ({
346352
isSource: boolean,
347353
) => {
348354
try {
349-
const targetNode = await ensureNodeShapeForFile(targetFile);
350355
const relationType = getRelationTypeById(plugin, relationTypeId);
351356
const relationLabel = relationType?.label ?? "";
352357

358+
const currentFile = await resolveLinkedFileFromSrc({
359+
app: plugin.app,
360+
canvasFile,
361+
src: nodeShape.props.src ?? "",
362+
});
363+
if (!currentFile || !targetFile) return;
364+
365+
const sourceFile = isSource ? currentFile : targetFile;
366+
const destFile = isSource ? targetFile : currentFile;
367+
const sourceId = await getNodeInstanceIdForFile(plugin, sourceFile);
368+
const destId = await getNodeInstanceIdForFile(plugin, destFile);
369+
if (!sourceId || !destId) {
370+
showToast({
371+
severity: "error",
372+
title: "Could Not Resolve Nodes",
373+
description:
374+
"Could not resolve node instance IDs for the selected files.",
375+
targetCanvasId: canvasFile.path,
376+
});
377+
return;
378+
}
379+
380+
const targetNode = await ensureNodeShapeForFile(targetFile);
381+
353382
const id: TLShapeId = createShapeId();
354383

355384
// Determine source and destination nodes
@@ -417,6 +446,17 @@ export const RelationsPanel = ({
417446
isExact: false,
418447
snap: "none",
419448
});
449+
450+
const { id: relationInstanceId } = await addRelation(plugin, {
451+
type: relationTypeId,
452+
source: sourceId,
453+
destination: destId,
454+
});
455+
editor.updateShape({
456+
id: shape.id,
457+
type: shape.type,
458+
meta: { ...shape.meta, relationInstanceId },
459+
});
420460
} catch (e) {
421461
console.error("Failed to create relation to file", e);
422462
showToast({
@@ -487,35 +527,36 @@ export const RelationsPanel = ({
487527
);
488528
};
489529

490-
const computeRelations = (
530+
const computeRelations = async (
491531
plugin: DiscourseGraphPlugin,
492532
file: TFile,
493-
): GroupedRelation[] => {
533+
): Promise<GroupedRelation[]> => {
494534
const fileCache = plugin.app.metadataCache.getFileCache(file);
495535
if (!fileCache?.frontmatter) return [];
496536

497537
const activeNodeTypeId = fileCache.frontmatter.nodeTypeId as string;
498538
if (!activeNodeTypeId) return [];
499539

540+
const nodeInstanceId = await getNodeInstanceIdForFile(plugin, file);
541+
if (!nodeInstanceId) return [];
542+
543+
const relations = await getRelationsForNodeInstanceId(
544+
plugin,
545+
nodeInstanceId,
546+
);
500547
const result = new Map<string, GroupedRelation>();
501548

502549
for (const relationType of plugin.settings.relationTypes) {
503-
const frontmatterLinks = fileCache.frontmatter[relationType.id] as unknown;
504-
if (!frontmatterLinks) continue;
505-
506-
const links = Array.isArray(frontmatterLinks)
507-
? (frontmatterLinks as unknown[])
508-
: [frontmatterLinks];
509-
510-
const relation = plugin.settings.discourseRelations.find(
550+
const typeLevelRelation = plugin.settings.discourseRelations.find(
511551
(rel) =>
512552
(rel.sourceId === activeNodeTypeId ||
513553
rel.destinationId === activeNodeTypeId) &&
514554
rel.relationshipTypeId === relationType.id,
515555
);
516-
if (!relation) continue;
556+
if (!typeLevelRelation) continue;
517557

518-
const isSource = relation.sourceId === activeNodeTypeId;
558+
const instanceRels = relations.filter((r) => r.type === relationType.id);
559+
const isSource = typeLevelRelation.sourceId === activeNodeTypeId;
519560
const label = isSource ? relationType.label : relationType.complement;
520561
const key = `${relationType.id}-${isSource}`;
521562

@@ -529,23 +570,15 @@ const computeRelations = (
529570
});
530571
}
531572

532-
for (const link of links) {
533-
const match = String(link).match(/\[\[(.*?)\]\]/);
534-
if (!match) continue;
535-
const linkedFileName = match[1] ?? "";
536-
const linked = plugin.app.metadataCache.getFirstLinkpathDest(
537-
linkedFileName,
538-
file.path,
539-
);
540-
if (!linked) continue;
541-
542-
const group = result.get(key);
543-
if (group && !group.linkedFiles.some((f) => f.path === linked.path)) {
573+
const group = result.get(key)!;
574+
for (const r of instanceRels) {
575+
const otherId = r.source === nodeInstanceId ? r.destination : r.source;
576+
const linked = await getFileForNodeInstanceId(plugin, otherId);
577+
if (linked && !group.linkedFiles.some((f) => f.path === linked.path)) {
544578
group.linkedFiles.push(linked);
545579
}
546580
}
547581
}
548582

549583
return Array.from(result.values());
550584
};
551-

apps/obsidian/src/components/canvas/shapes/DiscourseRelationShape.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import {
5959
} from "~/components/canvas/utils/relationUtils";
6060
import { RelationBindings } from "./DiscourseRelationBinding";
6161
import { DiscourseNodeShape, DiscourseNodeUtil } from "./DiscourseNodeShape";
62-
import { addRelationToFrontmatter } from "~/components/canvas/utils/frontmatterUtils";
62+
import { addRelationToRelationsJson } from "~/components/canvas/utils/relationJsonUtils";
6363
import { showToast } from "~/components/canvas/utils/toastUtils";
6464

6565
export enum ArrowHandles {
@@ -1210,16 +1210,23 @@ export class DiscourseRelationUtil extends ShapeUtil<DiscourseRelationShape> {
12101210
return;
12111211
}
12121212

1213-
// Add the bidirectional relation to frontmatter
1214-
const { alreadyExisted } = await addRelationToFrontmatter({
1215-
app: this.options.app,
1216-
plugin: this.options.plugin,
1217-
sourceFile,
1218-
targetFile,
1219-
relationTypeId: shape.props.relationTypeId,
1220-
});
1213+
const { alreadyExisted, relationInstanceId } =
1214+
await addRelationToRelationsJson({
1215+
app: this.options.app,
1216+
plugin: this.options.plugin,
1217+
sourceFile,
1218+
targetFile,
1219+
relationTypeId: shape.props.relationTypeId,
1220+
});
1221+
1222+
if (relationInstanceId) {
1223+
this.editor.updateShape({
1224+
id: shape.id,
1225+
type: shape.type,
1226+
meta: { ...shape.meta, relationInstanceId },
1227+
});
1228+
}
12211229

1222-
// Show success notice
12231230
const relationType = this.options.plugin.settings.relationTypes.find(
12241231
(rt) => rt.id === shape.props.relationTypeId,
12251232
);

apps/obsidian/src/components/canvas/utils/frontmatterUtils.ts

Lines changed: 0 additions & 103 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { App, TFile } from "obsidian";
2+
import type DiscourseGraphPlugin from "~/index";
3+
import { addRelation, getNodeInstanceIdForFile } from "~/utils/relationsStore";
4+
5+
/**
6+
* Persists a relation between two files to the relations store (relations.json).
7+
* Uses addRelation (checks for existing relation by default).
8+
*
9+
* @returns Object indicating whether the relation already existed and the relation instance id.
10+
*/
11+
export const addRelationToRelationsJson = async ({
12+
app,
13+
plugin,
14+
sourceFile,
15+
targetFile,
16+
relationTypeId,
17+
}: {
18+
app: App;
19+
plugin: DiscourseGraphPlugin;
20+
sourceFile: TFile;
21+
targetFile: TFile;
22+
relationTypeId: string;
23+
}): Promise<{ alreadyExisted: boolean; relationInstanceId?: string }> => {
24+
const sourceId = await getNodeInstanceIdForFile(plugin, sourceFile);
25+
const destId = await getNodeInstanceIdForFile(plugin, targetFile);
26+
if (!sourceId || !destId) {
27+
console.warn("Could not resolve nodeInstanceIds for relation files");
28+
return { alreadyExisted: false };
29+
}
30+
31+
const { id, alreadyExisted } = await addRelation(plugin, {
32+
type: relationTypeId,
33+
source: sourceId,
34+
destination: destId,
35+
});
36+
return { alreadyExisted, relationInstanceId: id };
37+
};

apps/obsidian/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal";
2626
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
2727
import { FileChangeListener } from "~/utils/fileChangeListener";
2828
import generateUid from "~/utils/generateUid";
29-
import type { DiscourseNode, DiscourseRelation } from "~/types";
29+
import { migrateFrontmatterRelationsToRelationsJson } from "~/utils/relationsStore";
3030

3131
export default class DiscourseGraphPlugin extends Plugin {
3232
settings: Settings = { ...DEFAULT_SETTINGS };
@@ -39,6 +39,10 @@ export default class DiscourseGraphPlugin extends Plugin {
3939
async onload() {
4040
await this.loadSettings();
4141

42+
await migrateFrontmatterRelationsToRelationsJson(this).catch((error) => {
43+
console.error("Failed to migrate frontmatter relations:", error);
44+
});
45+
4246
if (this.settings.syncModeEnabled === true) {
4347
void initializeSupabaseSync(this).catch((error) => {
4448
console.error("Failed to initialize Supabase sync:", error);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { uuidv7 } from "uuidv7";
2+
import type { TFile } from "obsidian";
3+
import type DiscourseGraphPlugin from "~/index";
4+
5+
/**
6+
* Ensures the file has a nodeInstanceId in frontmatter. If missing, generates one (uuidv7) and writes it.
7+
* Used by sync and relations store so every discourse node has a stable instance id.
8+
*/
9+
export const ensureNodeInstanceId = async (
10+
plugin: DiscourseGraphPlugin,
11+
file: TFile,
12+
frontmatter: Record<string, unknown>,
13+
): Promise<string> => {
14+
const existingId = frontmatter["nodeInstanceId"] as string | undefined;
15+
if (existingId && typeof existingId === "string") {
16+
return existingId;
17+
}
18+
19+
const nodeInstanceId = uuidv7() as string;
20+
await plugin.app.fileManager.processFrontMatter(file, (fm) => {
21+
(fm as Record<string, unknown>).nodeInstanceId = nodeInstanceId;
22+
});
23+
24+
return nodeInstanceId;
25+
};

0 commit comments

Comments
 (0)