rspace-online/modules/rnotes/converters/roam.ts

172 lines
4.7 KiB
TypeScript

/**
* Roam Research JSON → rNotes converter.
*
* Import: Roam JSON export ([{ title, children: [{ string, children }] }])
* Converts recursive tree → indented markdown bullets.
* Handles Roam syntax: ((block-refs)), {{embed}}, ^^highlight^^, [[page refs]]
*/
import { markdownToTiptap, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter, hashContent } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
interface RoamBlock {
string?: string;
uid?: string;
children?: RoamBlock[];
'create-time'?: number;
'edit-time'?: number;
}
interface RoamPage {
title: string;
uid?: string;
children?: RoamBlock[];
'create-time'?: number;
'edit-time'?: number;
}
/** Convert Roam block tree to indented markdown. */
function blocksToMarkdown(blocks: RoamBlock[], depth = 0): string {
const lines: string[] = [];
for (const block of blocks) {
if (!block.string && (!block.children || block.children.length === 0)) continue;
if (block.string) {
const indent = ' '.repeat(depth);
const text = convertRoamSyntax(block.string);
lines.push(`${indent}- ${text}`);
}
if (block.children && block.children.length > 0) {
lines.push(blocksToMarkdown(block.children, depth + 1));
}
}
return lines.join('\n');
}
/** Convert Roam-specific syntax to standard markdown. */
function convertRoamSyntax(text: string): string {
// [[page references]] → [page references](page references)
text = text.replace(/\[\[([^\]]+)\]\]/g, '[$1]($1)');
// ((block refs)) → (ref)
text = text.replace(/\(\(([a-zA-Z0-9_-]+)\)\)/g, '(ref:$1)');
// {{embed: ((ref))}} → (embedded ref)
text = text.replace(/\{\{embed:\s*\(\(([^)]+)\)\)\}\}/g, '> (embedded: $1)');
// {{[[TODO]]}} and {{[[DONE]]}}
text = text.replace(/\{\{\[\[TODO\]\]\}\}/g, '- [ ]');
text = text.replace(/\{\{\[\[DONE\]\]\}\}/g, '- [x]');
// ^^highlight^^ → ==highlight== (or just **highlight**)
text = text.replace(/\^\^([^^]+)\^\^/g, '**$1**');
// **bold** already valid markdown
// __italic__ → *italic*
text = text.replace(/__([^_]+)__/g, '*$1*');
return text;
}
/** Extract tags from Roam page content (inline [[refs]] and #tags). */
function extractRoamTags(blocks: RoamBlock[]): string[] {
const tags = new Set<string>();
function walk(items: RoamBlock[]) {
for (const block of items) {
if (block.string) {
// [[page refs]]
const pageRefs = block.string.match(/\[\[([^\]]+)\]\]/g);
if (pageRefs) {
for (const ref of pageRefs) {
const tag = ref.slice(2, -2).toLowerCase().replace(/\s+/g, '-');
if (tag.length <= 30) tags.add(tag); // Skip very long refs
}
}
// #tags
const hashTags = block.string.match(/#([a-zA-Z0-9_-]+)/g);
if (hashTags) {
for (const t of hashTags) tags.add(t.slice(1).toLowerCase());
}
}
if (block.children) walk(block.children);
}
}
walk(blocks);
return Array.from(tags).slice(0, 20); // Cap tags
}
const roamConverter: NoteConverter = {
id: 'roam',
name: 'Roam Research',
requiresAuth: false,
async import(input: ImportInput): Promise<ImportResult> {
if (!input.fileData) {
throw new Error('Roam import requires a JSON file');
}
const jsonStr = new TextDecoder().decode(input.fileData);
let pages: RoamPage[];
try {
pages = JSON.parse(jsonStr);
} catch {
throw new Error('Invalid Roam Research JSON format');
}
if (!Array.isArray(pages)) {
throw new Error('Expected a JSON array of Roam pages');
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const page of pages) {
try {
if (!page.title) continue;
const children = page.children || [];
const markdown = children.length > 0
? blocksToMarkdown(children)
: '';
if (!markdown.trim() && children.length === 0) continue; // Skip empty pages
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
const tags = extractRoamTags(children);
notes.push({
title: page.title,
content: tiptapJson,
contentPlain,
markdown,
tags,
sourceRef: {
source: 'roam',
externalId: page.uid || page.title,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
});
} catch (err) {
warnings.push(`Failed to parse page "${page.title}": ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Roam Research Import', warnings };
},
async export(): Promise<ExportResult> {
throw new Error('Roam Research export is not supported — use Roam\'s native import');
},
};
registerConverter(roamConverter);