/** * 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 { 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 { 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 { 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);