/** * 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(); 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 { 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 { throw new Error('Roam Research export is not supported — use Roam\'s native import'); }, }; registerConverter(roamConverter);