462 lines
12 KiB
TypeScript
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 ``;
|
|
}
|
|
|
|
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);
|