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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +167 -0
- data/Rakefile +8 -0
- data/exe/synotion +7 -0
- data/lib/synotion/cli.rb +66 -0
- data/lib/synotion/client.rb +106 -0
- data/lib/synotion/configuration.rb +27 -0
- data/lib/synotion/markdown_converter.rb +225 -0
- data/lib/synotion/syncer.rb +175 -0
- data/lib/synotion/update_mode.rb +14 -0
- data/lib/synotion/version.rb +3 -0
- data/lib/synotion.rb +31 -0
- metadata +114 -0
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
data/exe/synotion
ADDED
data/lib/synotion/cli.rb
ADDED
|
@@ -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
|
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: []
|