diff --git a/src/formats/roam/convert.ts b/src/formats/roam/convert.ts index fda3ab03..0d5c4ffa 100644 --- a/src/formats/roam/convert.ts +++ b/src/formats/roam/convert.ts @@ -12,6 +12,14 @@ import { convertDateString, sanitizeFileNameKeepPath } from './utils'; const roamSpecificMarkup = ['POMO', 'word-count', 'date', 'slider', 'encrypt', 'TaoOfRoam', 'orphans', 'count', 'character-count', 'comment-button', 'query', 'streak', 'attr-table', 'mentions', 'search', 'roam/render', 'calc']; const roamSpecificMarkupRe = new RegExp(`\\{\\{(\\[\\[)?(${roamSpecificMarkup.join('|')})(\\]\\])?.*?\\}\\}(\\})?`, 'g'); +/** + * The block Roam marks a table with. + * + * The brackets are one alternation rather than two optional groups, so only + * the two spellings Roam writes match - `{{[[table}}` is not one of them. + */ +const roamTableRe = /^\{\{(\[\[table\]\]|table)\}\}$/i; + export interface RoamConverterOptions { /** The daily-note format to rewrite Roam's own date pages into. */ userDNPFormat: string; @@ -112,9 +120,15 @@ export class RoamPageConverter { return blockText; }; - async jsonToMarkdown(graphFolder: string, attachmentsFolder: string, json: RoamPage | RoamBlock, indent: string = '', isChild: boolean = false, setTitleProperty: string, createdTimestamp: number, updatedTimestamp: number): Promise { - let markdown: string[] = []; - let frontMatterYAML: string[] = []; + /** + * Fold a block's own timestamps into the page's oldest and newest. + * + * The recursion carries these down from block to block, so one reached any + * other way - a table's cells, which are walked rather than recursed into - + * has to fold its own in, or a page whose latest edit happened inside a + * table comes out dated before its own content. + */ + private accumulateTimestamps(json: RoamPage | RoamBlock, createdTimestamp: number, updatedTimestamp: number): void { // use Roam's create-time and edit-time values to set timestamps const jsonEditTime = json['edit-time']; const jsonCreateTime = json['create-time']; @@ -144,16 +158,37 @@ export class RoamPageConverter { else { this.oldestTimestamp = createdTimestamp; } + } + + async jsonToMarkdown(graphFolder: string, attachmentsFolder: string, json: RoamPage | RoamBlock, indent: string = '', isChild: boolean = false, setTitleProperty: string, createdTimestamp: number, updatedTimestamp: number): Promise { + let markdown: string[] = []; + let frontMatterYAML: string[] = []; + + this.accumulateTimestamps(json, createdTimestamp, updatedTimestamp); - if ('string' in json && json.string) { - 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())) { + // The block's children are the table's rows, so they are read as + // cells here instead of being recursed into as bullets. + const table = await this.convertRoamTable(graphFolder, attachmentsFolder, json); + if (table) markdown.push(table); } + else { + if ('string' in json && json.string) { + const prefix = json.heading ? '#'.repeat(json.heading) + ' ' : ''; + const scrubbed = await this.roamMarkupScrubber(graphFolder, attachmentsFolder, json.string); + markdown.push(`${isChild ? indent + '* ' : indent}${prefix}${scrubbed}`); + } - if (json.children) { - for (const child of json.children) { - markdown.push(await this.jsonToMarkdown(graphFolder, attachmentsFolder, child, indent + ' ', true, '', this.oldestTimestamp, this.newestTimestamp)); + if (json.children) { + for (const child of json.children) { + const converted = await this.jsonToMarkdown(graphFolder, attachmentsFolder, child, indent + ' ', true, '', this.oldestTimestamp, this.newestTimestamp); + // A table marker with no rows under it converts to nothing, + // and leaves no line behind either. Every other block keeps + // its line, empty or not, the way it always has. + if (converted || !roamTableRe.test((child.string ?? '').trim())) { + markdown.push(converted); + } + } } } @@ -192,4 +227,55 @@ export class RoamPageConverter { return markdown.join('\n'); } + + /** + * A Roam table, as a markdown pipe table. + * + * Roam stores a table as the marker block's children: each child is a row, + * and the columns of that row are its first child, that child's first + * child, and so on down a linear chain. The first row is read as the + * header, which is what Roam shows too. + * + * The table is written at the left margin with a blank line either side, + * whatever depth the marker sat at. Obsidian does not render a pipe table + * indented inside a list item, so keeping the outline's indentation here + * would keep the bullets tidy and leave the table as rows of text. + */ + private async convertRoamTable(graphFolder: string, attachmentsFolder: string, json: RoamPage | RoamBlock): Promise { + const rows: string[][] = []; + + for (const row of json.children ?? []) { + const cells: string[] = []; + + for (let cell: RoamBlock | undefined = row; cell; cell = cell.children?.[0]) { + this.accumulateTimestamps(cell, this.oldestTimestamp, this.newestTimestamp); + + if (cell.children && cell.children.length > 1) { + // Only the first child continues the row, so anything Roam + // allowed alongside it is not part of the table and cannot + // be shown. Say so rather than dropping it quietly. + console.warn(`Roam table cell "${cell.string}" has ${cell.children.length} children; only the first is read as the next column.`); + } + + const scrubbed = await this.roamMarkupScrubber(graphFolder, attachmentsFolder, cell.string ?? ''); + // A pipe ends the cell and a newline ends the row, so neither + // can survive as itself. + cells.push(scrubbed.replace(/\|/g, '\\|').replace(/\n/g, '
')); + } + + rows.push(cells); + } + + if (rows.length === 0) return ''; + + // Roam lets a row stop short; markdown wants every row the same width. + const width = Math.max(...rows.map(row => row.length)); + for (const row of rows) { + while (row.length < width) row.push(''); + } + + rows.splice(1, 0, rows[0].map(() => '---')); + + return '\n' + rows.map(row => `| ${row.join(' | ')} |`).join('\n') + '\n'; + } } diff --git a/tests/roam/convert.test.ts b/tests/roam/convert.test.ts index 28bdb7bb..82c3fbac 100644 --- a/tests/roam/convert.test.ts +++ b/tests/roam/convert.test.ts @@ -18,7 +18,7 @@ import * as nodeOs from 'node:os'; import * as nodePath from 'node:path'; import { RoamPageConverter } from '../../src/formats/roam/convert'; -import { RoamPage } from '../../src/formats/roam/models/roam-json'; +import { RoamBlock, RoamPage } from '../../src/formats/roam/models/roam-json'; import { convertDateString, sanitizeFileNameKeepPath } from '../../src/formats/roam/utils'; import { expectedFor, expectTree, fixtures } from '../helpers'; @@ -125,3 +125,63 @@ test('turns a Roam quote into a blockquote', async () => { test('turns a page alias into an Obsidian alias', async () => { assert.equal(await scrubber().roamMarkupScrubber('', '', '[shown]([[Real Page]])'), '[[Real Page|shown]]'); }); + +/** + * Tables are the one place the converter reads the tree rather than a block's + * text, so the shapes Roam can produce are checked here by name. The recorded + * pages cover the ordinary case. + */ + +/** A row, as Roam stores it: each column is the previous column's first child. */ +function row(cells: string[]): RoamBlock { + const [first, ...rest] = cells; + return rest.length > 0 ? { string: first, children: [row(rest)] } : { string: first }; +} + +/** One page holding one table marker, converted. */ +async function convertTable(rows: string[][], marker: string = '{{[[table]]}}'): Promise { + const page: RoamPage = { + title: 'Tables', uid: 'tables', + children: [rows.length > 0 ? { string: marker, children: rows.map(row) } : { string: marker }], + }; + + return scrubber().jsonToMarkdown('Tables', 'Tables/Attachments', page, '', false, '', 0, 0); +} + +test('converts a Roam table to a pipe table, first row as the header', async () => { + assert.equal( + await convertTable([['Name', 'Colour'], ['Apple', 'Red']]), + '\n| Name | Colour |\n| --- | --- |\n| Apple | Red |\n'); +}); + +test('converts the bare {{table}} spelling too', async () => { + assert.equal(await convertTable([['One']], '{{table}}'), '\n| One |\n| --- |\n'); +}); + +test('leaves an unbalanced table marker as an ordinary block', async () => { + assert.equal(await convertTable([['One']], '{{[[table}}'), ' * {{[[table}}\n * One'); +}); + +test('pads a row Roam left short', async () => { + assert.equal( + await convertTable([['Name', 'Colour'], ['Apple']]), + '\n| Name | Colour |\n| --- | --- |\n| Apple | |\n'); +}); + +test('escapes a pipe inside a cell', async () => { + assert.equal(await convertTable([['a | b']]), '\n| a \\| b |\n| --- |\n'); +}); + +test('keeps a multi-line cell on one row', async () => { + assert.equal(await convertTable([['one\ntwo']]), '\n| one
two |\n| --- |\n'); +}); + +test('a table marker with no rows leaves nothing behind', async () => { + assert.equal(await convertTable([]), ''); +}); + +test('converts the markup inside a cell', async () => { + assert.equal( + await convertTable([['{{[[TODO]]}} ^^done^^']]), + '\n| [ ] ==done== |\n| --- |\n'); +}); diff --git a/tests/roam/expected/help-graph-excerpt/Table.md b/tests/roam/expected/help-graph-excerpt/Table.md index d78859af..a3b8606a 100644 --- a/tests/roam/expected/help-graph-excerpt/Table.md +++ b/tests/roam/expected/help-graph-excerpt/Table.md @@ -1,5 +1,4 @@ * [[--]] - * {{[[table]]}} * ## Articles: * [How to build a table in Roam Research](https://web.archive.org/web/20201109133038/https://www.roamtips.com/home/create-tables-roam-research) - [[Roam Tips and Hacks]] * ## Community Videos: diff --git a/tests/roam/expected/small-test-graph/Theme Tester.md b/tests/roam/expected/small-test-graph/Theme Tester.md index 98646998..ef3bd1e6 100644 --- a/tests/roam/expected/small-test-graph/Theme Tester.md +++ b/tests/roam/expected/small-test-graph/Theme Tester.md @@ -94,13 +94,12 @@ and a second line of amazing code here``` * "Cats" : 85 * "Rats" : 15 * Table - * {{[[table]]}} - * another table - * another column - * more subtext - * even more subtext - * more subtext - * even more subtext + +| another table | another column | +| --- | --- | +| more subtext | even more subtext | +| more subtext | even more subtext | + * Kanban * {{[[kanban]]}} * one