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
3 changes: 3 additions & 0 deletions apps/roam/src/components/settings/utils/accessors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,13 +1090,16 @@ export const createDiscourseNodeType = async ({
text,
shortcut,
format,
uid,
}: {
text: string;
shortcut: string;
format: string;
uid?: string;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings panel must not pass a uid, since Roam generates one. The importer must, to reuse the remote type id so later imports match on id. createPage already accepts an optional uid and falls back to generateUID().

}): Promise<DiscourseNode> => {
const pageUid = await createPage({
title: `${DISCOURSE_NODE_PAGE_PREFIX}${text}`,
uid,
tree: [
{ text: "Shortcut", children: [{ text: shortcut }] },
{ text: "Tag", children: [{ text: "" }] },
Expand Down
50 changes: 50 additions & 0 deletions apps/roam/src/utils/__tests__/importSharedNodes.test.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand All @@ -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",
Expand All @@ -45,6 +63,7 @@ const successResult = (

beforeEach(() => {
vi.clearAllMocks();
mockedResolveSharedNodeTypes.mockResolvedValue(new Map());
});

describe("importSharedNodes", () => {
Expand Down Expand Up @@ -89,6 +108,37 @@ describe("importSharedNodes", () => {
});
});

it("resolves node types once and gives each node the one for its schema", async () => {
const sharedNodes = [
makeSharedNode("node-1"),
{ ...makeSharedNode("node-2"), schemaId: 300 },
];
mockedResolveSharedNodeTypes.mockResolvedValue(
new Map([[sharedNodes[0].schemaId, 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
Expand Down
130 changes: 130 additions & 0 deletions apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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("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({
client,
sharedNode: decoratedSharedNode,
nodeType: { format: "[[EVD]] - {content} - {Source}" },
});

expect(result.success).toBe(true);
expect(pageFromMarkdown).toHaveBeenCalledWith({
page: { title: decoratedSharedNode.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");
Expand Down
38 changes: 38 additions & 0 deletions apps/roam/src/utils/__tests__/refreshImportedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -27,6 +29,9 @@ vi.mock("~/utils/materializeSharedNode", async (importOriginal) => ({
...(await importOriginal<typeof import("~/utils/materializeSharedNode")>()),
materializeSharedNode: vi.fn(),
}));
vi.mock("~/utils/resolveSharedNodeTypes", () => ({
resolveSharedNodeTypes: vi.fn(),
}));
vi.mock("~/utils/supabaseContext", () => ({
getLoggedInClient: vi.fn(),
}));
Expand All @@ -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";
Expand All @@ -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",
Expand All @@ -69,6 +86,7 @@ beforeEach(() => {
});
mockedGetLoggedInClient.mockResolvedValue(client);
mockedGetSharedNodeByRid.mockResolvedValue(sharedNode);
mockedResolveSharedNodeTypes.mockResolvedValue(new Map());
mockedMaterializeSharedNode.mockResolvedValue({
success: true,
action: "updated",
Expand All @@ -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.schemaId, 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);

Expand Down
Loading