-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-2219 Add an asset copy stage to Roam node materialization #1401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,10 +20,12 @@ import { | |
| type ImportedSourceIdentity, | ||
| } from "./importedSourceIdentity"; | ||
| import { getErrorMessage } from "./getErrorMessage"; | ||
| import { importNodeAssets, type AssetImportReport } from "./importNodeAssets"; | ||
|
|
||
| type MaterializationStage = | ||
| | "validate-input" | ||
| | "fetch-content" | ||
| | "copy-assets" | ||
| | "find-imported-node" | ||
| | "title-collision" | ||
| | "create-page" | ||
|
|
@@ -49,6 +51,17 @@ type MaterializationSuccess = SourceIdentity & { | |
| success: true; | ||
| action: "created" | "updated" | "skipped"; | ||
| pageUid: string; | ||
| /** | ||
| * What the asset stage did. Absent on a skipped import, which replaces no content and | ||
| * so copies nothing. An asset that could not be copied appears here rather than | ||
| * failing the node. | ||
| * | ||
| * Nothing reads it yet, and that is the intended state: `importSharedNodes` and | ||
| * `refreshImportedNode` both discard it, so a degraded asset is currently invisible to | ||
| * the user. Surfacing cross-app failures is ENG-1877's work, and this field exists so | ||
| * that ticket has a shape to read rather than a behaviour to add first. | ||
| */ | ||
| assets?: AssetImportReport; | ||
| }; | ||
|
|
||
| export type MaterializeSharedNodeResult = | ||
|
|
@@ -159,6 +172,49 @@ const fetchFullMarkdown = async ({ | |
| return { markdown: markdown.trim() ? markdown : "" }; | ||
| }; | ||
|
|
||
| /** | ||
| * The title check both import paths make, extracted so materialization can make it before | ||
| * the asset stage runs. | ||
| * | ||
| * A collision imports nothing and tells the user to rename the other page, which reads as | ||
| * a clean no-op. Running the asset stage first would owe a rollback instead, and an upload | ||
| * cannot be rolled back safely (see `mirrorAssetToRoamStorage`). The check is two | ||
| * synchronous reads, so ordering it first avoids the question. | ||
| * | ||
| * Sequencing does not solve it. `importSharedNodes` runs one node at a time, which rules | ||
| * out a race inside a single run, but nothing serializes two users importing at once. | ||
| * | ||
| * Still made again inside the two paths: they are exported behaviour in their own right, | ||
| * and the message belongs with the check rather than being duplicated at the call site. | ||
| */ | ||
| const titleCollisionFailure = ({ | ||
| identity, | ||
| importedPageUid, | ||
| title, | ||
| }: { | ||
| identity: SourceIdentity; | ||
| importedPageUid?: string; | ||
| title: string; | ||
| }): MaterializationFailure | undefined => { | ||
| if (!importedPageUid) | ||
| return getPageUidByPageTitle(title) | ||
| ? failure({ | ||
| identity, | ||
| message: `A page titled "${title}" already exists and was not imported from "${identity.sourceNodeRid}". Rename or remove that page, then import again`, | ||
| stage: "title-collision", | ||
| }) | ||
| : undefined; | ||
|
|
||
| const localTitle = getPageTitleByPageUid(importedPageUid); | ||
| if (localTitle === title || !getPageUidByPageTitle(title)) return undefined; | ||
| return failure({ | ||
| identity, | ||
| message: `Cannot rename the imported page "${localTitle}" to "${title}": another page already has that title. Rename or remove that page, then import again`, | ||
| pageUid: importedPageUid, | ||
| stage: "title-collision", | ||
| }); | ||
| }; | ||
|
|
||
| const createImportedPage = async ({ | ||
| identity, | ||
| markdown, | ||
|
|
@@ -168,12 +224,8 @@ const createImportedPage = async ({ | |
| markdown: string; | ||
| title: string; | ||
| }): Promise<MaterializeSharedNodeResult> => { | ||
| if (getPageUidByPageTitle(title)) | ||
| return failure({ | ||
| identity, | ||
| message: `A page titled "${title}" already exists and was not imported from "${identity.sourceNodeRid}". Rename or remove that page, then import again`, | ||
| stage: "title-collision", | ||
| }); | ||
| const collision = titleCollisionFailure({ identity, title }); | ||
| if (collision) return collision; | ||
|
|
||
| const pageUid = window.roamAlphaAPI.util.generateUID(); | ||
| try { | ||
|
|
@@ -233,13 +285,12 @@ const updateImportedPage = async ({ | |
| }): Promise<MaterializeSharedNodeResult> => { | ||
| const localTitle = getPageTitleByPageUid(pageUid); | ||
| const needsRename = localTitle !== title; | ||
| if (needsRename && getPageUidByPageTitle(title)) | ||
| return failure({ | ||
| identity, | ||
| message: `Cannot rename the imported page "${localTitle}" to "${title}": another page already has that title. Rename or remove that page, then import again`, | ||
| pageUid, | ||
| stage: "title-collision", | ||
| }); | ||
| const collision = titleCollisionFailure({ | ||
| identity, | ||
| importedPageUid: pageUid, | ||
| title, | ||
| }); | ||
| if (collision) return collision; | ||
|
|
||
| try { | ||
| const previousChildren = getShallowTreeByParentUid(pageUid); | ||
|
|
@@ -364,16 +415,49 @@ export const materializeSharedNode = async ({ | |
| stage: "fetch-content", | ||
| }); | ||
|
|
||
| return importedPageUid | ||
| // Before the assets, so a rejected import uploads nothing. See `titleCollisionFailure`. | ||
| const collision = titleCollisionFailure({ | ||
| identity, | ||
| importedPageUid: importedPageUid ?? undefined, | ||
| title: pageTitle, | ||
| }); | ||
| if (collision) return collision; | ||
|
|
||
| // Between fetching the content and replacing the page with it: the markdown written | ||
| // below is the rewritten one, and the copies it points at exist by then. | ||
| // | ||
| // Nothing known throws out of the stage today: it reports its per-asset failures and | ||
| // catches its reference query. This covers the residue, the link rewrite and whatever a | ||
| // later edit adds outside those guards. Without it such a throw leaves a stage-less | ||
| // rejection, which callers can only report as an unexplained error. | ||
| const assets = await importNodeAssets({ | ||
| client, | ||
| sharedNode, | ||
| markdown: content.markdown, | ||
| }).catch((error: unknown) => ({ error })); | ||
| if ("error" in assets) | ||
| return failure({ | ||
| error: assets.error, | ||
| identity, | ||
| message: `Failed to copy the assets of "${sharedNode.title}"`, | ||
| stage: "copy-assets", | ||
| }); | ||
| const { markdown, report } = assets; | ||
|
|
||
| const result = await (importedPageUid | ||
| ? updateImportedPage({ | ||
| identity, | ||
| markdown: content.markdown, | ||
| markdown, | ||
| pageUid: importedPageUid, | ||
| title: pageTitle, | ||
| }) | ||
| : createImportedPage({ | ||
| identity, | ||
| markdown: content.markdown, | ||
| markdown, | ||
| title: pageTitle, | ||
| }); | ||
| })); | ||
|
|
||
| // Carried on success only. A node that failed to import has a stage of its own to | ||
| // report, and the assets it did or did not copy are not what the reader needs. | ||
| return result.success ? { ...result, assets: report } : result; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Failed asset copies never retry When Learn moreAsset failures live in Example: An Obsidian node imports while one image download times out. Its vault link remains unresolved, but the node records the current source timestamp. Retrying the group import after connectivity recovers reports the node as skipped and never retries the image. Recommended fix: Persist asset-materialization state or a stage version with the imported source identity. Only take the freshness shortcut when both content and assets are current; leave transient asset failures retryable while relying on the asset registry to reuse successful copies. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.