diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json
index 99e457466..70537f7fc 100644
--- a/apps/obsidian/package.json
+++ b/apps/obsidian/package.json
@@ -19,10 +19,10 @@
"@octokit/core": "^6.1.2",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
+ "@types/mime-types": "3.0.1",
"@types/node": "^20",
"@types/react": "catalog:obsidian",
"@types/react-dom": "catalog:obsidian",
- "@types/mime-types": "3.0.1",
"autoprefixer": "^10.4.21",
"builtin-modules": "3.3.0",
"dotenv": "^16.4.5",
@@ -43,6 +43,7 @@
"@repo/utils": "workspace:*",
"@supabase/supabase-js": "catalog:",
"date-fns": "^4.1.0",
+ "gray-matter": "^4.0.3",
"mime-types": "^3.0.1",
"nanoid": "^4.0.2",
"react": "catalog:obsidian",
diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx
index 7adc4d1ec..0ed2c6326 100644
--- a/apps/obsidian/src/components/DiscourseContextView.tsx
+++ b/apps/obsidian/src/components/DiscourseContextView.tsx
@@ -1,4 +1,4 @@
-import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
+import { ItemView, TFile, WorkspaceLeaf, Notice } from "obsidian";
import { createRoot, Root } from "react-dom/client";
import DiscourseGraphPlugin from "~/index";
import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression";
@@ -6,6 +6,8 @@ import { RelationshipSection } from "~/components/RelationshipSection";
import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
import { PluginProvider, usePlugin } from "~/components/PluginContext";
import { getNodeTypeById } from "~/utils/typeUtils";
+import { refreshImportedFile } from "~/utils/importNodes";
+import { useState } from "react";
type DiscourseContextProps = {
activeFile: TFile | null;
@@ -13,6 +15,7 @@ type DiscourseContextProps = {
const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
const plugin = usePlugin();
+ const [isRefreshing, setIsRefreshing] = useState(false);
const extractContentFromTitle = (format: string, title: string): string => {
if (!format) return "";
@@ -21,6 +24,30 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
return match?.[1] ?? title;
};
+ const handleRefresh = async () => {
+ if (!activeFile || isRefreshing) return;
+
+ setIsRefreshing(true);
+ try {
+ const result = await refreshImportedFile({ plugin, file: activeFile });
+ if (result.success) {
+ new Notice("File refreshed successfully", 3000);
+ } else {
+ new Notice(
+ `Failed to refresh file: ${result.error || "Unknown error"}`,
+ 5000,
+ );
+ }
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ new Notice(`Refresh failed: ${errorMessage}`, 5000);
+ console.error("Refresh failed:", error);
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
const renderContent = () => {
if (!activeFile) {
return
No file is open
;
@@ -45,6 +72,20 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
if (!nodeType) {
return Unknown node type: {frontmatter.nodeTypeId}
;
}
+
+ const isImported = !!frontmatter.importedFromSpaceUri;
+ const modifiedAt =
+ typeof frontmatter.lastModified === "number"
+ ? frontmatter.lastModified
+ : activeFile.stat.mtime;
+ const sourceDates =
+ isImported && activeFile?.stat
+ ? {
+ createdAt: new Date(activeFile.stat.ctime).toLocaleString(),
+ modifiedAt: new Date(modifiedAt).toLocaleString(),
+ }
+ : null;
+
return (
<>
@@ -56,6 +97,18 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
/>
)}
{nodeType.name || "Unnamed Node Type"}
+ {isImported && (
+
+ )}
{nodeType.format && (
@@ -64,6 +117,13 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
{extractContentFromTitle(nodeType.format, activeFile.basename)}
)}
+
+ {isImported && sourceDates && (
+
+
Created in source: {sourceDates.createdAt}
+
Last modified in source: {sourceDates.modifiedAt}
+
+ )}
diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx
new file mode 100644
index 000000000..16f72d78c
--- /dev/null
+++ b/apps/obsidian/src/components/ImportNodesModal.tsx
@@ -0,0 +1,380 @@
+import { App, Modal, Notice } from "obsidian";
+import { createRoot, Root } from "react-dom/client";
+import { StrictMode, useState, useEffect, useCallback } from "react";
+import type DiscourseGraphPlugin from "../index";
+import type { ImportableNode, GroupWithNodes } from "~/types";
+import {
+ getAvailableGroups,
+ getPublishedNodesForGroups,
+ getLocalNodeInstanceIds,
+ getSpaceNameFromIds,
+ importSelectedNodes,
+} from "~/utils/importNodes";
+import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
+
+type ImportNodesModalProps = {
+ plugin: DiscourseGraphPlugin;
+ onClose: () => void;
+};
+
+const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
+ const [step, setStep] = useState<"loading" | "select" | "importing">(
+ "loading",
+ );
+ const [groupsWithNodes, setGroupsWithNodes] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [importProgress, setImportProgress] = useState({
+ current: 0,
+ total: 0,
+ });
+
+ const loadImportableNodes = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const client = await getLoggedInClient(plugin);
+ if (!client) {
+ new Notice("Cannot get Supabase client");
+ onClose();
+ return;
+ }
+
+ const context = await getSupabaseContext(plugin);
+ if (!context) {
+ new Notice("Cannot get Supabase context");
+ onClose();
+ return;
+ }
+
+ const groups = await getAvailableGroups(client);
+ if (groups.length === 0) {
+ new Notice("You are not a member of any groups");
+ onClose();
+ return;
+ }
+
+ const groupIds = groups.map((g) => g.group_id);
+
+ const publishedNodes = await getPublishedNodesForGroups({
+ client,
+ groupIds,
+ currentSpaceId: context.spaceId,
+ });
+
+ const localNodeInstanceIds = getLocalNodeInstanceIds(plugin);
+
+ // Filter out nodes that already exist locally
+ const importableNodes = publishedNodes.filter(
+ (node) => !localNodeInstanceIds.has(node.source_local_id),
+ );
+
+ const uniqueSpaceIds = [
+ ...new Set(importableNodes.map((n) => n.space_id)),
+ ];
+ const spaceNames = await getSpaceNameFromIds(client, uniqueSpaceIds);
+ const grouped: Map = new Map();
+
+ for (const node of importableNodes) {
+ const groupId = String(node.space_id);
+ if (!grouped.has(groupId)) {
+ grouped.set(groupId, {
+ groupId,
+ groupName:
+ spaceNames.get(node.space_id) ?? `Space ${node.space_id}`,
+ nodes: [],
+ });
+ }
+
+ const group = grouped.get(groupId)!;
+ group.nodes.push({
+ nodeInstanceId: node.source_local_id,
+ title: node.text,
+ spaceId: node.space_id,
+ spaceName: spaceNames.get(node.space_id) ?? `Space ${node.space_id}`,
+ groupId,
+ selected: false,
+ createdAt: node.createdAt,
+ modifiedAt: node.modifiedAt,
+ filePath: node.filePath,
+ });
+ }
+
+ setGroupsWithNodes(Array.from(grouped.values()));
+ setStep("select");
+ } catch (error) {
+ console.error("Error loading importable nodes:", error);
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ new Notice(`Failed to load nodes: ${errorMessage}`, 5000);
+ onClose();
+ } finally {
+ setIsLoading(false);
+ }
+ }, [plugin, onClose]);
+
+ useEffect(() => {
+ void loadImportableNodes();
+ }, [loadImportableNodes]);
+
+ const handleNodeToggle = (groupId: string, nodeIndex: number) => {
+ setGroupsWithNodes((prev) =>
+ prev.map((group) => {
+ if (group.groupId !== groupId) return group;
+ return {
+ ...group,
+ nodes: group.nodes.map((node, idx) =>
+ idx === nodeIndex ? { ...node, selected: !node.selected } : node,
+ ),
+ };
+ }),
+ );
+ };
+
+ const handleImport = async () => {
+ const selectedNodes: ImportableNode[] = [];
+ for (const group of groupsWithNodes) {
+ for (const node of group.nodes) {
+ if (node.selected) {
+ selectedNodes.push(node);
+ }
+ }
+ }
+
+ if (selectedNodes.length === 0) {
+ new Notice("Please select at least one node to import");
+ return;
+ }
+
+ setStep("importing");
+ setImportProgress({ current: 0, total: selectedNodes.length });
+
+ try {
+ const result = await importSelectedNodes({
+ plugin,
+ selectedNodes,
+ onProgress: (current, total) => {
+ setImportProgress({ current, total });
+ },
+ });
+
+ if (result.failed > 0) {
+ new Notice(
+ `Import completed with some issues:\n${result.success} files imported successfully\n${result.failed} files failed`,
+ 5000,
+ );
+ } else {
+ new Notice(`Successfully imported ${result.success} node(s)`, 3000);
+ }
+
+ onClose();
+ } catch (error) {
+ console.error("Error importing nodes:", error);
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ new Notice(`Import failed: ${errorMessage}`, 5000);
+ setStep("select");
+ }
+ };
+
+ const renderLoadingStep = () => (
+
+
Loading importable nodes...
+
+ Fetching groups and published nodes
+
+
+ );
+
+ const renderSelectStep = () => {
+ const totalNodes = groupsWithNodes.reduce(
+ (sum, group) => sum + group.nodes.length,
+ 0,
+ );
+ const selectedCount = groupsWithNodes.reduce(
+ (sum, group) => sum + group.nodes.filter((n) => n.selected).length,
+ 0,
+ );
+
+ // Group nodes by space for better organization
+ const nodesBySpace = new Map<
+ number,
+ {
+ spaceName: string;
+ nodes: Array<{
+ node: ImportableNode;
+ groupId: string;
+ nodeIndex: number;
+ }>;
+ }
+ >();
+
+ for (const group of groupsWithNodes) {
+ for (const [nodeIndex, node] of group.nodes.entries()) {
+ if (!nodesBySpace.has(node.spaceId)) {
+ nodesBySpace.set(node.spaceId, {
+ spaceName: node.spaceName,
+ nodes: [],
+ });
+ }
+ nodesBySpace.get(node.spaceId)!.nodes.push({
+ node,
+ groupId: group.groupId,
+ nodeIndex,
+ });
+ }
+ }
+
+ return (
+
+
Select Nodes to Import
+
+ {totalNodes > 0
+ ? `${totalNodes} importable node(s) found. Select which nodes to import into your vault.`
+ : "No importable nodes found."}
+
+
+
+
+
+
+
+
+ {Array.from(nodesBySpace.entries()).map(
+ ([spaceId, { spaceName, nodes }]) => {
+ return (
+
+
+ 📂
+
+ {spaceName}
+
+
+ ({nodes.length} node{nodes.length !== 1 ? "s" : ""})
+
+
+
+ {nodes.map(({ node, groupId, nodeIndex }) => (
+
+
handleNodeToggle(groupId, nodeIndex)}
+ className="mr-3 mt-1 flex-shrink-0"
+ />
+
+
+ ))}
+
+ );
+ },
+ )}
+
+
+
+
+
+
+
+ );
+ };
+
+ const renderImportingStep = () => (
+
+
Importing nodes
+
+
+
+ {importProgress.current} of {importProgress.total} node(s) processed
+
+
+
+ );
+
+ if (isLoading || step === "loading") {
+ return renderLoadingStep();
+ }
+
+ switch (step) {
+ case "select":
+ return renderSelectStep();
+ case "importing":
+ return renderImportingStep();
+ default:
+ return null;
+ }
+};
+
+export class ImportNodesModal extends Modal {
+ private plugin: DiscourseGraphPlugin;
+ private root: Root | null = null;
+
+ constructor(app: App, plugin: DiscourseGraphPlugin) {
+ super(app);
+ this.plugin = plugin;
+ }
+
+ onOpen() {
+ const { contentEl } = this;
+ contentEl.empty();
+ this.root = createRoot(contentEl);
+ this.root.render(
+
+ this.close()} />
+ ,
+ );
+ }
+
+ onClose() {
+ if (this.root) {
+ this.root.unmount();
+ this.root = null;
+ }
+ }
+}
diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts
index 0b139c2be..36c65e301 100644
--- a/apps/obsidian/src/index.ts
+++ b/apps/obsidian/src/index.ts
@@ -272,7 +272,16 @@ export default class DiscourseGraphPlugin extends Plugin {
const keysToHide: string[] = [];
if (!this.settings.showIdsInFrontmatter) {
- keysToHide.push("nodeTypeId");
+ keysToHide.push(
+ ...[
+ "nodeTypeId",
+ "importedFromSpaceUri",
+ "nodeInstanceId",
+ "publishedToGroups",
+ "lastModified",
+ "importedAssets",
+ ],
+ );
keysToHide.push(...this.settings.relationTypes.map((rt) => rt.id));
}
diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts
index cbf3d090d..f8502b4a8 100644
--- a/apps/obsidian/src/services/QueryEngine.ts
+++ b/apps/obsidian/src/services/QueryEngine.ts
@@ -290,6 +290,53 @@ export class QueryEngine {
}
}
+ /**
+ * Find an existing imported file by nodeInstanceId and importedFromSpaceUri
+ * Uses DataCore when available; falls back to vault iteration otherwise
+ * Returns the file if found, null otherwise
+ */
+ findExistingImportedFile = (
+ nodeInstanceId: string,
+ importedFromSpaceUri: string,
+ ): TFile | null => {
+ if (this.dc) {
+ try {
+ const safeId = nodeInstanceId
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"');
+ const safeUri = importedFromSpaceUri
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"');
+ const dcQuery = `@page and nodeInstanceId = "${safeId}" and importedFromSpaceUri = "${safeUri}"`;
+ const results = this.dc.query(dcQuery);
+
+ for (const page of results) {
+ if (page.$path) {
+ const file = this.app.vault.getAbstractFileByPath(page.$path);
+ if (file && file instanceof TFile) {
+ return file;
+ }
+ }
+ }
+ } catch (error) {
+ console.warn("Error querying DataCore for imported file:", error);
+ }
+ }
+
+ // Fallback: DataCore absent, query failed, or indexed field mismatch
+ const allFiles = this.app.vault.getMarkdownFiles();
+ for (const f of allFiles) {
+ const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
+ if (
+ fm?.nodeInstanceId === nodeInstanceId &&
+ fm.importedFromSpaceUri === importedFromSpaceUri
+ ) {
+ return f;
+ }
+ }
+ return null;
+ };
+
private async fallbackScanVault(
patterns: BulkImportPattern[],
validNodeTypes: DiscourseNode[],
diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts
index b9f849e08..7c4be2588 100644
--- a/apps/obsidian/src/types.ts
+++ b/apps/obsidian/src/types.ts
@@ -61,4 +61,23 @@ export type BulkImportPattern = {
enabled: boolean;
};
+export type ImportableNode = {
+ nodeInstanceId: string;
+ title: string;
+ spaceId: number;
+ spaceName: string;
+ groupId: string;
+ selected: boolean;
+ /** From source Content (latest last_modified across variants). Set when loaded from getPublishedNodesForGroups. */
+ createdAt?: number;
+ modifiedAt?: number;
+ filePath?: string;
+};
+
+export type GroupWithNodes = {
+ groupId: string;
+ groupName?: string;
+ nodes: ImportableNode[];
+};
+
export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view";
diff --git a/apps/obsidian/src/utils/conceptConversion.ts b/apps/obsidian/src/utils/conceptConversion.ts
index 74e8853b6..1c7c55cc4 100644
--- a/apps/obsidian/src/utils/conceptConversion.ts
+++ b/apps/obsidian/src/utils/conceptConversion.ts
@@ -5,6 +5,7 @@ import type { SupabaseContext } from "./supabaseContext";
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase";
import type { Json } from "@repo/database/dbTypes";
+import DiscourseGraphPlugin from "..";
/**
* Get extra data (author, timestamps) from file metadata
@@ -37,7 +38,7 @@ export const discourseNodeSchemaToLocalConcept = ({
node;
return {
space_id: context.spaceId,
- name: name,
+ name,
source_local_id: id,
is_schema: true,
author_local_id: accountLocalId,
@@ -92,7 +93,11 @@ export const relatedConcepts = (concept: LocalConceptDataInput): string[] => {
};
/**
- * Recursively order concepts by dependency
+ * Recursively order concepts by dependency so that dependents (e.g. instances)
+ * come after their dependencies (e.g. schemas). When we look up a related
+ * concept by id in `remainder`, we use the same id that appears in
+ * schema_represented_by_local_id or local_reference_content — so that id
+ * must equal some concept's source_local_id or it is reported as "missing".
*/
const orderConceptsRec = (
ordered: LocalConceptDataInput[],
@@ -119,6 +124,13 @@ const orderConceptsRec = (
return missing;
};
+/**
+ * Order concepts so dependencies (schemas) are before dependents (instances).
+ * Assumes every concept has source_local_id; concepts without it are excluded
+ * from the map (same as Roam). A node type is "missing" when an instance
+ * references schema_represented_by_local_id = X but no concept in the input
+ * has source_local_id === X (e.g. schema not included, or id vs nodeTypeId mismatch).
+ */
export const orderConceptsByDependency = (
concepts: LocalConceptDataInput[],
): { ordered: LocalConceptDataInput[]; missing: string[] } => {
@@ -126,7 +138,7 @@ export const orderConceptsByDependency = (
const conceptById: { [key: string]: LocalConceptDataInput } =
Object.fromEntries(
concepts
- .filter((c) => c.source_local_id)
+ .filter((c) => c.source_local_id != null && c.source_local_id !== "")
.map((c) => [c.source_local_id!, c]),
);
const ordered: LocalConceptDataInput[] = [];
diff --git a/apps/obsidian/src/utils/fileChangeListener.ts b/apps/obsidian/src/utils/fileChangeListener.ts
index f01de6270..c81dd923f 100644
--- a/apps/obsidian/src/utils/fileChangeListener.ts
+++ b/apps/obsidian/src/utils/fileChangeListener.ts
@@ -80,7 +80,7 @@ export class FileChangeListener {
/**
* Check if a file is a DG node (has nodeTypeId in frontmatter that matches a node type in settings)
*/
- private isDiscourseNode(file: TAbstractFile): boolean {
+ private shouldSyncFile(file: TAbstractFile): boolean {
if (!(file instanceof TFile)) {
return false;
}
@@ -91,13 +91,17 @@ export class FileChangeListener {
}
const cache = this.plugin.app.metadataCache.getFileCache(file);
- const nodeTypeId = cache?.frontmatter?.nodeTypeId as string | undefined;
+ const frontmatter = cache?.frontmatter;
+ const nodeTypeId = frontmatter?.nodeTypeId as string | undefined;
if (!nodeTypeId || typeof nodeTypeId !== "string") {
return false;
}
- // Verify that the nodeTypeId matches one of the node types in settings
+ if (frontmatter?.importedFromSpaceUri) {
+ return false;
+ }
+
return !!getNodeTypeById(this.plugin, nodeTypeId);
}
@@ -115,7 +119,7 @@ export class FileChangeListener {
this.pendingCreates.add(file.path);
- if (this.isDiscourseNode(file)) {
+ if (this.shouldSyncFile(file)) {
this.queueChange(file.path, "title");
this.queueChange(file.path, "content");
this.pendingCreates.delete(file.path);
@@ -126,7 +130,7 @@ export class FileChangeListener {
* Handle file modification event
*/
private handleFileModify(file: TAbstractFile): void {
- if (!this.isDiscourseNode(file)) {
+ if (!this.shouldSyncFile(file)) {
return;
}
@@ -151,6 +155,10 @@ export class FileChangeListener {
* Handle file rename event
*/
private handleFileRename(file: TAbstractFile, oldPath: string): void {
+ if (!this.shouldSyncFile(file)) {
+ return;
+ }
+
console.log(`File renamed: ${oldPath} -> ${file.path}`);
this.queueChange(file.path, "title", oldPath);
}
@@ -159,7 +167,7 @@ export class FileChangeListener {
* Handle metadata changes (placeholder for relation metadata)
*/
private handleMetadataChange(file: TFile): void {
- if (!this.isDiscourseNode(file)) {
+ if (!this.shouldSyncFile(file)) {
return;
}
diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts
new file mode 100644
index 000000000..7eb8090c0
--- /dev/null
+++ b/apps/obsidian/src/utils/importNodes.ts
@@ -0,0 +1,1430 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type { Json } from "@repo/database/dbTypes";
+import matter from "gray-matter";
+import { App, TFile } from "obsidian";
+import type { DGSupabaseClient } from "@repo/database/lib/client";
+import type DiscourseGraphPlugin from "~/index";
+import { getLoggedInClient, getSupabaseContext } from "./supabaseContext";
+import type { DiscourseNode, ImportableNode } from "~/types";
+import { QueryEngine } from "~/services/QueryEngine";
+
+export const getAvailableGroups = async (
+ client: DGSupabaseClient,
+): Promise<{ group_id: string }[]> => {
+ const { data, error } = await client
+ .from("group_membership")
+ .select("group_id")
+ .eq("member_id", (await client.auth.getUser()).data.user?.id || "");
+
+ if (error) {
+ console.error("Error fetching groups:", error);
+ throw new Error(`Failed to fetch groups: ${error.message}`);
+ }
+
+ return data || [];
+};
+
+export const getPublishedNodesForGroups = async ({
+ client,
+ groupIds,
+ currentSpaceId,
+}: {
+ client: DGSupabaseClient;
+ groupIds: string[];
+ currentSpaceId: number;
+}): Promise<
+ Array<{
+ source_local_id: string;
+ space_id: number;
+ text: string;
+ createdAt: number;
+ modifiedAt: number;
+ filePath: string | undefined;
+ }>
+> => {
+ if (groupIds.length === 0) {
+ return [];
+ }
+
+ // Query my_contents (RLS applied); exclude current space. Get both variants so we can use
+ // the latest last_modified per node and prefer "direct" for text (title).
+ const { data, error } = await client
+ .from("my_contents")
+ .select(
+ "source_local_id, space_id, text, created, last_modified, variant, metadata",
+ )
+ .neq("space_id", currentSpaceId);
+
+ if (error) {
+ console.error("Error fetching published nodes:", error);
+ throw new Error(`Failed to fetch published nodes: ${error.message}`);
+ }
+
+ if (!data || data.length === 0) {
+ return [];
+ }
+
+ type Row = {
+ source_local_id: string | null;
+ space_id: number | null;
+ text: string | null;
+ created: string | null;
+ last_modified: string | null;
+ variant: string | null;
+ metadata: Json;
+ };
+
+ const key = (r: Row) => `${r.space_id ?? ""}\t${r.source_local_id ?? ""}`;
+ const groups = new Map();
+ for (const row of data as Row[]) {
+ if (row.source_local_id == null || row.space_id == null) continue;
+ const k = key(row);
+ if (!groups.has(k)) groups.set(k, []);
+ groups.get(k)!.push(row);
+ }
+
+ const nodes: Array<{
+ source_local_id: string;
+ space_id: number;
+ text: string;
+ createdAt: number;
+ modifiedAt: number;
+ filePath: string | undefined;
+ }> = [];
+
+ for (const rows of groups.values()) {
+ const withDate = rows.filter(
+ (r) => r.last_modified != null && r.text != null,
+ );
+ if (withDate.length === 0) continue;
+ const latest = withDate.reduce((a, b) =>
+ (a.last_modified ?? "") >= (b.last_modified ?? "") ? a : b,
+ );
+ const direct = rows.find((r) => r.variant === "direct");
+ const text = direct?.text ?? latest.text ?? "";
+ const createdAt = latest.created
+ ? new Date(latest.created + "Z").valueOf()
+ : 0;
+ const modifiedAt = latest.last_modified
+ ? new Date(latest.last_modified + "Z").valueOf()
+ : 0;
+ const filePath: string | undefined =
+ direct &&
+ typeof direct.metadata === "object" &&
+ typeof (direct.metadata as Record).filePath === "string"
+ ? (direct.metadata as Record).filePath
+ : undefined;
+ nodes.push({
+ source_local_id: latest.source_local_id!,
+ space_id: latest.space_id!,
+ text,
+ createdAt,
+ modifiedAt,
+ filePath,
+ });
+ }
+
+ return nodes;
+};
+
+export const getLocalNodeInstanceIds = (
+ plugin: DiscourseGraphPlugin,
+): Set => {
+ const allFiles = plugin.app.vault.getMarkdownFiles();
+ const nodeInstanceIds = new Set();
+
+ for (const file of allFiles) {
+ const cache = plugin.app.metadataCache.getFileCache(file);
+ const frontmatter = cache?.frontmatter;
+
+ if (frontmatter?.nodeInstanceId) {
+ nodeInstanceIds.add(frontmatter.nodeInstanceId as string);
+ }
+ }
+
+ return nodeInstanceIds;
+};
+
+export const getSpaceNameFromId = async (
+ client: DGSupabaseClient,
+ spaceId: number,
+): Promise => {
+ const { data, error } = await client
+ .from("Space")
+ .select("name")
+ .eq("id", spaceId)
+ .maybeSingle();
+
+ if (error || !data) {
+ console.error("Error fetching space name:", error);
+ return `space-${spaceId}`;
+ }
+
+ return data.name;
+};
+
+export const getSpaceNameIdFromUri = async (
+ client: DGSupabaseClient,
+ spaceUri: string,
+): Promise<{ spaceName: string; spaceId: number }> => {
+ const { data, error } = await client
+ .from("Space")
+ .select("name, id")
+ .eq("url", spaceUri)
+ .maybeSingle();
+
+ if (error || !data) {
+ console.error("Error fetching space name:", error);
+ return { spaceName: "", spaceId: -1 };
+ }
+
+ return { spaceName: data.name, spaceId: data.id };
+};
+
+export const getSpaceNameFromIds = async (
+ client: DGSupabaseClient,
+ spaceIds: number[],
+): Promise