From d3e18add6d957d4d7562341b2db59f8dbd145217 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 13:19:31 +0530 Subject: [PATCH 01/15] ENG-2157 Add decorateTitle helper for rebuilding titles from core_title --- .../src/lib/__tests__/decorateTitle.test.ts | 31 +++++++++++++++++++ packages/database/src/lib/decorateTitle.ts | 12 +++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/database/src/lib/__tests__/decorateTitle.test.ts create mode 100644 packages/database/src/lib/decorateTitle.ts diff --git a/packages/database/src/lib/__tests__/decorateTitle.test.ts b/packages/database/src/lib/__tests__/decorateTitle.test.ts new file mode 100644 index 000000000..fef3776cb --- /dev/null +++ b/packages/database/src/lib/__tests__/decorateTitle.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { decorateTitle } from "../decorateTitle"; + +describe("decorateTitle", () => { + it("substitutes the core title for {content}", () => { + expect(decorateTitle("[[CLM]] - {content}", "sleep improves memory")).toBe( + "[[CLM]] - sleep improves memory", + ); + expect(decorateTitle("CLM - {content}", "sleep improves memory")).toBe( + "CLM - sleep improves memory", + ); + }); + + it("matches the content placeholder case-insensitively", () => { + expect(decorateTitle("QUE - {Content}", "why")).toBe("QUE - why"); + }); + + it("substitutes the empty string for other placeholders", () => { + expect( + decorateTitle("[[EVD]] - {content} - {Source}", "REM sleep and recall"), + ).toBe("[[EVD]] - REM sleep and recall - "); + }); + + it("returns the empty string for an empty format", () => { + expect(decorateTitle("", "anything")).toBe(""); + }); + + it("keeps a core title that contains the separator", () => { + expect(decorateTitle("CLM - {content}", "a - b")).toBe("CLM - a - b"); + }); +}); diff --git a/packages/database/src/lib/decorateTitle.ts b/packages/database/src/lib/decorateTitle.ts new file mode 100644 index 000000000..6017fdb02 --- /dev/null +++ b/packages/database/src/lib/decorateTitle.ts @@ -0,0 +1,12 @@ +// Inverse of the apps' extractContentFromTitle: rebuild a local title from a +// node type format and the core_title stored in Concept.literal_content. +// "{content}" takes the core title; every other placeholder (e.g. "{Source}") +// becomes the empty string, so "[[EVD]] - {content} - {Source}" yields +// "[[EVD]] - - ". Callers decide the fallback when the format is +// empty or the core title is missing. +const FORMAT_PLACEHOLDER = /{[a-zA-Z]+}/g; + +export const decorateTitle = (format: string, coreTitle: string): string => + format.replace(FORMAT_PLACEHOLDER, (placeholder) => + placeholder.toLowerCase() === "{content}" ? coreTitle : "", + ); From de2811dbf6d42bfbf8e3ff285f57742a0b578428 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 13:43:42 +0530 Subject: [PATCH 02/15] ENG-2157 Decorate imported node titles in Obsidian from core_title --- apps/obsidian/src/utils/importNodes.ts | 185 ++++++++++++----------- apps/obsidian/src/utils/importPreview.ts | 9 +- 2 files changed, 102 insertions(+), 92 deletions(-) diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index c8dfc391e..4d7880d0f 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -21,6 +21,8 @@ import { } from "./importRelations"; import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; +import { getNodeTypeById } from "./typeUtils"; +import { decorateTitle } from "@repo/database/lib/decorateTitle"; type PublishedNode = { source_local_id: string; @@ -327,7 +329,12 @@ type NodeTypeSchemaForInstance = { name: string; }; -export const fetchNodeTypeSchemasForInstances = async ({ +type NodeInstanceImportInfo = { + schema?: NodeTypeSchemaForInstance; + coreTitle?: string; +}; + +export const fetchNodeImportInfoForInstances = async ({ client, spaceId, nodeInstanceIds, @@ -335,12 +342,14 @@ export const fetchNodeTypeSchemasForInstances = async ({ client: DGSupabaseClient; spaceId: number; nodeInstanceIds: string[]; -}): Promise> => { - const result = new Map(); +}): Promise> => { + const result = new Map(); const { data: instanceRows, error: instanceError } = await client .from("my_concepts") - .select("source_local_id, schema_id") + .select( + "source_local_id, schema_id, core_title:literal_content->>core_title", + ) .eq("space_id", spaceId) .eq("is_schema", false) .eq("is_relation", false) @@ -358,35 +367,42 @@ export const fetchNodeTypeSchemasForInstances = async ({ .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("is_relation", false) - .in("id", schemaIds); - - if (schemaError || !schemaRows) { - console.error("Error fetching node type schemas:", schemaError); - return result; - } const schemasById = new Map(); - 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, - }); + if (schemaIds.length > 0) { + 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("is_relation", false) + .in("id", schemaIds); + + if (schemaError || !schemaRows) { + console.error("Error fetching node type schemas:", schemaError); + } else { + 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); + if (row.source_local_id === null) continue; + result.set(row.source_local_id, { + schema: + row.schema_id === null ? undefined : schemasById.get(row.schema_id), + coreTitle: row.core_title ?? undefined, + }); } return result; @@ -1159,8 +1175,6 @@ export const mapNodeTypeIdToLocal = async ({ const processFileContent = async ({ plugin, - client, - sourceSpaceId, sourceSpaceUri, rawContent, filePath, @@ -1168,11 +1182,9 @@ const processFileContent = async ({ importedModifiedAt, authorId, nodeInstanceId, - nodeTypeIdFromConcept, + nodeTypeId, }: { plugin: DiscourseGraphPlugin; - client: DGSupabaseClient; - sourceSpaceId: number; sourceSpaceUri: string; rawContent: string; filePath: string; @@ -1180,25 +1192,9 @@ const processFileContent = async ({ importedModifiedAt?: number; authorId?: number; nodeInstanceId: string; - nodeTypeIdFromConcept?: string; -}): Promise< - { file: TFile; error?: never } | { file?: never; error: string } -> => { - // 1. Parse frontmatter from rawContent (metadataCache is updated async and is - // often empty immediately after create/modify) and resolve the node type - // before any vault write, so a failed lookup leaves existing files untouched. - const { frontmatter } = parseFrontmatter(rawContent); - const sourceNodeTypeId = - typeof frontmatter.nodeTypeId === "string" - ? frontmatter.nodeTypeId - : nodeTypeIdFromConcept; - if (sourceNodeTypeId === undefined) { - return { - error: "importedNode missing sourceNodeTypeId", - }; - } - - // 2. Create or update the file with the fetched content. + nodeTypeId: string; +}): Promise => { + // Create or update the file with the fetched content. // On create, set file metadata (ctime/mtime) to original vault dates via vault adapter. let file: TFile | null = plugin.app.vault.getFileByPath(filePath); const stat = @@ -1214,19 +1210,11 @@ const processFileContent = async ({ await plugin.app.vault.process(file, () => rawContent, stat); } - const mappedNodeTypeId = await mapNodeTypeIdToLocal({ - plugin, - client, - sourceSpaceId, - sourceSpaceUri, - sourceNodeTypeId, - }); - await plugin.app.fileManager.processFrontMatter( file, (fm) => { const record = fm as Record; - record.nodeTypeId = mappedNodeTypeId; + record.nodeTypeId = nodeTypeId; record.nodeInstanceId = nodeInstanceId; record.importedFromRid = spaceUriAndLocalIdToRid( sourceSpaceUri, @@ -1239,7 +1227,7 @@ const processFileContent = async ({ stat, ); - return { file }; + return file; }; export const importSelectedNodes = async ({ @@ -1308,7 +1296,7 @@ export const importSelectedNodes = async ({ spaceName, }); - const nodeTypeSchemasByInstance = await fetchNodeTypeSchemasForInstances({ + const nodeImportInfoByInstance = await fetchNodeImportInfoForInstances({ client, spaceId, nodeInstanceIds: nodes.map((n) => n.nodeInstanceId), @@ -1355,8 +1343,44 @@ export const importSelectedNodes = async ({ const originalNodePath: string | undefined = contentFilePath ?? node.filePath; - // Sanitize file name - const sanitizedFileName = sanitizeFileName(fileName); + const nodeImportInfo = nodeImportInfoByInstance.get( + node.nodeInstanceId, + ); + + // Parse frontmatter from content (metadataCache is updated async and is + // often empty immediately after create/modify) and resolve the node type + // before any vault write, so a failed lookup leaves existing files untouched. + const { frontmatter } = parseFrontmatter(content); + const sourceNodeTypeId = + typeof frontmatter.nodeTypeId === "string" + ? frontmatter.nodeTypeId + : nodeImportInfo?.schema?.nodeTypeId; + if (sourceNodeTypeId === undefined) { + console.error( + `Error processing file content for node ${node.nodeInstanceId}:`, + "importedNode missing sourceNodeTypeId", + ); + failedCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + continue; + } + + const mappedNodeTypeId = await mapNodeTypeIdToLocal({ + plugin, + client, + sourceSpaceId: spaceId, + sourceSpaceUri: spaceUri, + sourceNodeTypeId, + }); + + const localNodeType = getNodeTypeById(plugin, mappedNodeTypeId); + const coreTitle = nodeImportInfo?.coreTitle; + const titleForFileName = + coreTitle !== undefined && localNodeType?.format + ? decorateTitle(localNodeType.format, coreTitle) + : fileName; + const sanitizedFileName = sanitizeFileName(titleForFileName); let finalFilePath: string; if (existingFile) { @@ -1364,10 +1388,13 @@ export const importSelectedNodes = async ({ finalFilePath = existingFile.path; } else { // Preserve source vault folder structure under import/{vaultName} when we have filePath from Content - const pathUnderImport = + const sourceFolder = contentFilePath && contentFilePath.includes("/") - ? sanitizePathForImport(contentFilePath) - : `${sanitizedFileName}.md`; + ? sanitizePathForImport(contentFilePath.replace(/\/[^/]*$/, "")) + : ""; + const pathUnderImport = sourceFolder + ? `${sourceFolder}/${sanitizedFileName}.md` + : `${sanitizedFileName}.md`; finalFilePath = `${importFolderPath}/${pathUnderImport}`; // Ensure all parent folders exist (e.g. import/VaultName/Discourse Nodes/SubFolder) @@ -1380,12 +1407,8 @@ export const importSelectedNodes = async ({ } } - // Process the file content (maps nodeTypeId, handles frontmatter, stores import timestamps) - // This updates existing file or creates new one - const result = await processFileContent({ + const processedFile = await processFileContent({ plugin, - client, - sourceSpaceId: spaceId, sourceSpaceUri: spaceUri, rawContent: content, filePath: finalFilePath, @@ -1393,25 +1416,9 @@ export const importSelectedNodes = async ({ importedModifiedAt: modifiedAt, authorId, nodeInstanceId: node.nodeInstanceId, - nodeTypeIdFromConcept: nodeTypeSchemasByInstance.get( - node.nodeInstanceId, - )?.nodeTypeId, + nodeTypeId: mappedNodeTypeId, }); - if (result.error) { - console.error( - `Error processing file content for node ${node.nodeInstanceId}:`, - result.error, - ); - failedCount++; - processedCount++; - onProgress?.(processedCount, totalNodes); - continue; - } - - // typescript should not need this assertion? - const processedFile = result.file!; - // Import assets for this node (use originalNodePath so assets go under import/{space}/ relative to note) const assetImportResult = await importAssetsForNode({ plugin, diff --git a/apps/obsidian/src/utils/importPreview.ts b/apps/obsidian/src/utils/importPreview.ts index 6507b04a1..99c0a913c 100644 --- a/apps/obsidian/src/utils/importPreview.ts +++ b/apps/obsidian/src/utils/importPreview.ts @@ -5,7 +5,7 @@ import { getImportedNodesInfo, getLocalNodeKeyToEndpointId, } from "./relationsStore"; -import { fetchNodeTypeSchemasForInstances, getSpaceUris } from "./importNodes"; +import { fetchNodeImportInfoForInstances, getSpaceUris } from "./importNodes"; import { QueryEngine } from "~/services/QueryEngine"; import { fetchRelationInstancesFromSpace, @@ -82,13 +82,16 @@ export const computeImportPreview = async ({ } for (const [spaceId, nodes] of nodesBySpace.entries()) { - const nodeTypeSchemasByInstance = await fetchNodeTypeSchemasForInstances({ + const nodeImportInfoByInstance = await fetchNodeImportInfoForInstances({ client, spaceId, nodeInstanceIds: nodes.map((n) => n.nodeInstanceId), }); - for (const { nodeTypeId, name } of nodeTypeSchemasByInstance.values()) { + for (const { schema } of nodeImportInfoByInstance.values()) { + if (!schema) continue; + const { nodeTypeId, name } = schema; + // Track name for triplet resolution if (!nodeTypeIdToName.has(nodeTypeId)) { nodeTypeIdToName.set(nodeTypeId, name); From 907869b0473bb8a6bb6a0e96c0c28438ec1bc729 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 16:20:29 +0530 Subject: [PATCH 03/15] Keep the incoming title when the format has placeholders core_title cannot fill decorateTitle now returns null for formats without a {content} placeholder or with placeholders such as {Source}: substituting the empty string dropped the source from a Roam-format Evidence name and produced a title that no longer matched the format. The Obsidian format-expression helper reuses the shared placeholder pattern so decorate and match agree. --- .../utils/getDiscourseNodeFormatExpression.ts | 4 ++- apps/obsidian/src/utils/importNodes.ts | 8 +++--- .../src/lib/__tests__/decorateTitle.test.ts | 14 ++++++---- packages/database/src/lib/decorateTitle.ts | 28 +++++++++++++------ 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts index ea2e96bdc..0c4cd63fb 100644 --- a/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts +++ b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts @@ -1,9 +1,11 @@ +import { FORMAT_PLACEHOLDER } from "@repo/database/lib/decorateTitle"; + export const getDiscourseNodeFormatExpression = (format: string) => format ? new RegExp( `^${format .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") - .replace(/{[a-zA-Z]+}/g, "(.*?)")}$`, + .replace(FORMAT_PLACEHOLDER, "(.*?)")}$`, "s", ) : /$^/; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 4d7880d0f..bde0f108d 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -1376,11 +1376,11 @@ export const importSelectedNodes = async ({ const localNodeType = getNodeTypeById(plugin, mappedNodeTypeId); const coreTitle = nodeImportInfo?.coreTitle; - const titleForFileName = - coreTitle !== undefined && localNodeType?.format + const decoratedTitle = + coreTitle !== undefined && localNodeType ? decorateTitle(localNodeType.format, coreTitle) - : fileName; - const sanitizedFileName = sanitizeFileName(titleForFileName); + : null; + const sanitizedFileName = sanitizeFileName(decoratedTitle ?? fileName); let finalFilePath: string; if (existingFile) { diff --git a/packages/database/src/lib/__tests__/decorateTitle.test.ts b/packages/database/src/lib/__tests__/decorateTitle.test.ts index fef3776cb..5bff56c35 100644 --- a/packages/database/src/lib/__tests__/decorateTitle.test.ts +++ b/packages/database/src/lib/__tests__/decorateTitle.test.ts @@ -15,17 +15,21 @@ describe("decorateTitle", () => { expect(decorateTitle("QUE - {Content}", "why")).toBe("QUE - why"); }); - it("substitutes the empty string for other placeholders", () => { + it("returns null for a format with placeholders the core title cannot fill", () => { expect( decorateTitle("[[EVD]] - {content} - {Source}", "REM sleep and recall"), - ).toBe("[[EVD]] - REM sleep and recall - "); + ).toBeNull(); }); - it("returns the empty string for an empty format", () => { - expect(decorateTitle("", "anything")).toBe(""); + it("returns null for a format without a content placeholder", () => { + expect(decorateTitle("", "anything")).toBeNull(); + expect(decorateTitle("CLM", "anything")).toBeNull(); }); - it("keeps a core title that contains the separator", () => { + it("keeps a core title that contains the separator or replacement patterns", () => { expect(decorateTitle("CLM - {content}", "a - b")).toBe("CLM - a - b"); + expect(decorateTitle("CLM - {content}", "costs $& more")).toBe( + "CLM - costs $& more", + ); }); }); diff --git a/packages/database/src/lib/decorateTitle.ts b/packages/database/src/lib/decorateTitle.ts index 6017fdb02..7b3e39b1b 100644 --- a/packages/database/src/lib/decorateTitle.ts +++ b/packages/database/src/lib/decorateTitle.ts @@ -1,12 +1,22 @@ // Inverse of the apps' extractContentFromTitle: rebuild a local title from a // node type format and the core_title stored in Concept.literal_content. -// "{content}" takes the core title; every other placeholder (e.g. "{Source}") -// becomes the empty string, so "[[EVD]] - {content} - {Source}" yields -// "[[EVD]] - - ". Callers decide the fallback when the format is -// empty or the core title is missing. -const FORMAT_PLACEHOLDER = /{[a-zA-Z]+}/g; +// Returns null when the format cannot be rebuilt from the core title alone: +// it is empty, has no {content} placeholder, or carries other placeholders +// such as {Source} whose values the database does not hold yet. Callers fall +// back to the incoming title in that case. +export const FORMAT_PLACEHOLDER = /{[a-zA-Z]+}/g; -export const decorateTitle = (format: string, coreTitle: string): string => - format.replace(FORMAT_PLACEHOLDER, (placeholder) => - placeholder.toLowerCase() === "{content}" ? coreTitle : "", - ); +export const decorateTitle = ( + format: string, + coreTitle: string, +): string | null => { + const placeholders = format.match(FORMAT_PLACEHOLDER) ?? []; + if ( + placeholders.length === 0 || + placeholders.some( + (placeholder) => placeholder.toLowerCase() !== "{content}", + ) + ) + return null; + return format.replace(FORMAT_PLACEHOLDER, () => coreTitle); +}; From b44e8622c5a16e99bb003fdc11a1ef7eb94febc0 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 13:40:59 +0530 Subject: [PATCH 04/15] ENG-2156 Decorate imported node titles in Roam from core_title --- .../components/settings/utils/accessors.ts | 3 + .../utils/__tests__/importSharedNodes.test.ts | 47 ++++ .../__tests__/materializeSharedNode.test.ts | 130 +++++++++ .../__tests__/refreshImportedNode.test.ts | 38 +++ .../__tests__/resolveSharedNodeTypes.test.ts | 256 ++++++++++++++++++ apps/roam/src/utils/importSharedNodes.ts | 8 +- apps/roam/src/utils/materializeSharedNode.ts | 12 +- apps/roam/src/utils/refreshImportedNode.ts | 6 + apps/roam/src/utils/resolveSharedNodeTypes.ts | 101 +++++++ .../src/lib/__tests__/sharedNodes.test.ts | 21 +- packages/database/src/lib/sharedNodes.ts | 8 +- 11 files changed, 624 insertions(+), 6 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts create mode 100644 apps/roam/src/utils/resolveSharedNodeTypes.ts diff --git a/apps/roam/src/components/settings/utils/accessors.ts b/apps/roam/src/components/settings/utils/accessors.ts index 20250daff..014f9fff2 100644 --- a/apps/roam/src/components/settings/utils/accessors.ts +++ b/apps/roam/src/components/settings/utils/accessors.ts @@ -1090,13 +1090,16 @@ export const createDiscourseNodeType = async ({ text, shortcut, format, + uid, }: { text: string; shortcut: string; format: string; + uid?: string; }): Promise => { const pageUid = await createPage({ title: `${DISCOURSE_NODE_PAGE_PREFIX}${text}`, + uid, tree: [ { text: "Shortcut", children: [{ text: shortcut }] }, { text: "Tag", children: [{ text: "" }] }, diff --git a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts index d89d70400..23c0fed30 100644 --- a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts +++ b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import { importSharedNodes, isFailedSharedNodeImport, } from "~/utils/importSharedNodes"; import { materializeSharedNode } from "~/utils/materializeSharedNode"; +import { resolveSharedNodeTypes } from "~/utils/resolveSharedNodeTypes"; vi.mock("~/utils/materializeSharedNode", async () => { const actual = await vi.importActual< @@ -14,13 +16,29 @@ vi.mock("~/utils/materializeSharedNode", async () => { return { ...actual, materializeSharedNode: vi.fn() }; }); +vi.mock("~/utils/resolveSharedNodeTypes", () => ({ + resolveSharedNodeTypes: vi.fn(), +})); + const mockedMaterializeSharedNode = vi.mocked(materializeSharedNode); +const mockedResolveSharedNodeTypes = vi.mocked(resolveSharedNodeTypes); + +const NODE_TYPE: DiscourseNode = { + text: "Evidence", + type: "evd-type-uid", + shortcut: "E", + format: "[[EVD]] - {content}", + specification: [], + backedBy: "user", + canvasSettings: {}, +}; const client = {} as DGSupabaseClient; const makeSharedNode = (sourceLocalId: string): SharedNode => ({ rid: `orn:obsidian.note:vault-a/${sourceLocalId}`, sourceLocalId, + schemaId: 200, spaceId: 20, spaceName: "Research vault", spaceUri: "obsidian:vault-a", @@ -45,6 +63,7 @@ const successResult = ( beforeEach(() => { vi.clearAllMocks(); + mockedResolveSharedNodeTypes.mockResolvedValue(new Map()); }); describe("importSharedNodes", () => { @@ -89,6 +108,34 @@ describe("importSharedNodes", () => { }); }); + it("resolves node types once and gives each node the one for its schema", async () => { + const sharedNodes = ["node-1", "node-2"].map(makeSharedNode); + mockedResolveSharedNodeTypes.mockResolvedValue( + new Map([[sharedNodes[0].rid, NODE_TYPE]]), + ); + mockedMaterializeSharedNode + .mockResolvedValueOnce(successResult(sharedNodes[0], "created")) + .mockResolvedValueOnce(successResult(sharedNodes[1], "created")); + + await importSharedNodes({ client, sharedNodes, onProgress: vi.fn() }); + + expect(mockedResolveSharedNodeTypes).toHaveBeenCalledTimes(1); + expect(mockedResolveSharedNodeTypes).toHaveBeenCalledWith({ + client, + sharedNodes, + }); + expect(mockedMaterializeSharedNode).toHaveBeenNthCalledWith(1, { + client, + sharedNode: sharedNodes[0], + nodeType: NODE_TYPE, + }); + expect(mockedMaterializeSharedNode).toHaveBeenNthCalledWith(2, { + client, + sharedNode: sharedNodes[1], + nodeType: undefined, + }); + }); + it("keeps importing the remaining nodes when a materialization throws", async () => { const sharedNodes = ["node-1", "node-2"].map(makeSharedNode); mockedMaterializeSharedNode diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts index 425404fc8..724f3cf1b 100644 --- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts +++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts @@ -49,9 +49,14 @@ const pageCreate = vi.fn(); const pageDelete = vi.fn(); const updatePage = vi.fn(); +const CORE_TITLE = "REM sleep and recall"; +const DECORATED_TITLE = "[[EVD]] - REM sleep and recall"; +const NODE_TYPE = { format: "[[EVD]] - {content}" }; + const sharedNode: SharedNode = { rid: "orn:obsidian.note:vault-a/node-1", sourceLocalId: "node-1", + schemaId: 200, spaceId: 20, spaceName: "Research vault", spaceUri: "obsidian:vault-a", @@ -63,6 +68,11 @@ const sharedNode: SharedNode = { directMetadata: null, }; +const decoratedSharedNode: SharedNode = { + ...sharedNode, + coreTitle: CORE_TITLE, +}; + const roamSharedNode: SharedNode = { ...sharedNode, rid: "https://roamresearch.com/#/app/source-graph/node-2", @@ -337,6 +347,126 @@ describe("materializeSharedNode", () => { }); }); + it("decorates the page title with the local node type format", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: decoratedSharedNode, + nodeType: NODE_TYPE, + }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: DECORATED_TITLE, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("keeps the incoming title when the source published no core title", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode, + nodeType: NODE_TYPE, + }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: sharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("keeps the incoming title when the local node type has no format", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: decoratedSharedNode, + nodeType: { format: "" }, + }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: sharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("decorates a format whose source placeholder has no value yet", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: decoratedSharedNode, + nodeType: { format: "[[EVD]] - {content} - {Source}" }, + }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: `${DECORATED_TITLE} - `, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("strips the Roam heading by the source title while decorating the page title", async () => { + const { client } = clientWithFullContent({ + text: `# ${roamSharedNode.title}\n\n- REM sleep improves recall`, + contentType: "text/roam+markdown", + }); + + const result = await materializeSharedNode({ + client, + sharedNode: { ...roamSharedNode, coreTitle: CORE_TITLE }, + nodeType: NODE_TYPE, + }); + + expect(result.success).toBe(true); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: DECORATED_TITLE, uid: GENERATED_PAGE_UID }, + "markdown-string": "- REM sleep improves recall", + }); + }); + + it("renames the imported page when decoration changes its title", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title); + + const result = await materializeSharedNode({ + client, + sharedNode: decoratedSharedNode, + nodeType: NODE_TYPE, + }); + + expect(result.success).toBe(true); + expect(updatePage).toHaveBeenCalledWith({ + page: { uid: EXISTING_PAGE_UID, title: DECORATED_TITLE }, + }); + }); + + it("leaves an already decorated title untouched when refreshing", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedGetPageTitleByPageUid.mockReturnValue(DECORATED_TITLE); + mockedReadImportedSourceIdentity.mockReturnValue({ + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + + const result = await materializeSharedNode({ + client, + sharedNode: decoratedSharedNode, + nodeType: NODE_TYPE, + force: true, + }); + + expect(result).toMatchObject({ success: true, action: "updated" }); + expect(updatePage).not.toHaveBeenCalled(); + }); + it("refuses to clobber a page that was not imported from this source", async () => { const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); mockedGetPageUidByPageTitle.mockReturnValue("unrelated-page-uid"); diff --git a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts index a308aa3ee..a0badc322 100644 --- a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts +++ b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts @@ -5,10 +5,12 @@ import { getSharedNodeByRid, type SharedNode, } from "@repo/database/lib/sharedNodes"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; import { readImportedSourceIdentity } from "~/utils/importedSourceIdentity"; import internalError from "~/utils/internalError"; import { materializeSharedNode } from "~/utils/materializeSharedNode"; import { refreshImportedNode } from "~/utils/refreshImportedNode"; +import { resolveSharedNodeTypes } from "~/utils/resolveSharedNodeTypes"; import { getLoggedInClient } from "~/utils/supabaseContext"; vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({ @@ -27,6 +29,9 @@ vi.mock("~/utils/materializeSharedNode", async (importOriginal) => ({ ...(await importOriginal()), materializeSharedNode: vi.fn(), })); +vi.mock("~/utils/resolveSharedNodeTypes", () => ({ + resolveSharedNodeTypes: vi.fn(), +})); vi.mock("~/utils/supabaseContext", () => ({ getLoggedInClient: vi.fn(), })); @@ -37,6 +42,17 @@ const mockedReadImportedSourceIdentity = vi.mocked(readImportedSourceIdentity); const mockedInternalError = vi.mocked(internalError); const mockedMaterializeSharedNode = vi.mocked(materializeSharedNode); const mockedGetLoggedInClient = vi.mocked(getLoggedInClient); +const mockedResolveSharedNodeTypes = vi.mocked(resolveSharedNodeTypes); + +const NODE_TYPE: DiscourseNode = { + text: "Evidence", + type: "evd-type-uid", + shortcut: "E", + format: "[[EVD]] - {content}", + specification: [], + backedBy: "user", + canvasSettings: {}, +}; const PAGE_UID = "imported-page-uid"; const LOCAL_TITLE = "EVD - old local title"; @@ -47,6 +63,7 @@ const client = {} as DGSupabaseClient; const sharedNode: SharedNode = { rid: "orn:obsidian.note:vault-a/node-1", sourceLocalId: "node-1", + schemaId: 200, spaceId: 20, spaceName: "Research vault", spaceUri: "obsidian:vault-a", @@ -69,6 +86,7 @@ beforeEach(() => { }); mockedGetLoggedInClient.mockResolvedValue(client); mockedGetSharedNodeByRid.mockResolvedValue(sharedNode); + mockedResolveSharedNodeTypes.mockResolvedValue(new Map()); mockedMaterializeSharedNode.mockResolvedValue({ success: true, action: "updated", @@ -88,14 +106,34 @@ describe("refreshImportedNode", () => { client, rid: sharedNode.rid, }); + expect(mockedResolveSharedNodeTypes).toHaveBeenCalledWith({ + client, + sharedNodes: [sharedNode], + }); expect(mockedMaterializeSharedNode).toHaveBeenCalledWith({ client, sharedNode, + nodeType: undefined, force: true, }); expect(mockedInternalError).not.toHaveBeenCalled(); }); + it("passes the resolved node type to the materializer", async () => { + mockedResolveSharedNodeTypes.mockResolvedValue( + new Map([[sharedNode.rid, NODE_TYPE]]), + ); + + await refreshImportedNode({ pageUid: PAGE_UID }); + + expect(mockedMaterializeSharedNode).toHaveBeenCalledWith({ + client, + sharedNode, + nodeType: NODE_TYPE, + force: true, + }); + }); + it("fails when the page has no stored source identity", async () => { mockedReadImportedSourceIdentity.mockReturnValue(undefined); diff --git a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts new file mode 100644 index 000000000..74f4a148c --- /dev/null +++ b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import { createDiscourseNodeType } from "~/components/settings/utils/accessors"; +import getDiscourseNodes, { + type DiscourseNode, +} from "~/utils/getDiscourseNodes"; +import internalError from "~/utils/internalError"; +import { resolveSharedNodeTypes } from "~/utils/resolveSharedNodeTypes"; + +vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })); +vi.mock("~/components/settings/utils/accessors", () => ({ + createDiscourseNodeType: vi.fn(), +})); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: vi.fn(), + excludeDefaultNodes: (node: DiscourseNode) => node.backedBy !== "default", +})); +vi.mock("~/utils/internalError", () => ({ default: vi.fn() })); + +const mockedCreateDiscourseNodeType = vi.mocked(createDiscourseNodeType); +const mockedGetDiscourseNodes = vi.mocked(getDiscourseNodes); +const mockedInternalError = vi.mocked(internalError); + +const SCHEMA_ID = 200; +const REMOTE_TYPE_UID = "node_hs6r0kqxvbmc3l9ywtd2fp"; +const FORMAT = "[[EVD]] - {content}"; + +const evidenceType: DiscourseNode = { + text: "Evidence", + type: "local-evd-uid", + shortcut: "E", + format: FORMAT, + specification: [], + backedBy: "user", + canvasSettings: {}, +}; + +const pageType: DiscourseNode = { + text: "Page", + type: "page-node", + shortcut: "p", + format: "{content}", + specification: [], + backedBy: "default", + canvasSettings: {}, +}; + +const sharedNode: SharedNode = { + rid: "orn:obsidian.note:vault-a/node-1", + sourceLocalId: "node-1", + schemaId: SCHEMA_ID, + spaceId: 20, + spaceName: "Research vault", + spaceUri: "obsidian:vault-a", + platform: "Obsidian", + title: "EVD - REM sleep and recall", + coreTitle: "REM sleep and recall", + created: "2026-06-14T12:30:00.000Z", + lastModified: "2026-06-14T15:00:00.000Z", + authorId: 7, + directMetadata: null, +}; + +type SchemaRow = { + id: number; + name: string | null; + source_local_id: string | null; + format: string | null; + source_data_format: string | null; +}; + +const schemaRow = (overrides: Partial = {}): SchemaRow => ({ + id: SCHEMA_ID, + name: "Evidence", + source_local_id: REMOTE_TYPE_UID, + format: FORMAT, + source_data_format: null, + ...overrides, +}); + +const makeClient = ({ + rows = [schemaRow()], + error = null, +}: { + rows?: SchemaRow[]; + error?: { message: string } | null; +} = {}) => { + const result = { data: error ? null : rows, error }; + const builder = { + select: vi.fn(), + eq: vi.fn(), + in: vi.fn(), + then: ( + resolve: (value: typeof result) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + builder.select.mockReturnValue(builder); + builder.eq.mockReturnValue(builder); + builder.in.mockReturnValue(builder); + const from = vi.fn().mockReturnValue(builder); + return { client: { from } as unknown as DGSupabaseClient, builder, from }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockedGetDiscourseNodes.mockReturnValue([]); +}); + +describe("resolveSharedNodeTypes", () => { + it("matches the local node type carrying the published type id", async () => { + const { client, builder } = makeClient(); + const importedType = { ...evidenceType, type: REMOTE_TYPE_UID }; + mockedGetDiscourseNodes.mockReturnValue([ + { ...evidenceType, text: "Other name" }, + importedType, + ]); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map([[sharedNode.rid, importedType]])); + expect(builder.eq).toHaveBeenCalledWith("is_schema", true); + expect(builder.eq).toHaveBeenCalledWith("is_relation", false); + expect(builder.in).toHaveBeenCalledWith("id", [SCHEMA_ID]); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + }); + + it("matches the local node type by name when no id matches", async () => { + const { client } = makeClient(); + mockedGetDiscourseNodes.mockReturnValue([evidenceType]); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map([[sharedNode.rid, evidenceType]])); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + }); + + it("creates a local node type reusing the remote id and format", async () => { + const { client } = makeClient(); + const createdType = { ...evidenceType, type: REMOTE_TYPE_UID }; + mockedCreateDiscourseNodeType.mockResolvedValue(createdType); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map([[sharedNode.rid, createdType]])); + expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith({ + text: "Evidence", + shortcut: "", + format: FORMAT, + uid: REMOTE_TYPE_UID, + }); + }); + + it("reads the format Obsidian nests under source_data", async () => { + const { client } = makeClient({ + rows: [ + schemaRow({ + format: null, + source_data_format: "[[EVD]] - {content} - {Source}", + }), + ], + }); + mockedCreateDiscourseNodeType.mockResolvedValue(evidenceType); + + await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }); + + expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith( + expect.objectContaining({ format: "[[EVD]] - {content} - {Source}" }), + ); + }); + + it("creates a formatless node type from a schema published before formats", async () => { + const { client } = makeClient({ rows: [schemaRow({ format: null })] }); + mockedCreateDiscourseNodeType.mockResolvedValue(evidenceType); + + await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }); + + expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith( + expect.objectContaining({ format: "" }), + ); + }); + + it("never matches the default Page and Block types", async () => { + const { client } = makeClient({ + rows: [schemaRow({ name: "Page", source_local_id: "page-node" })], + }); + mockedGetDiscourseNodes.mockReturnValue([pageType]); + mockedCreateDiscourseNodeType.mockResolvedValue(evidenceType); + + await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }); + + expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith( + expect.objectContaining({ text: "Page", uid: "page-node" }), + ); + }); + + it("leaves out a node whose schema row is not visible", async () => { + const { client } = makeClient({ rows: [] }); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map()); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + }); + + it("fetches every node type schema in one query", async () => { + const { client, builder, from } = makeClient({ + rows: [schemaRow(), schemaRow({ id: 300, name: "Claim" })], + }); + mockedGetDiscourseNodes.mockReturnValue([evidenceType]); + mockedCreateDiscourseNodeType.mockResolvedValue(evidenceType); + + await resolveSharedNodeTypes({ + client, + sharedNodes: [ + sharedNode, + { ...sharedNode, rid: "orn:obsidian.note:vault-a/node-2" }, + { + ...sharedNode, + rid: "orn:obsidian.note:vault-a/node-3", + schemaId: 300, + }, + ], + }); + + expect(from).toHaveBeenCalledTimes(1); + expect(builder.in).toHaveBeenCalledWith("id", [SCHEMA_ID, 300]); + }); + + it("reports a failed type creation and leaves the node undecorated", async () => { + const { client } = makeClient(); + mockedCreateDiscourseNodeType.mockRejectedValue( + new Error("page create failed"), + ); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map()); + expect(mockedInternalError).toHaveBeenCalledWith( + expect.objectContaining({ sendEmail: false }), + ); + }); + + it("reports a failed schema query and leaves every node undecorated", async () => { + const { client } = makeClient({ error: { message: "permission denied" } }); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map()); + expect(mockedInternalError).toHaveBeenCalledWith( + expect.objectContaining({ sendEmail: false }), + ); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/importSharedNodes.ts b/apps/roam/src/utils/importSharedNodes.ts index 9557182d0..d86c145eb 100644 --- a/apps/roam/src/utils/importSharedNodes.ts +++ b/apps/roam/src/utils/importSharedNodes.ts @@ -4,6 +4,7 @@ import { getErrorMessage, materializeSharedNode, } from "./materializeSharedNode"; +import { resolveSharedNodeTypes } from "./resolveSharedNodeTypes"; export type FailedSharedNodeImport = { sharedNode: SharedNode; @@ -28,10 +29,15 @@ export const importSharedNodes = async ({ sharedNodes: SharedNode[]; onProgress: (current: number, total: number) => void; }): Promise => { + const nodeTypesByRid = await resolveSharedNodeTypes({ client, sharedNodes }); const items: SharedNodeImportItem[] = []; for (const sharedNode of sharedNodes) { try { - const result = await materializeSharedNode({ client, sharedNode }); + const result = await materializeSharedNode({ + client, + sharedNode, + nodeType: nodeTypesByRid.get(sharedNode.rid), + }); items.push( result.success ? { diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts index 599aed4a3..9dc0c700c 100644 --- a/apps/roam/src/utils/materializeSharedNode.ts +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -5,12 +5,14 @@ import { trimBlankLines, } from "@repo/content-model"; import type { DGSupabaseClient } from "@repo/database/lib/client"; +import { decorateTitle } from "@repo/database/lib/decorateTitle"; import { isRid } from "@repo/database/lib/rid"; import type { SharedNode } from "@repo/database/lib/sharedNodes"; import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; import deleteBlock from "roamjs-components/writes/deleteBlock"; +import type { DiscourseNode } from "./getDiscourseNodes"; import { findImportedNodeUidBySourceRid, readImportedSourceIdentity, @@ -292,10 +294,12 @@ const updateImportedPage = async ({ export const materializeSharedNode = async ({ client, sharedNode, + nodeType, force = false, }: { client: DGSupabaseClient; sharedNode: SharedNode; + nodeType?: Pick; force?: boolean; }): Promise => { const rawIdentity: SourceIdentity = { @@ -315,6 +319,10 @@ export const materializeSharedNode = async ({ sourceModifiedAt: validated.sourceModifiedAt, sourceNodeRid: sharedNode.rid, }; + const pageTitle = + sharedNode.coreTitle && nodeType?.format + ? decorateTitle(nodeType.format, sharedNode.coreTitle) + : validated.title; let importedPageUid: string | null; let storedIdentity: ImportedSourceIdentity | undefined; @@ -363,11 +371,11 @@ export const materializeSharedNode = async ({ identity, markdown: content.markdown, pageUid: importedPageUid, - title: validated.title, + title: pageTitle, }) : createImportedPage({ identity, markdown: content.markdown, - title: validated.title, + title: pageTitle, }); }; diff --git a/apps/roam/src/utils/refreshImportedNode.ts b/apps/roam/src/utils/refreshImportedNode.ts index 62e32381d..dc8b28d27 100644 --- a/apps/roam/src/utils/refreshImportedNode.ts +++ b/apps/roam/src/utils/refreshImportedNode.ts @@ -6,6 +6,7 @@ import { getErrorMessage, materializeSharedNode, } from "./materializeSharedNode"; +import { resolveSharedNodeTypes } from "./resolveSharedNodeTypes"; import { getLoggedInClient } from "./supabaseContext"; const REFRESH_ERROR_TYPE = "Imported node refresh failed"; @@ -47,9 +48,14 @@ export const refreshImportedNode = async ({ message: `The source of "${title}" is no longer shared with your groups, so it cannot be refreshed.`, }; + const nodeTypesByRid = await resolveSharedNodeTypes({ + client, + sharedNodes: [sharedNode], + }); const result = await materializeSharedNode({ client, sharedNode, + nodeType: nodeTypesByRid.get(sharedNode.rid), force: true, }); if (!result.success) { diff --git a/apps/roam/src/utils/resolveSharedNodeTypes.ts b/apps/roam/src/utils/resolveSharedNodeTypes.ts new file mode 100644 index 000000000..aabfbed9c --- /dev/null +++ b/apps/roam/src/utils/resolveSharedNodeTypes.ts @@ -0,0 +1,101 @@ +import posthog from "posthog-js"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { Tables } from "@repo/database/dbTypes"; +import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import { createDiscourseNodeType } from "~/components/settings/utils/accessors"; +import getDiscourseNodes, { + excludeDefaultNodes, + type DiscourseNode, +} from "./getDiscourseNodes"; +import internalError from "./internalError"; + +const SCHEMA_COLUMNS = + "format:literal_content->>format, id, name, source_data_format:literal_content->source_data->>format, source_local_id"; + +const RESOLVE_ERROR_TYPE = "Imported node type resolution failed"; +const RESOLVE_ERROR_OPERATION = "resolve-shared-node-types"; + +type SharedNodeSchema = Pick< + Tables<"my_concepts">, + "id" | "name" | "source_local_id" +> & { + format: string | null; + source_data_format: string | null; +}; + +const findOrCreateNodeType = async ( + schema: SharedNodeSchema, +): Promise => { + const localNodeTypes = getDiscourseNodes().filter(excludeDefaultNodes); + const matchedById = localNodeTypes.find( + (nodeType) => nodeType.type === schema.source_local_id, + ); + if (matchedById) return matchedById; + const matchedByName = localNodeTypes.find( + (nodeType) => nodeType.text === schema.name, + ); + if (matchedByName) return matchedByName; + if (!schema.name || !schema.source_local_id) return undefined; + + posthog.capture("Discourse Node: Type Created From Import", { + label: schema.name, + }); + return createDiscourseNodeType({ + text: schema.name, + shortcut: "", + format: schema.format ?? schema.source_data_format ?? "", + uid: schema.source_local_id, + }); +}; + +export const resolveSharedNodeTypes = async ({ + client, + sharedNodes, +}: { + client: DGSupabaseClient; + sharedNodes: SharedNode[]; +}): Promise> => { + const schemaIds = [...new Set(sharedNodes.map(({ schemaId }) => schemaId))]; + const { data, error } = await client + .from("my_concepts") + .select(SCHEMA_COLUMNS) + .eq("is_schema", true) + .eq("is_relation", false) + .in("id", schemaIds); + if (error) { + internalError({ + error, + type: RESOLVE_ERROR_TYPE, + context: { operation: RESOLVE_ERROR_OPERATION, schemaIds }, + sendEmail: false, + }); + return new Map(); + } + + const nodeTypeBySchemaId = new Map(); + for (const schema of data) { + if (schema.id === null) continue; + try { + const nodeType = await findOrCreateNodeType(schema); + if (nodeType) nodeTypeBySchemaId.set(schema.id, nodeType); + } catch (error) { + internalError({ + error, + type: RESOLVE_ERROR_TYPE, + context: { + operation: RESOLVE_ERROR_OPERATION, + schemaId: schema.id, + schemaName: schema.name, + }, + sendEmail: false, + }); + } + } + + return new Map( + sharedNodes.flatMap((sharedNode): [string, DiscourseNode][] => { + const nodeType = nodeTypeBySchemaId.get(sharedNode.schemaId); + return nodeType ? [[sharedNode.rid, nodeType]] : []; + }), + ); +}; diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts index 37b422299..75820c350 100644 --- a/packages/database/src/lib/__tests__/sharedNodes.test.ts +++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts @@ -14,6 +14,7 @@ const spaces: BuildArgs["spaces"] = [ ]; const nodes: BuildArgs["nodes"] = [ { + core_title: "REM sleep and recall", is_schema: false, last_modified: "2026-06-14T12:00:00", schema_id: 200, @@ -66,11 +67,13 @@ describe("buildSharedNodes", () => { { rid, sourceLocalId: "node-1", + schemaId: 200, spaceId: 20, spaceName: "Research vault", spaceUri: "obsidian:vault-a", platform: "Obsidian", title: "EVD - REM sleep and recall", + coreTitle: "REM sleep and recall", created: "2026-06-14T11:00:00.000Z", lastModified: "2026-06-14T15:00:00.000Z", authorId: 42, @@ -89,7 +92,12 @@ describe("buildSharedNodes", () => { }, ]; const roamNodes: BuildArgs["nodes"] = [ - { ...nodes[0]!, space_id: 30, source_local_id: "roam-uid-1" }, + { + ...nodes[0]!, + core_title: "Sleep improves memory consolidation", + space_id: 30, + source_local_id: "roam-uid-1", + }, ]; const roamDirect: BuildArgs["directContents"] = [ { @@ -118,11 +126,13 @@ describe("buildSharedNodes", () => { { rid: "https://roamresearch.com/#/app/research-graph/roam-uid-1", sourceLocalId: "roam-uid-1", + schemaId: 200, spaceId: 30, spaceName: "Research graph", spaceUri: "https://roamresearch.com/#/app/research-graph", platform: "Roam", title: "CLM - Sleep improves memory consolidation", + coreTitle: "Sleep improves memory consolidation", created: "2026-06-14T11:00:00.000Z", lastModified: "2026-06-14T15:00:00.000Z", authorId: 42, @@ -131,6 +141,13 @@ describe("buildSharedNodes", () => { ]); }); + it("leaves the core title unset when the source published none", () => { + expect( + build({ nodesOverride: [{ ...nodes[0]!, core_title: null }] })[0] + ?.coreTitle, + ).toBeUndefined(); + }); + it("discovers a node without full content", () => { expect(build({ fullOverride: [] })[0]?.lastModified).toBe( "2026-06-14T13:00:00.000Z", @@ -263,11 +280,13 @@ describe("getSharedNodeByRid", () => { await expect(getSharedNodeByRid({ client, rid })).resolves.toEqual({ rid, sourceLocalId: "node-1", + schemaId: 200, spaceId: 20, spaceName: "Research vault", spaceUri: "obsidian:vault-a", platform: "Obsidian", title: "EVD - REM sleep and recall", + coreTitle: "REM sleep and recall", created: "2026-06-14T11:00:00.000Z", lastModified: "2026-06-14T15:00:00.000Z", authorId: 42, diff --git a/packages/database/src/lib/sharedNodes.ts b/packages/database/src/lib/sharedNodes.ts index 36dd023e7..0e7a160a4 100644 --- a/packages/database/src/lib/sharedNodes.ts +++ b/packages/database/src/lib/sharedNodes.ts @@ -5,7 +5,7 @@ import type { Enums, Json, Tables } from "../dbTypes"; type SharedConcept = Pick< Tables<"my_concepts">, "is_schema" | "last_modified" | "schema_id" | "source_local_id" | "space_id" ->; +> & { core_title: string | null }; type SharedContent = Pick< Tables<"my_contents">, | "author_id" @@ -36,11 +36,13 @@ type ValidSharedSpace = { export type SharedNode = { rid: string; sourceLocalId: string; + schemaId: number; spaceId: number; spaceName: string; spaceUri: string; platform: Platform; title: string; + coreTitle?: string; created: string | null; lastModified: string; authorId?: number; @@ -55,7 +57,7 @@ export type SharedNodeRows = { }; const CONCEPT_COLUMNS = - "is_schema, last_modified, schema_id, source_local_id, space_id"; + "core_title:literal_content->>core_title, is_schema, last_modified, schema_id, source_local_id, space_id"; const DIRECT_CONTENT_COLUMNS = "author_id, created, last_modified, metadata, source_local_id, space_id, text, variant"; const FULL_CONTENT_SUMMARY_COLUMNS = "last_modified, source_local_id, space_id"; @@ -188,11 +190,13 @@ export const buildSharedNodes = ({ { rid, sourceLocalId: node.source_local_id, + schemaId: node.schema_id, spaceId: node.space_id, spaceName: space.name, spaceUri: space.url, platform: space.platform, title: direct.text, + coreTitle: node.core_title ?? undefined, created, lastModified, authorId: direct.author_id ?? undefined, From 7df22d0fe0fd3a7ea7be5fb2265d9114f364f13e Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 16:25:12 +0530 Subject: [PATCH 05/15] Refresh the legacy node type cache after creating a type from an import createDiscourseNodeType only invalidates the new-store cache; with the store flag off getDiscourseNodes reads discourseConfigRef.nodes, so a created type stayed invisible and every later import re-entered the create branch. Mirror the settings panel and call refreshConfigTree after a successful create, and only count the type as created once the create resolved. Built-in types now take part in name matching so a remote schema named Page resolves to Roam's Page instead of creating a user type that shadows it. The resolver returns the map keyed by schema id, which both callers already hold. Format precedence follows the Obsidian reader (source_data first, ||), and the Roam format-expression helper reuses the shared placeholder pattern. --- .../utils/__tests__/importSharedNodes.test.ts | 7 ++- .../__tests__/materializeSharedNode.test.ts | 4 +- .../__tests__/refreshImportedNode.test.ts | 2 +- .../__tests__/resolveSharedNodeTypes.test.ts | 43 +++++++++++-------- .../utils/getDiscourseNodeFormatExpression.ts | 4 +- apps/roam/src/utils/importSharedNodes.ts | 7 ++- apps/roam/src/utils/materializeSharedNode.ts | 4 +- apps/roam/src/utils/refreshImportedNode.ts | 4 +- apps/roam/src/utils/resolveSharedNodeTypes.ts | 31 ++++++------- 9 files changed, 58 insertions(+), 48 deletions(-) diff --git a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts index 23c0fed30..0712131d8 100644 --- a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts +++ b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts @@ -109,9 +109,12 @@ describe("importSharedNodes", () => { }); it("resolves node types once and gives each node the one for its schema", async () => { - const sharedNodes = ["node-1", "node-2"].map(makeSharedNode); + const sharedNodes = [ + makeSharedNode("node-1"), + { ...makeSharedNode("node-2"), schemaId: 300 }, + ]; mockedResolveSharedNodeTypes.mockResolvedValue( - new Map([[sharedNodes[0].rid, NODE_TYPE]]), + new Map([[sharedNodes[0].schemaId, NODE_TYPE]]), ); mockedMaterializeSharedNode .mockResolvedValueOnce(successResult(sharedNodes[0], "created")) diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts index 724f3cf1b..5cf18f4c0 100644 --- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts +++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts @@ -395,7 +395,7 @@ describe("materializeSharedNode", () => { }); }); - it("decorates a format whose source placeholder has no value yet", async () => { + it("keeps the incoming title when the format has a placeholder core_title cannot fill", async () => { const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); const result = await materializeSharedNode({ @@ -406,7 +406,7 @@ describe("materializeSharedNode", () => { expect(result.success).toBe(true); expect(pageFromMarkdown).toHaveBeenCalledWith({ - page: { title: `${DECORATED_TITLE} - `, uid: GENERATED_PAGE_UID }, + page: { title: decoratedSharedNode.title, uid: GENERATED_PAGE_UID }, "markdown-string": MATERIALIZED_MARKDOWN, }); }); diff --git a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts index a0badc322..4728b7d39 100644 --- a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts +++ b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts @@ -121,7 +121,7 @@ describe("refreshImportedNode", () => { it("passes the resolved node type to the materializer", async () => { mockedResolveSharedNodeTypes.mockResolvedValue( - new Map([[sharedNode.rid, NODE_TYPE]]), + new Map([[sharedNode.schemaId, NODE_TYPE]]), ); await refreshImportedNode({ pageUid: PAGE_UID }); diff --git a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts index 74f4a148c..cf2f2ef01 100644 --- a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts +++ b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts @@ -6,21 +6,22 @@ import getDiscourseNodes, { type DiscourseNode, } from "~/utils/getDiscourseNodes"; import internalError from "~/utils/internalError"; +import refreshConfigTree from "~/utils/refreshConfigTree"; import { resolveSharedNodeTypes } from "~/utils/resolveSharedNodeTypes"; -vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })); +const mockedCapture = vi.hoisted(() => vi.fn()); +vi.mock("posthog-js", () => ({ default: { capture: mockedCapture } })); vi.mock("~/components/settings/utils/accessors", () => ({ createDiscourseNodeType: vi.fn(), })); -vi.mock("~/utils/getDiscourseNodes", () => ({ - default: vi.fn(), - excludeDefaultNodes: (node: DiscourseNode) => node.backedBy !== "default", -})); +vi.mock("~/utils/getDiscourseNodes", () => ({ default: vi.fn() })); vi.mock("~/utils/internalError", () => ({ default: vi.fn() })); +vi.mock("~/utils/refreshConfigTree", () => ({ default: vi.fn() })); const mockedCreateDiscourseNodeType = vi.mocked(createDiscourseNodeType); const mockedGetDiscourseNodes = vi.mocked(getDiscourseNodes); const mockedInternalError = vi.mocked(internalError); +const mockedRefreshConfigTree = vi.mocked(refreshConfigTree); const SCHEMA_ID = 200; const REMOTE_TYPE_UID = "node_hs6r0kqxvbmc3l9ywtd2fp"; @@ -119,11 +120,12 @@ describe("resolveSharedNodeTypes", () => { await expect( resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), - ).resolves.toEqual(new Map([[sharedNode.rid, importedType]])); + ).resolves.toEqual(new Map([[SCHEMA_ID, importedType]])); expect(builder.eq).toHaveBeenCalledWith("is_schema", true); expect(builder.eq).toHaveBeenCalledWith("is_relation", false); expect(builder.in).toHaveBeenCalledWith("id", [SCHEMA_ID]); expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + expect(mockedRefreshConfigTree).not.toHaveBeenCalled(); }); it("matches the local node type by name when no id matches", async () => { @@ -132,7 +134,7 @@ describe("resolveSharedNodeTypes", () => { await expect( resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), - ).resolves.toEqual(new Map([[sharedNode.rid, evidenceType]])); + ).resolves.toEqual(new Map([[SCHEMA_ID, evidenceType]])); expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); }); @@ -143,20 +145,25 @@ describe("resolveSharedNodeTypes", () => { await expect( resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), - ).resolves.toEqual(new Map([[sharedNode.rid, createdType]])); + ).resolves.toEqual(new Map([[SCHEMA_ID, createdType]])); expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith({ text: "Evidence", shortcut: "", format: FORMAT, uid: REMOTE_TYPE_UID, }); + expect(mockedRefreshConfigTree).toHaveBeenCalledTimes(1); + expect(mockedCapture).toHaveBeenCalledWith( + "Discourse Node: Type Created From Import", + { label: "Evidence" }, + ); }); - it("reads the format Obsidian nests under source_data", async () => { + it("prefers the format Obsidian nests under source_data", async () => { const { client } = makeClient({ rows: [ schemaRow({ - format: null, + format: "EVD - {content}", source_data_format: "[[EVD]] - {content} - {Source}", }), ], @@ -181,18 +188,16 @@ describe("resolveSharedNodeTypes", () => { ); }); - it("never matches the default Page and Block types", async () => { + it("resolves a schema named like a built-in type to the built-in instead of shadowing it", async () => { const { client } = makeClient({ - rows: [schemaRow({ name: "Page", source_local_id: "page-node" })], + rows: [schemaRow({ name: "Page", source_local_id: "remote-page-type" })], }); mockedGetDiscourseNodes.mockReturnValue([pageType]); - mockedCreateDiscourseNodeType.mockResolvedValue(evidenceType); - - await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }); - expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith( - expect.objectContaining({ text: "Page", uid: "page-node" }), - ); + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map([[SCHEMA_ID, pageType]])); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); }); it("leaves out a node whose schema row is not visible", async () => { @@ -240,6 +245,8 @@ describe("resolveSharedNodeTypes", () => { expect(mockedInternalError).toHaveBeenCalledWith( expect.objectContaining({ sendEmail: false }), ); + expect(mockedRefreshConfigTree).not.toHaveBeenCalled(); + expect(mockedCapture).not.toHaveBeenCalled(); }); it("reports a failed schema query and leaves every node undecorated", async () => { diff --git a/apps/roam/src/utils/getDiscourseNodeFormatExpression.ts b/apps/roam/src/utils/getDiscourseNodeFormatExpression.ts index 30ed3884b..d0a329eaf 100644 --- a/apps/roam/src/utils/getDiscourseNodeFormatExpression.ts +++ b/apps/roam/src/utils/getDiscourseNodeFormatExpression.ts @@ -1,7 +1,9 @@ +import { FORMAT_PLACEHOLDER } from "@repo/database/lib/decorateTitle"; + export const getDiscourseNodeFormatInnerExpression = (format: string): string => `${format .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") - .replace(/{[a-zA-Z]+}/g, "(.*?)")}`; + .replace(FORMAT_PLACEHOLDER, "(.*?)")}`; const getDiscourseNodeFormatExpression = (format: string): RegExp => format diff --git a/apps/roam/src/utils/importSharedNodes.ts b/apps/roam/src/utils/importSharedNodes.ts index d86c145eb..6a4f23abc 100644 --- a/apps/roam/src/utils/importSharedNodes.ts +++ b/apps/roam/src/utils/importSharedNodes.ts @@ -29,14 +29,17 @@ export const importSharedNodes = async ({ sharedNodes: SharedNode[]; onProgress: (current: number, total: number) => void; }): Promise => { - const nodeTypesByRid = await resolveSharedNodeTypes({ client, sharedNodes }); + const nodeTypesBySchemaId = await resolveSharedNodeTypes({ + client, + sharedNodes, + }); const items: SharedNodeImportItem[] = []; for (const sharedNode of sharedNodes) { try { const result = await materializeSharedNode({ client, sharedNode, - nodeType: nodeTypesByRid.get(sharedNode.rid), + nodeType: nodeTypesBySchemaId.get(sharedNode.schemaId), }); items.push( result.success diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts index 9dc0c700c..e98504f25 100644 --- a/apps/roam/src/utils/materializeSharedNode.ts +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -320,9 +320,9 @@ export const materializeSharedNode = async ({ sourceNodeRid: sharedNode.rid, }; const pageTitle = - sharedNode.coreTitle && nodeType?.format + (sharedNode.coreTitle && nodeType ? decorateTitle(nodeType.format, sharedNode.coreTitle) - : validated.title; + : null) ?? validated.title; let importedPageUid: string | null; let storedIdentity: ImportedSourceIdentity | undefined; diff --git a/apps/roam/src/utils/refreshImportedNode.ts b/apps/roam/src/utils/refreshImportedNode.ts index dc8b28d27..66f52eba5 100644 --- a/apps/roam/src/utils/refreshImportedNode.ts +++ b/apps/roam/src/utils/refreshImportedNode.ts @@ -48,14 +48,14 @@ export const refreshImportedNode = async ({ message: `The source of "${title}" is no longer shared with your groups, so it cannot be refreshed.`, }; - const nodeTypesByRid = await resolveSharedNodeTypes({ + const nodeTypesBySchemaId = await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode], }); const result = await materializeSharedNode({ client, sharedNode, - nodeType: nodeTypesByRid.get(sharedNode.rid), + nodeType: nodeTypesBySchemaId.get(sharedNode.schemaId), force: true, }); if (!result.success) { diff --git a/apps/roam/src/utils/resolveSharedNodeTypes.ts b/apps/roam/src/utils/resolveSharedNodeTypes.ts index aabfbed9c..bdc2736fc 100644 --- a/apps/roam/src/utils/resolveSharedNodeTypes.ts +++ b/apps/roam/src/utils/resolveSharedNodeTypes.ts @@ -3,11 +3,9 @@ import type { DGSupabaseClient } from "@repo/database/lib/client"; import type { Tables } from "@repo/database/dbTypes"; import type { SharedNode } from "@repo/database/lib/sharedNodes"; import { createDiscourseNodeType } from "~/components/settings/utils/accessors"; -import getDiscourseNodes, { - excludeDefaultNodes, - type DiscourseNode, -} from "./getDiscourseNodes"; +import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; import internalError from "./internalError"; +import refreshConfigTree from "./refreshConfigTree"; const SCHEMA_COLUMNS = "format:literal_content->>format, id, name, source_data_format:literal_content->source_data->>format, source_local_id"; @@ -26,7 +24,7 @@ type SharedNodeSchema = Pick< const findOrCreateNodeType = async ( schema: SharedNodeSchema, ): Promise => { - const localNodeTypes = getDiscourseNodes().filter(excludeDefaultNodes); + const localNodeTypes = getDiscourseNodes(); const matchedById = localNodeTypes.find( (nodeType) => nodeType.type === schema.source_local_id, ); @@ -37,15 +35,17 @@ const findOrCreateNodeType = async ( if (matchedByName) return matchedByName; if (!schema.name || !schema.source_local_id) return undefined; - posthog.capture("Discourse Node: Type Created From Import", { - label: schema.name, - }); - return createDiscourseNodeType({ + const nodeType = await createDiscourseNodeType({ text: schema.name, shortcut: "", - format: schema.format ?? schema.source_data_format ?? "", + format: schema.source_data_format || schema.format || "", uid: schema.source_local_id, }); + refreshConfigTree(); + posthog.capture("Discourse Node: Type Created From Import", { + label: schema.name, + }); + return nodeType; }; export const resolveSharedNodeTypes = async ({ @@ -54,7 +54,7 @@ export const resolveSharedNodeTypes = async ({ }: { client: DGSupabaseClient; sharedNodes: SharedNode[]; -}): Promise> => { +}): Promise> => { const schemaIds = [...new Set(sharedNodes.map(({ schemaId }) => schemaId))]; const { data, error } = await client .from("my_concepts") @@ -69,7 +69,7 @@ export const resolveSharedNodeTypes = async ({ context: { operation: RESOLVE_ERROR_OPERATION, schemaIds }, sendEmail: false, }); - return new Map(); + return new Map(); } const nodeTypeBySchemaId = new Map(); @@ -92,10 +92,5 @@ export const resolveSharedNodeTypes = async ({ } } - return new Map( - sharedNodes.flatMap((sharedNode): [string, DiscourseNode][] => { - const nodeType = nodeTypeBySchemaId.get(sharedNode.schemaId); - return nodeType ? [[sharedNode.rid, nodeType]] : []; - }), - ); + return nodeTypeBySchemaId; }; From fad4f742f29f65dfa09894262689692a044c72c3 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 31 Aug 2026 10:53:44 +0530 Subject: [PATCH 06/15] ENG-2156 Treat Page, Block and Any as reserved node type names on import --- .../__tests__/resolveSharedNodeTypes.test.ts | 38 +++++++++++++++++-- apps/roam/src/utils/resolveSharedNodeTypes.ts | 14 +++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts index cf2f2ef01..d7a323866 100644 --- a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts +++ b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts @@ -14,7 +14,10 @@ vi.mock("posthog-js", () => ({ default: { capture: mockedCapture } })); vi.mock("~/components/settings/utils/accessors", () => ({ createDiscourseNodeType: vi.fn(), })); -vi.mock("~/utils/getDiscourseNodes", () => ({ default: vi.fn() })); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: vi.fn(), + excludeDefaultNodes: (node: DiscourseNode) => node.backedBy !== "default", +})); vi.mock("~/utils/internalError", () => ({ default: vi.fn() })); vi.mock("~/utils/refreshConfigTree", () => ({ default: vi.fn() })); @@ -188,7 +191,7 @@ describe("resolveSharedNodeTypes", () => { ); }); - it("resolves a schema named like a built-in type to the built-in instead of shadowing it", async () => { + it("never resolves a schema named like a built-in type to the built-in by name", async () => { const { client } = makeClient({ rows: [schemaRow({ name: "Page", source_local_id: "remote-page-type" })], }); @@ -196,7 +199,36 @@ describe("resolveSharedNodeTypes", () => { await expect( resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), - ).resolves.toEqual(new Map([[SCHEMA_ID, pageType]])); + ).resolves.toEqual(new Map()); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + expect(mockedInternalError).not.toHaveBeenCalled(); + }); + + it.each(["Block", "Any"])( + "never creates a node type from a schema carrying the reserved name %s", + async (name) => { + const { client } = makeClient({ + rows: [schemaRow({ name, source_local_id: "remote-reserved-type" })], + }); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map()); + expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); + expect(mockedInternalError).not.toHaveBeenCalled(); + }, + ); + + it("resolves a schema named like a built-in to the user-configured type carrying that name", async () => { + const { client } = makeClient({ + rows: [schemaRow({ name: "Page", source_local_id: "remote-page-type" })], + }); + const userPageType = { ...evidenceType, text: "Page" }; + mockedGetDiscourseNodes.mockReturnValue([userPageType]); + + await expect( + resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }), + ).resolves.toEqual(new Map([[SCHEMA_ID, userPageType]])); expect(mockedCreateDiscourseNodeType).not.toHaveBeenCalled(); }); diff --git a/apps/roam/src/utils/resolveSharedNodeTypes.ts b/apps/roam/src/utils/resolveSharedNodeTypes.ts index bdc2736fc..5ff7b09bd 100644 --- a/apps/roam/src/utils/resolveSharedNodeTypes.ts +++ b/apps/roam/src/utils/resolveSharedNodeTypes.ts @@ -3,13 +3,18 @@ import type { DGSupabaseClient } from "@repo/database/lib/client"; import type { Tables } from "@repo/database/dbTypes"; import type { SharedNode } from "@repo/database/lib/sharedNodes"; import { createDiscourseNodeType } from "~/components/settings/utils/accessors"; -import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; +import getDiscourseNodes, { + excludeDefaultNodes, + type DiscourseNode, +} from "./getDiscourseNodes"; import internalError from "./internalError"; import refreshConfigTree from "./refreshConfigTree"; const SCHEMA_COLUMNS = "format:literal_content->>format, id, name, source_data_format:literal_content->source_data->>format, source_local_id"; +const RESERVED_NODE_TYPE_NAMES = new Set(["Page", "Block", "Any"]); + const RESOLVE_ERROR_TYPE = "Imported node type resolution failed"; const RESOLVE_ERROR_OPERATION = "resolve-shared-node-types"; @@ -29,11 +34,12 @@ const findOrCreateNodeType = async ( (nodeType) => nodeType.type === schema.source_local_id, ); if (matchedById) return matchedById; - const matchedByName = localNodeTypes.find( - (nodeType) => nodeType.text === schema.name, - ); + const matchedByName = localNodeTypes + .filter(excludeDefaultNodes) + .find((nodeType) => nodeType.text === schema.name); if (matchedByName) return matchedByName; if (!schema.name || !schema.source_local_id) return undefined; + if (RESERVED_NODE_TYPE_NAMES.has(schema.name)) return undefined; const nodeType = await createDiscourseNodeType({ text: schema.name, From 43b9d4aef9d70452bb955642c138986ece528d2b Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 31 Aug 2026 10:58:04 +0530 Subject: [PATCH 07/15] ENG-2156 Prefer the top-level schema format through a shared dual-read helper --- .../__tests__/resolveSharedNodeTypes.test.ts | 20 ++++++++++++++++++- apps/roam/src/utils/resolveSharedNodeTypes.ts | 6 +++++- packages/utils/src/resolveSchemaFormat.ts | 17 ++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/utils/src/resolveSchemaFormat.ts diff --git a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts index c1b9923da..29b67f4be 100644 --- a/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts +++ b/apps/roam/src/utils/__tests__/resolveSharedNodeTypes.test.ts @@ -162,7 +162,7 @@ describe("resolveSharedNodeTypes", () => { ); }); - it("prefers the format Obsidian nests under source_data", async () => { + it("prefers the contract format over the one Obsidian nests under source_data", async () => { const { client } = makeClient({ rows: [ schemaRow({ @@ -175,6 +175,24 @@ describe("resolveSharedNodeTypes", () => { await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }); + expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith( + expect.objectContaining({ format: "EVD - {content}" }), + ); + }); + + it("falls back to the source_data format when the contract format is missing", async () => { + const { client } = makeClient({ + rows: [ + schemaRow({ + format: null, + source_data_format: "[[EVD]] - {content} - {Source}", + }), + ], + }); + mockedCreateDiscourseNodeType.mockResolvedValue(evidenceType); + + await resolveSharedNodeTypes({ client, sharedNodes: [sharedNode] }); + expect(mockedCreateDiscourseNodeType).toHaveBeenCalledWith( expect.objectContaining({ format: "[[EVD]] - {content} - {Source}" }), ); diff --git a/apps/roam/src/utils/resolveSharedNodeTypes.ts b/apps/roam/src/utils/resolveSharedNodeTypes.ts index 07b2c5b60..353761b0f 100644 --- a/apps/roam/src/utils/resolveSharedNodeTypes.ts +++ b/apps/roam/src/utils/resolveSharedNodeTypes.ts @@ -2,6 +2,7 @@ import posthog from "posthog-js"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import type { Tables } from "@repo/database/dbTypes"; import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import { resolveSchemaFormat } from "@repo/utils/resolveSchemaFormat"; import { createDiscourseNodeType } from "~/components/settings/utils/accessors"; import getDiscourseNodes, { excludeDefaultNodes, @@ -44,7 +45,10 @@ const findOrCreateNodeType = async ( const nodeType = await createDiscourseNodeType({ label: schema.name, shortcut: "", - format: schema.source_data_format || schema.format || "", + format: resolveSchemaFormat({ + format: schema.format, + sourceDataFormat: schema.source_data_format, + }), uid: schema.source_local_id, }); refreshConfigTree(); diff --git a/packages/utils/src/resolveSchemaFormat.ts b/packages/utils/src/resolveSchemaFormat.ts new file mode 100644 index 000000000..d276a01b9 --- /dev/null +++ b/packages/utils/src/resolveSchemaFormat.ts @@ -0,0 +1,17 @@ +/** + * A node type schema's title format currently lives in two places in the + * concept's literal_content: the top-level `format` key (written by Roam) + * and `source_data.format` (written by Obsidian). + * + * The top-level key is the contract and is always authoritative; the nested + * key is read only as a fallback. This dual read is temporary and goes away + * once Obsidian writes the top-level key and existing rows are backfilled + * (Post-V0 Roam-Obsidian sync architecture). + */ +export const resolveSchemaFormat = ({ + format, + sourceDataFormat, +}: { + format?: string | null; + sourceDataFormat?: string | null; +}): string => format || sourceDataFormat || ""; From 03f90527bdfcba03b629d1d5268509d658476f66 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 31 Aug 2026 11:15:14 +0530 Subject: [PATCH 08/15] ENG-2156 Correct the resolveSchemaFormat comment on who writes the top-level key --- packages/utils/src/resolveSchemaFormat.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/utils/src/resolveSchemaFormat.ts b/packages/utils/src/resolveSchemaFormat.ts index d276a01b9..95c0e258d 100644 --- a/packages/utils/src/resolveSchemaFormat.ts +++ b/packages/utils/src/resolveSchemaFormat.ts @@ -1,12 +1,13 @@ /** - * A node type schema's title format currently lives in two places in the - * concept's literal_content: the top-level `format` key (written by Roam) - * and `source_data.format` (written by Obsidian). + * A node type schema's title format lives in two places in the concept's + * literal_content: the top-level `format` key and `source_data.format` + * (written by Obsidian). Nothing writes the top-level key yet: Roam starts + * writing it with ENG-2158, and ENG-2175 backfills rows published before it. * * The top-level key is the contract and is always authoritative; the nested - * key is read only as a fallback. This dual read is temporary and goes away - * once Obsidian writes the top-level key and existing rows are backfilled - * (Post-V0 Roam-Obsidian sync architecture). + * key is read only as a fallback. The dual read is temporary and goes away + * once Obsidian also writes the top-level key (a follow-up PR under Post-V0 + * Roam-Obsidian sync architecture) and existing rows are backfilled. */ export const resolveSchemaFormat = ({ format, From f5fad362c8979a519db6ce8bd254a0efd5cff9fa Mon Sep 17 00:00:00 2001 From: sid597 Date: Wed, 2 Sep 2026 14:26:45 +0530 Subject: [PATCH 09/15] ENG-2156 Note that Roam now writes the top-level schema format --- packages/utils/src/resolveSchemaFormat.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/resolveSchemaFormat.ts b/packages/utils/src/resolveSchemaFormat.ts index 95c0e258d..40b19d5cf 100644 --- a/packages/utils/src/resolveSchemaFormat.ts +++ b/packages/utils/src/resolveSchemaFormat.ts @@ -1,13 +1,13 @@ /** * A node type schema's title format lives in two places in the concept's * literal_content: the top-level `format` key and `source_data.format` - * (written by Obsidian). Nothing writes the top-level key yet: Roam starts - * writing it with ENG-2158, and ENG-2175 backfills rows published before it. + * (written by Obsidian). Roam writes the top-level key as of ENG-2158, and + * ENG-2175 backfills Roam rows published before it. * * The top-level key is the contract and is always authoritative; the nested * key is read only as a fallback. The dual read is temporary and goes away - * once Obsidian also writes the top-level key (a follow-up PR under Post-V0 - * Roam-Obsidian sync architecture) and existing rows are backfilled. + * once Obsidian also writes the top-level key (ENG-2208) and existing rows + * are backfilled. */ export const resolveSchemaFormat = ({ format, From 08e051eface0d358bd69438346c0eb635713b544 Mon Sep 17 00:00:00 2001 From: sid597 Date: Wed, 2 Sep 2026 14:57:40 +0530 Subject: [PATCH 10/15] ENG-2142 Give cross-space slot RIDs the platform subtype --- .../database/src/lib/__tests__/sharedNodes.test.ts | 2 +- packages/database/src/lib/sharedNodes.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts index b854153ee..f251d30d1 100644 --- a/packages/database/src/lib/__tests__/sharedNodes.test.ts +++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts @@ -208,7 +208,7 @@ describe("buildSharedNodes", () => { })[0]?.slots, ).toEqual({ evidence: "node-5", - claim: "orn:obsidian:vault-b/node-6", + claim: "orn:obsidian.note:vault-b/node-6", }); }); diff --git a/packages/database/src/lib/sharedNodes.ts b/packages/database/src/lib/sharedNodes.ts index a71310876..8bfde5a2a 100644 --- a/packages/database/src/lib/sharedNodes.ts +++ b/packages/database/src/lib/sharedNodes.ts @@ -39,6 +39,9 @@ type SharedSpace = Pick< >; type Platform = Enums<"Platform">; +const nodeRidSubtype = (platform: Platform): string | undefined => + platform === "Obsidian" ? "note" : undefined; + type ValidSharedSpace = { name: string; platform: Platform; @@ -193,7 +196,7 @@ export const buildSharedNodes = ({ rid = spaceUriAndLocalIdToRid( space.url, node.source_local_id, - space.platform === "Obsidian" ? "note" : undefined, + nodeRidSubtype(space.platform), ); } catch { return []; @@ -208,7 +211,11 @@ export const buildSharedNodes = ({ if (!space || !c.source_local_id || !c.id) return [c.id, undefined]; return [ c.id, - spaceUriAndLocalIdToRid(space.url, c.source_local_id), + spaceUriAndLocalIdToRid( + space.url, + c.source_local_id, + nodeRidSubtype(space.platform), + ), ]; }) as [number, string | undefined][], ); From 31854b8e871ac43ab7637f3810a4780f3d45796a Mon Sep 17 00:00:00 2001 From: sid597 Date: Wed, 2 Sep 2026 15:21:21 +0530 Subject: [PATCH 11/15] ENG-2142 Name the resolved source in imported Roam titles --- .../components/DiscoverSharedNodesDialog.tsx | 12 +- .../src/utils/__tests__/findTargetUid.test.ts | 93 +++++++ .../utils/__tests__/importSharedNodes.test.ts | 57 ++++ .../__tests__/materializeSharedNode.test.ts | 257 +++++++++++++++++- .../__tests__/refreshImportedNode.test.ts | 24 ++ .../src/utils/__tests__/sourceSlot.test.ts | 52 ++++ apps/roam/src/utils/findTargetUid.ts | 36 +++ apps/roam/src/utils/importSharedNodes.ts | 22 +- apps/roam/src/utils/importSharedRelations.ts | 30 +- apps/roam/src/utils/materializeSharedNode.ts | 71 ++++- apps/roam/src/utils/refreshImportedNode.ts | 2 +- apps/roam/src/utils/sourceSlot.ts | 30 ++ 12 files changed, 642 insertions(+), 44 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/findTargetUid.test.ts create mode 100644 apps/roam/src/utils/__tests__/sourceSlot.test.ts create mode 100644 apps/roam/src/utils/findTargetUid.ts diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx index cb299caa1..629337647 100644 --- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx +++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx @@ -118,17 +118,23 @@ const ImportResultsSummary = ({ (item) => item.status === "skipped", ).length; const failedImports = results.filter(isFailedSharedNodeImport); + const warnings = results.flatMap((item) => + item.status !== "failed" && item.warning + ? [{ sharedNode: item.sharedNode, message: item.warning }] + : [], + ); + const importNotices = [...failedImports, ...warnings]; return ( 0 ? Intent.WARNING : Intent.SUCCESS} + intent={importNotices.length > 0 ? Intent.WARNING : Intent.SUCCESS} title={`${importedCount} imported, ${skippedCount} skipped, ${failedImports.length} failed`} > {skippedCount > 0 && (
Skipped nodes were already up to date in this graph.
)} - {failedImports.length > 0 && ( + {importNotices.length > 0 && (
    - {failedImports.map((item) => ( + {importNotices.map((item) => (
  • {item.sharedNode.title}:{" "} {item.message} diff --git a/apps/roam/src/utils/__tests__/findTargetUid.test.ts b/apps/roam/src/utils/__tests__/findTargetUid.test.ts new file mode 100644 index 000000000..b9fd45c05 --- /dev/null +++ b/apps/roam/src/utils/__tests__/findTargetUid.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { findTargetUid, sharedReferenceRid } from "~/utils/findTargetUid"; +import { findImportedNodeUidBySourceRid } from "~/utils/importedSourceIdentity"; + +vi.mock("~/utils/importedSourceIdentity", () => ({ + findImportedNodeUidBySourceRid: vi.fn(), +})); + +const mockedFindImportedNodeUidBySourceRid = vi.mocked( + findImportedNodeUidBySourceRid, +); + +const LOCAL_GRAPH = "local-graph"; +const LOCAL_SPACE_URI = `https://roamresearch.com/#/app/${LOCAL_GRAPH}`; +const OBSIDIAN_SPACE_URI = "obsidian:vault-a"; +const roamQuery = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { graph: { name: LOCAL_GRAPH }, q: roamQuery }, + }; + roamQuery.mockReturnValue([]); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(null); +}); + +describe("sharedReferenceRid", () => { + it("passes a RID through", () => { + expect( + sharedReferenceRid( + "orn:obsidian.note:vault-b/node-6", + OBSIDIAN_SPACE_URI, + ), + ).toBe("orn:obsidian.note:vault-b/node-6"); + }); + + it("builds a note RID from a local id in an Obsidian space", () => { + expect(sharedReferenceRid("node-9", OBSIDIAN_SPACE_URI)).toBe( + "orn:obsidian.note:vault-a/node-9", + ); + }); + + it("builds a URL RID from a local id in a Roam space", () => { + expect( + sharedReferenceRid( + "page-uid", + "https://roamresearch.com/#/app/other-graph", + ), + ).toBe("https://roamresearch.com/#/app/other-graph/page-uid"); + }); +}); + +describe("findTargetUid", () => { + it("returns the local id of a RID in this graph when the page exists", async () => { + roamQuery.mockReturnValue([[1]]); + + await expect( + findTargetUid(`${LOCAL_SPACE_URI}/page-uid`, OBSIDIAN_SPACE_URI), + ).resolves.toBe("page-uid"); + expect(roamQuery).toHaveBeenCalledWith( + '[:find (?e) :where [?e :block/uid "page-uid"]]', + ); + expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + }); + + it("returns null for a RID in this graph whose page is missing", async () => { + await expect( + findTargetUid(`${LOCAL_SPACE_URI}/page-uid`, OBSIDIAN_SPACE_URI), + ).resolves.toBeNull(); + expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + }); + + it("looks up a local id of the publisher's space as an imported note", async () => { + mockedFindImportedNodeUidBySourceRid.mockResolvedValue("imported-uid"); + + await expect(findTargetUid("node-9", OBSIDIAN_SPACE_URI)).resolves.toBe( + "imported-uid", + ); + expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledWith( + "orn:obsidian.note:vault-a/node-9", + ); + expect(roamQuery).not.toHaveBeenCalled(); + }); + + it("looks up a RID of another space as imported", async () => { + await expect( + findTargetUid("orn:obsidian.note:vault-b/node-6", OBSIDIAN_SPACE_URI), + ).resolves.toBeNull(); + expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledWith( + "orn:obsidian.note:vault-b/node-6", + ); + }); +}); diff --git a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts index 0712131d8..d4299a271 100644 --- a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts +++ b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts @@ -20,6 +20,13 @@ vi.mock("~/utils/resolveSharedNodeTypes", () => ({ resolveSharedNodeTypes: vi.fn(), })); +// Runs before the imports above: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); + const mockedMaterializeSharedNode = vi.mocked(materializeSharedNode); const mockedResolveSharedNodeTypes = vi.mocked(resolveSharedNodeTypes); @@ -139,6 +146,56 @@ describe("importSharedNodes", () => { }); }); + it("materializes a node before the nodes that name it as their source", async () => { + const evidence = { + ...makeSharedNode("node-1"), + slots: { sourceDocument: "node-2" }, + }; + const source = makeSharedNode("node-2"); + const other = makeSharedNode("node-3"); + mockedMaterializeSharedNode + .mockResolvedValueOnce(successResult(source, "created")) + .mockResolvedValueOnce(successResult(evidence, "created")) + .mockResolvedValueOnce(successResult(other, "created")); + + const items = await importSharedNodes({ + client, + sharedNodes: [evidence, source, other], + onProgress: vi.fn(), + }); + + expect( + mockedMaterializeSharedNode.mock.calls.map(([args]) => args.sharedNode), + ).toEqual([source, evidence, other]); + expect(items.map((item) => item.sharedNode)).toEqual([ + source, + evidence, + other, + ]); + }); + + it("reports the materializer's warning on the imported node", async () => { + const sharedNodes = [makeSharedNode("node-1")]; + mockedMaterializeSharedNode.mockResolvedValueOnce({ + ...successResult(sharedNodes[0], "created"), + warning: "No source was published with this node.", + }); + + const items = await importSharedNodes({ + client, + sharedNodes, + onProgress: vi.fn(), + }); + + expect(items).toEqual([ + { + sharedNode: sharedNodes[0], + status: "imported", + warning: "No source was published with this node.", + }, + ]); + }); + it("keeps importing the remaining nodes when a materialization throws", async () => { const sharedNodes = ["node-1", "node-2"].map(makeSharedNode); mockedMaterializeSharedNode diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts index 5cf18f4c0..aa2cf91c0 100644 --- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts +++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts @@ -28,6 +28,13 @@ vi.mock("~/utils/importedSourceIdentity", () => ({ writeImportedSourceIdentity: vi.fn(), })); +// Runs before the imports above: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); + const mockedGetPageTitleByPageUid = vi.mocked(getPageTitleByPageUid); const mockedGetPageUidByPageTitle = vi.mocked(getPageUidByPageTitle); const mockedGetShallowTreeByParentUid = vi.mocked(getShallowTreeByParentUid); @@ -48,11 +55,19 @@ const blockFromMarkdown = vi.fn(); const pageCreate = vi.fn(); const pageDelete = vi.fn(); const updatePage = vi.fn(); +const roamQuery = vi.fn(); const CORE_TITLE = "REM sleep and recall"; const DECORATED_TITLE = "[[EVD]] - REM sleep and recall"; const NODE_TYPE = { format: "[[EVD]] - {content}" }; +const LOCAL_GRAPH = "local-graph"; +const SOURCED_NODE_TYPE = { format: "[[EVD]] - {content} - {Source}" }; +const SOURCE_PAGE_UID = "source-page-uid"; +const SOURCE_TITLE = "@Smith 2020"; +const SOURCED_TITLE = `[[EVD]] - REM sleep and recall - [[${SOURCE_TITLE}]]`; +const IMPORTED_SOURCE_RID = "orn:obsidian.note:vault-b/node-6"; + const sharedNode: SharedNode = { rid: "orn:obsidian.note:vault-a/node-1", sourceLocalId: "node-1", @@ -129,6 +144,8 @@ beforeEach(() => { (globalThis as { window: unknown }).window = { roamAlphaAPI: { updatePage, + graph: { name: LOCAL_GRAPH }, + q: roamQuery, util: { generateUID: vi.fn(() => GENERATED_PAGE_UID) }, data: { block: { fromMarkdown: blockFromMarkdown }, @@ -395,20 +412,254 @@ describe("materializeSharedNode", () => { }); }); - it("keeps the incoming title when the format has a placeholder core_title cannot fill", async () => { + it("keeps the incoming title and warns when no source was published", async () => { const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); const result = await materializeSharedNode({ client, sharedNode: decoratedSharedNode, - nodeType: { format: "[[EVD]] - {content} - {Source}" }, + nodeType: SOURCED_NODE_TYPE, }); - expect(result.success).toBe(true); + expect(result).toMatchObject({ + success: true, + warning: + "No source was published with this node, so its title was kept as published.", + }); expect(pageFromMarkdown).toHaveBeenCalledWith({ page: { title: decoratedSharedNode.title, uid: GENERATED_PAGE_UID }, "markdown-string": MATERIALIZED_MARKDOWN, }); + expect(roamQuery).not.toHaveBeenCalled(); + }); + + it("does not warn about a source when the publisher sent no core title", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode, + nodeType: SOURCED_NODE_TYPE, + }); + + expect(result).toEqual({ + success: true, + action: "created", + pageUid: GENERATED_PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + }); + + it("names a source page this graph owns", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + roamQuery.mockReturnValue([[1]]); + mockedGetPageTitleByPageUid.mockImplementation((uid) => + uid === SOURCE_PAGE_UID ? SOURCE_TITLE : "", + ); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { + sourceDocument: `https://roamresearch.com/#/app/${LOCAL_GRAPH}/${SOURCE_PAGE_UID}`, + }, + }, + nodeType: SOURCED_NODE_TYPE, + }); + + expect(result).toEqual({ + success: true, + action: "created", + pageUid: GENERATED_PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + expect(roamQuery).toHaveBeenCalledWith( + `[:find (?e) :where [?e :block/uid "${SOURCE_PAGE_UID}"]]`, + ); + expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledTimes(1); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: SOURCED_TITLE, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("names a source page imported from the publisher's own space", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockImplementation((rid) => + Promise.resolve( + rid === "orn:obsidian.note:vault-a/node-9" ? SOURCE_PAGE_UID : null, + ), + ); + mockedGetPageTitleByPageUid.mockImplementation((uid) => + uid === SOURCE_PAGE_UID ? SOURCE_TITLE : "", + ); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { sourceDocument: "node-9" }, + }, + nodeType: SOURCED_NODE_TYPE, + }); + + expect(result).toMatchObject({ success: true, action: "created" }); + expect(result).not.toHaveProperty("warning"); + expect(roamQuery).not.toHaveBeenCalled(); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: SOURCED_TITLE, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("names a source page imported from a third space", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockImplementation((rid) => + Promise.resolve(rid === IMPORTED_SOURCE_RID ? SOURCE_PAGE_UID : null), + ); + mockedGetPageTitleByPageUid.mockImplementation((uid) => + uid === SOURCE_PAGE_UID ? SOURCE_TITLE : "", + ); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { sourceDocument: IMPORTED_SOURCE_RID }, + }, + nodeType: SOURCED_NODE_TYPE, + }); + + expect(result).toMatchObject({ success: true, action: "created" }); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: SOURCED_TITLE, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("keeps the incoming title and warns when the source is not in this graph", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { sourceDocument: IMPORTED_SOURCE_RID }, + }, + nodeType: SOURCED_NODE_TYPE, + }); + + expect(result).toMatchObject({ + success: true, + action: "created", + warning: `Its source (${IMPORTED_SOURCE_RID}) is not in this graph, so its title was kept as published. Import the source, then refresh this page.`, + }); + expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledWith( + IMPORTED_SOURCE_RID, + ); + expect(pageFromMarkdown).toHaveBeenCalledWith({ + page: { title: decoratedSharedNode.title, uid: GENERATED_PAGE_UID }, + "markdown-string": MATERIALIZED_MARKDOWN, + }); + }); + + it("does not look up the source of an import that is up to date", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID); + mockedReadImportedSourceIdentity.mockReturnValue({ + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { sourceDocument: IMPORTED_SOURCE_RID }, + }, + nodeType: SOURCED_NODE_TYPE, + }); + + expect(result).toMatchObject({ success: true, action: "skipped" }); + expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledTimes(1); + expect(mockedGetPageTitleByPageUid).not.toHaveBeenCalled(); + }); + + it("leaves a title that already names its source untouched when refreshing", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockImplementation((rid) => + Promise.resolve( + rid === IMPORTED_SOURCE_RID + ? SOURCE_PAGE_UID + : rid === sharedNode.rid + ? EXISTING_PAGE_UID + : null, + ), + ); + mockedGetPageTitleByPageUid.mockImplementation((uid) => + uid === SOURCE_PAGE_UID ? SOURCE_TITLE : SOURCED_TITLE, + ); + mockedReadImportedSourceIdentity.mockReturnValue({ + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { sourceDocument: IMPORTED_SOURCE_RID }, + }, + nodeType: SOURCED_NODE_TYPE, + force: true, + }); + + expect(result).toEqual({ + success: true, + action: "updated", + pageUid: EXISTING_PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + expect(updatePage).not.toHaveBeenCalled(); + }); + + it("renames an imported page once its source arrives and it is refreshed", async () => { + const { client } = clientWithFullContent({ text: FULL_MARKDOWN }); + mockedFindImportedNodeUidBySourceRid.mockImplementation((rid) => + Promise.resolve( + rid === IMPORTED_SOURCE_RID + ? SOURCE_PAGE_UID + : rid === sharedNode.rid + ? EXISTING_PAGE_UID + : null, + ), + ); + mockedGetPageTitleByPageUid.mockImplementation((uid) => + uid === SOURCE_PAGE_UID ? SOURCE_TITLE : decoratedSharedNode.title, + ); + mockedReadImportedSourceIdentity.mockReturnValue({ + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + }); + + const result = await materializeSharedNode({ + client, + sharedNode: { + ...decoratedSharedNode, + slots: { sourceDocument: IMPORTED_SOURCE_RID }, + }, + nodeType: SOURCED_NODE_TYPE, + force: true, + }); + + expect(result).toMatchObject({ success: true, action: "updated" }); + expect(updatePage).toHaveBeenCalledWith({ + page: { uid: EXISTING_PAGE_UID, title: SOURCED_TITLE }, + }); }); it("strips the Roam heading by the source title while decorating the page title", async () => { diff --git a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts index 4728b7d39..0e8da662a 100644 --- a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts +++ b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts @@ -36,6 +36,13 @@ vi.mock("~/utils/supabaseContext", () => ({ getLoggedInClient: vi.fn(), })); +// Runs before the imports above: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); + const mockedGetPageTitleByPageUid = vi.mocked(getPageTitleByPageUid); const mockedGetSharedNodeByRid = vi.mocked(getSharedNodeByRid); const mockedReadImportedSourceIdentity = vi.mocked(readImportedSourceIdentity); @@ -119,6 +126,23 @@ describe("refreshImportedNode", () => { expect(mockedInternalError).not.toHaveBeenCalled(); }); + it("appends the materializer's warning to the message", async () => { + mockedMaterializeSharedNode.mockResolvedValue({ + success: true, + action: "updated", + pageUid: PAGE_UID, + sourceModifiedAt: sharedNode.lastModified, + sourceNodeRid: sharedNode.rid, + warning: "No source was published with this node.", + }); + + await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({ + success: true, + message: + 'Refreshed "EVD - REM sleep and recall" from Research vault. No source was published with this node.', + }); + }); + it("passes the resolved node type to the materializer", async () => { mockedResolveSharedNodeTypes.mockResolvedValue( new Map([[sharedNode.schemaId, NODE_TYPE]]), diff --git a/apps/roam/src/utils/__tests__/sourceSlot.test.ts b/apps/roam/src/utils/__tests__/sourceSlot.test.ts new file mode 100644 index 000000000..7f2734934 --- /dev/null +++ b/apps/roam/src/utils/__tests__/sourceSlot.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +// Runs before the imports below: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); + +import { titleWithSource } from "~/utils/sourceSlot"; + +describe("titleWithSource", () => { + it("fills the content and source placeholders", () => { + expect( + titleWithSource({ + format: "[[EVD]] - {content} - {Source}", + coreTitle: "REM sleep and recall", + sourceTitle: "@Smith 2020", + }), + ).toBe("[[EVD]] - REM sleep and recall - [[@Smith 2020]]"); + }); + + it("matches placeholder names regardless of case", () => { + expect( + titleWithSource({ + format: "{SOURCE}: {Content}", + coreTitle: "x", + sourceTitle: "y", + }), + ).toBe("[[y]]: x"); + }); + + it("returns null when the format has a placeholder it cannot fill", () => { + expect( + titleWithSource({ + format: "[[EVD]] - {content} - {Source} - {Author}", + coreTitle: "x", + sourceTitle: "y", + }), + ).toBeNull(); + }); + + it("returns null when the format has no content placeholder", () => { + expect( + titleWithSource({ + format: "[[EVD]] - {Source}", + coreTitle: "x", + sourceTitle: "y", + }), + ).toBeNull(); + }); +}); diff --git a/apps/roam/src/utils/findTargetUid.ts b/apps/roam/src/utils/findTargetUid.ts new file mode 100644 index 000000000..816cbafbd --- /dev/null +++ b/apps/roam/src/utils/findTargetUid.ts @@ -0,0 +1,36 @@ +import { + isRid, + ridToSpaceUriAndLocalId, + spaceUriAndLocalIdToRid, +} from "@repo/database/lib/rid"; +import canonicalRoamUrl from "./canonicalRoamUrl"; +import { findImportedNodeUidBySourceRid } from "./importedSourceIdentity"; + +// A shared node refers to another node by its bare local id when both live in the same +// space, and by a RID otherwise. "note" is the subtype node RIDs carry; URL-shaped Roam +// RIDs ignore it. +export const sharedReferenceRid = ( + localOrRid: string, + spaceUri: string, +): string => + isRid(localOrRid) + ? localOrRid + : spaceUriAndLocalIdToRid(spaceUri, localOrRid, "note"); + +// The local page for a node another space refers to: its own uid when the RID points +// into this graph, else the page imported from it. +export const findTargetUid = async ( + localOrRid: string, + spaceUri: string, +): Promise => { + const rid = sharedReferenceRid(localOrRid, spaceUri); + const { spaceUri: ridSpaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(rid); + if (ridSpaceUri === canonicalRoamUrl()) { + const result = window.roamAlphaAPI.q( + `[:find (?e) :where [?e :block/uid "${sourceLocalId}"]]`, + ); + if (!result || result.length === 0) return null; + return sourceLocalId; + } + return await findImportedNodeUidBySourceRid(rid); +}; diff --git a/apps/roam/src/utils/importSharedNodes.ts b/apps/roam/src/utils/importSharedNodes.ts index 6a4f23abc..9e6c9fc3b 100644 --- a/apps/roam/src/utils/importSharedNodes.ts +++ b/apps/roam/src/utils/importSharedNodes.ts @@ -1,10 +1,12 @@ import type { DGSupabaseClient } from "@repo/database/lib/client"; import type { SharedNode } from "@repo/database/lib/sharedNodes"; +import { sharedReferenceRid } from "./findTargetUid"; import { getErrorMessage, materializeSharedNode, } from "./materializeSharedNode"; import { resolveSharedNodeTypes } from "./resolveSharedNodeTypes"; +import { SOURCE_SLOT } from "./sourceSlot"; export type FailedSharedNodeImport = { sharedNode: SharedNode; @@ -13,13 +15,28 @@ export type FailedSharedNodeImport = { }; export type SharedNodeImportItem = - | { sharedNode: SharedNode; status: "imported" | "skipped" } + | { sharedNode: SharedNode; status: "imported" | "skipped"; warning?: string } | FailedSharedNodeImport; export const isFailedSharedNodeImport = ( item: SharedNodeImportItem, ): item is FailedSharedNodeImport => item.status === "failed"; +// A node's title can only name its source once that source has a local page, so the +// nodes other batch members refer to are materialized first. +const orderSourcesFirst = (sharedNodes: SharedNode[]): SharedNode[] => { + const referencedRids = new Set( + sharedNodes.flatMap((node) => { + const source = node.slots?.[SOURCE_SLOT]; + return source ? [sharedReferenceRid(source, node.spaceUri)] : []; + }), + ); + return [ + ...sharedNodes.filter((node) => referencedRids.has(node.rid)), + ...sharedNodes.filter((node) => !referencedRids.has(node.rid)), + ]; +}; + export const importSharedNodes = async ({ client, sharedNodes, @@ -34,7 +51,7 @@ export const importSharedNodes = async ({ sharedNodes, }); const items: SharedNodeImportItem[] = []; - for (const sharedNode of sharedNodes) { + for (const sharedNode of orderSourcesFirst(sharedNodes)) { try { const result = await materializeSharedNode({ client, @@ -46,6 +63,7 @@ export const importSharedNodes = async ({ ? { sharedNode, status: result.action === "skipped" ? "skipped" : "imported", + ...(result.warning ? { warning: result.warning } : {}), } : { sharedNode, status: "failed", message: result.error.message }, ); diff --git a/apps/roam/src/utils/importSharedRelations.ts b/apps/roam/src/utils/importSharedRelations.ts index adb7aa5cd..866aa5a74 100644 --- a/apps/roam/src/utils/importSharedRelations.ts +++ b/apps/roam/src/utils/importSharedRelations.ts @@ -6,9 +6,9 @@ import type { } from "@repo/database/crossAppContracts"; import { spaceUriAndLocalIdToRid, - isRid, ridToSpaceUriAndLocalId, } from "@repo/database/lib/rid"; +import { findTargetUid } from "./findTargetUid"; import { findImportedNodeUidBySourceRid, getImportedSourceRids, @@ -27,7 +27,6 @@ import { import { discoverSharedRelations } from "./discoverSharedRelations"; import { DGSupabaseClient } from "@repo/database/lib/client"; import { deleteBlock } from "roamjs-components/writes"; -import canonicalRoamUrl from "./canonicalRoamUrl"; const matchImportedNodeSchemas = async ( nodeSchemas: CrossAppNodeSchema[], @@ -175,33 +174,6 @@ const matchImportedRelationSchemas = async ( return result; }; -const localSpaceUrl = canonicalRoamUrl(window.roamAlphaAPI.graph.name); - -const findTargetUid = async ( - localOrRid: string, - spaceUri: string, - ridType?: string, -): Promise => { - if (isRid(localOrRid)) { - const { spaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(localOrRid); - if (spaceUri === localSpaceUrl) { - // check existence - const result = window.roamAlphaAPI.q( - `[:find (?e) :where [?e :block/uid "${sourceLocalId}"]]`, - ); - if (!result || result.length === 0) return null; - return sourceLocalId; - } - } else { - localOrRid = spaceUriAndLocalIdToRid( - spaceUri, - localOrRid, - ridType ?? "note", - ); - } - return await findImportedNodeUidBySourceRid(localOrRid); -}; - const importRelations = async ( schemaRidToLocalId: Record, relations: CrossAppRelation[], diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts index 6fbd7cac0..149c52b22 100644 --- a/apps/roam/src/utils/materializeSharedNode.ts +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -12,6 +12,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; import deleteBlock from "roamjs-components/writes/deleteBlock"; +import { findTargetUid } from "./findTargetUid"; import type { DiscourseNode } from "./getDiscourseNodes"; import { findImportedNodeUidBySourceRid, @@ -19,6 +20,11 @@ import { writeImportedSourceIdentity, type ImportedSourceIdentity, } from "./importedSourceIdentity"; +import { + schemaHasSourceSlot, + SOURCE_SLOT, + titleWithSource, +} from "./sourceSlot"; type MaterializationStage = | "validate-input" @@ -48,6 +54,7 @@ type MaterializationSuccess = SourceIdentity & { success: true; action: "created" | "updated" | "skipped"; pageUid: string; + warning?: string; }; export type MaterializeSharedNodeResult = @@ -128,6 +135,55 @@ const validateSharedNode = ( return { sourceModifiedAt: modifiedAt.toISOString(), title }; }; +// The Source page a node's sourceDocument slot names, when this graph has it. The +// warning explains a title kept as published, which the user can fix by importing the +// source and refreshing. +const resolveSourceTitle = async ( + sharedNode: SharedNode, +): Promise<{ sourceTitle: string } | { warning: string }> => { + const slotValue = sharedNode.slots?.[SOURCE_SLOT]; + if (!slotValue) + return { + warning: + "No source was published with this node, so its title was kept as published.", + }; + const sourceUid = await findTargetUid(slotValue, sharedNode.spaceUri); + const sourceTitle = sourceUid ? getPageTitleByPageUid(sourceUid) : ""; + if (!sourceTitle) + return { + warning: `Its source (${slotValue}) is not in this graph, so its title was kept as published. Import the source, then refresh this page.`, + }; + return { sourceTitle }; +}; + +const buildPageTitle = async ({ + sharedNode, + nodeType, + incomingTitle, +}: { + sharedNode: SharedNode; + nodeType?: Pick; + incomingTitle: string; +}): Promise<{ title: string; warning?: string }> => { + const coreTitle = sharedNode.coreTitle; + if (!coreTitle || !nodeType) return { title: incomingTitle }; + if (!schemaHasSourceSlot(nodeType)) + return { + title: decorateTitle(nodeType.format, coreTitle) ?? incomingTitle, + }; + const source = await resolveSourceTitle(sharedNode); + if ("warning" in source) + return { title: incomingTitle, warning: source.warning }; + return { + title: + titleWithSource({ + format: nodeType.format, + coreTitle, + sourceTitle: source.sourceTitle, + }) ?? incomingTitle, + }; +}; + const fetchFullMarkdown = async ({ client, sharedNode, @@ -319,10 +375,6 @@ export const materializeSharedNode = async ({ sourceModifiedAt: validated.sourceModifiedAt, sourceNodeRid: sharedNode.rid, }; - const pageTitle = - (sharedNode.coreTitle && nodeType - ? decorateTitle(nodeType.format, sharedNode.coreTitle) - : null) ?? validated.title; let importedPageUid: string | null; let storedIdentity: ImportedSourceIdentity | undefined; @@ -356,6 +408,12 @@ export const materializeSharedNode = async ({ pageUid: importedPageUid, }; + const { title: pageTitle, warning } = await buildPageTitle({ + sharedNode, + nodeType, + incomingTitle: validated.title, + }); + const content = await fetchFullMarkdown({ client, sharedNode }).catch( (error: unknown) => ({ error: getErrorMessage(error) }), ); @@ -366,7 +424,7 @@ export const materializeSharedNode = async ({ stage: "fetch-content", }); - return importedPageUid + const result = await (importedPageUid ? updateImportedPage({ identity, markdown: content.markdown, @@ -377,5 +435,6 @@ export const materializeSharedNode = async ({ identity, markdown: content.markdown, title: pageTitle, - }); + })); + return result.success && warning ? { ...result, warning } : result; }; diff --git a/apps/roam/src/utils/refreshImportedNode.ts b/apps/roam/src/utils/refreshImportedNode.ts index 66f52eba5..a2e2afec9 100644 --- a/apps/roam/src/utils/refreshImportedNode.ts +++ b/apps/roam/src/utils/refreshImportedNode.ts @@ -78,7 +78,7 @@ export const refreshImportedNode = async ({ }; return { success: true, - message: `Refreshed "${sharedNode.title}" from ${sharedNode.spaceName}.`, + message: `Refreshed "${sharedNode.title}" from ${sharedNode.spaceName}.${result.warning ? ` ${result.warning}` : ""}`, }; } catch (error) { internalError({ diff --git a/apps/roam/src/utils/sourceSlot.ts b/apps/roam/src/utils/sourceSlot.ts index 9a6c88561..ba4fd85dc 100644 --- a/apps/roam/src/utils/sourceSlot.ts +++ b/apps/roam/src/utils/sourceSlot.ts @@ -1,3 +1,4 @@ +import { FORMAT_PLACEHOLDER } from "@repo/database/lib/decorateTitle"; import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; @@ -69,3 +70,32 @@ export const sourceUidOfNode = ( return undefined; return getPageUidByPageTitle(sourceTitle) || undefined; }; + +const FILLABLE_PLACEHOLDERS = new Set(["{content}", "{source}"]); + +// Inverse of sourceUidOfNode, for the pull side: the local title of a node whose format +// names a source, built from its core title and the Source page's title. Null when the +// format has a placeholder neither fills, so the caller keeps the incoming title. +export const titleWithSource = ({ + format, + coreTitle, + sourceTitle, +}: { + format: string; + coreTitle: string; + sourceTitle: string; +}): string | null => { + const placeholders = (format.match(FORMAT_PLACEHOLDER) ?? []).map( + (placeholder) => placeholder.toLowerCase(), + ); + if ( + !placeholders.includes("{content}") || + placeholders.some((placeholder) => !FILLABLE_PLACEHOLDERS.has(placeholder)) + ) + return null; + return format.replace(FORMAT_PLACEHOLDER, (placeholder) => + placeholder.toLowerCase() === "{content}" + ? coreTitle + : `[[${sourceTitle}]]`, + ); +}; From 07b61b224166509f0d6d69ac15365725613f2e75 Mon Sep 17 00:00:00 2001 From: sid597 Date: Wed, 2 Sep 2026 15:40:08 +0530 Subject: [PATCH 12/15] ENG-2142 Show source warnings with warning intent and cover the remaining identity paths --- .../components/DiscoverSharedNodesDialog.tsx | 2 +- .../RefreshImportedNodeTitleButton.tsx | 10 ++++++-- .../src/utils/__tests__/findTargetUid.test.ts | 9 +++++++ .../__tests__/refreshImportedNode.test.ts | 6 ++--- .../src/utils/__tests__/sourceSlot.test.ts | 10 ++++++++ apps/roam/src/utils/importSharedNodes.ts | 4 ++- apps/roam/src/utils/materializeSharedNode.ts | 4 +-- apps/roam/src/utils/refreshImportedNode.ts | 4 ++- apps/roam/src/utils/sourceSlot.ts | 13 +++++++--- .../src/lib/__tests__/sharedNodes.test.ts | 25 +++++++++++++++++++ 10 files changed, 72 insertions(+), 15 deletions(-) diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx index 629337647..543c82d8c 100644 --- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx +++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx @@ -127,7 +127,7 @@ const ImportResultsSummary = ({ return ( 0 ? Intent.WARNING : Intent.SUCCESS} - title={`${importedCount} imported, ${skippedCount} skipped, ${failedImports.length} failed`} + title={`${importedCount} imported, ${skippedCount} skipped, ${failedImports.length} failed${warnings.length > 0 ? `, ${warnings.length} with warnings` : ""}`} > {skippedCount > 0 && (
    Skipped nodes were already up to date in this graph.
    diff --git a/apps/roam/src/components/RefreshImportedNodeTitleButton.tsx b/apps/roam/src/components/RefreshImportedNodeTitleButton.tsx index 1e6eec39a..9367d092a 100644 --- a/apps/roam/src/components/RefreshImportedNodeTitleButton.tsx +++ b/apps/roam/src/components/RefreshImportedNodeTitleButton.tsx @@ -23,8 +23,14 @@ const RefreshImportedNodeTitleButton = ({ id: result.success ? "refresh-imported-node-success" : "refresh-imported-node-failed", - intent: result.success ? "success" : "danger", - content: result.message, + intent: !result.success + ? "danger" + : result.warning + ? "warning" + : "success", + content: result.warning + ? `${result.message} ${result.warning}` + : result.message, }); } finally { setRefreshing(false); diff --git a/apps/roam/src/utils/__tests__/findTargetUid.test.ts b/apps/roam/src/utils/__tests__/findTargetUid.test.ts index b9fd45c05..e79abe548 100644 --- a/apps/roam/src/utils/__tests__/findTargetUid.test.ts +++ b/apps/roam/src/utils/__tests__/findTargetUid.test.ts @@ -70,6 +70,15 @@ describe("findTargetUid", () => { expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled(); }); + it("treats a local id of this graph's space as a page uid", async () => { + roamQuery.mockReturnValue([[1]]); + + await expect(findTargetUid("page-uid", LOCAL_SPACE_URI)).resolves.toBe( + "page-uid", + ); + expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled(); + }); + it("looks up a local id of the publisher's space as an imported note", async () => { mockedFindImportedNodeUidBySourceRid.mockResolvedValue("imported-uid"); diff --git a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts index 0e8da662a..785af8b4a 100644 --- a/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts +++ b/apps/roam/src/utils/__tests__/refreshImportedNode.test.ts @@ -126,7 +126,7 @@ describe("refreshImportedNode", () => { expect(mockedInternalError).not.toHaveBeenCalled(); }); - it("appends the materializer's warning to the message", async () => { + it("passes the materializer's warning through", async () => { mockedMaterializeSharedNode.mockResolvedValue({ success: true, action: "updated", @@ -138,8 +138,8 @@ describe("refreshImportedNode", () => { await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({ success: true, - message: - 'Refreshed "EVD - REM sleep and recall" from Research vault. No source was published with this node.', + message: 'Refreshed "EVD - REM sleep and recall" from Research vault.', + warning: "No source was published with this node.", }); }); diff --git a/apps/roam/src/utils/__tests__/sourceSlot.test.ts b/apps/roam/src/utils/__tests__/sourceSlot.test.ts index 7f2734934..2e58314e2 100644 --- a/apps/roam/src/utils/__tests__/sourceSlot.test.ts +++ b/apps/roam/src/utils/__tests__/sourceSlot.test.ts @@ -30,6 +30,16 @@ describe("titleWithSource", () => { ).toBe("[[y]]: x"); }); + it("inserts titles that contain replacement patterns verbatim", () => { + expect( + titleWithSource({ + format: "[[EVD]] - {content} - {Source}", + coreTitle: "costs $& more", + sourceTitle: "$1 paper", + }), + ).toBe("[[EVD]] - costs $& more - [[$1 paper]]"); + }); + it("returns null when the format has a placeholder it cannot fill", () => { expect( titleWithSource({ diff --git a/apps/roam/src/utils/importSharedNodes.ts b/apps/roam/src/utils/importSharedNodes.ts index 9e6c9fc3b..3eca603e2 100644 --- a/apps/roam/src/utils/importSharedNodes.ts +++ b/apps/roam/src/utils/importSharedNodes.ts @@ -23,7 +23,9 @@ export const isFailedSharedNodeImport = ( ): item is FailedSharedNodeImport => item.status === "failed"; // A node's title can only name its source once that source has a local page, so the -// nodes other batch members refer to are materialized first. +// nodes other batch members refer to are materialized first. One level only: a source +// that itself names a source in the batch is not ordered after it, and its title is +// filled on the next refresh instead. const orderSourcesFirst = (sharedNodes: SharedNode[]): SharedNode[] => { const referencedRids = new Set( sharedNodes.flatMap((node) => { diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts index 149c52b22..d285dd2a2 100644 --- a/apps/roam/src/utils/materializeSharedNode.ts +++ b/apps/roam/src/utils/materializeSharedNode.ts @@ -135,9 +135,7 @@ const validateSharedNode = ( return { sourceModifiedAt: modifiedAt.toISOString(), title }; }; -// The Source page a node's sourceDocument slot names, when this graph has it. The -// warning explains a title kept as published, which the user can fix by importing the -// source and refreshing. +// The Source page a node's sourceDocument slot names, when this graph has it. const resolveSourceTitle = async ( sharedNode: SharedNode, ): Promise<{ sourceTitle: string } | { warning: string }> => { diff --git a/apps/roam/src/utils/refreshImportedNode.ts b/apps/roam/src/utils/refreshImportedNode.ts index a2e2afec9..c75b09a15 100644 --- a/apps/roam/src/utils/refreshImportedNode.ts +++ b/apps/roam/src/utils/refreshImportedNode.ts @@ -15,6 +15,7 @@ const REFRESH_ERROR_OPERATION = "refresh-imported-node"; type RefreshImportedNodeResult = { success: boolean; message: string; + warning?: string; }; export const refreshImportedNode = async ({ @@ -78,7 +79,8 @@ export const refreshImportedNode = async ({ }; return { success: true, - message: `Refreshed "${sharedNode.title}" from ${sharedNode.spaceName}.${result.warning ? ` ${result.warning}` : ""}`, + message: `Refreshed "${sharedNode.title}" from ${sharedNode.spaceName}.`, + ...(result.warning ? { warning: result.warning } : {}), }; } catch (error) { internalError({ diff --git a/apps/roam/src/utils/sourceSlot.ts b/apps/roam/src/utils/sourceSlot.ts index ba4fd85dc..12b662c50 100644 --- a/apps/roam/src/utils/sourceSlot.ts +++ b/apps/roam/src/utils/sourceSlot.ts @@ -12,11 +12,13 @@ import { extractFieldFromTitle } from "./extractContentFromTitle"; export const SOURCE_SLOT = "sourceDocument"; const DEFAULT_SOURCE_SCHEMA_ID = "_SRC-node"; +const CONTENT_PLACEHOLDER = "{content}"; +const SOURCE_PLACEHOLDER = "{source}"; type NodeFormat = Pick; export const schemaHasSourceSlot = (schema: NodeFormat): boolean => - (schema?.format ?? "").toLowerCase().includes("{source}"); + (schema?.format ?? "").toLowerCase().includes(SOURCE_PLACEHOLDER); const sourceNodeType = (allNodes: DiscourseNode[]): DiscourseNode | undefined => allNodes.find((node) => node.text.toLowerCase() === "source"); @@ -71,7 +73,10 @@ export const sourceUidOfNode = ( return getPageUidByPageTitle(sourceTitle) || undefined; }; -const FILLABLE_PLACEHOLDERS = new Set(["{content}", "{source}"]); +const FILLABLE_PLACEHOLDERS = new Set([ + CONTENT_PLACEHOLDER, + SOURCE_PLACEHOLDER, +]); // Inverse of sourceUidOfNode, for the pull side: the local title of a node whose format // names a source, built from its core title and the Source page's title. Null when the @@ -89,12 +94,12 @@ export const titleWithSource = ({ (placeholder) => placeholder.toLowerCase(), ); if ( - !placeholders.includes("{content}") || + !placeholders.includes(CONTENT_PLACEHOLDER) || placeholders.some((placeholder) => !FILLABLE_PLACEHOLDERS.has(placeholder)) ) return null; return format.replace(FORMAT_PLACEHOLDER, (placeholder) => - placeholder.toLowerCase() === "{content}" + placeholder.toLowerCase() === CONTENT_PLACEHOLDER ? coreTitle : `[[${sourceTitle}]]`, ); diff --git a/packages/database/src/lib/__tests__/sharedNodes.test.ts b/packages/database/src/lib/__tests__/sharedNodes.test.ts index f251d30d1..c7d03566a 100644 --- a/packages/database/src/lib/__tests__/sharedNodes.test.ts +++ b/packages/database/src/lib/__tests__/sharedNodes.test.ts @@ -212,6 +212,31 @@ describe("buildSharedNodes", () => { }); }); + it("builds a URL rid without subtype for a slot in a Roam space", () => { + const roamSpace: BuildArgs["spaces"][number] = { + id: 22, + name: "Research graph", + platform: "Roam", + url: "https://roamresearch.com/#/app/research-graph", + }; + const nodeWithRoamSlot: BuildArgs["nodes"][number] = { + ...nodes[0]!, + reference_content: { sourceDocument: 8 }, + concepts_of_relation: [ + { id: 8, space_id: 22, source_local_id: "roam-uid-8" }, + ], + }; + expect( + build({ + nodesOverride: [nodeWithRoamSlot], + spacesOverride: [...spaces, roamSpace], + })[0]?.slots, + ).toEqual({ + sourceDocument: + "https://roamresearch.com/#/app/research-graph/roam-uid-8", + }); + }); + it("leaves slots undefined when the node references nothing", () => { expect(build()[0]?.slots).toBeUndefined(); }); From d73e86989ecee5d496626c269b93f800c8cb7b33 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 00:34:22 +0530 Subject: [PATCH 13/15] ENG-2142 Reuse relation schemas with multiple query patterns --- .../__tests__/importSharedRelations.test.ts | 97 +++++++++++++++++++ apps/roam/src/utils/importSharedRelations.ts | 8 +- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/importSharedRelations.test.ts diff --git a/apps/roam/src/utils/__tests__/importSharedRelations.test.ts b/apps/roam/src/utils/__tests__/importSharedRelations.test.ts new file mode 100644 index 000000000..3883df788 --- /dev/null +++ b/apps/roam/src/utils/__tests__/importSharedRelations.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; +import { importSharedRelations } from "~/utils/importSharedRelations"; +import getDiscourseRelations from "~/utils/getDiscourseRelations"; +import { createRelationSchema } from "~/utils/createRelationSchema"; + +vi.hoisted(() => { + vi.stubGlobal("window", { roamAlphaAPI: { graph: { name: "local" } } }); +}); +vi.mock("~/utils/getDiscourseRelations", () => ({ default: vi.fn() })); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => [{ type: "local-claim", text: "Claim" }], +})); +vi.mock("~/utils/importedSourceIdentity", () => ({ + getImportedSourceRids: async () => new Set(), + findImportedNodeUidBySourceRid: vi.fn(), + writeImportedSourceIdentity: vi.fn(), +})); +vi.mock("~/components/settings/utils/accessors", () => ({ + createDiscourseNodeType: vi.fn(), +})); +vi.mock("~/utils/createRelationSchema", () => ({ + createRelationSchema: vi.fn(), +})); +vi.mock("~/utils/createReifiedBlock", () => ({ + getReifiedRelations: async () => [], + createReifiedRelation: vi.fn(), +})); +vi.mock("roamjs-components/writes", () => ({ deleteBlock: vi.fn() })); +vi.mock("~/utils/discoverSharedRelations", () => ({ + discoverSharedRelations: async () => ({ + relations: [], + relTypeSchemas: [], + nodeSchemas: [ + { + localId: "claim", + rid: "orn:obsidian.schema:remote/claim", + label: "Claim", + authorId: "author", + createdAt: new Date("2026-09-07"), + }, + ], + relTripleSchemas: [ + { + localId: "supports", + rid: "orn:obsidian.schema:remote/supports", + label: "Supports", + complement: "Supported by", + sourceType: "claim", + destinationType: "claim", + authorId: "author", + createdAt: new Date("2026-09-07"), + }, + ], + }), +})); + +const relation = (id: string): DiscourseRelation => ({ + id, + label: "Supports", + complement: "Supported by", + source: "local-claim", + destination: "local-claim", + triples: [], +}); +const client = {} as DGSupabaseClient; + +beforeEach(() => vi.clearAllMocks()); + +describe("importSharedRelations schema matching", () => { + it("reuses one schema when its query patterns produce multiple matches", async () => { + vi.mocked(getDiscourseRelations).mockReturnValue([ + { + ...relation("local-supports"), + triples: [["source", "references", "destination"]], + }, + { + ...relation("local-supports"), + triples: [["source", "is in page", "destination"]], + }, + ]); + await expect(importSharedRelations(client, 7)).resolves.toBeUndefined(); + expect(createRelationSchema).not.toHaveBeenCalled(); + }); + + it("rejects matches to two different schemas", async () => { + vi.mocked(getDiscourseRelations).mockReturnValue([ + relation("supports-one"), + relation("supports-two"), + ]); + await expect(importSharedRelations(client, 7)).rejects.toThrow( + "multiple matches", + ); + expect(createRelationSchema).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/importSharedRelations.ts b/apps/roam/src/utils/importSharedRelations.ts index 866aa5a74..70732bfc4 100644 --- a/apps/roam/src/utils/importSharedRelations.ts +++ b/apps/roam/src/utils/importSharedRelations.ts @@ -138,11 +138,13 @@ const matchImportedRelationSchemas = async ( r.source === source && r.destination === destination, ); - if (match.length > 1) { + // Each query pattern can produce a match for the same local schema. + const matchIds = [...new Set(match.map(({ id }) => id))]; + if (matchIds.length > 1) { throw new Error("multiple matches"); } - if (match.length === 1) { - blockUid = match[0].id; + if (matchIds.length === 1) { + blockUid = matchIds[0]; } else { blockUid = await createRelationSchema({ label, From a50f5c0b4519e917939cc899351ed466e0ef24e8 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 00:41:32 +0530 Subject: [PATCH 14/15] Return promises explicitly in relation import test mocks --- .../__tests__/importSharedRelations.test.ts | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/roam/src/utils/__tests__/importSharedRelations.test.ts b/apps/roam/src/utils/__tests__/importSharedRelations.test.ts index 3883df788..80e4d95c8 100644 --- a/apps/roam/src/utils/__tests__/importSharedRelations.test.ts +++ b/apps/roam/src/utils/__tests__/importSharedRelations.test.ts @@ -13,7 +13,7 @@ vi.mock("~/utils/getDiscourseNodes", () => ({ default: () => [{ type: "local-claim", text: "Claim" }], })); vi.mock("~/utils/importedSourceIdentity", () => ({ - getImportedSourceRids: async () => new Set(), + getImportedSourceRids: () => Promise.resolve(new Set()), findImportedNodeUidBySourceRid: vi.fn(), writeImportedSourceIdentity: vi.fn(), })); @@ -24,36 +24,37 @@ vi.mock("~/utils/createRelationSchema", () => ({ createRelationSchema: vi.fn(), })); vi.mock("~/utils/createReifiedBlock", () => ({ - getReifiedRelations: async () => [], + getReifiedRelations: () => Promise.resolve([]), createReifiedRelation: vi.fn(), })); vi.mock("roamjs-components/writes", () => ({ deleteBlock: vi.fn() })); vi.mock("~/utils/discoverSharedRelations", () => ({ - discoverSharedRelations: async () => ({ - relations: [], - relTypeSchemas: [], - nodeSchemas: [ - { - localId: "claim", - rid: "orn:obsidian.schema:remote/claim", - label: "Claim", - authorId: "author", - createdAt: new Date("2026-09-07"), - }, - ], - relTripleSchemas: [ - { - localId: "supports", - rid: "orn:obsidian.schema:remote/supports", - label: "Supports", - complement: "Supported by", - sourceType: "claim", - destinationType: "claim", - authorId: "author", - createdAt: new Date("2026-09-07"), - }, - ], - }), + discoverSharedRelations: () => + Promise.resolve({ + relations: [], + relTypeSchemas: [], + nodeSchemas: [ + { + localId: "claim", + rid: "orn:obsidian.schema:remote/claim", + label: "Claim", + authorId: "author", + createdAt: new Date("2026-09-07"), + }, + ], + relTripleSchemas: [ + { + localId: "supports", + rid: "orn:obsidian.schema:remote/supports", + label: "Supports", + complement: "Supported by", + sourceType: "claim", + destinationType: "claim", + authorId: "author", + createdAt: new Date("2026-09-07"), + }, + ], + }), })); const relation = (id: string): DiscourseRelation => ({ From 80acda8bc2c7b2c60271fca96b27f1ca4e8bb927 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 01:27:04 +0530 Subject: [PATCH 15/15] Bind shared source identifiers in ENG-2142 Roam queries --- apps/roam/src/utils/__tests__/findTargetUid.test.ts | 12 +++++++++++- .../utils/__tests__/materializeSharedNode.test.ts | 3 ++- apps/roam/src/utils/findTargetUid.ts | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/utils/__tests__/findTargetUid.test.ts b/apps/roam/src/utils/__tests__/findTargetUid.test.ts index e79abe548..c259b73eb 100644 --- a/apps/roam/src/utils/__tests__/findTargetUid.test.ts +++ b/apps/roam/src/utils/__tests__/findTargetUid.test.ts @@ -58,11 +58,21 @@ describe("findTargetUid", () => { findTargetUid(`${LOCAL_SPACE_URI}/page-uid`, OBSIDIAN_SPACE_URI), ).resolves.toBe("page-uid"); expect(roamQuery).toHaveBeenCalledWith( - '[:find (?e) :where [?e :block/uid "page-uid"]]', + "[:find (?e) :in $ ?uid :where [?e :block/uid ?uid]]", + "page-uid", ); expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled(); }); + it("passes a shared id containing query syntax as data", async () => { + const sharedId = 'page-uid"] [(= ?e ?e)] ; "'; + await expect(findTargetUid(sharedId, LOCAL_SPACE_URI)).resolves.toBeNull(); + expect(roamQuery).toHaveBeenCalledWith( + "[:find (?e) :in $ ?uid :where [?e :block/uid ?uid]]", + sharedId, + ); + }); + it("returns null for a RID in this graph whose page is missing", async () => { await expect( findTargetUid(`${LOCAL_SPACE_URI}/page-uid`, OBSIDIAN_SPACE_URI), diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts index aa2cf91c0..4a5009534 100644 --- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts +++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts @@ -477,7 +477,8 @@ describe("materializeSharedNode", () => { sourceNodeRid: sharedNode.rid, }); expect(roamQuery).toHaveBeenCalledWith( - `[:find (?e) :where [?e :block/uid "${SOURCE_PAGE_UID}"]]`, + "[:find (?e) :in $ ?uid :where [?e :block/uid ?uid]]", + SOURCE_PAGE_UID, ); expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledTimes(1); expect(pageFromMarkdown).toHaveBeenCalledWith({ diff --git a/apps/roam/src/utils/findTargetUid.ts b/apps/roam/src/utils/findTargetUid.ts index 816cbafbd..18d9104c1 100644 --- a/apps/roam/src/utils/findTargetUid.ts +++ b/apps/roam/src/utils/findTargetUid.ts @@ -27,7 +27,8 @@ export const findTargetUid = async ( const { spaceUri: ridSpaceUri, sourceLocalId } = ridToSpaceUriAndLocalId(rid); if (ridSpaceUri === canonicalRoamUrl()) { const result = window.roamAlphaAPI.q( - `[:find (?e) :where [?e :block/uid "${sourceLocalId}"]]`, + "[:find (?e) :in $ ?uid :where [?e :block/uid ?uid]]", + sourceLocalId, ); if (!result || result.length === 0) return null; return sourceLocalId;