Skip to content

Roam: Convert a table to a markdown table - #505

Open
cristip73 wants to merge 1 commit into
obsidianmd:masterfrom
cristip73:roam-table-conversion
Open

Roam: Convert a table to a markdown table#505
cristip73 wants to merge 1 commit into
obsidianmd:masterfrom
cristip73:roam-table-conversion

Conversation

@cristip73

@cristip73 cristip73 commented Feb 23, 2026

Copy link
Copy Markdown

Roam stores a table as a {{[[table]]}} marker whose children are the rows, each row's columns being a chain of first children. Nothing read that shape, so a table arrived as bullets: the marker on one line and the cells nested under it, tabular structure gone. Partially addresses #180.

The marker's children are now read as cells and written as a pipe table. The first row becomes the header, as Roam shows it; a short row is padded; a pipe or a newline in a cell is escaped so it cannot end the cell or the row; and cells go through the same markup rewrites as any other block.

Rebased and revised

This has been rewritten against src/formats/roam/convert.ts and now carries its recordings, so the change is visible in the diff rather than described.

Each point from the review:

  • Unbalanced marker. The brackets are one alternation, /^\{\{(\[\[table\]\]|table)\}\}$/i, so {{[[table}} no longer matches. Covered by a test.
  • Cells with more than one child. Only the first child continues the row, so anything alongside it cannot be shown. It is now a console.warn naming the cell and the count, rather than a silent drop.
  • children: [] vs undefined. The detection no longer requires children. Both go through the same path and both leave nothing behind, marker included.
  • Building the table. The separator is spliced in after the header and every row is formatted the same way.
  • The table rendered indented under its bullet. This is the one that changed the output shape. The table is now written at the left margin with a blank line either side, whatever depth the marker sat at. Obsidian does not render a pipe table inside a list item, so keeping the outline's indentation would keep the bullets tidy and leave the table as rows of text.

One thing worth flagging: timestamp accumulation moved into its own method. Cells are walked rather than recursed into, so without it a page whose most recent edit lives inside a table would come out dated before its own content.

Recordings

tests/roam/expected/help-graph-excerpt/Table.md and tests/roam/expected/small-test-graph/Theme Tester.md are the only two that move. Nothing else in either graph changes.

Eight named tests cover the shapes the recordings do not: the bare {{table}} spelling, the unbalanced marker, a short row, a pipe in a cell, a multi-line cell, a marker with no rows, and markup inside a cell.

npm test 300 passing, npm run typecheck and npm run lint:check clean, and lint:review reports the same single pre-existing error on this file as master does.

#506 has been closed. The code-block fix that was tangled up in it will come back on its own once this lands.

@tgrosinger tgrosinger 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.

Thank you for submitting this improvement! Overall it looks great, just a few suggestions.

I also noticed when running the tests/roam/small-test-graph.json, the table is still indented under the bullet point, causing it to not render correctly. Can that be fixed here too?

Image

Comment thread src/formats/roam-json.ts Outdated
const binaryRegex = /https:\/\/firebasestorage(.*?)\?alt(.*?)/;

const blockRefRegex = /(?<=\(\()\b(.*?)\b(?=\)\))/g;
const roamTableRe = /^\{\{(\[\[)?table(\]\])?\}\}$/i;

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.

The current regex has two independent optional groups:

/^\{\{(\[\[)?table(\]\])?\}\}$/i
       ^^^^        ^^^^
       group 1     group 2

Each ? makes its group independently optional, producing 4 possible matches:

Group 1 Group 2 Matches Valid Roam?
absent absent {{table}} yes
present present {{[[table]]}} yes
present absent {{[[table}} no
absent present {{table]]}} no

The proposed fix ties the brackets together as a single alternation:

/^\{\{(\[\[table\]\]|table)\}\}$/i

This only matches {{[[table]]}} or {{table}} -- brackets are always balanced.

Suggested change
const roamTableRe = /^\{\{(\[\[)?table(\]\])?\}\}$/i;
const roamTableRe = /^\{\{(\[\[table\]\]|table)\}\}$/i;

Comment thread src/formats/roam-json.ts Outdated
while (current) {
const scrubbed = await this.roamMarkupScrubber(graphFolder, attachmentsFolder, current.string || '');
cells.push(scrubbed.replace(/\|/g, '\\|'));
current = current.children?.[0];

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.

If a Roam table cell has multiple children (e.g., user added sub-items), all but the first are silently discarded with no warning. This could cause data loss without any indication to the user. Consider logging a warning when current.children.length > 1.

Comment thread src/formats/roam-json.ts Outdated
const prefix = json.heading ? '#'.repeat(json.heading) + ' ' : '';
const scrubbed = await this.roamMarkupScrubber(graphFolder, attachmentsFolder, json.string);
markdown.push(`${isChild ? indent + '* ' : indent}${prefix}${scrubbed}`);
if ('string' in json && json.string && roamTableRe.test(json.string.trim()) && json.children) {

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.

  • children: [] (truthy) enters convertRoamTable, returns '' -- table marker vanishes silently
  • children: undefined falls to the else branch and renders raw {{[[table]]}} in output

Consider having the table-detection guard not require json.children, and letting convertRoamTable handle all cases uniformly.

Comment thread src/formats/roam-json.ts Outdated
// Build pipe table
const lines: string[] = [];
// Header row
lines.push(indent + '| ' + tableData[0].join(' | ') + ' |');

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.

The formatting of the various table sections could be unified and simplified by inserting the separator row after the header:

const separator = tableData[0].map(() => '---');
tableData.splice(1, 0, separator);

const lines = tableData.map(row => indent + '| ' + row.join(' | ') + ' |');

Roam stores a table as a {{[[table]]}} marker whose children are the rows,
each row's columns being a chain of first children. Nothing read that shape,
so a table arrived as bullets - the marker on one line and the cells nested
under it, tabular structure gone.

The marker's children are now read as cells and written as a pipe table:
the first row is the header, as Roam shows it, a short row is padded, and a
pipe or a newline in a cell is escaped so it cannot end the cell or the row.
Cells go through the same markup rewrites as any other block.

The table is written at the left margin with a blank line either side,
whatever depth the marker sat at, because Obsidian does not render a pipe
table indented inside a list item - keeping the outline's indentation would
keep the bullets tidy and leave the table as rows of text.

A marker with no rows under it converts to nothing and leaves no line
behind. Timestamp accumulation moved to its own method, so cells - walked
rather than recursed into - still count towards the page's dates.
@cristip73
cristip73 force-pushed the roam-table-conversion branch from 8f74dc3 to af39677 Compare August 7, 2026 08:47
@cristip73 cristip73 changed the title Roam: Convert {{[[table]]}} blocks to Markdown pipe tables Roam: Convert a table to a markdown table Aug 7, 2026
@cristip73

Copy link
Copy Markdown
Author

Sorry for the long silence on this - thank you for the review, it was more useful than the delay suggests.

Rebased onto master and rewritten against src/formats/roam/convert.ts. All four suggestions are in, and the indentation you spotted is fixed: the table is written at the left margin now, since Obsidian will not render one inside a list item.

The test harness that arrived in the meantime made this much easier to show. Table.md and Theme Tester.md are the only recordings that move, so the before and after is in the diff rather than in a screenshot.

I have closed #506. The code-block fix that was mixed into it will come back on its own branch once this lands - and you were right about the language tags, the real problem there was Roam gluing the fences to the content, not the tags themselves.

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.

2 participants