synotion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e245b18b4c8a491475b7a561d079ca31a5ccaeeb451fea9ab27e7cba62a768ee
4
+ data.tar.gz: 68e471af7154e8c488d983898bda7ad580a2adb64d599f167342bc47f6bc570a
5
+ SHA512:
6
+ metadata.gz: 6a1a6879d09e30060b8b6c37d5d8a49cbfa52c60f3dec80199d30fd042c4d803c8bdcdc951837879d0fa4052eb41d61b4ed082b3c903f9e742f950792593f411
7
+ data.tar.gz: f1dc5845d7ca55c1ca7ea92367b709631837b0b60e985edf2f2420939ac0c3474afbcea57e7cf40a9ad45315f2115cdfd3e0596690d8b594c19a87ecc65324bf
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## Unreleased
9
+
10
+ - Initial release
11
+
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # Synotion
2
+
3
+ A Ruby gem to sync Markdown files to Notion pages.
4
+
5
+ ## Features
6
+
7
+ - Multiple update modes: create, append, replace, or upsert pages
8
+ - Flexible page identification by custom properties, filenames, or titles
9
+ - Automatic Markdown to Notion blocks conversion
10
+ - Command-line interface
11
+ - YAML front matter support
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ gem install synotion
17
+ ```
18
+
19
+ Or add to your Gemfile:
20
+
21
+ ```ruby
22
+ gem 'synotion'
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ 1. Create a Notion integration at https://www.notion.so/my-integrations
28
+ 2. Share your database with the integration
29
+ 3. Run:
30
+
31
+ ```bash
32
+ synotion sync README.md \
33
+ --api-key=secret_xxx \
34
+ --database-id=your-database-id
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### CLI
40
+
41
+ ```bash
42
+ # Basic usage
43
+ synotion sync README.md \
44
+ --api-key=$NOTION_API_KEY \
45
+ --database-id=your-database-id
46
+
47
+ # Specify update mode
48
+ synotion sync CHANGELOG.md \
49
+ --api-key=$NOTION_API_KEY \
50
+ --database-id=your-database-id \
51
+ --mode=append
52
+
53
+ # Use config file
54
+ synotion sync README.md --config=.notion-sync.yml
55
+ ```
56
+
57
+ ### Ruby API
58
+
59
+ ```ruby
60
+ require 'synotion'
61
+
62
+ # Configure globally
63
+ Synotion.configure do |config|
64
+ config.notion_api_key = ENV['NOTION_API_KEY']
65
+ config.database_id = 'your-database-id'
66
+ end
67
+
68
+ # Sync a file
69
+ syncer = Synotion::Syncer.new
70
+ result = syncer.sync('README.md')
71
+ # => { action: 'created', page_id: 'xxx', mode: :upsert }
72
+
73
+ # Sync with options
74
+ result = syncer.sync('docs/api.md',
75
+ mode: :replace,
76
+ title: 'API Documentation'
77
+ )
78
+ ```
79
+
80
+ ### Configuration File
81
+
82
+ Create `.notion-sync.yml`:
83
+
84
+ ```yaml
85
+ notion_api_key: secret_xxx
86
+ database_id: your-database-id
87
+ update_mode: upsert
88
+ unique_property: source_file
89
+ ```
90
+
91
+ ## Configuration Options
92
+
93
+ | Option | Description | Default |
94
+ |--------|-------------|---------|
95
+ | `notion_api_key` | Notion API key (required) | - |
96
+ | `database_id` | Target database ID | - |
97
+ | `page_id` | Target page ID (alternative to database_id) | - |
98
+ | `update_mode` | How to handle existing pages | `:upsert` |
99
+ | `unique_property` | Property for page identification | `'source_file'` |
100
+ | `title_from` | How to extract page title | `:first_heading` |
101
+ | `sync_metadata` | Add last_synced timestamp | `true` |
102
+
103
+ ### Update Modes
104
+
105
+ | Mode | Behavior |
106
+ |------|----------|
107
+ | `:create` | Create new page only (skip if exists) |
108
+ | `:append` | Append content to existing page |
109
+ | `:replace` | Replace entire page content |
110
+ | `:upsert` | Update if exists, create if not (default) |
111
+
112
+ ### Supported Markdown Elements
113
+
114
+ - Headings (H1, H2, H3)
115
+ - Paragraphs
116
+ - Code blocks with syntax highlighting
117
+ - Bulleted lists
118
+ - Numbered lists
119
+ - Quotes
120
+ - Dividers
121
+ - Todo items / Checkboxes
122
+ - YAML front matter
123
+
124
+ ## Notion Setup
125
+
126
+ 1. Create an Integration
127
+
128
+ - Go to https://www.notion.so/my-integrations
129
+ - Click "New integration"
130
+ - Copy the "Internal Integration Token"
131
+
132
+ 2. Create a Database
133
+
134
+ Create a Notion database with these properties:
135
+
136
+ | Property | Type | Purpose |
137
+ |----------|------|---------|
138
+ | Name | Title | Page title (required) |
139
+ | source_file | Text | File path for unique identification |
140
+ | last_synced | Date | Last sync timestamp (optional) |
141
+
142
+ 3. Share Database
143
+
144
+ - Open your database in Notion
145
+ - Click "..." → "Add connections"
146
+ - Select your integration
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ # Install dependencies
152
+ bundle install
153
+
154
+ # Run tests
155
+ bundle exec rspec
156
+
157
+ # Try in console
158
+ bundle exec exe/synotion --help
159
+ ```
160
+
161
+ ## Contributing
162
+
163
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/synotion.
164
+
165
+ ## License
166
+
167
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/synotion ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'synotion'
6
+
7
+ Synotion::CLI.start(ARGV)
@@ -0,0 +1,66 @@
1
+ require 'thor'
2
+ require 'yaml'
3
+
4
+ module Synotion
5
+ class CLI < Thor
6
+ desc 'sync FILE_PATH', 'Sync a markdown file to Notion'
7
+ method_option :api_key, type: :string, desc: 'Notion API key (or set NOTION_API_KEY env var)'
8
+ method_option :database_id, type: :string, desc: 'Notion database ID'
9
+ method_option :page_id, type: :string, desc: 'Notion page ID for direct update'
10
+ method_option :mode, type: :string, default: 'upsert', desc: 'Update mode: create, append, replace, upsert'
11
+ method_option :unique_property, type: :string, default: 'source_file', desc: 'Property name for unique identification'
12
+ method_option :unique_value, type: :string, desc: 'Value for unique property (defaults to file path)'
13
+ method_option :title, type: :string, desc: 'Custom page title'
14
+ method_option :config, type: :string, desc: 'Path to config file (.notion-sync.yml)'
15
+
16
+ def sync(file_path)
17
+ if options[:config] && File.exist?(options[:config])
18
+ load_config_file(options[:config])
19
+ end
20
+
21
+ syncer_options = {}
22
+ syncer_options[:notion_api_key] = options[:api_key] if options[:api_key]
23
+ syncer_options[:database_id] = options[:database_id] if options[:database_id]
24
+ syncer_options[:page_id] = options[:page_id] if options[:page_id]
25
+
26
+ sync_options = {}
27
+ sync_options[:mode] = options[:mode].to_sym if options[:mode]
28
+ sync_options[:unique_property] = options[:unique_property] if options[:unique_property]
29
+ sync_options[:unique_value] = options[:unique_value] if options[:unique_value]
30
+ sync_options[:title] = options[:title] if options[:title]
31
+
32
+ syncer = Syncer.new(syncer_options)
33
+ result = syncer.sync(file_path, sync_options)
34
+
35
+ puts "✓ Successfully #{result[:action]} page"
36
+ puts " Page ID: #{result[:page_id]}"
37
+ puts " Mode: #{result[:mode]}" if result[:mode]
38
+ rescue StandardError => e
39
+ puts "✗ Error: #{e.message}"
40
+ exit 1
41
+ end
42
+
43
+ desc 'version', 'Show version'
44
+ def version
45
+ puts "Synotion version #{Synotion::VERSION}"
46
+ end
47
+
48
+ private
49
+
50
+ def load_config_file(config_path)
51
+ config_data = YAML.load_file(config_path)
52
+
53
+ Synotion.configure do |config|
54
+ config.notion_api_key = config_data['notion_api_key'] if config_data['notion_api_key']
55
+ config.database_id = config_data['database_id'] if config_data['database_id']
56
+ config.page_id = config_data['page_id'] if config_data['page_id']
57
+ config.unique_property = config_data['unique_property'] if config_data['unique_property']
58
+ config.update_mode = config_data['update_mode'].to_sym if config_data['update_mode']
59
+ config.title_from = config_data['title_from'].to_sym if config_data['title_from']
60
+ config.sync_metadata = config_data['sync_metadata'] if config_data.key?('sync_metadata')
61
+ end
62
+ rescue StandardError => e
63
+ puts "Warning: Failed to load config file: #{e.message}"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,106 @@
1
+ require 'notion-ruby-client'
2
+
3
+ module Synotion
4
+ class Client
5
+ attr_reader :api_client
6
+
7
+ def initialize(api_key)
8
+ @api_client = Notion::Client.new(token: api_key)
9
+ end
10
+
11
+ def find_page(database_id:, property:, value:)
12
+ response = api_client.database_query(
13
+ database_id: database_id,
14
+ filter: {
15
+ property: property,
16
+ rich_text: {
17
+ equals: value
18
+ }
19
+ }
20
+ )
21
+
22
+ return nil if response.results.empty?
23
+
24
+ response.results.first
25
+ rescue Notion::Api::Errors::NotionError => e
26
+ raise NotionAPIError, "Failed to find page: #{e.message}"
27
+ end
28
+
29
+ def create_page(database_id:, properties:, children: [])
30
+ response = api_client.create_page(
31
+ parent: { database_id: database_id },
32
+ properties: properties,
33
+ children: children
34
+ )
35
+ response
36
+ rescue Notion::Api::Errors::NotionError => e
37
+ raise NotionAPIError, "Failed to create page: #{e.message}"
38
+ end
39
+
40
+ def update_page(page_id:, children:, mode:)
41
+ case mode
42
+ when UpdateMode::REPLACE
43
+ delete_blocks(page_id)
44
+ append_blocks(page_id, children)
45
+ when UpdateMode::APPEND
46
+ append_blocks(page_id, children)
47
+ else
48
+ raise ArgumentError, "Invalid update mode for update_page: #{mode}"
49
+ end
50
+ rescue Notion::Api::Errors::NotionError => e
51
+ raise NotionAPIError, "Failed to update page: #{e.message}"
52
+ end
53
+
54
+ def append_blocks(page_id, children)
55
+ return if children.empty?
56
+
57
+ children.each_slice(100) do |chunk|
58
+ api_client.block_append_children(
59
+ block_id: page_id,
60
+ children: chunk
61
+ )
62
+ end
63
+ rescue Notion::Api::Errors::NotionError => e
64
+ raise NotionAPIError, "Failed to append blocks: #{e.message}"
65
+ end
66
+
67
+ def delete_blocks(page_id)
68
+ blocks = get_page_blocks(page_id)
69
+ blocks.each do |block|
70
+ api_client.delete_block(block_id: block.id)
71
+ end
72
+ rescue Notion::Api::Errors::NotionError => e
73
+ raise NotionAPIError, "Failed to delete blocks: #{e.message}"
74
+ end
75
+
76
+ def get_page_blocks(page_id)
77
+ blocks = []
78
+ cursor = nil
79
+
80
+ loop do
81
+ response = api_client.block_children(
82
+ block_id: page_id,
83
+ start_cursor: cursor
84
+ )
85
+
86
+ blocks.concat(response.results)
87
+
88
+ break unless response.has_more
89
+ cursor = response.next_cursor
90
+ end
91
+
92
+ blocks
93
+ rescue Notion::Api::Errors::NotionError => e
94
+ raise NotionAPIError, "Failed to get page blocks: #{e.message}"
95
+ end
96
+
97
+ def update_page_properties(page_id:, properties:)
98
+ api_client.update_page(
99
+ page_id: page_id,
100
+ properties: properties
101
+ )
102
+ rescue Notion::Api::Errors::NotionError => e
103
+ raise NotionAPIError, "Failed to update page properties: #{e.message}"
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,27 @@
1
+ module Synotion
2
+ class Configuration
3
+ attr_accessor :notion_api_key,
4
+ :database_id,
5
+ :page_id,
6
+ :unique_property,
7
+ :update_mode,
8
+ :title_from,
9
+ :sync_metadata
10
+
11
+ def initialize
12
+ @notion_api_key = ENV.fetch('NOTION_API_KEY', nil)
13
+ @database_id = nil
14
+ @page_id = nil
15
+ @unique_property = 'source_file'
16
+ @update_mode = UpdateMode::UPSERT
17
+ @title_from = :first_heading
18
+ @sync_metadata = true
19
+ end
20
+
21
+ def validate!
22
+ raise ConfigurationError, 'notion_api_key is required' if notion_api_key.nil? || notion_api_key.empty?
23
+ raise ConfigurationError, 'Either database_id or page_id must be specified' if database_id.nil? && page_id.nil?
24
+ raise ConfigurationError, "Invalid update_mode: #{update_mode}" unless UpdateMode.valid?(update_mode)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,225 @@
1
+ require 'redcarpet'
2
+ require 'front_matter_parser'
3
+
4
+ module Synotion
5
+ class MarkdownConverter
6
+ attr_reader :markdown_content, :frontmatter, :content_without_frontmatter
7
+
8
+ def initialize(markdown_content)
9
+ @markdown_content = markdown_content
10
+ parse_frontmatter
11
+ end
12
+
13
+ def to_notion_blocks
14
+ blocks = []
15
+ lines = content_without_frontmatter.split("\n")
16
+ i = 0
17
+
18
+ while i < lines.length
19
+ line = lines[i]
20
+
21
+ if line.strip.empty?
22
+ i += 1
23
+ next
24
+ end
25
+
26
+ if line.start_with?('#')
27
+ blocks << parse_heading(line)
28
+ i += 1
29
+ elsif line.start_with?('```')
30
+ code_block, lines_consumed = parse_code_block(lines[i..-1])
31
+ blocks << code_block if code_block
32
+ i += lines_consumed
33
+ elsif line.match?(/^\s*[-*]\s+\[[ x]\]/)
34
+ blocks << parse_todo(line)
35
+ i += 1
36
+ elsif line.match?(/^\s*[-*+]\s/)
37
+ list_items, lines_consumed = parse_list(lines[i..-1], :bulleted_list_item)
38
+ blocks.concat(list_items)
39
+ i += lines_consumed
40
+ elsif line.match?(/^\s*\d+\.\s/)
41
+ list_items, lines_consumed = parse_list(lines[i..-1], :numbered_list_item)
42
+ blocks.concat(list_items)
43
+ i += lines_consumed
44
+ elsif line.start_with?('>')
45
+ blocks << parse_quote(line)
46
+ i += 1
47
+ elsif line.match?(/^[-*_]{3,}$/)
48
+ blocks << { type: 'divider', divider: {} }
49
+ i += 1
50
+ else
51
+ paragraph, lines_consumed = parse_paragraph(lines[i..-1])
52
+ blocks << paragraph if paragraph
53
+ i += lines_consumed
54
+ end
55
+ end
56
+
57
+ blocks
58
+ rescue StandardError => e
59
+ raise MarkdownParseError, "Failed to parse markdown: #{e.message}"
60
+ end
61
+
62
+ def extract_title
63
+ first_heading = content_without_frontmatter.lines.find { |line| line.start_with?('#') }
64
+ return first_heading.sub(/^#+\s*/, '').strip if first_heading
65
+
66
+ return frontmatter['title'] if frontmatter&.key?('title')
67
+
68
+ nil
69
+ end
70
+
71
+ def extract_frontmatter
72
+ frontmatter
73
+ end
74
+
75
+ private
76
+
77
+ def parse_frontmatter
78
+ parsed = FrontMatterParser::Parser.new(:md).call(@markdown_content)
79
+ @frontmatter = parsed.front_matter
80
+ @content_without_frontmatter = parsed.content
81
+ rescue StandardError
82
+ @frontmatter = {}
83
+ @content_without_frontmatter = @markdown_content
84
+ end
85
+
86
+ def parse_heading(line)
87
+ level = line.match(/^(#+)/)[1].length
88
+ text = line.sub(/^#+\s*/, '')
89
+
90
+ heading_type = case level
91
+ when 1 then 'heading_1'
92
+ when 2 then 'heading_2'
93
+ else 'heading_3'
94
+ end
95
+
96
+ {
97
+ type: heading_type,
98
+ heading_type => {
99
+ rich_text: [{ type: 'text', text: { content: text.strip } }]
100
+ }
101
+ }
102
+ end
103
+
104
+ def parse_code_block(lines)
105
+ first_line = lines[0]
106
+ language = first_line.sub('```', '').strip
107
+ language = 'plain text' if language.empty?
108
+
109
+ code_lines = []
110
+ i = 1
111
+
112
+ while i < lines.length && !lines[i].start_with?('```')
113
+ code_lines << lines[i]
114
+ i += 1
115
+ end
116
+
117
+ code_content = code_lines.join("\n")
118
+
119
+ block = {
120
+ type: 'code',
121
+ code: {
122
+ rich_text: [{ type: 'text', text: { content: code_content } }],
123
+ language: language
124
+ }
125
+ }
126
+
127
+ [block, i + 1]
128
+ end
129
+
130
+ def parse_list(lines, type)
131
+ items = []
132
+ i = 0
133
+
134
+ pattern = if type == :bulleted_list_item
135
+ /^\s*[-*+]\s/
136
+ else
137
+ /^\s*\d+\.\s/
138
+ end
139
+
140
+ while i < lines.length
141
+ line = lines[i]
142
+ break unless line.match?(pattern)
143
+
144
+ text = if type == :bulleted_list_item
145
+ line.sub(/^\s*[-*+]\s+/, '').strip
146
+ else
147
+ line.sub(/^\s*\d+\.\s+/, '').strip
148
+ end
149
+
150
+ items << {
151
+ type: type.to_s,
152
+ type => {
153
+ rich_text: [{ type: 'text', text: { content: text } }]
154
+ }
155
+ }
156
+ i += 1
157
+ end
158
+
159
+ [items, i]
160
+ end
161
+
162
+ def parse_quote(line)
163
+ text = line.sub(/^>\s*/, '')
164
+
165
+ {
166
+ type: 'quote',
167
+ quote: {
168
+ rich_text: [{ type: 'text', text: { content: text.strip } }]
169
+ }
170
+ }
171
+ end
172
+
173
+ def parse_todo(line)
174
+ match = line.match(/^\s*[-*]\s+\[([x ])\]\s*(.*)/)
175
+ checked = match[1] == 'x'
176
+ text = match[2].strip
177
+
178
+ {
179
+ type: 'to_do',
180
+ to_do: {
181
+ rich_text: [{ type: 'text', text: { content: text } }],
182
+ checked: checked
183
+ }
184
+ }
185
+ end
186
+
187
+ def parse_paragraph(lines)
188
+ paragraph_lines = []
189
+ i = 0
190
+
191
+ while i < lines.length
192
+ line = lines[i]
193
+ break if line.strip.empty?
194
+ break if line.start_with?('#', '```', '>', '-', '*', '+') || line.match?(/^\d+\./)
195
+
196
+ paragraph_lines << line
197
+ i += 1
198
+ end
199
+
200
+ return [nil, i] if paragraph_lines.empty?
201
+
202
+ text = paragraph_lines.join(' ').strip
203
+ rich_text = parse_inline_formatting(text)
204
+
205
+ block = {
206
+ type: 'paragraph',
207
+ paragraph: {
208
+ rich_text: rich_text
209
+ }
210
+ }
211
+
212
+ [block, i]
213
+ end
214
+
215
+ def parse_inline_formatting(text)
216
+ segments = []
217
+ current_pos = 0
218
+
219
+ [{
220
+ type: 'text',
221
+ text: { content: text }
222
+ }]
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,175 @@
1
+ require 'time'
2
+
3
+ module Synotion
4
+ class Syncer
5
+ attr_reader :config, :client
6
+
7
+ def initialize(options = {})
8
+ @config = build_config(options)
9
+ @config.validate!
10
+ @client = Client.new(@config.notion_api_key)
11
+ end
12
+
13
+ def sync(markdown_file_path, options = {})
14
+ raise ArgumentError, "File not found: #{markdown_file_path}" unless File.exist?(markdown_file_path)
15
+
16
+ markdown_content = File.read(markdown_file_path)
17
+ identifier = options[:unique_value] || markdown_file_path
18
+
19
+ sync_content(markdown_content, identifier, options.merge(filename: markdown_file_path))
20
+ end
21
+
22
+ def sync_content(markdown_content, identifier, options = {})
23
+ mode = options[:mode] || config.update_mode
24
+ database_id = options[:database_id] || config.database_id
25
+ page_id = options[:page_id] || config.page_id
26
+
27
+ converter = MarkdownConverter.new(markdown_content)
28
+ blocks = converter.to_notion_blocks
29
+ title = options[:title] || extract_title(converter, options[:filename])
30
+
31
+ if page_id
32
+ sync_to_page(page_id, blocks, mode, title)
33
+ elsif database_id
34
+ unique_property = options[:unique_property] || config.unique_property
35
+ sync_to_database(database_id, identifier, blocks, mode, title, unique_property, options)
36
+ else
37
+ raise ConfigurationError, 'Either page_id or database_id must be specified'
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def build_config(options)
44
+ if options.empty?
45
+ Synotion.configuration
46
+ else
47
+ config = Configuration.new
48
+ options.each do |key, value|
49
+ config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
50
+ end
51
+ config
52
+ end
53
+ end
54
+
55
+ def extract_title(converter, filename = nil)
56
+ case config.title_from
57
+ when :first_heading
58
+ converter.extract_title || (filename ? File.basename(filename, '.*') : 'Untitled')
59
+ when :filename
60
+ filename ? File.basename(filename, '.*') : 'Untitled'
61
+ when :custom
62
+ 'Untitled'
63
+ else
64
+ converter.extract_title || 'Untitled'
65
+ end
66
+ end
67
+
68
+ def sync_to_page(page_id, blocks, mode, title)
69
+ case mode
70
+ when UpdateMode::REPLACE
71
+ client.update_page(page_id: page_id, children: blocks, mode: mode)
72
+ { action: 'updated', page_id: page_id, mode: mode }
73
+ when UpdateMode::APPEND
74
+ client.update_page(page_id: page_id, children: blocks, mode: mode)
75
+ { action: 'appended', page_id: page_id, mode: mode }
76
+ else
77
+ raise ArgumentError, "Invalid mode for page sync: #{mode}. Use :replace or :append"
78
+ end
79
+ end
80
+
81
+ def sync_to_database(database_id, identifier, blocks, mode, title, unique_property, options)
82
+ existing_page = client.find_page(
83
+ database_id: database_id,
84
+ property: unique_property,
85
+ value: identifier
86
+ )
87
+
88
+ case mode
89
+ when UpdateMode::CREATE
90
+ if existing_page
91
+ { action: 'skipped', page_id: existing_page.id, reason: 'page already exists' }
92
+ else
93
+ create_database_page(database_id, identifier, blocks, title, unique_property, options)
94
+ end
95
+ when UpdateMode::UPSERT
96
+ if existing_page
97
+ update_database_page(existing_page.id, blocks, UpdateMode::REPLACE)
98
+ else
99
+ create_database_page(database_id, identifier, blocks, title, unique_property, options)
100
+ end
101
+ when UpdateMode::REPLACE
102
+ if existing_page
103
+ update_database_page(existing_page.id, blocks, UpdateMode::REPLACE)
104
+ else
105
+ raise PageNotFoundError, "Page not found for identifier: #{identifier}"
106
+ end
107
+ when UpdateMode::APPEND
108
+ if existing_page
109
+ update_database_page(existing_page.id, blocks, UpdateMode::APPEND)
110
+ else
111
+ raise PageNotFoundError, "Page not found for identifier: #{identifier}"
112
+ end
113
+ else
114
+ raise ArgumentError, "Invalid update mode: #{mode}"
115
+ end
116
+ end
117
+
118
+ def create_database_page(database_id, identifier, blocks, title, unique_property, options)
119
+ properties = build_properties(title, identifier, unique_property, options[:additional_properties])
120
+
121
+ response = client.create_page(
122
+ database_id: database_id,
123
+ properties: properties,
124
+ children: blocks
125
+ )
126
+
127
+ { action: 'created', page_id: response.id, mode: UpdateMode::CREATE }
128
+ end
129
+
130
+ def update_database_page(page_id, blocks, mode)
131
+ client.update_page(page_id: page_id, children: blocks, mode: mode)
132
+
133
+ action = mode == UpdateMode::REPLACE ? 'updated' : 'appended'
134
+ { action: action, page_id: page_id, mode: mode }
135
+ end
136
+
137
+ def build_properties(title, identifier, unique_property, additional_properties = {})
138
+ properties = {
139
+ 'title' => {
140
+ 'title' => [
141
+ {
142
+ 'text' => {
143
+ 'content' => title
144
+ }
145
+ }
146
+ ]
147
+ }
148
+ }
149
+
150
+ if unique_property != 'title'
151
+ properties[unique_property] = {
152
+ 'rich_text' => [
153
+ {
154
+ 'text' => {
155
+ 'content' => identifier
156
+ }
157
+ }
158
+ ]
159
+ }
160
+ end
161
+
162
+ if config.sync_metadata
163
+ properties['last_synced'] = {
164
+ 'date' => {
165
+ 'start' => Time.now.iso8601
166
+ }
167
+ }
168
+ end
169
+
170
+ properties.merge!(additional_properties) if additional_properties
171
+
172
+ properties
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,14 @@
1
+ module Synotion
2
+ module UpdateMode
3
+ CREATE = :create
4
+ APPEND = :append
5
+ REPLACE = :replace
6
+ UPSERT = :upsert
7
+
8
+ ALL = [CREATE, APPEND, REPLACE, UPSERT].freeze
9
+
10
+ def self.valid?(mode)
11
+ ALL.include?(mode)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ module Synotion
2
+ VERSION = "0.1.0"
3
+ end
data/lib/synotion.rb ADDED
@@ -0,0 +1,31 @@
1
+ require_relative "synotion/version"
2
+ require_relative "synotion/update_mode"
3
+ require_relative "synotion/configuration"
4
+ require_relative "synotion/client"
5
+ require_relative "synotion/markdown_converter"
6
+ require_relative "synotion/syncer"
7
+ require_relative "synotion/cli"
8
+
9
+ module Synotion
10
+ class Error < StandardError; end
11
+ class ConfigurationError < Error; end
12
+ class NotionAPIError < Error; end
13
+ class PageNotFoundError < Error; end
14
+ class MarkdownParseError < Error; end
15
+
16
+ class << self
17
+ attr_writer :configuration
18
+
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = Configuration.new
29
+ end
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: synotion
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: notion-ruby-client
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: redcarpet
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.6'
40
+ - !ruby/object:Gem::Dependency
41
+ name: front_matter_parser
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: thor
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.3'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.3'
68
+ description: A Ruby gem to synchronize Markdown files to Notion pages with support
69
+ for multiple update modes (create, append, replace, upsert)
70
+ email:
71
+ - t.yudai92@gmail.com
72
+ executables:
73
+ - synotion
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE.txt
79
+ - README.md
80
+ - Rakefile
81
+ - exe/synotion
82
+ - lib/synotion.rb
83
+ - lib/synotion/cli.rb
84
+ - lib/synotion/client.rb
85
+ - lib/synotion/configuration.rb
86
+ - lib/synotion/markdown_converter.rb
87
+ - lib/synotion/syncer.rb
88
+ - lib/synotion/update_mode.rb
89
+ - lib/synotion/version.rb
90
+ homepage: https://github.com/ydah/synotion
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ homepage_uri: https://github.com/ydah/synotion
95
+ source_code_uri: https://github.com/ydah/synotion
96
+ changelog_uri: https://github.com/ydah/synotion/blob/main/CHANGELOG.md
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 2.7.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.6.9
112
+ specification_version: 4
113
+ summary: Sync Markdown files to Notion pages
114
+ test_files: []