Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 8 additions & 17 deletions apps/roam/src/components/DiscoverSharedNodesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,7 +49,7 @@ const SharedNodeRow = ({
aria-label={`Select ${node.title}`}
checked={selected}
className="m-0"
disabled={selectionDisabled || !isImportableSharedNode(node)}
disabled={selectionDisabled}
onChange={onToggleSelected}
/>
</td>
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
});
};
Expand Down Expand Up @@ -360,10 +351,10 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
<tr>
<th>
<Checkbox
aria-label="Select all importable nodes"
aria-label="Select all nodes"
checked={allVisibleSelected}
className="m-0"
disabled={importing || importableVisibleRids.length === 0}
disabled={importing || visibleRids.length === 0}
indeterminate={!allVisibleSelected && someVisibleSelected}
onChange={toggleAllVisibleSelected}
/>
Expand Down
84 changes: 78 additions & 6 deletions apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand Down
20 changes: 12 additions & 8 deletions apps/roam/src/utils/materializeSharedNode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
contentTypes,
stripFrontmatter,
stripTitleHeading,
trimBlankLines,
} from "@repo/content-model";
import type { DGSupabaseClient } from "@repo/database/lib/client";
Expand Down Expand Up @@ -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` };

Expand Down Expand Up @@ -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 : "" };
};

Expand Down
31 changes: 31 additions & 0 deletions packages/content-model/src/__tests__/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
normalizeLineEndings,
stripFrontmatter,
stripTitleHeading,
trimBlankLines,
} from "../text/index.js";

Expand All @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions packages/content-model/src/text/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down