diff --git a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
index a27a5f1eb..4a6b94fbb 100644
--- a/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
+++ b/apps/roam/src/components/DiscoverSharedNodesDialog.tsx
@@ -1,6 +1,7 @@
import {
Button,
Callout,
+ Checkbox,
Classes,
Dialog,
HTMLTable,
@@ -13,24 +14,54 @@ import {
} from "@blueprintjs/core";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import createOverlayRender from "roamjs-components/util/createOverlayRender";
+import type { SharedNode } from "@repo/database/lib/sharedNodes";
+import { discoverSharedNodes } from "~/utils/discoverSharedNodes";
import {
- discoverSharedNodes,
- type DiscoveredSharedNode,
-} from "~/utils/discoverSharedNodes";
+ importSharedNodes,
+ isFailedSharedNodeImport,
+ type SharedNodeImportItem,
+} from "~/utils/importSharedNodes";
import internalError from "~/utils/internalError";
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
+const IMPORT_ERROR_TYPE = "Shared node import failed";
+const IMPORT_ERROR_OPERATION = "import-shared-nodes";
+
const formatModifiedAt = (modifiedAt: string): string =>
new Date(modifiedAt).toLocaleString();
-const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
+const isImportableSharedNode = (node: SharedNode): boolean =>
+ node.platform === "Obsidian";
+
+const SharedNodeRow = ({
+ node,
+ alreadyImported,
+ selected,
+ selectionDisabled,
+ onToggleSelected,
+}: {
+ node: SharedNode;
+ alreadyImported: boolean;
+ selected: boolean;
+ selectionDisabled: boolean;
+ onToggleSelected: () => void;
+}) => (
|
- {node.sourceApp}
+
+ |
+
+ {node.platform}
|
- {node.sourceSpaceName}
+ {node.spaceName}
(
Classes.TEXT_MUTED,
"max-w-52 truncate text-xs",
].join(" ")}
- title={node.sourceSpaceId}
+ title={node.spaceUri}
>
- {node.sourceSpaceId}
+ {node.spaceUri}
|
@@ -49,24 +80,24 @@ const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
|
- {node.sourceNodeId ? (
+ {node.sourceLocalId ? (
- {node.sourceNodeId}
+ {node.sourceLocalId}
) : (
Not provided
)}
|
-
- {formatModifiedAt(node.modifiedAt)}
+ |
+ {formatModifiedAt(node.lastModified)}
|
- {node.alreadyImported ? (
+ {alreadyImported ? (
Imported
@@ -77,26 +108,72 @@ const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
|
);
+const ImportResultsSummary = ({
+ results,
+}: {
+ results: SharedNodeImportItem[];
+}) => {
+ const importedCount = results.filter(
+ (item) => item.status === "imported",
+ ).length;
+ const skippedCount = results.filter(
+ (item) => item.status === "skipped",
+ ).length;
+ const failedImports = results.filter(isFailedSharedNodeImport);
+ return (
+ 0 ? Intent.WARNING : Intent.SUCCESS}
+ title={`${importedCount} imported, ${skippedCount} skipped, ${failedImports.length} failed`}
+ >
+ {skippedCount > 0 && (
+ Skipped nodes were already up to date in this graph.
+ )}
+ {failedImports.length > 0 && (
+
+ {failedImports.map((item) => (
+ -
+ {item.sharedNode.title}:{" "}
+ {item.message}
+
+ ))}
+
+ )}
+
+ );
+};
+
const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
- const [nodes, setNodes] = useState([]);
+ const [nodes, setNodes] = useState([]);
+ const [importedRids, setImportedRids] = useState>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");
+ const [selectedRids, setSelectedRids] = useState>(new Set());
+ const [importProgress, setImportProgress] = useState<{
+ current: number;
+ total: number;
+ } | null>(null);
+ const [importResults, setImportResults] = useState<
+ SharedNodeImportItem[] | null
+ >(null);
+ const importing = importProgress !== null;
const loadNodes = useCallback(async (): Promise => {
setLoading(true);
setError("");
+ setSelectedRids(new Set());
+ setImportResults(null);
try {
const context = await getSupabaseContext();
if (!context) throw new Error("Could not connect to shared persistence.");
const client = await getLoggedInClient();
if (!client) throw new Error("Could not connect to shared persistence.");
- setNodes(
- await discoverSharedNodes({
- client,
- currentSpaceId: context.spaceId,
- }),
- );
+ const { sharedNodes, importedSourceRids } = await discoverSharedNodes({
+ client,
+ currentSpaceId: context.spaceId,
+ });
+ setNodes(sharedNodes);
+ setImportedRids(importedSourceRids);
} catch (loadError) {
internalError({
error: loadError,
@@ -123,21 +200,106 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
if (!normalizedSearch) return nodes;
return nodes.filter((node) =>
[
- node.sourceApp,
- node.sourceSpaceName,
- node.sourceSpaceId,
+ node.platform,
+ node.spaceName,
+ node.spaceUri,
node.title,
- node.sourceNodeId,
- ].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)),
+ node.sourceLocalId,
+ ].some((value) => value.toLocaleLowerCase().includes(normalizedSearch)),
);
}, [nodes, searchTerm]);
+ const importableVisibleRids = visibleNodes
+ .filter(isImportableSharedNode)
+ .map((node) => node.rid);
+ const allVisibleSelected =
+ importableVisibleRids.length > 0 &&
+ importableVisibleRids.every((rid) => selectedRids.has(rid));
+ const someVisibleSelected = importableVisibleRids.some((rid) =>
+ selectedRids.has(rid),
+ );
+
+ const toggleNodeSelected = (rid: string): void => {
+ setSelectedRids((previous) => {
+ const next = new Set(previous);
+ if (next.has(rid)) next.delete(rid);
+ else next.add(rid);
+ return next;
+ });
+ };
+
+ 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));
+ return next;
+ });
+ };
+
+ const importSelectedNodes = async (): Promise => {
+ const selectedNodes = nodes.filter((node) => selectedRids.has(node.rid));
+
+ setImportResults(null);
+ setImportProgress({ current: 0, total: selectedNodes.length });
+ try {
+ const client = await getLoggedInClient();
+ if (!client) throw new Error("Could not connect to shared persistence.");
+ const results = await importSharedNodes({
+ client,
+ sharedNodes: selectedNodes,
+ onProgress: (current, total) => setImportProgress({ current, total }),
+ });
+ setImportResults(results);
+ const newlyImportedRids = results
+ .filter((item) => item.status !== "failed")
+ .map((item) => item.sharedNode.rid);
+ setImportedRids((previous) => {
+ const next = new Set(previous);
+ newlyImportedRids.forEach((rid) => next.add(rid));
+ return next;
+ });
+ const failedImports = results.filter(isFailedSharedNodeImport);
+ setSelectedRids(
+ new Set(failedImports.map((item) => item.sharedNode.rid)),
+ );
+ if (failedImports.length > 0) {
+ internalError({
+ error: new Error(
+ `${failedImports.length} of ${results.length} shared node imports failed`,
+ ),
+ type: IMPORT_ERROR_TYPE,
+ context: {
+ operation: IMPORT_ERROR_OPERATION,
+ failureMessages: failedImports.map((item) => item.message),
+ },
+ sendEmail: false,
+ });
+ }
+ } catch (importError) {
+ internalError({
+ error: importError,
+ type: IMPORT_ERROR_TYPE,
+ context: { operation: IMPORT_ERROR_OPERATION },
+ sendEmail: false,
+ userMessage:
+ importError instanceof Error
+ ? importError.message
+ : "Could not import the selected shared nodes.",
+ });
+ } finally {
+ setImportProgress(null);
+ }
+ };
+
return (
diff --git a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts b/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
deleted file mode 100644
index 4f2667b40..000000000
--- a/apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { toDiscoveredSharedNodes } from "~/utils/discoverSharedNodes";
-import type { SharedNode } from "@repo/database/lib/sharedNodes";
-
-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,
-};
-
-describe("toDiscoveredSharedNodes", () => {
- it("maps a shared node to the exact discovered shared node shape", () => {
- expect(
- toDiscoveredSharedNodes({
- sharedNodes: [sharedNode],
- importedSourceRids: new Set([sharedNode.rid]),
- }),
- ).toEqual([
- {
- alreadyImported: true,
- modifiedAt: "2026-06-14T15:00:00.000Z",
- sourceApp: "Obsidian",
- sourceNodeId: "node-1",
- sourceNodeRid: "orn:obsidian.note:vault-a/node-1",
- sourceSpaceId: "obsidian:vault-a",
- sourceSpaceName: "Research vault",
- title: "EVD - REM sleep and recall",
- },
- ]);
- });
-
- it("matches imports by RID rather than source-local ID alone", () => {
- expect(
- toDiscoveredSharedNodes({
- sharedNodes: [sharedNode],
- importedSourceRids: new Set(["orn:obsidian.note:another-vault/node-1"]),
- })[0]?.alreadyImported,
- ).toBe(false);
- });
-});
diff --git a/apps/roam/src/utils/__tests__/importSharedNodes.test.ts b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts
new file mode 100644
index 000000000..d89d70400
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/importSharedNodes.test.ts
@@ -0,0 +1,113 @@
+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 {
+ importSharedNodes,
+ isFailedSharedNodeImport,
+} from "~/utils/importSharedNodes";
+import { materializeSharedNode } from "~/utils/materializeSharedNode";
+
+vi.mock("~/utils/materializeSharedNode", async () => {
+ const actual = await vi.importActual<
+ typeof import("~/utils/materializeSharedNode")
+ >("~/utils/materializeSharedNode");
+ return { ...actual, materializeSharedNode: vi.fn() };
+});
+
+const mockedMaterializeSharedNode = vi.mocked(materializeSharedNode);
+
+const client = {} as DGSupabaseClient;
+
+const makeSharedNode = (sourceLocalId: string): SharedNode => ({
+ rid: `orn:obsidian.note:vault-a/${sourceLocalId}`,
+ sourceLocalId,
+ spaceId: 20,
+ spaceName: "Research vault",
+ spaceUri: "obsidian:vault-a",
+ platform: "Obsidian",
+ title: `EVD - ${sourceLocalId}`,
+ created: "2026-06-14T12:30:00.000Z",
+ lastModified: "2026-06-14T15:00:00.000Z",
+ authorId: 7,
+ directMetadata: null,
+});
+
+const successResult = (
+ sharedNode: SharedNode,
+ action: "created" | "updated" | "skipped",
+) => ({
+ success: true as const,
+ action,
+ pageUid: `page-${sharedNode.sourceLocalId}`,
+ sourceModifiedAt: sharedNode.lastModified,
+ sourceNodeRid: sharedNode.rid,
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("importSharedNodes", () => {
+ it("reports one outcome per node and progress after each", async () => {
+ const sharedNodes = ["node-1", "node-2", "node-3", "node-4"].map(
+ makeSharedNode,
+ );
+ mockedMaterializeSharedNode
+ .mockResolvedValueOnce(successResult(sharedNodes[0], "created"))
+ .mockResolvedValueOnce(successResult(sharedNodes[1], "updated"))
+ .mockResolvedValueOnce(successResult(sharedNodes[2], "skipped"))
+ .mockResolvedValueOnce({
+ success: false,
+ sourceModifiedAt: sharedNodes[3].lastModified,
+ sourceNodeRid: sharedNodes[3].rid,
+ error: { message: "title collision", stage: "title-collision" },
+ });
+ const onProgress = vi.fn();
+
+ const items = await importSharedNodes({ client, sharedNodes, onProgress });
+
+ expect(items).toEqual([
+ { sharedNode: sharedNodes[0], status: "imported" },
+ { sharedNode: sharedNodes[1], status: "imported" },
+ { sharedNode: sharedNodes[2], status: "skipped" },
+ {
+ sharedNode: sharedNodes[3],
+ status: "failed",
+ message: "title collision",
+ },
+ ]);
+ expect(items.filter(isFailedSharedNodeImport)).toEqual([items[3]]);
+ expect(onProgress.mock.calls).toEqual([
+ [1, 4],
+ [2, 4],
+ [3, 4],
+ [4, 4],
+ ]);
+ expect(mockedMaterializeSharedNode).toHaveBeenNthCalledWith(1, {
+ client,
+ sharedNode: sharedNodes[0],
+ });
+ });
+
+ it("keeps importing the remaining nodes when a materialization throws", async () => {
+ const sharedNodes = ["node-1", "node-2"].map(makeSharedNode);
+ mockedMaterializeSharedNode
+ .mockRejectedValueOnce(new Error("roam api unavailable"))
+ .mockResolvedValueOnce(successResult(sharedNodes[1], "created"));
+
+ const items = await importSharedNodes({
+ client,
+ sharedNodes,
+ onProgress: vi.fn(),
+ });
+
+ expect(items).toEqual([
+ {
+ sharedNode: sharedNodes[0],
+ status: "failed",
+ message: "roam api unavailable",
+ },
+ { sharedNode: sharedNodes[1], status: "imported" },
+ ]);
+ });
+});
diff --git a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
index eb562462a..be20076ee 100644
--- a/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
+++ b/apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
@@ -7,6 +7,7 @@ import type { DGSupabaseClient } from "@repo/database/lib/client";
import type { SharedNode } from "@repo/database/lib/sharedNodes";
import {
findImportedNodeUidBySourceRid,
+ readImportedSourceIdentity,
writeImportedSourceIdentity,
} from "~/utils/importedSourceIdentity";
import { materializeSharedNode } from "~/utils/materializeSharedNode";
@@ -23,6 +24,7 @@ vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({
vi.mock("roamjs-components/writes/deleteBlock", () => ({ default: vi.fn() }));
vi.mock("~/utils/importedSourceIdentity", () => ({
findImportedNodeUidBySourceRid: vi.fn(),
+ readImportedSourceIdentity: vi.fn(),
writeImportedSourceIdentity: vi.fn(),
}));
@@ -33,6 +35,7 @@ const mockedDeleteBlock = vi.mocked(deleteBlock);
const mockedFindImportedNodeUidBySourceRid = vi.mocked(
findImportedNodeUidBySourceRid,
);
+const mockedReadImportedSourceIdentity = vi.mocked(readImportedSourceIdentity);
const mockedWriteImportedSourceIdentity = vi.mocked(
writeImportedSourceIdentity,
);
@@ -120,6 +123,7 @@ beforeEach(() => {
mockedGetShallowTreeByParentUid.mockReturnValue([]);
mockedGetPageUidByPageTitle.mockReturnValue("");
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(null);
+ mockedReadImportedSourceIdentity.mockReturnValue(undefined);
});
describe("materializeSharedNode", () => {
@@ -232,6 +236,58 @@ describe("materializeSharedNode", () => {
);
});
+ it("skips an imported page whose source has not changed", async () => {
+ const { client, from } = clientWithFullContent({ text: FULL_MARKDOWN });
+ mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
+ mockedReadImportedSourceIdentity.mockReturnValue({
+ sourceModifiedAt: sharedNode.lastModified,
+ sourceNodeRid: sharedNode.rid,
+ });
+
+ await expect(
+ materializeSharedNode({ client, sharedNode }),
+ ).resolves.toEqual({
+ success: true,
+ action: "skipped",
+ pageUid: EXISTING_PAGE_UID,
+ sourceModifiedAt: sharedNode.lastModified,
+ sourceNodeRid: sharedNode.rid,
+ });
+ expect(from).not.toHaveBeenCalled();
+ expect(blockFromMarkdown).not.toHaveBeenCalled();
+ expect(mockedDeleteBlock).not.toHaveBeenCalled();
+ expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled();
+ });
+
+ it("updates an imported page whose source changed since the import", async () => {
+ const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
+ mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
+ mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title);
+ mockedReadImportedSourceIdentity.mockReturnValue({
+ sourceModifiedAt: "2026-06-14T14:00:00.000Z",
+ sourceNodeRid: sharedNode.rid,
+ });
+
+ const result = await materializeSharedNode({ client, sharedNode });
+
+ expect(result).toMatchObject({ success: true, action: "updated" });
+ expect(blockFromMarkdown).toHaveBeenCalled();
+ });
+
+ it("updates an imported page whose stored modified time is invalid", async () => {
+ const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
+ mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
+ mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title);
+ mockedReadImportedSourceIdentity.mockReturnValue({
+ sourceModifiedAt: "not-a-date",
+ sourceNodeRid: sharedNode.rid,
+ });
+
+ const result = await materializeSharedNode({ client, sharedNode });
+
+ expect(result).toMatchObject({ success: true, action: "updated" });
+ });
+
it("renames the imported page when the source title changed", async () => {
const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts
index 13d651d81..cdf226df3 100644
--- a/apps/roam/src/utils/discoverSharedNodes.ts
+++ b/apps/roam/src/utils/discoverSharedNodes.ts
@@ -3,48 +3,21 @@ import {
listGroupSharedNodes,
type SharedNode,
} from "@repo/database/lib/sharedNodes";
-import type { Enums } from "@repo/database/dbTypes";
import { getImportedSourceRids } from "./importedSourceIdentity";
-export type DiscoveredSharedNode = {
- alreadyImported: boolean;
- modifiedAt: string;
- sourceApp: Enums<"Platform">;
- sourceNodeId?: string;
- sourceNodeRid: string;
- sourceSpaceId: string;
- sourceSpaceName: string;
- title: string;
-};
-
-export const toDiscoveredSharedNodes = ({
- sharedNodes,
- importedSourceRids,
-}: {
- sharedNodes: SharedNode[];
- importedSourceRids: ReadonlySet;
-}): DiscoveredSharedNode[] =>
- sharedNodes.map((sharedNode) => ({
- alreadyImported: importedSourceRids.has(sharedNode.rid),
- modifiedAt: sharedNode.lastModified,
- sourceApp: sharedNode.platform,
- sourceNodeId: sharedNode.sourceLocalId || undefined,
- sourceNodeRid: sharedNode.rid,
- sourceSpaceId: sharedNode.spaceUri,
- sourceSpaceName: sharedNode.spaceName,
- title: sharedNode.title,
- }));
-
export const discoverSharedNodes = async ({
client,
currentSpaceId,
}: {
client: DGSupabaseClient;
currentSpaceId: number;
-}): Promise => {
+}): Promise<{
+ sharedNodes: SharedNode[];
+ importedSourceRids: Set;
+}> => {
const [sharedNodes, importedSourceRids] = await Promise.all([
listGroupSharedNodes({ client, currentSpaceId }),
getImportedSourceRids(),
]);
- return toDiscoveredSharedNodes({ sharedNodes, importedSourceRids });
+ return { sharedNodes, importedSourceRids };
};
diff --git a/apps/roam/src/utils/importSharedNodes.ts b/apps/roam/src/utils/importSharedNodes.ts
new file mode 100644
index 000000000..9557182d0
--- /dev/null
+++ b/apps/roam/src/utils/importSharedNodes.ts
@@ -0,0 +1,53 @@
+import type { DGSupabaseClient } from "@repo/database/lib/client";
+import type { SharedNode } from "@repo/database/lib/sharedNodes";
+import {
+ getErrorMessage,
+ materializeSharedNode,
+} from "./materializeSharedNode";
+
+export type FailedSharedNodeImport = {
+ sharedNode: SharedNode;
+ status: "failed";
+ message: string;
+};
+
+export type SharedNodeImportItem =
+ | { sharedNode: SharedNode; status: "imported" | "skipped" }
+ | FailedSharedNodeImport;
+
+export const isFailedSharedNodeImport = (
+ item: SharedNodeImportItem,
+): item is FailedSharedNodeImport => item.status === "failed";
+
+export const importSharedNodes = async ({
+ client,
+ sharedNodes,
+ onProgress,
+}: {
+ client: DGSupabaseClient;
+ sharedNodes: SharedNode[];
+ onProgress: (current: number, total: number) => void;
+}): Promise => {
+ const items: SharedNodeImportItem[] = [];
+ for (const sharedNode of sharedNodes) {
+ try {
+ const result = await materializeSharedNode({ client, sharedNode });
+ items.push(
+ result.success
+ ? {
+ sharedNode,
+ status: result.action === "skipped" ? "skipped" : "imported",
+ }
+ : { sharedNode, status: "failed", message: result.error.message },
+ );
+ } catch (error) {
+ items.push({
+ sharedNode,
+ status: "failed",
+ message: getErrorMessage(error),
+ });
+ }
+ onProgress(items.length, sharedNodes.length);
+ }
+ return items;
+};
diff --git a/apps/roam/src/utils/materializeSharedNode.ts b/apps/roam/src/utils/materializeSharedNode.ts
index 5646db8d1..9c9300e39 100644
--- a/apps/roam/src/utils/materializeSharedNode.ts
+++ b/apps/roam/src/utils/materializeSharedNode.ts
@@ -12,7 +12,9 @@ import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeB
import deleteBlock from "roamjs-components/writes/deleteBlock";
import {
findImportedNodeUidBySourceRid,
+ readImportedSourceIdentity,
writeImportedSourceIdentity,
+ type ImportedSourceIdentity,
} from "./importedSourceIdentity";
type MaterializationStage =
@@ -41,7 +43,7 @@ type MaterializationFailure = SourceIdentity & {
type MaterializationSuccess = SourceIdentity & {
success: true;
- action: "created" | "updated";
+ action: "created" | "updated" | "skipped";
pageUid: string;
};
@@ -67,9 +69,22 @@ type RoamMarkdownApi = {
const getRoamMarkdownApi = (): RoamMarkdownApi =>
window.roamAlphaAPI.data as unknown as RoamMarkdownApi;
-const getErrorMessage = (error: unknown): string =>
+export const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
+const isImportUpToDate = ({
+ sourceModifiedAt,
+ storedModifiedAt,
+}: {
+ sourceModifiedAt: string;
+ storedModifiedAt: string;
+}): boolean => {
+ const storedTime = Date.parse(storedModifiedAt);
+ return (
+ !Number.isNaN(storedTime) && storedTime >= Date.parse(sourceModifiedAt)
+ );
+};
+
const failure = ({
error,
identity,
@@ -295,19 +310,13 @@ export const materializeSharedNode = async ({
sourceNodeRid: sharedNode.rid,
};
- const content = await fetchFullMarkdown({ client, sharedNode }).catch(
- (error: unknown) => ({ error: getErrorMessage(error) }),
- );
- if ("error" in content)
- return failure({
- identity,
- message: `Could not fetch the content of "${sharedNode.title}" from "${sharedNode.spaceName}": ${content.error}`,
- stage: "fetch-content",
- });
-
let importedPageUid: string | null;
+ let storedIdentity: ImportedSourceIdentity | undefined;
try {
importedPageUid = await findImportedNodeUidBySourceRid(sharedNode.rid);
+ storedIdentity = importedPageUid
+ ? readImportedSourceIdentity(importedPageUid)
+ : undefined;
} catch (error) {
return failure({
error,
@@ -317,6 +326,31 @@ export const materializeSharedNode = async ({
});
}
+ if (
+ importedPageUid &&
+ storedIdentity &&
+ isImportUpToDate({
+ sourceModifiedAt: identity.sourceModifiedAt,
+ storedModifiedAt: storedIdentity.sourceModifiedAt,
+ })
+ )
+ return {
+ ...identity,
+ success: true,
+ action: "skipped",
+ pageUid: importedPageUid,
+ };
+
+ const content = await fetchFullMarkdown({ client, sharedNode }).catch(
+ (error: unknown) => ({ error: getErrorMessage(error) }),
+ );
+ if ("error" in content)
+ return failure({
+ identity,
+ message: `Could not fetch the content of "${sharedNode.title}" from "${sharedNode.spaceName}": ${content.error}`,
+ stage: "fetch-content",
+ });
+
return importedPageUid
? updateImportedPage({
identity,