jekyll-baserow-headless-cms 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/.claude/settings.json +12 -0
- data/.mcp.json +68 -0
- data/.rspec +3 -0
- data/.rubocop.yml +87 -0
- data/CHANGELOG.md +38 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +291 -0
- data/Rakefile +26 -0
- data/docs/EXAMPLES_AND_CONFIGURATION.md +407 -0
- data/docs/architecture.excalidraw +3979 -0
- data/docs/architecture.png +0 -0
- data/docs/architecture.svg +5 -0
- data/docs/templates/github-actions_baserow-sync.yml +61 -0
- data/lib/jekyll-baserow-headless-cms.rb +14 -0
- data/lib/jekyll_baserow_headless_cms/baserow_client.rb +106 -0
- data/lib/jekyll_baserow_headless_cms/data_organizers.rb +249 -0
- data/lib/jekyll_baserow_headless_cms/field_extractors.rb +182 -0
- data/lib/jekyll_baserow_headless_cms/generator.rb +178 -0
- data/lib/jekyll_baserow_headless_cms/version.rb +5 -0
- metadata +189 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Baserow Sync Workflow
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
baserow_event:
|
|
7
|
+
description: 'Type of Baserow event'
|
|
8
|
+
required: false
|
|
9
|
+
type: string
|
|
10
|
+
default: 'webhook'
|
|
11
|
+
row_id:
|
|
12
|
+
description: 'Baserow row ID'
|
|
13
|
+
required: false
|
|
14
|
+
type: string
|
|
15
|
+
table_id:
|
|
16
|
+
description: 'Baserow table ID'
|
|
17
|
+
required: false
|
|
18
|
+
type: string
|
|
19
|
+
default: 'all'
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
contents: read
|
|
23
|
+
pages: write
|
|
24
|
+
id-token: write
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
build:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- name: Checkout
|
|
31
|
+
uses: actions/checkout@v4
|
|
32
|
+
|
|
33
|
+
- name: Setup Ruby
|
|
34
|
+
uses: ruby/setup-ruby@v1
|
|
35
|
+
with:
|
|
36
|
+
ruby-version: '3.3'
|
|
37
|
+
bundler-cache: true
|
|
38
|
+
|
|
39
|
+
- name: Build with Jekyll
|
|
40
|
+
run: bundle exec jekyll build
|
|
41
|
+
env:
|
|
42
|
+
JEKYLL_ENV: production
|
|
43
|
+
BASEROW_TOKEN: ${{ secrets.BASEROW_TOKEN }}
|
|
44
|
+
BASEROW_SKILLS_TABLE: ${{ secrets.BASEROW_SKILLS_TABLE }}
|
|
45
|
+
BASEROW_EXPERIENCES_TABLE: ${{ secrets.BASEROW_EXPERIENCES_TABLE }}
|
|
46
|
+
BASEROW_BLOG_TABLE: ${{ secrets.BASEROW_BLOG_TABLE }}
|
|
47
|
+
# Add more table secrets as needed
|
|
48
|
+
|
|
49
|
+
- name: Upload artifact
|
|
50
|
+
uses: actions/upload-pages-artifact@v3
|
|
51
|
+
|
|
52
|
+
deploy:
|
|
53
|
+
environment:
|
|
54
|
+
name: github-pages
|
|
55
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
needs: build
|
|
58
|
+
steps:
|
|
59
|
+
- name: Deploy to GitHub Pages
|
|
60
|
+
id: deployment
|
|
61
|
+
uses: actions/deploy-pages@v4
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jekyll'
|
|
4
|
+
require_relative 'jekyll_baserow_headless_cms/version'
|
|
5
|
+
require_relative 'jekyll_baserow_headless_cms/baserow_client'
|
|
6
|
+
require_relative 'jekyll_baserow_headless_cms/field_extractors'
|
|
7
|
+
require_relative 'jekyll_baserow_headless_cms/data_organizers'
|
|
8
|
+
require_relative 'jekyll_baserow_headless_cms/generator'
|
|
9
|
+
|
|
10
|
+
module JekyllBaserowHeadlessCMS
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class ConfigurationError < Error; end
|
|
13
|
+
class APIError < Error; end
|
|
14
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module JekyllBaserowHeadlessCMS
|
|
8
|
+
# Client for interacting with the Baserow REST API
|
|
9
|
+
class BaserowClient
|
|
10
|
+
DEFAULT_API_BASE_URL = 'https://api.baserow.io'
|
|
11
|
+
ROW_PAGE_SIZE = 200
|
|
12
|
+
|
|
13
|
+
def initialize(token, base_url: nil)
|
|
14
|
+
@token = token
|
|
15
|
+
raise ConfigurationError, 'Baserow token is required' if @token.nil? || @token.empty?
|
|
16
|
+
|
|
17
|
+
@base_url = (base_url && !base_url.empty? ? base_url : DEFAULT_API_BASE_URL).chomp('/')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Query a Baserow table and return all rows
|
|
21
|
+
# @param table_id [String, Integer] The ID of the table to query
|
|
22
|
+
# @param order_by [String] Optional Baserow order_by expression (e.g. "field_name" or "-field_name")
|
|
23
|
+
# @param filters [String] Optional Baserow filters expression (raw query string value)
|
|
24
|
+
# @return [Hash] The API response with results
|
|
25
|
+
def query_table(table_id, order_by: nil, filters: nil)
|
|
26
|
+
all_results = []
|
|
27
|
+
uri = build_rows_uri(table_id, order_by: order_by, filters: filters)
|
|
28
|
+
|
|
29
|
+
loop do
|
|
30
|
+
response = execute_request(uri, build_request(:get, uri))
|
|
31
|
+
all_results.concat(response['results'])
|
|
32
|
+
|
|
33
|
+
break unless response['next']
|
|
34
|
+
|
|
35
|
+
uri = URI(response['next'])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{ 'results' => all_results }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Retrieve a single row
|
|
42
|
+
# @param table_id [String, Integer] The ID of the table
|
|
43
|
+
# @param row_id [String, Integer] The ID of the row to retrieve
|
|
44
|
+
# @return [Hash] The row data
|
|
45
|
+
def get_row(table_id, row_id)
|
|
46
|
+
uri = URI("#{@base_url}/api/database/rows/table/#{table_id}/#{row_id}/")
|
|
47
|
+
uri.query = URI.encode_www_form({ user_field_names: true })
|
|
48
|
+
|
|
49
|
+
execute_request(uri, build_request(:get, uri))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_rows_uri(table_id, order_by:, filters:)
|
|
55
|
+
uri = URI("#{@base_url}/api/database/rows/table/#{table_id}/")
|
|
56
|
+
params = { user_field_names: true, size: ROW_PAGE_SIZE }
|
|
57
|
+
params[:order_by] = order_by if order_by
|
|
58
|
+
params[:filters] = filters if filters
|
|
59
|
+
uri.query = URI.encode_www_form(params)
|
|
60
|
+
uri
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_request(method, uri)
|
|
64
|
+
request_class = case method
|
|
65
|
+
when :get then Net::HTTP::Get
|
|
66
|
+
else raise ArgumentError, "Unknown method: #{method}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
request = request_class.new(uri)
|
|
70
|
+
request['Authorization'] = "Token #{@token}"
|
|
71
|
+
request['Content-Type'] = 'application/json'
|
|
72
|
+
request
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def execute_request(uri, request)
|
|
76
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
77
|
+
http.request(request)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
handle_response(response)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def handle_response(response)
|
|
84
|
+
case response
|
|
85
|
+
when Net::HTTPSuccess
|
|
86
|
+
JSON.parse(response.body)
|
|
87
|
+
when Net::HTTPUnauthorized
|
|
88
|
+
raise APIError, 'Invalid Baserow token (401 Unauthorized)'
|
|
89
|
+
when Net::HTTPNotFound
|
|
90
|
+
raise APIError, 'Table or row not found (404 Not Found)'
|
|
91
|
+
when Net::HTTPTooManyRequests
|
|
92
|
+
raise APIError, 'Rate limit exceeded (429 Too Many Requests)'
|
|
93
|
+
else
|
|
94
|
+
error_message = parse_error_message(response)
|
|
95
|
+
raise APIError, "Baserow API error: #{response.code} #{error_message}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_error_message(response)
|
|
100
|
+
error_json = JSON.parse(response.body)
|
|
101
|
+
error_json['detail'] || error_json['error'] || response.message
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
response.message
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllBaserowHeadlessCMS
|
|
4
|
+
# Module for organizing Baserow data into different structures
|
|
5
|
+
module DataOrganizers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Organize data based on the specified organizer type
|
|
9
|
+
# @param baserow_data [Hash] Raw data from the Baserow API (rows under 'results')
|
|
10
|
+
# @param config [Hash] Collection configuration
|
|
11
|
+
# @return [Hash, Array] Organized data
|
|
12
|
+
def organize(baserow_data, config)
|
|
13
|
+
organizer = config['organizer'] || 'simple_list'
|
|
14
|
+
fields_config = config['fields'] || []
|
|
15
|
+
sort_by = config['sort_by']
|
|
16
|
+
sort_order = config['sort_order'] || 'asc'
|
|
17
|
+
|
|
18
|
+
case organizer
|
|
19
|
+
when 'simple_list'
|
|
20
|
+
organize_simple_list(baserow_data, fields_config, sort_by, sort_order)
|
|
21
|
+
when 'items_by_category'
|
|
22
|
+
organize_items_by_category(baserow_data, fields_config)
|
|
23
|
+
when 'grouped_by'
|
|
24
|
+
group_field = config['group_by']
|
|
25
|
+
organize_grouped_by(baserow_data, fields_config, group_field, sort_by, sort_order)
|
|
26
|
+
when 'nested'
|
|
27
|
+
parent_field = config['parent_field'] || 'parent_id'
|
|
28
|
+
organize_nested(baserow_data, fields_config, parent_field, sort_by, sort_order)
|
|
29
|
+
else
|
|
30
|
+
organize_simple_list(baserow_data, fields_config, sort_by, sort_order)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Organize as a simple sorted list
|
|
35
|
+
# @param baserow_data [Hash] Raw data from the Baserow API
|
|
36
|
+
# @param fields_config [Array<Hash>] Field configuration
|
|
37
|
+
# @param sort_by [String] Field to sort by
|
|
38
|
+
# @param sort_order [String] Sort order ('asc' or 'desc')
|
|
39
|
+
# @return [Array<Hash>] Sorted list of items
|
|
40
|
+
def organize_simple_list(baserow_data, fields_config, sort_by, sort_order)
|
|
41
|
+
items = baserow_data['results'].map do |row|
|
|
42
|
+
item = FieldExtractors.extract_all(row, fields_config)
|
|
43
|
+
item['id'] = row['id']
|
|
44
|
+
item
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Filter out items without title
|
|
48
|
+
items = items.select { |item| item['title'] && !item['title'].to_s.empty? }
|
|
49
|
+
|
|
50
|
+
# Sort if sort_by is specified
|
|
51
|
+
sort_items(items, sort_by, sort_order)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Organize items grouped by category
|
|
55
|
+
# Useful for skills, products, team members, or any items with category grouping
|
|
56
|
+
# @param baserow_data [Hash] Raw data from the Baserow API
|
|
57
|
+
# @param fields_config [Array<Hash>] Field configuration
|
|
58
|
+
# @return [Hash] Items grouped by category
|
|
59
|
+
def organize_items_by_category(baserow_data, _fields_config)
|
|
60
|
+
items_by_category = {}
|
|
61
|
+
|
|
62
|
+
baserow_data['results'].each do |row|
|
|
63
|
+
process_category_row(row, items_by_category)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sort_categories_and_items(items_by_category)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Process a single row for category organization
|
|
70
|
+
def process_category_row(row, items_by_category)
|
|
71
|
+
name = FieldExtractors.extract(row, 'Name', 'text')
|
|
72
|
+
return if name.nil? || name.empty?
|
|
73
|
+
|
|
74
|
+
category_data = extract_category_data(row)
|
|
75
|
+
category_name = category_data[:name]
|
|
76
|
+
|
|
77
|
+
items_by_category[category_name] ||= build_category_hash(category_data)
|
|
78
|
+
items_by_category[category_name]['items'] << build_category_item(row, name, category_data)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Extract category-related data from a row (via lookup fields, Baserow's rollup equivalent)
|
|
82
|
+
def extract_category_data(row)
|
|
83
|
+
{
|
|
84
|
+
name: FieldExtractors.extract(row, 'Category', 'lookup') || 'Other',
|
|
85
|
+
icon: FieldExtractors.extract(row, 'Icon', 'lookup'),
|
|
86
|
+
color: FieldExtractors.extract(row, 'Color', 'lookup'),
|
|
87
|
+
order: FieldExtractors.extract(row, 'Category Order', 'lookup')
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build the category hash structure
|
|
92
|
+
def build_category_hash(category_data)
|
|
93
|
+
order = category_data[:order]
|
|
94
|
+
{
|
|
95
|
+
'title' => category_data[:name],
|
|
96
|
+
'category' => category_data[:name],
|
|
97
|
+
'subcategory' => nil,
|
|
98
|
+
'icon' => category_data[:icon],
|
|
99
|
+
'order' => (order.is_a?(Array) ? order.first : order) || 999,
|
|
100
|
+
'items' => []
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Build an item hash for category organization
|
|
105
|
+
def build_category_item(row, name, category_data)
|
|
106
|
+
{
|
|
107
|
+
'name' => name,
|
|
108
|
+
'level' => FieldExtractors.extract(row, 'Level', 'number'),
|
|
109
|
+
'years' => FieldExtractors.extract(row, 'Years', 'number'),
|
|
110
|
+
'description' => nil,
|
|
111
|
+
'icon' => nil,
|
|
112
|
+
'color' => category_data[:color],
|
|
113
|
+
'featured' => FieldExtractors.extract(row, 'Featured', 'boolean'),
|
|
114
|
+
'order' => FieldExtractors.extract(row, 'Order', 'number') || 999,
|
|
115
|
+
'id' => row['id']
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sort categories and their items
|
|
120
|
+
def sort_categories_and_items(items_by_category)
|
|
121
|
+
sorted = items_by_category.sort_by do |_, data|
|
|
122
|
+
order = data['order']
|
|
123
|
+
(order.is_a?(Array) ? order.first : order).to_i
|
|
124
|
+
end.to_h
|
|
125
|
+
|
|
126
|
+
sorted.each_value do |data|
|
|
127
|
+
data['items'].sort_by! { |item| item['order'].to_i }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
sorted
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Organize items grouped by a field
|
|
134
|
+
# @param baserow_data [Hash] Raw data from the Baserow API
|
|
135
|
+
# @param fields_config [Array<Hash>] Field configuration
|
|
136
|
+
# @param group_field [String] Field to group by
|
|
137
|
+
# @param sort_by [String] Field to sort by within groups
|
|
138
|
+
# @param sort_order [String] Sort order
|
|
139
|
+
# @return [Hash] Items grouped by field
|
|
140
|
+
def organize_grouped_by(baserow_data, fields_config, group_field, sort_by, sort_order)
|
|
141
|
+
grouped = {}
|
|
142
|
+
|
|
143
|
+
baserow_data['results'].each do |row|
|
|
144
|
+
item = FieldExtractors.extract_all(row, fields_config)
|
|
145
|
+
item['id'] = row['id']
|
|
146
|
+
|
|
147
|
+
next if item['title'].nil? || item['title'].to_s.empty?
|
|
148
|
+
|
|
149
|
+
group_key = item[group_field]
|
|
150
|
+
group_key = group_key.first if group_key.is_a?(Array)
|
|
151
|
+
group_key ||= 'Other'
|
|
152
|
+
|
|
153
|
+
grouped[group_key] ||= []
|
|
154
|
+
grouped[group_key] << item
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Sort within groups
|
|
158
|
+
grouped.each_value do |items|
|
|
159
|
+
sort_items(items, sort_by, sort_order)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
grouped
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Organize items in a nested tree structure
|
|
166
|
+
# @param baserow_data [Hash] Raw data from the Baserow API
|
|
167
|
+
# @param fields_config [Array<Hash>] Field configuration
|
|
168
|
+
# @param parent_field [String] Field containing parent reference
|
|
169
|
+
# @param sort_by [String] Field to sort by
|
|
170
|
+
# @param sort_order [String] Sort order
|
|
171
|
+
# @return [Array<Hash>] Nested tree of items
|
|
172
|
+
def organize_nested(baserow_data, fields_config, parent_field, sort_by, sort_order)
|
|
173
|
+
items = {}
|
|
174
|
+
roots = []
|
|
175
|
+
|
|
176
|
+
# First pass: extract all items
|
|
177
|
+
baserow_data['results'].each do |row|
|
|
178
|
+
item = FieldExtractors.extract_all(row, fields_config)
|
|
179
|
+
item['id'] = row['id']
|
|
180
|
+
item['children'] = []
|
|
181
|
+
items[row['id']] = item
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Second pass: build tree structure
|
|
185
|
+
items.each_value do |item|
|
|
186
|
+
parent_ids = item[parent_field]
|
|
187
|
+
parent_id = parent_ids.is_a?(Array) ? parent_ids.first : parent_ids
|
|
188
|
+
|
|
189
|
+
if parent_id && items[parent_id]
|
|
190
|
+
items[parent_id]['children'] << item
|
|
191
|
+
else
|
|
192
|
+
roots << item
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Sort at each level
|
|
197
|
+
sort_nested(roots, sort_by, sort_order)
|
|
198
|
+
|
|
199
|
+
roots
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Sort items by field
|
|
203
|
+
# @param items [Array<Hash>] Items to sort
|
|
204
|
+
# @param sort_by [String] Field to sort by
|
|
205
|
+
# @param sort_order [String] Sort order ('asc' or 'desc')
|
|
206
|
+
# @return [Array<Hash>] Sorted items
|
|
207
|
+
def sort_items(items, sort_by, sort_order)
|
|
208
|
+
return items unless sort_by && !sort_by.empty?
|
|
209
|
+
|
|
210
|
+
items.sort_by! do |item|
|
|
211
|
+
value = item[sort_by]
|
|
212
|
+
case value
|
|
213
|
+
when nil then sort_order == 'desc' ? -Float::INFINITY : Float::INFINITY
|
|
214
|
+
when Numeric then value
|
|
215
|
+
when String then value.downcase
|
|
216
|
+
else value.to_s.downcase
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
items.reverse! if sort_order == 'desc'
|
|
221
|
+
items
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Recursively sort nested items
|
|
225
|
+
# @param items [Array<Hash>] Items to sort
|
|
226
|
+
# @param sort_by [String] Field to sort by
|
|
227
|
+
# @param sort_order [String] Sort order
|
|
228
|
+
def sort_nested(items, sort_by, sort_order)
|
|
229
|
+
sort_items(items, sort_by, sort_order)
|
|
230
|
+
|
|
231
|
+
items.each do |item|
|
|
232
|
+
sort_nested(item['children'], sort_by, sort_order) if item['children']&.any?
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Check if data is present (non-empty)
|
|
237
|
+
# @param data [Hash, Array] Data to check
|
|
238
|
+
# @return [Boolean] True if data is present
|
|
239
|
+
def data_present?(data)
|
|
240
|
+
return false if data.nil?
|
|
241
|
+
|
|
242
|
+
if data.is_a?(Hash)
|
|
243
|
+
data.size.positive?
|
|
244
|
+
else
|
|
245
|
+
data.length.positive?
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllBaserowHeadlessCMS
|
|
4
|
+
# Module for extracting values from Baserow field types
|
|
5
|
+
#
|
|
6
|
+
# Unlike Notion, Baserow returns row data as a flat hash (with
|
|
7
|
+
# `user_field_names=true`) where each key is already the field's value in
|
|
8
|
+
# its natural JSON shape. The declared +field_type+ tells us how to
|
|
9
|
+
# interpret that shape.
|
|
10
|
+
module FieldExtractors
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Extract a field value based on its type
|
|
14
|
+
# @param row [Hash] A row hash from the Baserow API
|
|
15
|
+
# @param field_name [String] The name of the field
|
|
16
|
+
# @param field_type [String] The expected type of the field
|
|
17
|
+
# @return [Object] The extracted value
|
|
18
|
+
def extract(row, field_name, field_type)
|
|
19
|
+
return nil unless row.key?(field_name)
|
|
20
|
+
|
|
21
|
+
value = row[field_name]
|
|
22
|
+
|
|
23
|
+
case field_type
|
|
24
|
+
when 'text', 'long_text'
|
|
25
|
+
extract_text(value)
|
|
26
|
+
when 'number', 'rating', 'count'
|
|
27
|
+
extract_number(value)
|
|
28
|
+
when 'boolean'
|
|
29
|
+
extract_boolean?(value)
|
|
30
|
+
when 'date', 'created_on', 'last_modified'
|
|
31
|
+
extract_date(value)
|
|
32
|
+
when 'single_select'
|
|
33
|
+
extract_single_select(value)
|
|
34
|
+
when 'multi_select'
|
|
35
|
+
extract_multi_select(value)
|
|
36
|
+
when 'url'
|
|
37
|
+
extract_url(value)
|
|
38
|
+
when 'email'
|
|
39
|
+
extract_email(value)
|
|
40
|
+
when 'phone_number'
|
|
41
|
+
extract_phone_number(value)
|
|
42
|
+
when 'link_row'
|
|
43
|
+
extract_link_row(value)
|
|
44
|
+
when 'lookup', 'formula_array'
|
|
45
|
+
extract_lookup(value)
|
|
46
|
+
when 'formula'
|
|
47
|
+
extract_formula(value)
|
|
48
|
+
when 'file'
|
|
49
|
+
extract_file(value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract all fields from a Baserow row based on configuration
|
|
54
|
+
# @param row [Hash] A row hash from the Baserow API
|
|
55
|
+
# @param fields_config [Array<Hash>] Configuration for each field
|
|
56
|
+
# @return [Hash] Extracted fields with normalized keys
|
|
57
|
+
def extract_all(row, fields_config)
|
|
58
|
+
item = {}
|
|
59
|
+
|
|
60
|
+
fields_config.each do |field_config|
|
|
61
|
+
field_name = field_config['name']
|
|
62
|
+
field_type = field_config['type']
|
|
63
|
+
field_key = field_config['key'] || normalize_key(field_name)
|
|
64
|
+
|
|
65
|
+
value = extract(row, field_name, field_type)
|
|
66
|
+
item[field_key] = value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Use 'title' as the main identifier, fall back to 'name'
|
|
70
|
+
item['title'] ||= item['name']
|
|
71
|
+
|
|
72
|
+
item
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Normalize a field name to a valid key
|
|
76
|
+
# @param name [String] The field name
|
|
77
|
+
# @return [String] The normalized key
|
|
78
|
+
def normalize_key(name)
|
|
79
|
+
name.downcase.gsub(/\s+/, '_')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Text / long text field
|
|
83
|
+
def extract_text(value)
|
|
84
|
+
return nil if value.nil? || value == ''
|
|
85
|
+
|
|
86
|
+
value.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Number / rating / count field (Baserow may return decimal numbers as strings)
|
|
90
|
+
def extract_number(value)
|
|
91
|
+
return nil if value.nil?
|
|
92
|
+
|
|
93
|
+
case value
|
|
94
|
+
when Numeric
|
|
95
|
+
value
|
|
96
|
+
when String
|
|
97
|
+
value.include?('.') ? value.to_f : value.to_i
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Boolean field
|
|
102
|
+
def extract_boolean?(value)
|
|
103
|
+
value == true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Date / created on / last modified field (returned as an ISO 8601 string)
|
|
107
|
+
def extract_date(value)
|
|
108
|
+
return nil if value.nil? || value == ''
|
|
109
|
+
|
|
110
|
+
value.to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Single select field: { "id" => 1, "value" => "Backend", "color" => "blue" }
|
|
114
|
+
def extract_single_select(value)
|
|
115
|
+
return nil unless value.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
value['value']
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Multiple select field: array of { "id" => .., "value" => .., "color" => .. }
|
|
121
|
+
def extract_multi_select(value)
|
|
122
|
+
return [] unless value.is_a?(Array)
|
|
123
|
+
|
|
124
|
+
value.map { |option| option['value'] }.compact
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# URL field
|
|
128
|
+
def extract_url(value)
|
|
129
|
+
return nil if value.nil? || value == ''
|
|
130
|
+
|
|
131
|
+
value.to_s
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Email field
|
|
135
|
+
def extract_email(value)
|
|
136
|
+
return nil if value.nil? || value == ''
|
|
137
|
+
|
|
138
|
+
value.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Phone number field
|
|
142
|
+
def extract_phone_number(value)
|
|
143
|
+
return nil if value.nil? || value == ''
|
|
144
|
+
|
|
145
|
+
value.to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Link to table field: array of { "id" => .., "value" => .. }
|
|
149
|
+
def extract_link_row(value)
|
|
150
|
+
return [] unless value.is_a?(Array)
|
|
151
|
+
|
|
152
|
+
value.map do |linked_row|
|
|
153
|
+
{ 'id' => linked_row['id'], 'name' => linked_row['value'] }.compact
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Lookup field, or a formula field returning an array: array of { "id" => .., "value" => .. }
|
|
158
|
+
# Mirrors Notion rollups: returns a single value if only one, otherwise an array
|
|
159
|
+
def extract_lookup(value)
|
|
160
|
+
return nil unless value.is_a?(Array)
|
|
161
|
+
|
|
162
|
+
values = value.map { |item| item.is_a?(Hash) ? item['value'] : item }.compact
|
|
163
|
+
return nil if values.empty?
|
|
164
|
+
|
|
165
|
+
values.length == 1 ? values.first : values
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Formula field returning a scalar value (string, number, boolean or date)
|
|
169
|
+
def extract_formula(value)
|
|
170
|
+
value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# File field: array of { "url" => .., "name" => .., "mime_type" => .., ... }
|
|
174
|
+
def extract_file(value)
|
|
175
|
+
return [] unless value.is_a?(Array)
|
|
176
|
+
|
|
177
|
+
value.map do |file|
|
|
178
|
+
{ 'name' => file['name'], 'url' => file['url'], 'type' => file['mime_type'] }.compact
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|