jekyll-notion-cms 1.0.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/.rspec +3 -0
- data/.rubocop.yml +87 -0
- data/CHANGELOG.md +48 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +362 -0
- data/Rakefile +26 -0
- data/docs/EXAMPLES_AND_CONFIGURATION.md +371 -0
- data/docs/architecture.excalidraw +3805 -0
- data/docs/architecture.png +0 -0
- data/docs/templates/github-actions_notion-sync.yml +101 -0
- data/docs/templates/n8n-workflow_Notion-database-change-trigger-GitHub-Actions.json +184 -0
- data/lib/jekyll-notion-cms.rb +14 -0
- data/lib/jekyll_notion_cms/data_organizers.rb +229 -0
- data/lib/jekyll_notion_cms/generator.rb +189 -0
- data/lib/jekyll_notion_cms/notion_client.rb +133 -0
- data/lib/jekyll_notion_cms/property_extractors.rb +331 -0
- data/lib/jekyll_notion_cms/version.rb +5 -0
- metadata +187 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module JekyllNotionCMS
|
|
7
|
+
# Jekyll Generator that fetches data from Notion databases
|
|
8
|
+
class Generator < Jekyll::Generator
|
|
9
|
+
safe true
|
|
10
|
+
priority :highest
|
|
11
|
+
|
|
12
|
+
def generate(site)
|
|
13
|
+
@site = site
|
|
14
|
+
@config = site.config['notion'] || {}
|
|
15
|
+
@collections_config = @config['collections'] || {}
|
|
16
|
+
|
|
17
|
+
unless @config['enabled'] != false
|
|
18
|
+
Jekyll.logger.info 'NotionCMS:', 'Plugin disabled in configuration'
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless ENV['NOTION_TOKEN']
|
|
23
|
+
Jekyll.logger.info 'NotionCMS:', 'No NOTION_TOKEN found, using collections fallback'
|
|
24
|
+
use_all_collections_fallback
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Jekyll.logger.info 'NotionCMS:', 'Fetching data from Notion API...'
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
@client = NotionClient.new(ENV.fetch('NOTION_TOKEN', nil))
|
|
32
|
+
|
|
33
|
+
@collections_config.each do |collection_name, collection_config|
|
|
34
|
+
fetch_collection_data(collection_name, collection_config)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Jekyll.logger.info 'NotionCMS:', 'All data fetched successfully'
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
Jekyll.logger.error 'NotionCMS:', "Error fetching data: #{e.message}"
|
|
40
|
+
Jekyll.logger.warn 'NotionCMS:', 'Falling back to collections'
|
|
41
|
+
use_all_collections_fallback
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Fetch data for a single collection
|
|
48
|
+
def fetch_collection_data(collection_name, config)
|
|
49
|
+
env_var = config['database_env']
|
|
50
|
+
data_file = config['data_file']
|
|
51
|
+
|
|
52
|
+
database_id = ENV.fetch(env_var, nil)
|
|
53
|
+
if database_id.nil? || database_id.empty? || database_id.start_with?('example_')
|
|
54
|
+
Jekyll.logger.info 'NotionCMS:', "No #{env_var} found, using fallback for #{collection_name}"
|
|
55
|
+
use_collection_fallback(collection_name, config)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
notion_data = @client.query_database(database_id)
|
|
61
|
+
organized_data = DataOrganizers.organize(notion_data, config)
|
|
62
|
+
|
|
63
|
+
if DataOrganizers.data_present?(organized_data)
|
|
64
|
+
data_key = data_file.sub('.yml', '').sub('.yaml', '')
|
|
65
|
+
@site.data[data_key] = organized_data
|
|
66
|
+
create_data_file(organized_data, data_file, collection_name)
|
|
67
|
+
|
|
68
|
+
count = organized_data.is_a?(Hash) ? organized_data.size : organized_data.length
|
|
69
|
+
Jekyll.logger.info 'NotionCMS:', "#{collection_name} fetched (#{count} items)"
|
|
70
|
+
else
|
|
71
|
+
Jekyll.logger.warn 'NotionCMS:', "No data found for #{collection_name}, using fallback"
|
|
72
|
+
use_collection_fallback(collection_name, config)
|
|
73
|
+
end
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
Jekyll.logger.error 'NotionCMS:', "Error fetching #{collection_name}: #{e.message}"
|
|
76
|
+
use_collection_fallback(collection_name, config)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Create a YAML data file
|
|
81
|
+
def create_data_file(data, file_name, collection_name)
|
|
82
|
+
data_dir = File.join(@site.source, '_data')
|
|
83
|
+
FileUtils.mkdir_p(data_dir)
|
|
84
|
+
|
|
85
|
+
data_file = File.join(data_dir, file_name)
|
|
86
|
+
new_content = data.to_yaml
|
|
87
|
+
|
|
88
|
+
# Skip if content unchanged
|
|
89
|
+
if File.exist?(data_file)
|
|
90
|
+
existing_content = File.read(data_file)
|
|
91
|
+
yaml_start = existing_content.index("---\n")
|
|
92
|
+
if yaml_start
|
|
93
|
+
existing_yaml = existing_content[yaml_start..]
|
|
94
|
+
if existing_yaml.strip == new_content.strip
|
|
95
|
+
Jekyll.logger.info 'NotionCMS:', "#{collection_name} data unchanged, skipping"
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
File.open(data_file, 'w') do |file|
|
|
102
|
+
file.write("# #{collection_name.capitalize} data imported from Notion\n")
|
|
103
|
+
file.write("# Auto-generated by jekyll-notion-cms - Do not edit manually\n")
|
|
104
|
+
file.write("# Last updated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
|
105
|
+
file.write(new_content)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Jekyll.logger.info 'NotionCMS:', "#{collection_name} written to _data/#{file_name}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Use fallback for all collections
|
|
112
|
+
def use_all_collections_fallback
|
|
113
|
+
@collections_config.each do |collection_name, config|
|
|
114
|
+
use_collection_fallback(collection_name, config)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Use Jekyll collections as fallback
|
|
119
|
+
def use_collection_fallback(collection_name, config)
|
|
120
|
+
Jekyll.logger.info 'NotionCMS:', "Using fallback for #{collection_name}"
|
|
121
|
+
|
|
122
|
+
data_file = config['data_file']
|
|
123
|
+
data_key = data_file.sub('.yml', '').sub('.yaml', '')
|
|
124
|
+
properties_config = config['properties'] || []
|
|
125
|
+
|
|
126
|
+
# Convert Jekyll collection to Notion-like format
|
|
127
|
+
mock_data = { 'results' => [] }
|
|
128
|
+
|
|
129
|
+
@site.collections[collection_name]&.docs&.each_with_index do |doc, index|
|
|
130
|
+
mock_data['results'] << {
|
|
131
|
+
'id' => "collection_#{index}",
|
|
132
|
+
'properties' => convert_doc_to_properties(doc.data, properties_config),
|
|
133
|
+
'created_time' => doc.data['date']&.to_s,
|
|
134
|
+
'last_edited_time' => doc.data['last_modified']&.to_s
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
organized_data = DataOrganizers.organize(mock_data, config)
|
|
139
|
+
@site.data[data_key] = organized_data
|
|
140
|
+
create_data_file(organized_data, data_file, collection_name)
|
|
141
|
+
|
|
142
|
+
count = organized_data.is_a?(Hash) ? organized_data.size : organized_data.length
|
|
143
|
+
Jekyll.logger.info 'NotionCMS:', "#{collection_name} fallback applied (#{count} items)"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Convert Jekyll document data to Notion-like properties
|
|
147
|
+
def convert_doc_to_properties(data, properties_config)
|
|
148
|
+
properties = {}
|
|
149
|
+
|
|
150
|
+
properties_config.each do |prop_config|
|
|
151
|
+
prop_name = prop_config['name']
|
|
152
|
+
prop_type = prop_config['type']
|
|
153
|
+
prop_key = prop_config['key'] || PropertyExtractors.normalize_key(prop_name)
|
|
154
|
+
|
|
155
|
+
value = data[prop_key] || data[prop_name.downcase] || data[prop_name]
|
|
156
|
+
next if value.nil?
|
|
157
|
+
|
|
158
|
+
properties[prop_name] = convert_value_to_notion_property(value, prop_type)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
properties
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Convert a value to Notion property format
|
|
165
|
+
def convert_value_to_notion_property(value, prop_type)
|
|
166
|
+
case prop_type
|
|
167
|
+
when 'title'
|
|
168
|
+
{ 'type' => 'title', 'title' => [{ 'plain_text' => value.to_s }] }
|
|
169
|
+
when 'rich_text'
|
|
170
|
+
{ 'type' => 'rich_text', 'rich_text' => [{ 'plain_text' => value.to_s }] }
|
|
171
|
+
when 'number'
|
|
172
|
+
{ 'type' => 'number', 'number' => value.to_i }
|
|
173
|
+
when 'checkbox'
|
|
174
|
+
{ 'type' => 'checkbox', 'checkbox' => !!value }
|
|
175
|
+
when 'date'
|
|
176
|
+
{ 'type' => 'date', 'date' => { 'start' => value.to_s } }
|
|
177
|
+
when 'select'
|
|
178
|
+
{ 'type' => 'select', 'select' => { 'name' => value.to_s } }
|
|
179
|
+
when 'multi_select'
|
|
180
|
+
items = value.is_a?(Array) ? value : [value]
|
|
181
|
+
{ 'type' => 'multi_select', 'multi_select' => items.map { |v| { 'name' => v.to_s } } }
|
|
182
|
+
when 'url'
|
|
183
|
+
{ 'type' => 'url', 'url' => value.to_s }
|
|
184
|
+
else
|
|
185
|
+
{ 'type' => 'rich_text', 'rich_text' => [{ 'plain_text' => value.to_s }] }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module JekyllNotionCMS
|
|
8
|
+
# Client for interacting with the Notion API
|
|
9
|
+
class NotionClient
|
|
10
|
+
NOTION_API_VERSION = '2022-06-28'
|
|
11
|
+
NOTION_API_BASE_URL = 'https://api.notion.com/v1'
|
|
12
|
+
|
|
13
|
+
def initialize(token)
|
|
14
|
+
@token = token
|
|
15
|
+
raise ConfigurationError, 'Notion token is required' if @token.nil? || @token.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Query a Notion database and return all results
|
|
19
|
+
# @param database_id [String] The ID of the database to query
|
|
20
|
+
# @param filter [Hash] Optional filter for the query
|
|
21
|
+
# @param sorts [Array] Optional sorts for the query
|
|
22
|
+
# @return [Hash] The API response with results
|
|
23
|
+
def query_database(database_id, filter: nil, sorts: nil)
|
|
24
|
+
all_results = []
|
|
25
|
+
start_cursor = nil
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
response = query_database_page(database_id, filter: filter, sorts: sorts, start_cursor: start_cursor)
|
|
29
|
+
all_results.concat(response['results'])
|
|
30
|
+
|
|
31
|
+
break unless response['has_more']
|
|
32
|
+
|
|
33
|
+
start_cursor = response['next_cursor']
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
{ 'results' => all_results }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Retrieve a single page
|
|
40
|
+
# @param page_id [String] The ID of the page to retrieve
|
|
41
|
+
# @return [Hash] The page data
|
|
42
|
+
def get_page(page_id)
|
|
43
|
+
uri = URI("#{NOTION_API_BASE_URL}/pages/#{page_id}")
|
|
44
|
+
request = build_request(:get, uri)
|
|
45
|
+
execute_request(uri, request)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Retrieve page content (blocks)
|
|
49
|
+
# @param page_id [String] The ID of the page
|
|
50
|
+
# @return [Hash] The blocks data
|
|
51
|
+
def get_page_content(page_id)
|
|
52
|
+
all_results = []
|
|
53
|
+
start_cursor = nil
|
|
54
|
+
|
|
55
|
+
loop do
|
|
56
|
+
uri = URI("#{NOTION_API_BASE_URL}/blocks/#{page_id}/children")
|
|
57
|
+
uri.query = URI.encode_www_form({ start_cursor: start_cursor, page_size: 100 }.compact)
|
|
58
|
+
|
|
59
|
+
request = build_request(:get, uri)
|
|
60
|
+
response = execute_request(uri, request)
|
|
61
|
+
|
|
62
|
+
all_results.concat(response['results'])
|
|
63
|
+
|
|
64
|
+
break unless response['has_more']
|
|
65
|
+
|
|
66
|
+
start_cursor = response['next_cursor']
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
{ 'results' => all_results }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def query_database_page(database_id, filter: nil, sorts: nil, start_cursor: nil)
|
|
75
|
+
uri = URI("#{NOTION_API_BASE_URL}/databases/#{database_id}/query")
|
|
76
|
+
|
|
77
|
+
body = { page_size: 100 }
|
|
78
|
+
body[:filter] = filter if filter
|
|
79
|
+
body[:sorts] = sorts if sorts
|
|
80
|
+
body[:start_cursor] = start_cursor if start_cursor
|
|
81
|
+
|
|
82
|
+
request = build_request(:post, uri)
|
|
83
|
+
request.body = body.to_json
|
|
84
|
+
|
|
85
|
+
execute_request(uri, request)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_request(method, uri)
|
|
89
|
+
request_class = case method
|
|
90
|
+
when :get then Net::HTTP::Get
|
|
91
|
+
when :post then Net::HTTP::Post
|
|
92
|
+
else raise ArgumentError, "Unknown method: #{method}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
request = request_class.new(uri)
|
|
96
|
+
request['Authorization'] = "Bearer #{@token}"
|
|
97
|
+
request['Notion-Version'] = NOTION_API_VERSION
|
|
98
|
+
request['Content-Type'] = 'application/json'
|
|
99
|
+
request
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def execute_request(uri, request)
|
|
103
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
104
|
+
http.request(request)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
handle_response(response)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_response(response)
|
|
111
|
+
case response
|
|
112
|
+
when Net::HTTPSuccess
|
|
113
|
+
JSON.parse(response.body)
|
|
114
|
+
when Net::HTTPUnauthorized
|
|
115
|
+
raise APIError, 'Invalid Notion token (401 Unauthorized)'
|
|
116
|
+
when Net::HTTPNotFound
|
|
117
|
+
raise APIError, 'Database or page not found (404 Not Found)'
|
|
118
|
+
when Net::HTTPTooManyRequests
|
|
119
|
+
raise APIError, 'Rate limit exceeded (429 Too Many Requests)'
|
|
120
|
+
else
|
|
121
|
+
error_message = parse_error_message(response)
|
|
122
|
+
raise APIError, "Notion API error: #{response.code} #{error_message}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_error_message(response)
|
|
127
|
+
error_json = JSON.parse(response.body)
|
|
128
|
+
error_json['message'] || response.message
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
response.message
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllNotionCMS
|
|
4
|
+
# Module for extracting values from Notion property types
|
|
5
|
+
module PropertyExtractors
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Extract a property value based on its type
|
|
9
|
+
# @param properties [Hash] The properties hash from a Notion page
|
|
10
|
+
# @param property_name [String] The name of the property
|
|
11
|
+
# @param property_type [String] The expected type of the property
|
|
12
|
+
# @return [Object] The extracted value
|
|
13
|
+
def extract(properties, property_name, property_type)
|
|
14
|
+
property = properties[property_name]
|
|
15
|
+
return nil unless property
|
|
16
|
+
|
|
17
|
+
case property_type
|
|
18
|
+
when 'title'
|
|
19
|
+
extract_title(property)
|
|
20
|
+
when 'rich_text'
|
|
21
|
+
extract_rich_text(property)
|
|
22
|
+
when 'number'
|
|
23
|
+
extract_number(property)
|
|
24
|
+
when 'checkbox'
|
|
25
|
+
extract_checkbox(property)
|
|
26
|
+
when 'date'
|
|
27
|
+
extract_date(property)
|
|
28
|
+
when 'select'
|
|
29
|
+
extract_select(property)
|
|
30
|
+
when 'multi_select'
|
|
31
|
+
extract_multi_select(property)
|
|
32
|
+
when 'url'
|
|
33
|
+
extract_url(property)
|
|
34
|
+
when 'email'
|
|
35
|
+
extract_email(property)
|
|
36
|
+
when 'phone_number'
|
|
37
|
+
extract_phone_number(property)
|
|
38
|
+
when 'rollup'
|
|
39
|
+
extract_rollup(property)
|
|
40
|
+
when 'formula'
|
|
41
|
+
extract_formula(property)
|
|
42
|
+
when 'formula_array'
|
|
43
|
+
extract_formula_array(property)
|
|
44
|
+
when 'relation'
|
|
45
|
+
extract_relation(property)
|
|
46
|
+
when 'people'
|
|
47
|
+
extract_people(property)
|
|
48
|
+
when 'files'
|
|
49
|
+
extract_files(property)
|
|
50
|
+
when 'created_time'
|
|
51
|
+
extract_created_time(property)
|
|
52
|
+
when 'last_edited_time'
|
|
53
|
+
extract_last_edited_time(property)
|
|
54
|
+
when 'status'
|
|
55
|
+
extract_status(property)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extract all properties from a Notion page based on configuration
|
|
60
|
+
# @param properties [Hash] The properties hash from a Notion page
|
|
61
|
+
# @param properties_config [Array<Hash>] Configuration for each property
|
|
62
|
+
# @return [Hash] Extracted properties with normalized keys
|
|
63
|
+
def extract_all(properties, properties_config)
|
|
64
|
+
item = {}
|
|
65
|
+
|
|
66
|
+
properties_config.each do |prop_config|
|
|
67
|
+
prop_name = prop_config['name']
|
|
68
|
+
prop_type = prop_config['type']
|
|
69
|
+
prop_key = prop_config['key'] || normalize_key(prop_name)
|
|
70
|
+
|
|
71
|
+
value = extract(properties, prop_name, prop_type)
|
|
72
|
+
item[prop_key] = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Use 'title' as the main identifier, fall back to 'name'
|
|
76
|
+
item['title'] ||= item['name']
|
|
77
|
+
|
|
78
|
+
item
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Normalize a property name to a valid key
|
|
82
|
+
# @param name [String] The property name
|
|
83
|
+
# @return [String] The normalized key
|
|
84
|
+
def normalize_key(name)
|
|
85
|
+
name.downcase.gsub(/\s+/, '_')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Title property
|
|
89
|
+
def extract_title(property)
|
|
90
|
+
return nil unless property['type'] == 'title'
|
|
91
|
+
|
|
92
|
+
property['title'].map { |text| text['plain_text'] }.join
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Rich text property
|
|
96
|
+
def extract_rich_text(property)
|
|
97
|
+
return nil unless property['type'] == 'rich_text'
|
|
98
|
+
return nil if property['rich_text'].nil? || property['rich_text'].empty?
|
|
99
|
+
|
|
100
|
+
property['rich_text'].map { |text| text['plain_text'] }.join
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Number property (also handles select-to-number conversion)
|
|
104
|
+
def extract_number(property)
|
|
105
|
+
case property['type']
|
|
106
|
+
when 'number'
|
|
107
|
+
property['number']
|
|
108
|
+
when 'select'
|
|
109
|
+
convert_select_to_number(property['select']&.dig('name'))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Convert common select values to numbers
|
|
114
|
+
def convert_select_to_number(value)
|
|
115
|
+
case value
|
|
116
|
+
when 'Expert', 'Avancé', 'Advanced' then 90
|
|
117
|
+
when 'Intermédiaire', 'Intermediate' then 70
|
|
118
|
+
when 'Débutant', 'Beginner' then 50
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Checkbox property
|
|
123
|
+
def extract_checkbox(property)
|
|
124
|
+
property['type'] == 'checkbox' ? property['checkbox'] : false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Date property
|
|
128
|
+
def extract_date(property)
|
|
129
|
+
return nil unless property['type'] == 'date'
|
|
130
|
+
return nil if property['date'].nil?
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
'start' => property['date']['start'],
|
|
134
|
+
'end' => property['date']['end'],
|
|
135
|
+
'time_zone' => property['date']['time_zone']
|
|
136
|
+
}.compact
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Select property
|
|
140
|
+
def extract_select(property)
|
|
141
|
+
return nil unless property['type'] == 'select'
|
|
142
|
+
|
|
143
|
+
property['select']&.dig('name')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Multi-select property
|
|
147
|
+
def extract_multi_select(property)
|
|
148
|
+
return [] unless property['type'] == 'multi_select'
|
|
149
|
+
|
|
150
|
+
property['multi_select'].map { |item| item['name'] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# URL property (also handles rich_text containing URLs)
|
|
154
|
+
def extract_url(property)
|
|
155
|
+
case property['type']
|
|
156
|
+
when 'url'
|
|
157
|
+
property['url']
|
|
158
|
+
when 'rich_text'
|
|
159
|
+
extract_rich_text(property)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Email property
|
|
164
|
+
def extract_email(property)
|
|
165
|
+
return nil unless property['type'] == 'email'
|
|
166
|
+
|
|
167
|
+
property['email']
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Phone number property
|
|
171
|
+
def extract_phone_number(property)
|
|
172
|
+
return nil unless property['type'] == 'phone_number'
|
|
173
|
+
|
|
174
|
+
property['phone_number']
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Rollup property
|
|
178
|
+
def extract_rollup(property)
|
|
179
|
+
return nil unless property['type'] == 'rollup'
|
|
180
|
+
return nil unless property['rollup']
|
|
181
|
+
|
|
182
|
+
rollup = property['rollup']
|
|
183
|
+
|
|
184
|
+
case rollup['type']
|
|
185
|
+
when 'array'
|
|
186
|
+
extract_rollup_array(rollup['array'])
|
|
187
|
+
when 'number'
|
|
188
|
+
rollup['number']
|
|
189
|
+
when 'date'
|
|
190
|
+
rollup['date']&.dig('start')
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Extract first value from rollup array
|
|
195
|
+
def extract_rollup_array(array)
|
|
196
|
+
return nil if array.nil? || array.empty?
|
|
197
|
+
|
|
198
|
+
array.map do |item|
|
|
199
|
+
case item['type']
|
|
200
|
+
when 'title'
|
|
201
|
+
item['title'].map { |text| text['plain_text'] }.join
|
|
202
|
+
when 'rich_text'
|
|
203
|
+
item['rich_text'].map { |text| text['plain_text'] }.join
|
|
204
|
+
when 'select'
|
|
205
|
+
item['select']&.dig('name')
|
|
206
|
+
when 'number'
|
|
207
|
+
item['number']
|
|
208
|
+
end
|
|
209
|
+
end.compact.first
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Formula property
|
|
213
|
+
def extract_formula(property)
|
|
214
|
+
return nil unless property['type'] == 'formula'
|
|
215
|
+
return nil if property['formula'].nil?
|
|
216
|
+
|
|
217
|
+
formula = property['formula']
|
|
218
|
+
|
|
219
|
+
case formula['type']
|
|
220
|
+
when 'string'
|
|
221
|
+
formula['string']
|
|
222
|
+
when 'number'
|
|
223
|
+
formula['number']
|
|
224
|
+
when 'boolean'
|
|
225
|
+
formula['boolean']
|
|
226
|
+
when 'date'
|
|
227
|
+
formula['date']&.dig('start')
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Formula returning array
|
|
232
|
+
def extract_formula_array(property)
|
|
233
|
+
return [] unless property['type'] == 'formula'
|
|
234
|
+
return [] if property['formula'].nil?
|
|
235
|
+
|
|
236
|
+
formula = property['formula']
|
|
237
|
+
|
|
238
|
+
case formula['type']
|
|
239
|
+
when 'array'
|
|
240
|
+
extract_formula_array_items(formula['array'])
|
|
241
|
+
when 'string'
|
|
242
|
+
parse_formula_string(formula['string'])
|
|
243
|
+
else
|
|
244
|
+
[]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Extract items from formula array
|
|
249
|
+
def extract_formula_array_items(array)
|
|
250
|
+
return [] unless array
|
|
251
|
+
|
|
252
|
+
array.map do |item|
|
|
253
|
+
case item['type']
|
|
254
|
+
when 'string'
|
|
255
|
+
item['string']
|
|
256
|
+
when 'rich_text'
|
|
257
|
+
item['rich_text']&.map { |text| text['plain_text'] }&.join
|
|
258
|
+
end
|
|
259
|
+
end.compact
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Parse formula string to array (split by delimiter)
|
|
263
|
+
def parse_formula_string(string_value)
|
|
264
|
+
return [] if string_value.nil? || string_value.empty?
|
|
265
|
+
|
|
266
|
+
string_value.split('- ').map do |item|
|
|
267
|
+
cleaned = item.strip.gsub(/^\.+|\.+$/, '')
|
|
268
|
+
cleaned.empty? ? nil : cleaned
|
|
269
|
+
end.compact
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Relation property
|
|
273
|
+
def extract_relation(property)
|
|
274
|
+
return [] unless property['type'] == 'relation'
|
|
275
|
+
return [] if property['relation'].nil? || property['relation'].empty?
|
|
276
|
+
|
|
277
|
+
property['relation'].map { |relation| relation['id'] }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# People property
|
|
281
|
+
def extract_people(property)
|
|
282
|
+
return [] unless property['type'] == 'people'
|
|
283
|
+
return [] if property['people'].nil? || property['people'].empty?
|
|
284
|
+
|
|
285
|
+
property['people'].map do |person|
|
|
286
|
+
{
|
|
287
|
+
'id' => person['id'],
|
|
288
|
+
'name' => person['name'],
|
|
289
|
+
'email' => person.dig('person', 'email'),
|
|
290
|
+
'avatar_url' => person['avatar_url']
|
|
291
|
+
}.compact
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Files property
|
|
296
|
+
def extract_files(property)
|
|
297
|
+
return [] unless property['type'] == 'files'
|
|
298
|
+
return [] if property['files'].nil? || property['files'].empty?
|
|
299
|
+
|
|
300
|
+
property['files'].map do |file|
|
|
301
|
+
url = file.dig('file', 'url') || file.dig('external', 'url')
|
|
302
|
+
{
|
|
303
|
+
'name' => file['name'],
|
|
304
|
+
'url' => url,
|
|
305
|
+
'type' => file['type']
|
|
306
|
+
}.compact
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Created time property
|
|
311
|
+
def extract_created_time(property)
|
|
312
|
+
return nil unless property['type'] == 'created_time'
|
|
313
|
+
|
|
314
|
+
property['created_time']
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Last edited time property
|
|
318
|
+
def extract_last_edited_time(property)
|
|
319
|
+
return nil unless property['type'] == 'last_edited_time'
|
|
320
|
+
|
|
321
|
+
property['last_edited_time']
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Status property
|
|
325
|
+
def extract_status(property)
|
|
326
|
+
return nil unless property['type'] == 'status'
|
|
327
|
+
|
|
328
|
+
property['status']&.dig('name')
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|