172 lines
4.7 KiB
TypeScript
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);
|