-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-1857 Import Roam-origin shared nodes through the existing Obsidian importer #1266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -322,6 +322,76 @@ const fetchNodeContentForImport = async ({ | |
| }; | ||
| }; | ||
|
|
||
| type NodeTypeSchemaForInstance = { | ||
| nodeTypeId: string; | ||
| name: string; | ||
| }; | ||
|
|
||
| export const fetchNodeTypeSchemasForInstances = async ({ | ||
| client, | ||
| spaceId, | ||
| nodeInstanceIds, | ||
| }: { | ||
| client: DGSupabaseClient; | ||
| spaceId: number; | ||
| nodeInstanceIds: string[]; | ||
| }): Promise<Map<string, NodeTypeSchemaForInstance>> => { | ||
| const result = new Map<string, NodeTypeSchemaForInstance>(); | ||
|
|
||
| const { data: instanceRows, error: instanceError } = await client | ||
| .from("my_concepts") | ||
| .select("source_local_id, schema_id") | ||
| .eq("space_id", spaceId) | ||
| .eq("is_schema", false) | ||
| .eq("arity", 0) | ||
| .in("source_local_id", nodeInstanceIds); | ||
|
|
||
| if (instanceError || !instanceRows) { | ||
| console.error("Error fetching node instance concepts:", instanceError); | ||
| return result; | ||
| } | ||
|
|
||
| const schemaIds = [ | ||
| ...new Set( | ||
| instanceRows | ||
| .map((row) => row.schema_id) | ||
| .filter((id): id is number => id !== null), | ||
| ), | ||
| ]; | ||
| if (schemaIds.length === 0) return result; | ||
|
|
||
| const { data: schemaRows, error: schemaError } = await client | ||
| .from("my_concepts") | ||
| .select("id, source_local_id, name") | ||
| .eq("space_id", spaceId) | ||
| .eq("is_schema", true) | ||
| .eq("arity", 0) | ||
| .in("id", schemaIds); | ||
|
|
||
| if (schemaError || !schemaRows) { | ||
| console.error("Error fetching node type schemas:", schemaError); | ||
| return result; | ||
| } | ||
|
|
||
| const schemasById = new Map<number, NodeTypeSchemaForInstance>(); | ||
| for (const row of schemaRows) { | ||
| if (row.id !== null && row.source_local_id !== null && row.name !== null) { | ||
| schemasById.set(row.id, { | ||
| nodeTypeId: row.source_local_id, | ||
| name: row.name, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| for (const row of instanceRows) { | ||
| if (row.source_local_id === null || row.schema_id === null) continue; | ||
| const schema = schemasById.get(row.schema_id); | ||
| if (schema) result.set(row.source_local_id, schema); | ||
| } | ||
|
|
||
| return result; | ||
| }; | ||
|
|
||
| /** | ||
| * Fetches created/last_modified from the source space Content (my_contents) for an imported node. | ||
| * Used by the discourse context view to show "last modified in original vault". | ||
|
|
@@ -939,9 +1009,6 @@ const sanitizePathForImport = (path: string): string => { | |
|
|
||
| type ParsedFrontmatter = { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trimmed to what this parse actually feeds: only |
||
| nodeTypeId?: string; | ||
| nodeInstanceId?: string; | ||
| publishedToGroups?: string[]; | ||
| authorId?: number; | ||
| [key: string]: unknown; | ||
| }; | ||
|
|
||
|
|
@@ -1096,22 +1163,24 @@ const processFileContent = async ({ | |
| sourceSpaceId, | ||
| sourceSpaceUri, | ||
| rawContent, | ||
| originalFilePath, | ||
| filePath, | ||
| importedCreatedAt, | ||
| importedModifiedAt, | ||
| authorId, | ||
| nodeInstanceId, | ||
| nodeTypeIdFromConcept, | ||
| }: { | ||
| plugin: DiscourseGraphPlugin; | ||
| client: DGSupabaseClient; | ||
| sourceSpaceId: number; | ||
| sourceSpaceUri: string; | ||
| rawContent: string; | ||
| originalFilePath?: string; | ||
| filePath: string; | ||
| importedCreatedAt?: number; | ||
| importedModifiedAt?: number; | ||
| authorId?: number; | ||
| nodeInstanceId: string; | ||
| nodeTypeIdFromConcept?: string; | ||
| }): Promise< | ||
| { file: TFile; error?: never } | { file?: never; error: string } | ||
| > => { | ||
|
|
@@ -1134,20 +1203,16 @@ const processFileContent = async ({ | |
| // 2. Parse frontmatter from rawContent (metadataCache is updated async and is | ||
| // often empty immediately after create/modify), then map nodeTypeId and update frontmatter. | ||
| const { frontmatter } = parseFrontmatter(rawContent); | ||
| const sourceNodeTypeId = frontmatter.nodeTypeId; | ||
| if (typeof sourceNodeTypeId !== "string") { | ||
| const sourceNodeTypeId = | ||
| typeof frontmatter.nodeTypeId === "string" | ||
| ? frontmatter.nodeTypeId | ||
| : nodeTypeIdFromConcept; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ordering: content frontmatter wins when present, so Obsidian-origin imports behave exactly as before; the Concept-derived id is only reached by frontmatter-less (Roam-origin) content. For Obsidian-origin the two values are identical by construction — publish writes
sid597 marked this conversation as resolved.
Outdated
|
||
| if (sourceNodeTypeId === undefined) { | ||
| await plugin.app.vault.delete(file); | ||
|
sid597 marked this conversation as resolved.
Outdated
|
||
| return { | ||
| error: "importedNode missing sourceNodeTypeId", | ||
| }; | ||
| } | ||
| const sourceNodeId = frontmatter.nodeInstanceId; | ||
| if (typeof sourceNodeId !== "string") { | ||
| await plugin.app.vault.delete(file); | ||
| return { | ||
| error: "importedNode missing nodeInstanceId", | ||
| }; | ||
| } | ||
|
|
||
| const mappedNodeTypeId = await mapNodeTypeIdToLocal({ | ||
| plugin, | ||
|
|
@@ -1161,12 +1226,11 @@ const processFileContent = async ({ | |
| file, | ||
| (fm) => { | ||
| const record = fm as Record<string, unknown>; | ||
| if (mappedNodeTypeId !== undefined) { | ||
| record.nodeTypeId = mappedNodeTypeId; | ||
| } | ||
| record.nodeTypeId = mappedNodeTypeId; | ||
| record.nodeInstanceId = nodeInstanceId; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line is what makes duplicate prevention work for Roam-origin nodes: |
||
| record.importedFromRid = spaceUriAndLocalIdToRid( | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now built from the caller-supplied |
||
| sourceSpaceUri, | ||
| sourceNodeId, | ||
| nodeInstanceId, | ||
| "note", | ||
| ); | ||
| record.lastModified = importedModifiedAt; | ||
|
|
@@ -1244,6 +1308,12 @@ export const importSelectedNodes = async ({ | |
| spaceName, | ||
| }); | ||
|
|
||
| const nodeTypeSchemasByInstance = await fetchNodeTypeSchemasForInstances({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Batched once per space, outside the per-node loop — mirrors where the preview step does the same resolution. |
||
| client, | ||
| spaceId, | ||
| nodeInstanceIds: nodes.map((n) => n.nodeInstanceId), | ||
| }); | ||
|
|
||
| // Process each node in this space | ||
| for (const node of nodes) { | ||
| try { | ||
|
|
@@ -1318,11 +1388,14 @@ export const importSelectedNodes = async ({ | |
| sourceSpaceId: spaceId, | ||
| sourceSpaceUri: spaceUri, | ||
| rawContent: content, | ||
| originalFilePath: contentFilePath, | ||
| filePath: finalFilePath, | ||
| importedCreatedAt: createdAt, | ||
| importedModifiedAt: modifiedAt, | ||
| authorId, | ||
| nodeInstanceId: node.nodeInstanceId, | ||
| nodeTypeIdFromConcept: nodeTypeSchemasByInstance.get( | ||
| node.nodeInstanceId, | ||
| )?.nodeTypeId, | ||
| }); | ||
|
|
||
| if (result.error) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ import { | |
| getImportedNodesInfo, | ||
| getLocalNodeKeyToEndpointId, | ||
| } from "./relationsStore"; | ||
| import { getSpaceUris } from "./importNodes"; | ||
| import { fetchNodeTypeSchemasForInstances, getSpaceUris } from "./importNodes"; | ||
| import { QueryEngine } from "~/services/QueryEngine"; | ||
| import { | ||
| fetchRelationInstancesFromSpace, | ||
|
|
@@ -82,69 +82,34 @@ export const computeImportPreview = async ({ | |
| } | ||
|
|
||
| for (const [spaceId, nodes] of nodesBySpace.entries()) { | ||
| // Get schema_ids for the selected node instances | ||
| const nodeInstanceIds = nodes.map((n) => n.nodeInstanceId); | ||
| const { data: conceptRows } = await client | ||
| .from("my_concepts") | ||
| .select("source_local_id, schema_id") | ||
| .eq("space_id", spaceId) | ||
| .eq("is_schema", false) | ||
| .in("source_local_id", nodeInstanceIds); | ||
|
|
||
| if (!conceptRows) continue; | ||
|
|
||
| const schemaIds = [ | ||
| ...new Set( | ||
| ( | ||
| conceptRows as Array<{ | ||
| source_local_id: string; | ||
| schema_id: number | null; | ||
| }> | ||
| ) | ||
| .map((r) => r.schema_id) | ||
| .filter((id): id is number => id != null), | ||
| ), | ||
| ]; | ||
|
|
||
| if (schemaIds.length === 0) continue; | ||
|
|
||
| // Resolve schema_ids to node type info | ||
| const { data: schemaRows } = await client | ||
| .from("my_concepts") | ||
| .select("source_local_id, name") | ||
| .eq("space_id", spaceId) | ||
| .eq("is_schema", true) | ||
| .in("id", schemaIds); | ||
|
|
||
| if (!schemaRows) continue; | ||
|
|
||
| for (const schema of schemaRows as Array<{ | ||
| source_local_id: string; | ||
| name: string; | ||
| }>) { | ||
| const sourceNodeTypeId = schema.source_local_id; | ||
| const nodeTypeSchemasByInstance = await fetchNodeTypeSchemasForInstances({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drop-in replacement for the two inline queries that lived here — same filters plus |
||
| client, | ||
| spaceId, | ||
| nodeInstanceIds: nodes.map((n) => n.nodeInstanceId), | ||
| }); | ||
|
|
||
| for (const { nodeTypeId, name } of nodeTypeSchemasByInstance.values()) { | ||
| // Track name for triplet resolution | ||
| if (!nodeTypeIdToName.has(sourceNodeTypeId)) { | ||
| nodeTypeIdToName.set(sourceNodeTypeId, schema.name); | ||
| if (!nodeTypeIdToName.has(nodeTypeId)) { | ||
| nodeTypeIdToName.set(nodeTypeId, name); | ||
| } | ||
|
|
||
| if (seenNodeTypeIds.has(sourceNodeTypeId)) continue; | ||
| seenNodeTypeIds.add(sourceNodeTypeId); | ||
| if (seenNodeTypeIds.has(nodeTypeId)) continue; | ||
| seenNodeTypeIds.add(nodeTypeId); | ||
|
|
||
| // Check against local node types (mirrors mapNodeTypeIdToLocal logic without side effects) | ||
| const matchById = plugin.settings.nodeTypes.find( | ||
| (nt) => nt.id === sourceNodeTypeId, | ||
| (nt) => nt.id === nodeTypeId, | ||
| ); | ||
| if (matchById) continue; | ||
|
|
||
| const matchByName = plugin.settings.nodeTypes.find( | ||
| (nt) => nt.name === schema.name, | ||
| (nt) => nt.name === name, | ||
| ); | ||
| if (matchByName) continue; | ||
|
|
||
| // This is a new node type that would be created | ||
| newNodeTypeSchemas.push({ id: sourceNodeTypeId, name: schema.name }); | ||
| newNodeTypeSchemas.push({ id: nodeTypeId, name }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,6 +78,58 @@ describe("buildSharedNodes", () => { | |
| ]); | ||
| }); | ||
|
|
||
| it("builds a Roam-origin shared node with a URL-based rid", () => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Roam-origin fixture: for an https space URL the rid is |
||
| const roamSpaces: BuildArgs["spaces"] = [ | ||
| { | ||
| id: 30, | ||
| name: "Research graph", | ||
| platform: "Roam", | ||
| url: "https://roamresearch.com/#/app/research-graph", | ||
| }, | ||
| ]; | ||
| const roamNodes: BuildArgs["nodes"] = [ | ||
| { ...nodes[0]!, space_id: 30, source_local_id: "roam-uid-1" }, | ||
| ]; | ||
| const roamDirect: BuildArgs["directContents"] = [ | ||
| { | ||
| ...directContents[0]!, | ||
| space_id: 30, | ||
| source_local_id: "roam-uid-1", | ||
| metadata: null, | ||
| text: "CLM - Sleep improves memory consolidation", | ||
| }, | ||
| ]; | ||
| const roamFull: BuildArgs["fullContentSummaries"] = [ | ||
| { | ||
| last_modified: "2026-06-14T15:00:00", | ||
| source_local_id: "roam-uid-1", | ||
| space_id: 30, | ||
| }, | ||
| ]; | ||
| expect( | ||
| build({ | ||
| nodesOverride: roamNodes, | ||
| directOverride: roamDirect, | ||
| fullOverride: roamFull, | ||
| spacesOverride: roamSpaces, | ||
| }), | ||
| ).toEqual([ | ||
| { | ||
| rid: "https://roamresearch.com/#/app/research-graph/roam-uid-1", | ||
| sourceLocalId: "roam-uid-1", | ||
| spaceId: 30, | ||
| spaceName: "Research graph", | ||
| spaceUri: "https://roamresearch.com/#/app/research-graph", | ||
| platform: "Roam", | ||
| title: "CLM - Sleep improves memory consolidation", | ||
| created: "2026-06-14T11:00:00.000Z", | ||
| lastModified: "2026-06-14T15:00:00.000Z", | ||
| authorId: 42, | ||
| directMetadata: null, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("discovers a node without full content", () => { | ||
| expect(build({ fullOverride: [] })[0]?.lastModified).toBe( | ||
| "2026-06-14T13:00:00.000Z", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Roam's
fullvariant is body-only markdown (buildFullMarkdowninapps/roam/src/utils/roamToCrossAppConverters.tsemits# Title+ body, no YAML), so there is no embedded frontmatter to read identity from — this resolves each instance's node type from the source space's Concept rows instead. It consolidates the two queriescomputeImportPreviewwas already running inline (parity table in the description).nameis consumed by the preview caller only; the import path usesnodeTypeId. Both queries carryarity = 0so relation instances / relation-type schemas (arity 2) can't slip in regardless of what ids a caller passes.