Skip to content
Merged
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
114 changes: 114 additions & 0 deletions apps/obsidian/src/utils/__tests__/importedNodeContent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
type ImportedNodeContentRow,
resolveImportedNodeContent,
} from "~/utils/importedNodeContent";

const directRow = (
overrides: Partial<ImportedNodeContentRow> = {},
): ImportedNodeContentRow => ({
variant: "direct",
text: "Note title",
created: "2026-01-01T00:00:00",
last_modified: "2026-01-02T00:00:00",
author_id: 7,
metadata: {},
...overrides,
});

const fullRow = (
overrides: Partial<ImportedNodeContentRow> = {},
): ImportedNodeContentRow => ({
variant: "full",
text: "body",
created: "2026-01-01T00:00:00",
last_modified: "2026-01-02T00:00:00",
author_id: 7,
metadata: {},
...overrides,
});

describe("resolveImportedNodeContent", () => {
it("pairs the direct title with the full body", () => {
expect(resolveImportedNodeContent([directRow(), fullRow()])).toMatchObject({
fileName: "Note title",
content: "body",
authorId: 7,
createdAt: new Date("2026-01-01T00:00:00Z").valueOf(),
modifiedAt: new Date("2026-01-02T00:00:00Z").valueOf(),
});
});

it("accepts an empty body, which a titled node with no content publishes", () => {
expect(
resolveImportedNodeContent([directRow(), fullRow({ text: "" })]),
).toMatchObject({ fileName: "Note title", content: "" });
});

it("rejects a full row whose text is absent", () => {
expect(
resolveImportedNodeContent([directRow(), fullRow({ text: null })]),
).toBeNull();
});

it("rejects a node with no full row", () => {
expect(resolveImportedNodeContent([directRow()])).toBeNull();
});

it("rejects a node with no title", () => {
expect(
resolveImportedNodeContent([directRow({ text: "" }), fullRow()]),
).toBeNull();
});

it("rejects a full row with no timestamps", () => {
expect(
resolveImportedNodeContent([
directRow(),
fullRow({ created: null, last_modified: null }),
]),
).toBeNull();
});

it("falls back to the direct row's author when the full row has none", () => {
expect(
resolveImportedNodeContent([directRow(), fullRow({ author_id: null })]),
).toMatchObject({ authorId: 7 });
});

it("rejects a node with no author on either row", () => {
expect(
resolveImportedNodeContent([
directRow({ author_id: null }),
fullRow({ author_id: null }),
]),
).toBeNull();
});

it("reads the source vault path from the direct row's metadata", () => {
expect(
resolveImportedNodeContent([
directRow({ metadata: { filePath: "Notes/note.md" } }),
fullRow(),
]),
).toMatchObject({ filePath: "Notes/note.md" });
});

it("leaves the path undefined when the metadata is an array", () => {
expect(
resolveImportedNodeContent([
directRow({ metadata: ["Notes/note.md"] }),
fullRow(),
]),
).toMatchObject({ filePath: undefined });
});

it("leaves the path undefined when the metadata does not carry one", () => {
expect(
resolveImportedNodeContent([
directRow({ metadata: { filePath: 3 } }),
fullRow(),
]),
).toMatchObject({ filePath: undefined });
});
});
50 changes: 6 additions & 44 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Json } from "@repo/database/dbTypes";
import matter from "gray-matter";
import { App, Notice, TFile } from "obsidian";
import type { DGSupabaseClient } from "@repo/database/lib/client";
Expand Down Expand Up @@ -32,6 +31,10 @@ import {
import { createTemplateFile } from "./templates";
import { resolveFolderForSpaceUri } from "./importFolderMetadata";
import { getNodeTypeById, isAcceptedSchema } from "./typeUtils";
import {
type ImportedNodeContent,
resolveImportedNodeContent,
} from "./importedNodeContent";
import { decorateTitle } from "@repo/database/lib/decorateTitle";

type PublishedNode = {
Expand Down Expand Up @@ -261,14 +264,7 @@ const fetchNodeContentForImport = async ({
client: DGSupabaseClient;
spaceId: number;
nodeInstanceId: string;
}): Promise<{
fileName: string;
content: string;
createdAt: number;
modifiedAt: number;
authorId: number;
filePath?: string;
} | null> => {
}): Promise<ImportedNodeContent | null> => {
const { data, error } = await client
.from("my_contents")
.select("text, created, last_modified, variant, metadata, author_id")
Expand All @@ -281,41 +277,7 @@ const fetchNodeContentForImport = async ({
return null;
}

const rows = (data ?? []) as Array<{
text: string | null;
created: string | null;
last_modified: string | null;
author_id: number | null;
variant: string | null;
metadata: Json;
}>;
const direct = rows.find((r) => r.variant === "direct");
const full = rows.find((r) => r.variant === "full");
const authorId = full?.author_id ?? direct?.author_id ?? null;

if (
!direct?.text ||
!full?.text ||
full.created === null ||
full.last_modified === null ||
authorId === null
) {
return null;
}

const filePath: string | undefined =
typeof direct.metadata === "object" &&
typeof (direct.metadata as Record<string, any>).filePath === "string"
? (direct.metadata as Record<string, any>).filePath
: undefined;
return {
fileName: direct.text,
content: full.text,
createdAt: new Date(full.created + "Z").valueOf(),
modifiedAt: new Date(full.last_modified + "Z").valueOf(),
filePath,
authorId,
};
return resolveImportedNodeContent(data ?? []);
};

type NodeTypeSchemaForInstance = {
Expand Down
54 changes: 54 additions & 0 deletions apps/obsidian/src/utils/importedNodeContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Json } from "@repo/database/dbTypes";

export type ImportedNodeContentRow = {
text: string | null;
created: string | null;
last_modified: string | null;
author_id: number | null;
variant: string | null;
metadata: Json;
};

export type ImportedNodeContent = {
fileName: string;
content: string;
createdAt: number;
modifiedAt: number;
authorId: number;
filePath?: string;
};

const readFilePath = (metadata: Json): string | undefined => {
if (typeof metadata !== "object" || metadata === null) return undefined;
const filePath = (metadata as Record<string, unknown>).filePath;
return typeof filePath === "string" ? filePath : undefined;
};

// An empty `full` body is valid: a titled node with no content publishes one,
// so only an absent row or a null text rejects the node.
export const resolveImportedNodeContent = (
rows: ImportedNodeContentRow[],
): ImportedNodeContent | null => {
const direct = rows.find((row) => row.variant === "direct");
const full = rows.find((row) => row.variant === "full");
const authorId = full?.author_id ?? direct?.author_id ?? null;

if (
!direct?.text ||
full?.text == null ||
full.created === null ||
full.last_modified === null ||
authorId === null
) {
return null;
}

return {
fileName: direct.text,
content: full.text,
createdAt: new Date(full.created + "Z").valueOf(),
modifiedAt: new Date(full.last_modified + "Z").valueOf(),
filePath: readFilePath(direct.metadata),
authorId,
Comment thread
maparent marked this conversation as resolved.
};
};
42 changes: 42 additions & 0 deletions apps/roam/src/utils/__tests__/buildFullMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import type { TreeNode } from "roamjs-components/types";

vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({
default: () => ({ children: [] }),
}));
vi.mock("roamjs-components/queries/getPageViewType", () => ({
default: () => "bullet",
}));
vi.mock("~/utils/pageToMarkdown", () => ({
toMarkdown: ({ c }: { c: TreeNode }) => `- ${c.text}`,
}));

// Runs before the imports below: getDiscourseNodes calls generateUID at module load.
vi.hoisted(() => {
(globalThis as { window?: unknown }).window = {
roamAlphaAPI: { util: { generateUID: () => "someUid" } },
};
});

import { buildFullMarkdown } from "~/utils/roamToCrossAppConverters";

const block = (text: string): TreeNode =>
({ text, children: [], order: 0, uid: "" }) as unknown as TreeNode;

describe("buildFullMarkdown", () => {
it("emits the body without the page title", () => {
expect(
buildFullMarkdown({ blocks: [block("first"), block("second")] }),
).toBe("- first\n- second\n");
});

it("emits an empty string for a page with no blocks", () => {
expect(buildFullMarkdown({ blocks: [] })).toBe("");
});

it("ignores blocks that are empty and childless", () => {
expect(buildFullMarkdown({ blocks: [block(""), block("kept")] })).toBe(
"- kept\n",
);
});
});
42 changes: 30 additions & 12 deletions apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,9 +833,9 @@ describe("materializeSharedNode", () => {
});
});

it("strips the Roam heading by the source title while decorating the page title", async () => {
it("keeps the Roam body intact while decorating the page title", async () => {
const { client } = clientWithFullContent({
text: `# ${roamSharedNode.title}\n\n- REM sleep improves recall`,
text: "- REM sleep improves recall",
contentType: "text/roam+markdown",
});

Expand Down Expand Up @@ -989,9 +989,9 @@ describe("materializeSharedNode", () => {
expect(mockedImportNodeAssets).toHaveBeenCalled();
});

it("imports a Roam-origin node and strips the duplicated title heading", async () => {
it("imports a Roam-origin node body verbatim", async () => {
const { client } = clientWithFullContent({
text: `# ${roamSharedNode.title}\n\n- REM sleep improves recall\n`,
text: "- REM sleep improves recall\n",
contentType: "text/roam+markdown",
});

Expand All @@ -1015,7 +1015,7 @@ describe("materializeSharedNode", () => {
it("brackets a media embed's URL before handing the markdown to Roam", async () => {
const embed = `{{[[pdf]]: ${ASSET_URL}}}`;
const { client } = clientWithFullContent({
text: `# ${roamSharedNode.title}\n\n${embed}\n`,
text: `${embed}\n`,
contentType: "text/roam+markdown",
});

Expand All @@ -1034,7 +1034,7 @@ describe("materializeSharedNode", () => {
it("brackets a media embed's URL when replacing an imported page too", async () => {
const embed = `{{[[audio]]: ${ASSET_URL}}}`;
const { client } = clientWithFullContent({
text: `# ${roamSharedNode.title}\n\n${embed}\n`,
text: `${embed}\n`,
contentType: "text/roam+markdown",
});
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
Expand All @@ -1052,9 +1052,9 @@ describe("materializeSharedNode", () => {
});
});

it("keeps a first line that does not match the shared title exactly", async () => {
it("normalizes CRLF in Roam body content", async () => {
const { client } = clientWithFullContent({
text: "# Some other heading\n\n- body",
text: "- first\r\n\r\n- second\r\n",
contentType: "text/roam+markdown",
});

Expand All @@ -1066,13 +1066,31 @@ describe("materializeSharedNode", () => {
expect(result.success).toBe(true);
expect(pageFromMarkdown).toHaveBeenCalledWith({
page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID },
"markdown-string": "# Some other heading\n\n- body",
"markdown-string": "- first\n\n- second",
});
});

it("creates a title-only page when Roam full content is only the heading", async () => {
it("keeps a leading heading that repeats the title, since the body owns it", async () => {
const { client } = clientWithFullContent({
text: `# ${roamSharedNode.title}\n`,
text: `# ${roamSharedNode.title}\n\n- body`,
contentType: "text/roam+markdown",
});

const result = await materializeSharedNode({
client,
sharedNode: roamSharedNode,
});

expect(result.success).toBe(true);
expect(pageFromMarkdown).toHaveBeenCalledWith({
page: { title: roamSharedNode.title, uid: GENERATED_PAGE_UID },
"markdown-string": `# ${roamSharedNode.title}\n\n- body`,
});
});

it("creates a title-only page when the Roam body is empty", async () => {
const { client } = clientWithFullContent({
text: "",
contentType: "text/roam+markdown",
});

Expand All @@ -1090,7 +1108,7 @@ describe("materializeSharedNode", () => {

it("rejects Obsidian markdown on a Roam-origin node", async () => {
const { client } = clientWithFullContent({
text: `# ${roamSharedNode.title}\n\n- body`,
text: "- body",
contentType: "text/obsidian+markdown",
});

Expand Down
Loading
Loading