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
62 changes: 62 additions & 0 deletions apps/roam/src/components/RefreshImportedNodeTitleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Button } from "@blueprintjs/core";
import posthog from "posthog-js";
import React, { useState } from "react";
import renderToast from "roamjs-components/components/Toast";
import { handleTitleAdditions } from "~/utils/handleTitleAdditions";
import { refreshImportedNode } from "~/utils/refreshImportedNode";

const REFRESH_TITLE_BUTTON_ATTRIBUTE =
"data-roamjs-refresh-imported-node-title-button";

const RefreshImportedNodeTitleButton = ({
uid,
}: {
uid: string;
}): JSX.Element => {
const [refreshing, setRefreshing] = useState(false);

const refresh = async (): Promise<void> => {
setRefreshing(true);
try {
const result = await refreshImportedNode({ pageUid: uid });
renderToast({
id: result.success
? "refresh-imported-node-success"
: "refresh-imported-node-failed",
intent: result.success ? "success" : "danger",
content: result.message,
});
} finally {
setRefreshing(false);
}
};

return (
<Button
text="Refresh"
icon="refresh"
minimal
outlined
loading={refreshing}
onClick={() => {
posthog.capture("Refresh Imported Node: Page Title Button Triggered", {
pageUid: uid,
});
void refresh();
}}
/>
);
};

export const renderRefreshImportedNodeTitleButton = ({
h1,
uid,
}: {
h1: HTMLHeadingElement;
uid: string;
}): void => {
if (h1.getAttribute(REFRESH_TITLE_BUTTON_ATTRIBUTE) === uid) return;

h1.setAttribute(REFRESH_TITLE_BUTTON_ATTRIBUTE, uid);
handleTitleAdditions(h1, <RefreshImportedNodeTitleButton uid={uid} />);
};
26 changes: 26 additions & 0 deletions apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,32 @@ describe("materializeSharedNode", () => {
expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled();
});

it("force-updates an imported page whose source has not changed", async () => {
const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title);
mockedReadImportedSourceIdentity.mockReturnValue({
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});

await expect(
materializeSharedNode({ client, sharedNode, force: true }),
).resolves.toEqual({
success: true,
action: "updated",
pageUid: EXISTING_PAGE_UID,
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});
expect(blockFromMarkdown).toHaveBeenCalled();
expect(mockedWriteImportedSourceIdentity).toHaveBeenCalledWith({
pageUid: EXISTING_PAGE_UID,
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});
});

it("updates an imported page whose source changed since the import", async () => {
const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
Expand Down
187 changes: 187 additions & 0 deletions apps/roam/src/utils/__tests__/refreshImportedNode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import {
getSharedNodeByRid,
type SharedNode,
} from "@repo/database/lib/sharedNodes";
import { readImportedSourceIdentity } from "~/utils/importedSourceIdentity";
import internalError from "~/utils/internalError";
import { materializeSharedNode } from "~/utils/materializeSharedNode";
import { refreshImportedNode } from "~/utils/refreshImportedNode";
import { getLoggedInClient } from "~/utils/supabaseContext";

vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({
default: vi.fn(),
}));
vi.mock("@repo/database/lib/sharedNodes", () => ({
getSharedNodeByRid: vi.fn(),
}));
vi.mock("~/utils/importedSourceIdentity", () => ({
findImportedNodeUidBySourceRid: vi.fn(),
readImportedSourceIdentity: vi.fn(),
writeImportedSourceIdentity: vi.fn(),
}));
vi.mock("~/utils/internalError", () => ({ default: vi.fn() }));
vi.mock("~/utils/materializeSharedNode", async (importOriginal) => ({
...(await importOriginal<typeof import("~/utils/materializeSharedNode")>()),
materializeSharedNode: vi.fn(),
}));
vi.mock("~/utils/supabaseContext", () => ({
getLoggedInClient: vi.fn(),
}));

const mockedGetPageTitleByPageUid = vi.mocked(getPageTitleByPageUid);
const mockedGetSharedNodeByRid = vi.mocked(getSharedNodeByRid);
const mockedReadImportedSourceIdentity = vi.mocked(readImportedSourceIdentity);
const mockedInternalError = vi.mocked(internalError);
const mockedMaterializeSharedNode = vi.mocked(materializeSharedNode);
const mockedGetLoggedInClient = vi.mocked(getLoggedInClient);

const PAGE_UID = "imported-page-uid";
const LOCAL_TITLE = "EVD - old local title";
const OTHER_PAGE_TITLE = "EVD - duplicate page";

const client = {} as DGSupabaseClient;

const sharedNode: SharedNode = {
rid: "orn:obsidian.note:vault-a/node-1",
sourceLocalId: "node-1",
spaceId: 20,
spaceName: "Research vault",
spaceUri: "obsidian:vault-a",
platform: "Obsidian",
title: "EVD - REM sleep and recall",
created: "2026-06-14T12:30:00.000Z",
lastModified: "2026-06-14T15:00:00.000Z",
authorId: 7,
directMetadata: null,
};

beforeEach(() => {
vi.clearAllMocks();
mockedGetPageTitleByPageUid.mockImplementation((uid) =>
uid === PAGE_UID ? LOCAL_TITLE : OTHER_PAGE_TITLE,
);
mockedReadImportedSourceIdentity.mockReturnValue({
sourceModifiedAt: "2026-06-14T12:00:00.000Z",
sourceNodeRid: sharedNode.rid,
});
mockedGetLoggedInClient.mockResolvedValue(client);
mockedGetSharedNodeByRid.mockResolvedValue(sharedNode);
mockedMaterializeSharedNode.mockResolvedValue({
success: true,
action: "updated",
pageUid: PAGE_UID,
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});
});

describe("refreshImportedNode", () => {
it("refreshes the page from its stored source identity", async () => {
await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: true,
message: 'Refreshed "EVD - REM sleep and recall" from Research vault.',
});
expect(mockedGetSharedNodeByRid).toHaveBeenCalledWith({
client,
rid: sharedNode.rid,
});
expect(mockedMaterializeSharedNode).toHaveBeenCalledWith({
client,
sharedNode,
force: true,
});
expect(mockedInternalError).not.toHaveBeenCalled();
});

it("fails when the page has no stored source identity", async () => {
mockedReadImportedSourceIdentity.mockReturnValue(undefined);

await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: false,
message: `"${LOCAL_TITLE}" has no stored source identity, so it cannot be refreshed.`,
});
expect(mockedGetLoggedInClient).not.toHaveBeenCalled();
expect(mockedMaterializeSharedNode).not.toHaveBeenCalled();
expect(mockedInternalError).not.toHaveBeenCalled();
});

it("fails when the database client is unavailable", async () => {
mockedGetLoggedInClient.mockResolvedValue(null);

await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: false,
message: "Could not connect to shared persistence.",
});
expect(mockedGetSharedNodeByRid).not.toHaveBeenCalled();
expect(mockedInternalError).not.toHaveBeenCalled();
});

it("fails when the source node is no longer shared", async () => {
mockedGetSharedNodeByRid.mockResolvedValue(null);

await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: false,
message: `The source of "${LOCAL_TITLE}" is no longer shared with your groups, so it cannot be refreshed.`,
});
expect(mockedMaterializeSharedNode).not.toHaveBeenCalled();
expect(mockedInternalError).not.toHaveBeenCalled();
});

it("reports the materialization failure message", async () => {
mockedMaterializeSharedNode.mockResolvedValue({
success: false,
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
error: {
message: 'Failed to replace the content of "EVD - old local title"',
stage: "replace-page-content",
},
});

await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: false,
message: 'Failed to replace the content of "EVD - old local title"',
});
expect(mockedInternalError).toHaveBeenCalledTimes(1);
expect(mockedInternalError.mock.calls[0]?.[0]).toMatchObject({
type: "Imported node refresh failed",
context: {
operation: "refresh-imported-node",
pageUid: PAGE_UID,
stage: "replace-page-content",
},
});
});

it("fails when a different page linked to the source was refreshed", async () => {
mockedMaterializeSharedNode.mockResolvedValue({
success: true,
action: "updated",
pageUid: "other-page-uid",
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});

await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: false,
message: `A different page ("${OTHER_PAGE_TITLE}") is linked to the same source and was refreshed instead.`,
});
});

it("reports an unexpected error", async () => {
const thrown = new Error("network down");
mockedGetSharedNodeByRid.mockRejectedValue(thrown);

await expect(refreshImportedNode({ pageUid: PAGE_UID })).resolves.toEqual({
success: false,
message: "Could not refresh this page: network down",
});
expect(mockedInternalError).toHaveBeenCalledTimes(1);
const reported = mockedInternalError.mock.calls[0]?.[0];
expect(reported?.error).toBe(thrown);
expect(reported?.type).toBe("Imported node refresh failed");
});
});
9 changes: 7 additions & 2 deletions apps/roam/src/utils/initializeObserversAndListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { mountLeftSidebar } from "~/components/LeftSidebarView";
import { getCleanTagText } from "~/components/settings/NodeConfig";
import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors";
import { renderPublishNodeTitleButton } from "~/components/PublishNodeTitleButton";
import { renderRefreshImportedNodeTitleButton } from "~/components/RefreshImportedNodeTitleButton";
import { readImportedSourceIdentity } from "~/utils/importedSourceIdentity";
import { renderCanvasEmbed } from "~/components/canvas/CanvasEmbed";
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
Expand Down Expand Up @@ -120,10 +122,13 @@ export const initObservers = ({
snapshot: settings,
});

const sharingEnabled =
settings.featureFlags[FEATURE_FLAG_KEYS.enableNodeSharing];
if (sharingEnabled && uid && readImportedSourceIdentity(uid)) {
renderRefreshImportedNodeTitleButton({ h1, uid });
}
const isDiscourseNode = node && node.backedBy !== "default";
if (isDiscourseNode) {
const sharingEnabled =
settings.featureFlags[FEATURE_FLAG_KEYS.enableNodeSharing];
if (sharingEnabled && node.backedBy === "user") {
renderPublishNodeTitleButton({
h1,
Expand Down
3 changes: 3 additions & 0 deletions apps/roam/src/utils/materializeSharedNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,11 @@ const updateImportedPage = async ({
export const materializeSharedNode = async ({
client,
sharedNode,
force = false,
}: {
client: DGSupabaseClient;
sharedNode: SharedNode;
force?: boolean;
}): Promise<MaterializeSharedNodeResult> => {
const rawIdentity: SourceIdentity = {
sourceModifiedAt: sharedNode.lastModified,
Expand Down Expand Up @@ -331,6 +333,7 @@ export const materializeSharedNode = async ({
}

if (
!force &&
importedPageUid &&
storedIdentity &&
isImportUpToDate({
Expand Down
Loading