From 62dbf65f995845dac79b38c3d1b262c769b8f2ea Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 18 Jun 2025 19:21:08 +0530 Subject: [PATCH] feat: allow converting a page to markdown --- app/services/notion_to_markdown.rb | 104 ++++++ spec/services/notion_to_markdown_spec.rb | 414 +++++++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 app/services/notion_to_markdown.rb create mode 100644 spec/services/notion_to_markdown_spec.rb diff --git a/app/services/notion_to_markdown.rb b/app/services/notion_to_markdown.rb new file mode 100644 index 000000000..e7318ecf7 --- /dev/null +++ b/app/services/notion_to_markdown.rb @@ -0,0 +1,104 @@ +class NotionToMarkdown + # Main entry: pass in the Notion page blocks (array of block hashes) + def convert(blocks) + blocks.map { |block| block_to_markdown(block) }.join("\n\n") + end + + private + + def block_to_markdown(block, depth = 0) + type = block['type'] + return '' unless type + + case type + when 'paragraph' + rich_text_to_md(block['paragraph']['rich_text']) + when 'heading_1' + "# #{rich_text_to_md(block['heading_1']['rich_text'])}" + when 'heading_2' + "## #{rich_text_to_md(block['heading_2']['rich_text'])}" + when 'heading_3' + "### #{rich_text_to_md(block['heading_3']['rich_text'])}" + when 'bulleted_list_item' + indent = ' ' * depth + "#{indent}- #{rich_text_to_md(block['bulleted_list_item']['rich_text'])}" + + children_to_md(block, depth + 1) + when 'numbered_list_item' + indent = ' ' * depth + "#{indent}1. #{rich_text_to_md(block['numbered_list_item']['rich_text'])}" + + children_to_md(block, depth + 1) + when 'to_do' + box = block['to_do']['checked'] ? '[x]' : '[ ]' + "- #{box} #{rich_text_to_md(block['to_do']['rich_text'])}" + when 'toggle' + summary = rich_text_to_md(block['toggle']['rich_text']) + details = children_to_md(block, depth + 1) + "
\n#{summary}\n\n#{details}\n
" + when 'quote' + quote = rich_text_to_md(block['quote']['rich_text']) + quote_lines = quote.lines.map { |line| "> #{line}" }.join + quote_lines + children_to_md(block, depth) + when 'code' + lang = block['code']['language'] || '' + code = block['code']['rich_text'].map { |t| t['plain_text'] }.join + "```#{lang}\n#{code}\n```" + when 'callout' + icon = block['callout']['icon']&.dig('emoji') || '💡' + content = rich_text_to_md(block['callout']['rich_text']) + "> [!#{icon}] #{content}" + children_to_md(block, depth + 1) + when 'divider' + '---' + when 'image' + url = block['image']['type'] == 'external' ? block['image']['external']['url'] : block['image']['file']['url'] + caption = block['image']['caption']&.map { |c| c['plain_text'] }&.join || 'image' + "![#{caption}](#{url})" + when 'bookmark' + url = block['bookmark']['url'] + "[#{url}](#{url})" + when 'child_page' + "## #{block['child_page']['title']}" + children_to_md(block, depth + 1) + when 'table' + table_to_md(block) + else + '' # Add more block types as needed + end + end + + def children_to_md(block, depth) + return '' unless block['has_children'] && block['children'] + + "\n" + block['children'].map { |child| block_to_markdown(child, depth) }.join("\n") + end + + def rich_text_to_md(rich_text_array) + return '' unless rich_text_array + + rich_text_array.map { |t| annotate_text(t) }.join + end + + def annotate_text(text_obj) + text = text_obj['plain_text'] + ann = text_obj['annotations'] || {} + + text = "**#{text}**" if ann['bold'] + text = "*#{text}*" if ann['italic'] + text = "~~#{text}~~" if ann['strikethrough'] + text = "#{text}" if ann['underline'] + text = "`#{text}`" if ann['code'] + text = "[#{text}](#{text_obj['href']})" if text_obj['href'] + text + end + + def table_to_md(block) + rows = block['children'] || [] + table = rows.map do |row| + cells = row['table_row']['cells'] + cells.map { |cell| rich_text_to_md(cell) } + end + return '' if table.empty? + + header = table.first + sep = header.map { '---' } + ([header, sep] + table[1..]).map { |row| "| #{row.join(' | ')} |" }.join("\n") + end +end \ No newline at end of file diff --git a/spec/services/notion_to_markdown_spec.rb b/spec/services/notion_to_markdown_spec.rb new file mode 100644 index 000000000..13bc39f8e --- /dev/null +++ b/spec/services/notion_to_markdown_spec.rb @@ -0,0 +1,414 @@ +require 'rails_helper' + +RSpec.describe NotionToMarkdown do + subject(:converter) { described_class.new } + + describe '#convert' do + context 'with paragraph blocks' do + let(:blocks) do + [ + { + 'type' => 'paragraph', + 'paragraph' => { + 'rich_text' => [ + { 'plain_text' => 'This is a simple paragraph.' } + ] + } + } + ] + end + + it 'converts paragraph to markdown' do + result = converter.convert(blocks) + expect(result).to eq('This is a simple paragraph.') + end + end + + context 'with heading blocks' do + let(:blocks) do + [ + { + 'type' => 'heading_1', + 'heading_1' => { + 'rich_text' => [{ 'plain_text' => 'Main Title' }] + } + }, + { + 'type' => 'heading_2', + 'heading_2' => { + 'rich_text' => [{ 'plain_text' => 'Subtitle' }] + } + }, + { + 'type' => 'heading_3', + 'heading_3' => { + 'rich_text' => [{ 'plain_text' => 'Sub-subtitle' }] + } + } + ] + end + + it 'converts headings to markdown' do + result = converter.convert(blocks) + expected = "# Main Title\n\n## Subtitle\n\n### Sub-subtitle" + expect(result).to eq(expected) + end + end + + context 'with list blocks' do + let(:blocks) do + [ + { + 'type' => 'bulleted_list_item', + 'bulleted_list_item' => { + 'rich_text' => [{ 'plain_text' => 'First bullet point' }] + } + }, + { + 'type' => 'numbered_list_item', + 'numbered_list_item' => { + 'rich_text' => [{ 'plain_text' => 'First numbered item' }] + } + } + ] + end + + it 'converts lists to markdown' do + result = converter.convert(blocks) + expected = "- First bullet point\n\n1. First numbered item" + expect(result).to eq(expected) + end + end + + context 'with to-do blocks' do + let(:blocks) do + [ + { + 'type' => 'to_do', + 'to_do' => { + 'checked' => false, + 'rich_text' => [{ 'plain_text' => 'Incomplete task' }] + } + }, + { + 'type' => 'to_do', + 'to_do' => { + 'checked' => true, + 'rich_text' => [{ 'plain_text' => 'Completed task' }] + } + } + ] + end + + it 'converts to-dos to markdown' do + result = converter.convert(blocks) + expected = "- [ ] Incomplete task\n\n- [x] Completed task" + expect(result).to eq(expected) + end + end + + context 'with rich text formatting' do + let(:blocks) do + [ + { + 'type' => 'paragraph', + 'paragraph' => { + 'rich_text' => [ + { + 'plain_text' => 'This is bold', + 'annotations' => { 'bold' => true } + }, + { 'plain_text' => ' and ' }, + { + 'plain_text' => 'this is italic', + 'annotations' => { 'italic' => true } + }, + { 'plain_text' => ' and ' }, + { + 'plain_text' => 'this is code', + 'annotations' => { 'code' => true } + } + ] + } + } + ] + end + + it 'applies rich text formatting' do + result = converter.convert(blocks) + expected = '**This is bold** and *this is italic* and `this is code`' + expect(result).to eq(expected) + end + end + + context 'with links' do + let(:blocks) do + [ + { + 'type' => 'paragraph', + 'paragraph' => { + 'rich_text' => [ + { + 'plain_text' => 'Visit our website', + 'href' => 'https://example.com' + } + ] + } + } + ] + end + + it 'converts links to markdown' do + result = converter.convert(blocks) + expected = '[Visit our website](https://example.com)' + expect(result).to eq(expected) + end + end + + context 'with code blocks' do + let(:blocks) do + [ + { + 'type' => 'code', + 'code' => { + 'language' => 'javascript', + 'rich_text' => [ + { 'plain_text' => "console.log('Hello, world!');" } + ] + } + } + ] + end + + it 'converts code blocks to markdown' do + result = converter.convert(blocks) + expected = "```javascript\nconsole.log('Hello, world!');\n```" + expect(result).to eq(expected) + end + end + + context 'with quote blocks' do + let(:blocks) do + [ + { + 'type' => 'quote', + 'quote' => { + 'rich_text' => [ + { 'plain_text' => 'This is a quote' } + ] + } + } + ] + end + + it 'converts quotes to markdown' do + result = converter.convert(blocks) + expected = '> This is a quote' + expect(result).to eq(expected) + end + end + + context 'with callout blocks' do + let(:blocks) do + [ + { + 'type' => 'callout', + 'callout' => { + 'icon' => { 'emoji' => '⚠️' }, + 'rich_text' => [ + { 'plain_text' => 'This is a warning' } + ] + } + } + ] + end + + it 'converts callouts to markdown' do + result = converter.convert(blocks) + expected = '> [!⚠️] This is a warning' + expect(result).to eq(expected) + end + end + + context 'with divider blocks' do + let(:blocks) do + [ + { + 'type' => 'divider', + 'divider' => {} + } + ] + end + + it 'converts dividers to markdown' do + result = converter.convert(blocks) + expected = '---' + expect(result).to eq(expected) + end + end + + context 'with image blocks' do + let(:blocks) do + [ + { + 'type' => 'image', + 'image' => { + 'type' => 'external', + 'external' => { + 'url' => 'https://example.com/image.jpg' + }, + 'caption' => [ + { 'plain_text' => 'Example image' } + ] + } + } + ] + end + + it 'converts images to markdown' do + result = converter.convert(blocks) + expected = '![Example image](https://example.com/image.jpg)' + expect(result).to eq(expected) + end + end + + context 'with bookmark blocks' do + let(:blocks) do + [ + { + 'type' => 'bookmark', + 'bookmark' => { + 'url' => 'https://example.com' + } + } + ] + end + + it 'converts bookmarks to markdown' do + result = converter.convert(blocks) + expected = '[https://example.com](https://example.com)' + expect(result).to eq(expected) + end + end + + context 'with table blocks' do + let(:blocks) do + [ + { + 'type' => 'table', + 'table' => {}, + 'children' => [ + { + 'type' => 'table_row', + 'table_row' => { + 'cells' => [ + [{ 'plain_text' => 'Name' }], + [{ 'plain_text' => 'Age' }] + ] + } + }, + { + 'type' => 'table_row', + 'table_row' => { + 'cells' => [ + [{ 'plain_text' => 'John' }], + [{ 'plain_text' => '30' }] + ] + } + } + ] + } + ] + end + + it 'converts tables to markdown' do + result = converter.convert(blocks) + expected = "| Name | Age |\n| --- | --- |\n| John | 30 |" + expect(result).to eq(expected) + end + end + + context 'with nested list items' do + let(:blocks) do + [ + { + 'type' => 'bulleted_list_item', + 'bulleted_list_item' => { + 'rich_text' => [{ 'plain_text' => 'Parent item' }] + }, + 'has_children' => true, + 'children' => [ + { + 'type' => 'bulleted_list_item', + 'bulleted_list_item' => { + 'rich_text' => [{ 'plain_text' => 'Child item' }] + } + } + ] + } + ] + end + + it 'handles nested lists with proper indentation' do + result = converter.convert(blocks) + expected = "- Parent item\n - Child item" + expect(result).to eq(expected) + end + end + + context 'with toggle blocks' do + let(:blocks) do + [ + { + 'type' => 'toggle', + 'toggle' => { + 'rich_text' => [{ 'plain_text' => 'Click to expand' }] + }, + 'has_children' => true, + 'children' => [ + { + 'type' => 'paragraph', + 'paragraph' => { + 'rich_text' => [{ 'plain_text' => 'Hidden content' }] + } + } + ] + } + ] + end + + it 'converts toggles to HTML details' do + result = converter.convert(blocks) + expected = "
\nClick to expand\n\n\nHidden content\n
" + expect(result).to eq(expected) + end + end + + context 'with unknown block types' do + let(:blocks) do + [ + { + 'type' => 'unknown_block_type', + 'unknown_block_type' => { + 'content' => 'Some content' + } + } + ] + end + + it 'ignores unknown block types' do + result = converter.convert(blocks) + expect(result).to eq('') + end + end + + context 'with empty blocks array' do + let(:blocks) { [] } + + it 'returns empty string' do + result = converter.convert(blocks) + expect(result).to eq('') + end + end + end +end \ No newline at end of file