Skip to content

[ENG-1858] Materialize Obsidian-origin markdown into Roam#1171

Open
sid597 wants to merge 1 commit into
eng-1856-store-source-identity-metadata-for-roam-imported-nodesfrom
eng-1858-materialize-obsidian-origin-markdown-into-roam
Open

[ENG-1858] Materialize Obsidian-origin markdown into Roam#1171
sid597 wants to merge 1 commit into
eng-1856-store-source-identity-metadata-for-roam-imported-nodesfrom
eng-1858-materialize-obsidian-origin-markdown-into-roam

Conversation

@sid597

@sid597 sid597 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What

Adds the Roam materializer: turn one Obsidian-origin shared node (CrossAppNode) into a local Roam page, or update the page already imported from the same source — no duplicates — preserving imported source identity for later refresh.

  • apps/roam/src/utils/markdownToRoamBlocks.ts — pure markdown -> InputTextNode[] (strip leading YAML frontmatter; ATX headings -> Roam heading blocks clamped to 1–3; list nesting by indentation; other lines -> blocks). Wikilinks/refs pass through. Unit-tested.
  • apps/roam/src/utils/materializeSharedNode.ts — orchestration: dedup by sourceNodeRid (ENG-1856 findImportedNodeUidByRid), create new page (createPage({title, tree})) / update existing (rename if title changed, clear + rewrite body) / skip when sourceModifiedAt unchanged; title-collision guard that refuses to overwrite a non-imported local page; identity persisted via ENG-1856 writeImportedSourceIdentity. Returns a structured MaterializeResult for ENG-1859 to tally created/updated/skipped/failed.
  • __tests__/markdownToRoamBlocks.test.ts — 9 parser cases.

⚠️ Stacked PR

Base is eng-1856-... (source-identity storage), not main. ENG-1856 is a draft-dependent baseline (PR #1161, not yet merged/approved). If ENG-1856's exported surface (importedSourceIdentity.ts) changes, this branch needs a history-preserving update. Review/merge ENG-1856 first.

Open question for @sid597 (node-type mapping)

The ENG-1856 boundary note assigns node-type mapping to ENG-1858 ("mirroring Obsidian's mapNodeTypeIdToLocal"). Obsidian types a note via frontmatter; Roam types a page via its title-format regex — so "mapping" here means reconciling the source title against local Roam node formats (and possibly creating local types). That's a genuine cross-grammar design decision, so MVP0 keeps the source's (already typed) title verbatim — an imported node is recognized as its type only when the title already matches a local format — and defers active type reconciliation/creation. node.nodeType is intentionally unused for now. Want me to (a) reformat titles to the matched local format, (b) also auto-create missing local types like Obsidian, or (c) keep verbatim (current)?

MVP0 behavior / scope

  • Title collision: if a same-titled page exists that wasn't imported from this source, return failed (don't clobber local content). Richer collision UX is out of scope (issue notes it as follow-up).
  • Input boundary: takes a fully-assembled CrossAppNode (incl. content.full); fetching/assembling from my_contents is ENG-1859's job (mirrors Obsidian's fetchNodeContentForImport).
  • Not user-reachable yet: this is the engine only — no command/UI. ENG-1859 wires it behind the existing Cross-app node import enabled flag.
  • Out of scope: relations, assets, refresh-all (1860/61).

Testing

  • markdownToRoamBlocks unit-tested (9 cases). vitest isn't installed in the worktree, so it runs in CI; pure logic also proven locally via tsx (9/9).
  • materializeSharedNode needs the Roam runtime → runtime proof owed (human) + Loom owed. See proof steps in the Linear issue / worklog handoff.

Linear

ENG-1858


Open in Devin Review

Add a Roam materializer that turns one Obsidian-origin CrossAppNode into a
local page: parse content.full markdown into a Roam block tree, then create
the page or update the one already imported from the same source (matched by
sourceNodeRid, no duplicate), persisting imported source identity via the
ENG-1856 helpers so it can be re-found and refreshed.

- markdownToRoamBlocks: pure markdown -> InputTextNode[] (frontmatter strip,
  ATX headings -> Roam heading blocks, list nesting by indentation); unit-tested.
- materializeSharedNode: create / update-by-RID / skip-if-unchanged, a title-
  collision guard that won't overwrite local pages, and a structured
  MaterializeResult for ENG-1859 to tally counts.

Stacked on ENG-1856 (source-identity storage). Node-type reconciliation and the
import command/UI are intentionally deferred (node-typing design question /
ENG-1859).
@linear-code

linear-code Bot commented Jun 28, 2026

Copy link
Copy Markdown

ENG-1858

@supabase

supabase Bot commented Jun 28, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Jun 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Jun 28, 2026 5:03pm

Request Review

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +32 to +36
await Promise.all(
blocks.map((node, order) =>
createBlock({ parentUid: pageUid, node, order }),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Page blocks may appear in wrong order because parallel creation races on positional insertion

New child blocks are all created concurrently (Promise.all at apps/roam/src/utils/materializeSharedNode.ts:32-36) with explicit positional order values, so the final on-page ordering depends on the server processing sequence.

Impact: An updated or newly imported page can display its content blocks in a scrambled order.

Roam API order parameter race under concurrent writes

In replacePageBody, after all existing children are deleted the code fires every createBlock call simultaneously:

await Promise.all(
  blocks.map((node, order) =>
    createBlock({ parentUid: pageUid, node, order }),
  ),
);

The order parameter tells Roam "insert this block at position N among the parent's current children." When multiple requests arrive concurrently the server resolves each order against whatever child list exists at the moment it processes that request. If the request for order=2 is processed before order=0, position 2 is evaluated against zero existing children, producing an incorrect final arrangement.

The safe pattern (used elsewhere in Roam extension code) is sequential creation:

for (let i = 0; i < blocks.length; i++) {
  await createBlock({ parentUid: pageUid, node: blocks[i], order: i });
}

This guarantees each block sees the prior siblings already in place.

Suggested change
await Promise.all(
blocks.map((node, order) =>
createBlock({ parentUid: pageUid, node, order }),
),
);
for (let i = 0; i < blocks.length; i++) {
await createBlock({ parentUid: pageUid, node: blocks[i], order: i });
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8bec5b7a6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +63 to +66
const listItem = LIST_ITEM_RE.exec(line);
if (listItem) {
const indent = listItem[1].replace(/\t/g, " ").length;
appendBlock({ text: listItem[2] }, indent);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve list-item headings when materializing markdown

When the imported markdown contains a list item that is also an ATX heading, such as - ## Source from the repo's Roam markdown exporter (pageToMarkdown.ts places the list prefix before the heading prefix), this branch captures the whole ## Source as plain text and never sets heading. Importing or refreshing those shared nodes loses Roam section-heading formatting and leaves literal markdown markers in the block; parse listItem[2] for HEADING_RE before appending the list node.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant