Skip to content
Open
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
288 changes: 274 additions & 14 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@ import { App, Notice, TFile } from "obsidian";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import { listGroupSharedNodes } from "@repo/database/lib/sharedNodes";
import type DiscourseGraphPlugin from "~/index";
import { getLoggedInClient, getSupabaseContext } from "./supabaseContext";
import {
getLocalSpaceUri,
getLoggedInClient,
getSupabaseContext,
} from "./supabaseContext";
import type { DiscourseNode, ImportableNode } from "~/types";
import { QueryEngine } from "~/services/QueryEngine";
import {
addRelationNoCheck,
findRelationBySourceDestinationType,
loadRelations,
getImportedNodesInfo,
getLocalNodeKeyToEndpointId,
} from "~/utils/relationsStore";
import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid";
import {
ridToSpaceUriAndLocalId,
spaceUriAndLocalIdToRid,
} from "@repo/database/lib/rid";
import type { PostgrestResponse } from "@supabase/supabase-js";
import type { Tables } from "@repo/database/dbTypes";
import { getSpaceNameIdFromRid } from "./spaceFromRid";
Expand All @@ -21,7 +31,7 @@ import {
} from "./importRelations";
import { createTemplateFile } from "./templates";
import { resolveFolderForSpaceUri } from "./importFolderMetadata";
import { getNodeTypeById } from "./typeUtils";
import { getNodeTypeById, isAcceptedSchema } from "./typeUtils";
import { decorateTitle } from "@repo/database/lib/decorateTitle";

type PublishedNode = {
Expand Down Expand Up @@ -332,6 +342,7 @@ type NodeTypeSchemaForInstance = {
type NodeInstanceImportInfo = {
schema?: NodeTypeSchemaForInstance;
coreTitle?: string;
sourceDocumentId?: number;
};

export const fetchNodeImportInfoForInstances = async ({
Expand All @@ -348,7 +359,7 @@ export const fetchNodeImportInfoForInstances = async ({
const { data: instanceRows, error: instanceError } = await client
.from("my_concepts")
.select(
"source_local_id, schema_id, core_title:literal_content->>core_title",
"source_local_id, schema_id, core_title:literal_content->>core_title, sourceDocument:reference_content->sourceDocument",
)
.eq("space_id", spaceId)
.eq("is_schema", false)
Expand Down Expand Up @@ -402,6 +413,8 @@ export const fetchNodeImportInfoForInstances = async ({
schema:
row.schema_id === null ? undefined : schemasById.get(row.schema_id),
coreTitle: row.core_title ?? undefined,
sourceDocumentId:
typeof row.sourceDocument === "number" ? row.sourceDocument : undefined,
});
}

Expand Down Expand Up @@ -1230,12 +1243,205 @@ const processFileContent = async ({
return file;
};

export const importSelectedNodes = async ({
const importSourceDocumentRelations = async ({
plugin,
selectedNodes,
onProgress,
precomputedData,
client,
localSpaceId,
spaceUri,
nodeImportInfoByInstance,
importedFiles,
}: {
plugin: DiscourseGraphPlugin;
client: DGSupabaseClient;
localSpaceId: number;
spaceUri: string;
nodeImportInfoByInstance: Map<string, NodeInstanceImportInfo>;
importedFiles: Map<string, TFile>;
}): Promise<void> => {
const nodesWithSource = [...nodeImportInfoByInstance].flatMap(
([nodeId, info]) => {
const file = importedFiles.get(
spaceUriAndLocalIdToRid(spaceUri, nodeId, "note"),
);
return file && info.sourceDocumentId !== undefined
? [{ file, nodeId, sourceDocumentId: info.sourceDocumentId }]
: [];
},
);
if (nodesWithSource.length === 0) return;

const { data: sources, error } = await client
.from("my_concepts")
.select("id, source_local_id, space_id")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

sourceDocument contains a database concept ID. This lookup resolves it to a space and local ID before finding a vault file. The Source must be independently shared and visible to the importing user.

.eq("is_schema", false)
.eq("is_relation", false)
.in("id", [
...new Set(nodesWithSource.map((node) => node.sourceDocumentId)),
]);
if (error) throw error;

const sourceSpaceIds = [
...new Set(
(sources ?? []).flatMap((source) =>
source.space_id === null ? [] : [source.space_id],
),
),
];
const sourceSpaceUris = await getSpaceUris(client, sourceSpaceIds);
const queryEngine = new QueryEngine(plugin.app);
const sourceFiles = new Map<number, TFile>();
const pendingSources = new Map<string, ImportableNode>();
const sourceRids = new Map<number, string>();
for (const source of sources ?? []) {
if (
source.id === null ||
source.space_id === null ||
source.source_local_id === null
)
continue;
const sourceSpaceUri = sourceSpaceUris.get(source.space_id);
if (!sourceSpaceUri) continue;
const rid = spaceUriAndLocalIdToRid(
sourceSpaceUri,
source.source_local_id,
"note",
);
sourceRids.set(source.id, rid);
const file =
importedFiles.get(rid) ??
(source.space_id === localSpaceId
? queryEngine
.getFilesWithNodeTypeId({ excludeImported: true })

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

A local ID is only unique within its space. Local Source lookup excludes imported notes; remote lookup uses the full origin RID. A same-ID note from another graph must not be reused here.

.find(
(file) =>
plugin.app.metadataCache.getFileCache(file)?.frontmatter
?.nodeInstanceId === source.source_local_id,
)
: queryEngine.getFileByImportedFromRid(rid));
Comment thread
sid597 marked this conversation as resolved.
if (file) {
sourceFiles.set(source.id, file);
importedFiles.set(rid, file);
} else {
pendingSources.set(rid, {
nodeInstanceId: source.source_local_id,
spaceId: source.space_id,
title: "",
spaceName: "",
groupId: "",
selected: false,
});
}
}
if (pendingSources.size > 0) {
await importNodes({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automatic Source imports use the normal importer, including schema mapping, decorated titles, and assets. This lookup supplies identity; the importer fetches the title and content. Sources already found in the vault are reused without overwriting their content.

plugin,
selectedNodes: [...pendingSources.values()],
importedFiles,
});
Comment thread
sid597 marked this conversation as resolved.
}
for (const [id, rid] of sourceRids) {
const file = importedFiles.get(rid);
if (file) sourceFiles.set(id, file);
}

const localSpaceUri = getLocalSpaceUri(plugin.app);
const indexedFiles = queryEngine.getFilesWithNodeInstanceId();
const legacyEndpointsForFile = ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Manually created relations may store bare IDs or this vault's RIDs. Those count as aliases only when the ID identifies this file uniquely. Otherwise, a relation between separate local notes could suppress the imported relation.

file,
nodeInstanceId,
}: {
file: TFile;
nodeInstanceId: string;
}): string[] => {
// Bare IDs and vault RIDs are ambiguous when another space uses the same ID.
const hasOtherFile =
indexedFiles.some(
(candidate) =>
candidate !== file &&
plugin.app.metadataCache.getFileCache(candidate)?.frontmatter
?.nodeInstanceId === nodeInstanceId,
) ||
[...importedFiles].some(
([rid, candidate]) =>
candidate !== file &&
ridToSpaceUriAndLocalId(rid).sourceLocalId === nodeInstanceId,
);
return hasOtherFile
? []
: [
nodeInstanceId,
spaceUriAndLocalIdToRid(localSpaceUri, nodeInstanceId, "note"),
];
};
for (const { file, nodeId, sourceDocumentId } of nodesWithSource) {
const sourceFile = sourceFiles.get(sourceDocumentId);
if (!sourceFile) {
const warning = `Imported ${file.basename}, but its Source is unavailable. No source relation was created.`;
console.warn(warning);
new Notice(warning);
continue;
}
const { frontmatter: current } = parseFrontmatter(
await plugin.app.vault.read(file),
);
const { frontmatter: source } = parseFrontmatter(
await plugin.app.vault.read(sourceFile),
);
const sourceNodeType = plugin.settings.nodeTypes.find(
(type) => type.id === source.nodeTypeId,
);
if (sourceNodeType?.name.toLowerCase() !== "source") continue;
Comment thread
sid597 marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The Source-named type matches Obsidian's push rule in #1381. Both the local triple and relation type must already be accepted. If either is missing or provisional, the notes import without creating a relation or new relation configuration.

const triple = plugin.settings.discourseRelations.find(
(relation) =>
isAcceptedSchema(relation) &&
relation.sourceId === current.nodeTypeId &&
relation.destinationId === source.nodeTypeId &&
plugin.settings.relationTypes.some(
(type) =>
type.id === relation.relationshipTypeId && isAcceptedSchema(type),
),
);
if (!triple) continue;
if (typeof source.nodeInstanceId !== "string") continue;
const currentEndpoint = spaceUriAndLocalIdToRid(spaceUri, nodeId, "note");
const sourceEndpoint =
typeof source.importedFromRid === "string"
? source.importedFromRid
: source.nodeInstanceId;
const relations = await loadRelations(plugin);
const currentEndpoints = [
currentEndpoint,
...legacyEndpointsForFile({ file, nodeInstanceId: nodeId }),
];
Comment thread
sid597 marked this conversation as resolved.
const sourceEndpoints = [
sourceEndpoint,
...legacyEndpointsForFile({
file: sourceFile,
nodeInstanceId: source.nodeInstanceId,
}),
];
Comment thread
sid597 marked this conversation as resolved.
if (
currentEndpoints.some((from) =>
sourceEndpoints.some((to) =>
findRelationBySourceDestinationType(
relations,
from,
to,
triple.relationshipTypeId,
),
),
)
)
continue;
await addRelationNoCheck(plugin, {
type: triple.relationshipTypeId,
source: currentEndpoint,
destination: sourceEndpoint,
});
}
};

type ImportSelectedNodesOptions = {
plugin: DiscourseGraphPlugin;
selectedNodes: ImportableNode[];
onProgress?: (current: number, total: number) => void;
Expand All @@ -1245,6 +1451,21 @@ export const importSelectedNodes = async ({
keyToRelationEndpointId: Map<string, string>;
relationInstancesBySpace: Map<number, RemoteRelationInstance[]>;
};
};

export const importSelectedNodes = (
options: ImportSelectedNodesOptions,
): Promise<{ success: number; failed: number }> =>
importNodes({ ...options, importedFiles: new Map() });

const importNodes = async ({
plugin,
selectedNodes,
onProgress,
precomputedData,
importedFiles,
}: ImportSelectedNodesOptions & {
importedFiles: Map<string, TFile>;
}): Promise<{ success: number; failed: number }> => {
const client = await getLoggedInClient(plugin);
if (!client) {
Expand Down Expand Up @@ -1311,10 +1532,12 @@ export const importSelectedNodes = async ({
"note",
);
// Check if file already exists by nodeInstanceId + importedFromRid
const existingFile = queryEngine.findExistingImportedFile(
node.nodeInstanceId,
importedFromRid,
);
const existingFile =
importedFiles.get(importedFromRid) ??
queryEngine.findExistingImportedFile(
node.nodeInstanceId,
importedFromRid,
);

const nodeContent = await fetchNodeContentForImport({
client,
Expand Down Expand Up @@ -1397,6 +1620,20 @@ export const importSelectedNodes = async ({
: `${sanitizedFileName}.md`;
finalFilePath = `${importFolderPath}/${pathUnderImport}`;

const desiredFilePath = finalFilePath;
let counter = 1;
let occupiedFile: TFile | null;
while (
(occupiedFile = plugin.app.vault.getFileByPath(finalFilePath))
) {
const { frontmatter } = parseFrontmatter(
await plugin.app.vault.read(occupiedFile),
);
if (frontmatter.importedFromRid === importedFromRid) break;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Two Sources can have the same title, so an occupied filename is reused only for the same origin RID. Other Sources get a suffix. The refresh path also recognizes its own suffixed file to avoid repeated renames.

finalFilePath = `${desiredFilePath.slice(0, -3)} (${counter}).md`;
counter++;
}

// Ensure all parent folders exist (e.g. import/VaultName/Discourse Nodes/SubFolder)
const dirParts = finalFilePath.split("/");
for (let i = 1; i < dirParts.length - 1; i++) {
Expand Down Expand Up @@ -1453,13 +1690,20 @@ export const importSelectedNodes = async ({
const newPath = `${currentDir}/${sanitizedFileName}.md`;
let targetPath = newPath;
let counter = 1;
while (await plugin.app.vault.adapter.exists(targetPath)) {
while (
(await plugin.app.vault.adapter.exists(targetPath)) &&
plugin.app.vault.getFileByPath(targetPath) !== processedFile
) {
targetPath = `${currentDir}/${sanitizedFileName} (${counter}).md`;
counter++;
}
await plugin.app.fileManager.renameFile(processedFile, targetPath);
if (targetPath !== processedFile.path) {
await plugin.app.fileManager.renameFile(processedFile, targetPath);
}
}

// The metadata cache can lag behind vault writes during a batch import.
importedFiles.set(importedFromRid, processedFile);

@sid597 sid597 Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The batch map keeps newly imported files available while Obsidian's metadata cache catches up. This prevents two Evidence nodes from importing the same Source twice.

successCount++;
processedCount++;
onProgress?.(processedCount, totalNodes);
Expand All @@ -1471,6 +1715,22 @@ export const importSelectedNodes = async ({
}
}

try {
await importSourceDocumentRelations({
plugin,
client,
localSpaceId: context.spaceId,
spaceUri,
nodeImportInfoByInstance,
importedFiles,
});
} catch (error) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Source relations are added after the selected notes have been written. A Source lookup or relation failure must not turn those successful imports into failures. Keep the notes and report the incomplete Source step.

console.warn("Could not import source documents:", error);
new Notice(
"Nodes imported, but their source relations could not be imported.",
);
}

// Import relations where both endpoints resolve in this vault (imported or local)
try {
let keyToRelationEndpointId: Map<string, string>;
Expand Down