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 && ( + + )} +
+ ); +}; + 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 ( void }) => { +
+ + +
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,