Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 96 additions & 10 deletions src/formats/roam/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> {
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'];
Expand Down Expand Up @@ -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<string> {
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);
}
}
}
}

Expand Down Expand Up @@ -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<string> {
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, '<br>'));
}

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';
}
}
62 changes: 61 additions & 1 deletion tests/roam/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> {
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<br>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');
});
1 change: 0 additions & 1 deletion tests/roam/expected/help-graph-excerpt/Table.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
13 changes: 6 additions & 7 deletions tests/roam/expected/small-test-graph/Theme Tester.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down