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

462 lines
12 KiB
TypeScript

/**
* Notion ↔ rNotes converter.
*
* Import: Notion API block types → markdown → TipTap JSON
* Export: TipTap JSON → Notion block format, creates pages via API
*/
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
const NOTION_API_VERSION = '2022-06-28';
const NOTION_API_BASE = 'https://api.notion.com/v1';
/** Rate-limited fetch for Notion API (3 req/s). */
let lastRequestTime = 0;
async function notionFetch(url: string, opts: RequestInit & { token: string }): Promise<any> {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < 334) { // ~3 req/s
await new Promise(r => setTimeout(r, 334 - elapsed));
}
lastRequestTime = Date.now();
const res = await fetch(url, {
...opts,
headers: {
'Authorization': `Bearer ${opts.token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json',
...opts.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Notion API error ${res.status}: ${body}`);
}
return res.json();
}
/** Convert a Notion rich text array to markdown. */
function richTextToMarkdown(richText: any[]): string {
if (!richText) return '';
return richText.map((rt: any) => {
let text = rt.plain_text || '';
const ann = rt.annotations || {};
if (ann.code) text = `\`${text}\``;
if (ann.bold) text = `**${text}**`;
if (ann.italic) text = `*${text}*`;
if (ann.strikethrough) text = `~~${text}~~`;
if (rt.href) text = `[${text}](${rt.href})`;
return text;
}).join('');
}
/** Convert a Notion block to markdown. */
function blockToMarkdown(block: any, indent = ''): string {
const type = block.type;
const data = block[type];
if (!data) return '';
switch (type) {
case 'paragraph':
return `${indent}${richTextToMarkdown(data.rich_text)}`;
case 'heading_1':
return `# ${richTextToMarkdown(data.rich_text)}`;
case 'heading_2':
return `## ${richTextToMarkdown(data.rich_text)}`;
case 'heading_3':
return `### ${richTextToMarkdown(data.rich_text)}`;
case 'bulleted_list_item':
return `${indent}- ${richTextToMarkdown(data.rich_text)}`;
case 'numbered_list_item':
return `${indent}1. ${richTextToMarkdown(data.rich_text)}`;
case 'to_do': {
const checked = data.checked ? 'x' : ' ';
return `${indent}- [${checked}] ${richTextToMarkdown(data.rich_text)}`;
}
case 'toggle':
return `${indent}- ${richTextToMarkdown(data.rich_text)}`;
case 'code': {
const lang = data.language || '';
const code = richTextToMarkdown(data.rich_text);
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
case 'quote':
return `> ${richTextToMarkdown(data.rich_text)}`;
case 'callout': {
const icon = data.icon?.emoji || '';
return `> ${icon} ${richTextToMarkdown(data.rich_text)}`;
}
case 'divider':
return '---';
case 'image': {
const url = data.file?.url || data.external?.url || '';
const caption = data.caption ? richTextToMarkdown(data.caption) : '';
return `![${caption}](${url})`;
}
case 'bookmark':
return `[${data.url}](${data.url})`;
case 'table': {
// Tables are handled via children blocks
return '';
}
case 'table_row': {
const cells = (data.cells || []).map((cell: any[]) => richTextToMarkdown(cell));
return `| ${cells.join(' | ')} |`;
}
case 'child_page':
return `**${data.title}** (sub-page)`;
case 'child_database':
return `**${data.title}** (database)`;
default:
// Try to extract rich_text if available
if (data.rich_text) {
return richTextToMarkdown(data.rich_text);
}
return '';
}
}
/** Convert TipTap markdown content to Notion blocks. */
function markdownToNotionBlocks(md: string): any[] {
const lines = md.split('\n');
const blocks: any[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Empty line
if (!line.trim()) {
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2];
const type = `heading_${level}` as string;
blocks.push({
type,
[type]: {
rich_text: [{ type: 'text', text: { content: text } }],
},
});
i++;
continue;
}
// Code blocks
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
blocks.push({
type: 'code',
code: {
rich_text: [{ type: 'text', text: { content: codeLines.join('\n') } }],
language: lang || 'plain text',
},
});
i++; // skip closing ```
continue;
}
// Blockquotes
if (line.startsWith('> ')) {
blocks.push({
type: 'quote',
quote: {
rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
},
});
i++;
continue;
}
// Task list items
const taskMatch = line.match(/^- \[([ x])\]\s+(.+)/);
if (taskMatch) {
blocks.push({
type: 'to_do',
to_do: {
rich_text: [{ type: 'text', text: { content: taskMatch[2] } }],
checked: taskMatch[1] === 'x',
},
});
i++;
continue;
}
// Bullet list items
if (line.match(/^[-*]\s+/)) {
blocks.push({
type: 'bulleted_list_item',
bulleted_list_item: {
rich_text: [{ type: 'text', text: { content: line.replace(/^[-*]\s+/, '') } }],
},
});
i++;
continue;
}
// Numbered list items
if (line.match(/^\d+\.\s+/)) {
blocks.push({
type: 'numbered_list_item',
numbered_list_item: {
rich_text: [{ type: 'text', text: { content: line.replace(/^\d+\.\s+/, '') } }],
},
});
i++;
continue;
}
// Horizontal rule
if (line.match(/^---+$/)) {
blocks.push({ type: 'divider', divider: {} });
i++;
continue;
}
// Default: paragraph
blocks.push({
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: line } }],
},
});
i++;
}
return blocks;
}
const notionConverter: NoteConverter = {
id: 'notion',
name: 'Notion',
requiresAuth: true,
async import(input: ImportInput): Promise<ImportResult> {
const token = input.accessToken;
if (!token) throw new Error('Notion import requires an access token. Connect your Notion account first.');
if (!input.pageIds || input.pageIds.length === 0) {
throw new Error('No Notion pages selected for import');
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const pageId of input.pageIds) {
try {
// Fetch page metadata
const page = await notionFetch(`${NOTION_API_BASE}/pages/${pageId}`, {
method: 'GET',
token,
});
// Extract title
const titleProp = page.properties?.title || page.properties?.Name;
const title = titleProp?.title?.[0]?.plain_text || 'Untitled';
// Fetch all blocks (paginated)
const allBlocks: any[] = [];
let cursor: string | undefined;
do {
const url = `${NOTION_API_BASE}/blocks/${pageId}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`;
const result = await notionFetch(url, { method: 'GET', token });
allBlocks.push(...(result.results || []));
cursor = result.has_more ? result.next_cursor : undefined;
} while (cursor);
// Handle table rows specially
const mdParts: string[] = [];
let inTable = false;
let tableRowIndex = 0;
for (const block of allBlocks) {
if (block.type === 'table') {
inTable = true;
tableRowIndex = 0;
// Fetch table children
const tableChildren = await notionFetch(
`${NOTION_API_BASE}/blocks/${block.id}/children?page_size=100`,
{ method: 'GET', token }
);
for (const child of tableChildren.results || []) {
const rowMd = blockToMarkdown(child);
mdParts.push(rowMd);
if (tableRowIndex === 0) {
// Add separator after header
const cellCount = (child.table_row?.cells || []).length;
mdParts.push(`| ${Array(cellCount).fill('---').join(' | ')} |`);
}
tableRowIndex++;
}
inTable = false;
} else {
const md = blockToMarkdown(block);
if (md) mdParts.push(md);
}
}
const markdown = mdParts.join('\n\n');
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
// Extract tags from Notion properties
const tags: string[] = [];
if (page.properties) {
for (const [key, value] of Object.entries(page.properties) as [string, any][]) {
if (value.type === 'multi_select') {
tags.push(...(value.multi_select || []).map((s: any) => s.name.toLowerCase()));
} else if (value.type === 'select' && value.select) {
tags.push(value.select.name.toLowerCase());
}
}
}
notes.push({
title,
content: tiptapJson,
contentPlain,
markdown,
tags: [...new Set(tags)],
sourceRef: {
source: 'notion',
externalId: pageId,
lastSyncedAt: Date.now(),
contentHash: String(allBlocks.length),
},
});
// Recursively import child pages if requested
if (input.recursive) {
for (const block of allBlocks) {
if (block.type === 'child_page') {
try {
const childResult = await this.import({
...input,
pageIds: [block.id],
recursive: true,
});
notes.push(...childResult.notes);
warnings.push(...childResult.warnings);
} catch (err) {
warnings.push(`Failed to import child page "${block.child_page?.title}": ${(err as Error).message}`);
}
}
}
}
} catch (err) {
warnings.push(`Failed to import page ${pageId}: ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Notion Import', warnings };
},
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
const token = opts.accessToken;
if (!token) throw new Error('Notion export requires an access token. Connect your Notion account first.');
const warnings: string[] = [];
const results: any[] = [];
for (const note of notes) {
try {
// Convert to markdown first
let md: string;
if (note.contentFormat === 'tiptap-json' && note.content) {
md = tiptapToMarkdown(note.content);
} else {
md = note.content?.replace(/<[^>]*>/g, '').trim() || '';
}
// Convert markdown to Notion blocks
const blocks = markdownToNotionBlocks(md);
// Create page in Notion
// If parentId is provided, create as child page; otherwise create in workspace root
const parent = opts.parentId
? { page_id: opts.parentId }
: { type: 'page_id' as const, page_id: opts.parentId || '' };
// For workspace-level pages, we need a database or page parent
// Default to creating standalone pages
const createBody: any = {
parent: opts.parentId
? { page_id: opts.parentId }
: { type: 'workspace', workspace: true },
properties: {
title: {
title: [{ type: 'text', text: { content: note.title } }],
},
},
children: blocks.slice(0, 100), // Notion limit: 100 blocks per request
};
const page = await notionFetch(`${NOTION_API_BASE}/pages`, {
method: 'POST',
token,
body: JSON.stringify(createBody),
});
results.push({ noteId: note.id, notionPageId: page.id });
// If more than 100 blocks, append in batches
if (blocks.length > 100) {
for (let i = 100; i < blocks.length; i += 100) {
const batch = blocks.slice(i, i + 100);
await notionFetch(`${NOTION_API_BASE}/blocks/${page.id}/children`, {
method: 'PATCH',
token,
body: JSON.stringify({ children: batch }),
});
}
}
} catch (err) {
warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`);
}
}
// Return results as JSON since we don't produce a file
const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings }));
return {
data,
filename: 'notion-export-results.json',
mimeType: 'application/json',
};
},
};
registerConverter(notionConverter);