diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
index 4a6b94fbb..fb87c6859 100644
--- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
+++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
@@ -30,9 +30,6 @@ const IMPORT_ERROR_OPERATION = "import-shared-nodes";
const formatModifiedAt = (modifiedAt: string): string =>
new Date(modifiedAt).toLocaleString();
-const isImportableSharedNode = (node: SharedNode): boolean =>
- node.platform === "Obsidian";
-
const SharedNodeRow = ({
node,
alreadyImported,
@@ -52,7 +49,7 @@ const SharedNodeRow = ({
aria-label={`Select ${node.title}`}
checked={selected}
className="m-0"
- disabled={selectionDisabled || !isImportableSharedNode(node)}
+ disabled={selectionDisabled}
onChange={onToggleSelected}
/>
@@ -209,15 +206,10 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
);
}, [nodes, searchTerm]);
- const importableVisibleRids = visibleNodes
- .filter(isImportableSharedNode)
- .map((node) => node.rid);
+ const visibleRids = visibleNodes.map((node) => node.rid);
const allVisibleSelected =
- importableVisibleRids.length > 0 &&
- importableVisibleRids.every((rid) => selectedRids.has(rid));
- const someVisibleSelected = importableVisibleRids.some((rid) =>
- selectedRids.has(rid),
- );
+ visibleRids.length > 0 && visibleRids.every((rid) => selectedRids.has(rid));
+ const someVisibleSelected = visibleRids.some((rid) => selectedRids.has(rid));
const toggleNodeSelected = (rid: string): void => {
setSelectedRids((previous) => {
@@ -231,9 +223,8 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
const toggleAllVisibleSelected = (): void => {
setSelectedRids((previous) => {
const next = new Set(previous);
- if (allVisibleSelected)
- importableVisibleRids.forEach((rid) => next.delete(rid));
- else importableVisibleRids.forEach((rid) => next.add(rid));
+ if (allVisibleSelected) visibleRids.forEach((rid) => next.delete(rid));
+ else visibleRids.forEach((rid) => next.add(rid));
return next;
});
};
@@ -360,10 +351,10 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
|
diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
index be20076ee..aa52bf039 100644
--- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
+++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
@@ -63,6 +63,16 @@ const sharedNode: SharedNode = {
directMetadata: null,
};
+const roamSharedNode: SharedNode = {
+ ...sharedNode,
+ rid: "https://roamresearch.com/#/app/source-graph/node-2",
+ sourceLocalId: "node-2",
+ spaceId: 21,
+ spaceName: "Source graph",
+ spaceUri: "https://roamresearch.com/#/app/source-graph",
+ platform: "Roam",
+};
+
const FULL_MARKDOWN = [
"---",
"nodeTypeId: evidence",
@@ -335,20 +345,82 @@ describe("materializeSharedNode", () => {
expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled();
});
- it("rejects a non-Obsidian source before fetching content", async () => {
- const { client, from } = clientWithFullContent({ text: FULL_MARKDOWN });
+ it("imports a Roam-origin node and strips the duplicated title heading", async () => {
+ const { client } = clientWithFullContent({
+ text: `# ${roamSharedNode.title}\n\n- REM sleep improves recall\n`,
+ contentType: "text/roam+markdown",
+ });
+
+ await expect(
+ materializeSharedNode({ client, sharedNode: roamSharedNode }),
+ ).resolves.toEqual({
+ success: true,
+ action: "created",
+ pageUid: GENERATED_PAGE_UID,
+ sourceModifiedAt: roamSharedNode.lastModified,
+ sourceNodeRid: roamSharedNode.rid,
+ });
+ expect(pageFromMarkdown).toHaveBeenCalledWith({
+ page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID },
+ "markdown-string": "- REM sleep improves recall",
+ });
+ });
+
+ it("keeps a first line that does not match the shared title exactly", async () => {
+ const { client } = clientWithFullContent({
+ text: "# Some other heading\n\n- body",
+ contentType: "text/roam+markdown",
+ });
const result = await materializeSharedNode({
client,
- sharedNode: { ...sharedNode, platform: "Roam" },
+ sharedNode: roamSharedNode,
+ });
+
+ expect(result.success).toBe(true);
+ expect(pageFromMarkdown).toHaveBeenCalledWith({
+ page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID },
+ "markdown-string": "# Some other heading\n\n- body",
+ });
+ });
+
+ it("creates a title-only page when Roam full content is only the heading", async () => {
+ const { client } = clientWithFullContent({
+ text: `# ${roamSharedNode.title}\n`,
+ contentType: "text/roam+markdown",
+ });
+
+ const result = await materializeSharedNode({
+ client,
+ sharedNode: roamSharedNode,
+ });
+
+ expect(result.success).toBe(true);
+ expect(pageCreate).toHaveBeenCalledWith({
+ page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID },
+ });
+ expect(pageFromMarkdown).not.toHaveBeenCalled();
+ });
+
+ it("rejects Obsidian markdown on a Roam-origin node", async () => {
+ const { client } = clientWithFullContent({
+ text: `# ${roamSharedNode.title}\n\n- body`,
+ contentType: "text/obsidian+markdown",
+ });
+
+ const result = await materializeSharedNode({
+ client,
+ sharedNode: roamSharedNode,
});
expect(result).toMatchObject({
success: false,
- error: { stage: "validate-input" },
+ error: { stage: "fetch-content" },
});
- expect(result.success === false && result.error.message).toContain("Roam");
- expect(from).not.toHaveBeenCalled();
+ expect(result.success === false && result.error.message).toContain(
+ "text/roam+markdown",
+ );
+ expect(pageFromMarkdown).not.toHaveBeenCalled();
});
it("rejects a source identifier that is not a RID", async () => {
diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts
index 9c9300e39..6e36569e6 100644
--- a/apps/roam/src/utils/materializeSharedNode.ts
+++ b/apps/roam/src/utils/materializeSharedNode.ts
@@ -1,6 +1,7 @@
import {
contentTypes,
stripFrontmatter,
+ stripTitleHeading,
trimBlankLines,
} from "@repo/content-model";
import type { DGSupabaseClient } from "@repo/database/lib/client";
@@ -110,11 +111,6 @@ const failure = ({
const validateSharedNode = (
sharedNode: SharedNode,
): { error: string } | { sourceModifiedAt: string; title: string } => {
- if (sharedNode.platform !== "Obsidian")
- return {
- error: `Materialization only supports Obsidian-origin nodes, and this node comes from ${sharedNode.platform}`,
- };
-
if (!isRid(sharedNode.rid))
return { error: `Source node RID "${sharedNode.rid}" is not a RID` };
@@ -147,11 +143,19 @@ const fetchFullMarkdown = async ({
.maybeSingle();
if (error) return { error: error.message };
if (!data?.text) return { markdown: "" };
- if (data.content_type !== contentTypes.obsidianMarkdown)
+ const expectedContentType =
+ sharedNode.platform === "Roam"
+ ? contentTypes.roamMarkdown
+ : contentTypes.obsidianMarkdown;
+ if (data.content_type !== expectedContentType)
return {
- error: `Unsupported full content type "${data.content_type}" — expected "${contentTypes.obsidianMarkdown}"`,
+ error: `Unsupported full content type "${data.content_type}" — expected "${expectedContentType}"`,
};
- const markdown = trimBlankLines(stripFrontmatter(data.text));
+ const withoutPreamble =
+ sharedNode.platform === "Roam"
+ ? stripTitleHeading({ markdown: data.text, title: sharedNode.title })
+ : stripFrontmatter(data.text);
+ const markdown = trimBlankLines(withoutPreamble);
return { markdown: markdown.trim() ? markdown : "" };
};
diff --git a/packages/content-model/src/__tests__/text.test.ts b/packages/content-model/src/__tests__/text.test.ts
index ed05450ea..6d446abe3 100644
--- a/packages/content-model/src/__tests__/text.test.ts
+++ b/packages/content-model/src/__tests__/text.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
normalizeLineEndings,
stripFrontmatter,
+ stripTitleHeading,
trimBlankLines,
} from "../text/index.js";
@@ -23,6 +24,36 @@ describe("trimBlankLines", () => {
});
});
+describe("stripTitleHeading", () => {
+ it("strips a title heading first line and following blank lines", () => {
+ expect(
+ stripTitleHeading({ markdown: "# Title\n\nbody", title: "Title" }),
+ ).toBe("body");
+ });
+
+ it("leaves markdown whose first line differs from the title", () => {
+ expect(
+ stripTitleHeading({ markdown: "# Other\n\nbody", title: "Title" }),
+ ).toBe("# Other\n\nbody");
+ });
+
+ it("requires an exact match, not a prefix", () => {
+ expect(
+ stripTitleHeading({ markdown: "# Title extra\nbody", title: "Title" }),
+ ).toBe("# Title extra\nbody");
+ });
+
+ it("returns an empty string for heading-only markdown", () => {
+ expect(stripTitleHeading({ markdown: "# Title", title: "Title" })).toBe("");
+ });
+
+ it("handles CRLF line endings", () => {
+ expect(
+ stripTitleHeading({ markdown: "# Title\r\nbody", title: "Title" }),
+ ).toBe("body");
+ });
+});
+
describe("stripFrontmatter", () => {
it("removes a leading YAML frontmatter block and following blank lines", () => {
expect(
diff --git a/packages/content-model/src/text/index.ts b/packages/content-model/src/text/index.ts
index 50f1d7281..08e96d329 100644
--- a/packages/content-model/src/text/index.ts
+++ b/packages/content-model/src/text/index.ts
@@ -4,6 +4,22 @@ export const normalizeLineEndings = (text: string): string =>
export const trimBlankLines = (text: string): string =>
text.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, "");
+export const stripTitleHeading = ({
+ markdown,
+ title,
+}: {
+ markdown: string;
+ title: string;
+}): string => {
+ const normalized = normalizeLineEndings(markdown);
+ const newlineIndex = normalized.indexOf("\n");
+ const firstLine =
+ newlineIndex === -1 ? normalized : normalized.slice(0, newlineIndex);
+ if (firstLine !== `# ${title}`) return normalized;
+ if (newlineIndex === -1) return "";
+ return normalized.slice(newlineIndex + 1).replace(/^\n+/, "");
+};
+
const FRONTMATTER_DELIMITER = "---";
export const stripFrontmatter = (markdown: string): string => {
|