airtable_sync 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8591433d500307558e933d3861df761d6da9434a51628ae0a961410cfc85d552
4
+ data.tar.gz: ce375ba7fbc498b4e46cc93cb25c824c1e216ee9dd0e46ddd7f15396b2bbbf54
5
+ SHA512:
6
+ metadata.gz: 73d23e7524abcbcad6b7bbc5a428cd06135fb3344f5ddaf9299b9392bef7f6d93fc1066985b6d57abcf955b08d466f4a765e59ffad16ca7fe04a5a5fa0509f7e
7
+ data.tar.gz: 408e6f38899dac41d6638da0dba2d1007f94a1b10bb5c7df4fc99f33c77ebdc1d94f0578bf03f5e2b651b5f4ba94d7020b52715f8a3ee0cd05037e6f29f22be0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2023 Viktor
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,175 @@
1
+ [![Build Status](https://github.com/vfonic/airtable_sync/workflows/build/badge.svg)](https://github.com/vfonic/airtable_sync/actions)
2
+
3
+ # AirtableSync
4
+
5
+ Keep your Ruby on Rails records in sync with AirTable.
6
+
7
+ Currently only one way Rails => AirTable synchronization.
8
+
9
+ For the latest changes, see the [CHANGELOG.md](CHANGELOG.md).
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'airtable_sync'
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ $ bundle
23
+ ```
24
+
25
+ Then run the install generator:
26
+
27
+ ```bash
28
+ bundle exec rails generate airtable_sync:install
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ 1. Generate AirTable API key
34
+ https://airtable.com/create/apikey
35
+ 2.
36
+
37
+ ### Configuration options
38
+
39
+ In `config/initializers/airtable_sync.rb` you can specify configuration options:
40
+
41
+ 1. `api_key`
42
+ 2. `webflow_site_id`
43
+ 3. `skip_airtable_sync` - skip synchronization for different environments.
44
+
45
+ Example:
46
+
47
+ ```rb
48
+ AirtableSync.configure do |config|
49
+ config.api_key = ENV.fetch('AIRTABLE_API_KEY')
50
+ config.skip_airtable_sync = ActiveModel::Type::Boolean.new.cast(ENV.fetch('SKIP_AIRTABLE_SYNC'))
51
+ end
52
+ ```
53
+
54
+ ### Add AirtableSync to models
55
+
56
+ For each model that you want to sync to AirTable, you need to run the connection generator:
57
+
58
+ ```bash
59
+ bundle exec rails generate airtable_sync:connection Article
60
+ ```
61
+
62
+ Please note that this _does not_ create the AirTable table. You need to already have it or create it manually.
63
+
64
+ ### Create AirTable tables
65
+
66
+ As mentioned above, you need to create the AirTable tables yourself.
67
+
68
+ ### Set `airtable_base_id`
69
+
70
+ There are couple of ways how you can set the `airtable_base_id` to be used.
71
+
72
+ #### Set `airtable_base_id` through configuration
73
+
74
+ In `config/initializers/airtable_sync.rb` you can specify `airtable_base_id`:
75
+
76
+ ```ruby
77
+ AirtableSync.configure do |config|
78
+ config.airtable_base_id = ENV.fetch('AIRTABLE_BASE_ID')
79
+ end
80
+ ```
81
+
82
+ ### Customize fields to synchronize
83
+
84
+ By default, AirtableSync calls `#as_airtable_json` on a record to get the fields that it needs to push to AirTable. `#as_airtable_json` simply calls `#as_json` in its default implementation. To change this behavior, you can override `#as_airtable_json` in your model:
85
+
86
+ ```ruby
87
+ # app/models/article.rb
88
+ class Article < ApplicationRecord
89
+ include AirtableSync::RecordSync
90
+
91
+ def as_airtable_json
92
+ {
93
+ 'Post Title': self.title.capitalize,
94
+ 'Short Description': self.title.parameterize,
95
+ 'Long Description': self.created_at,
96
+ }
97
+ end
98
+ end
99
+ ```
100
+
101
+ ### Sync a Rails model to different AirTable table
102
+
103
+ If AirTable table name does not match the Rails model collection name, you need to specify table name for each `CreateRecordJob`, `DestroyRecordJob`, and `UpdateRecordJob` call. If not specified, model collection name is used as the table name.
104
+ You also need to replace included `AirtableSync::RecordSync` with custom callbacks that will call appropriate AirtableSync jobs.
105
+
106
+ For example:
107
+
108
+ ```ruby
109
+ AirtableSync::CreateRecordJob.perform_later(model_name, id, table_name)
110
+ AirtableSync::UpdateRecordJob.perform_later(model_name, id, table_name)
111
+ AirtableSync::DestroyRecordJob.perform_later(table_name:, airtable_id:)
112
+ ```
113
+
114
+ Where:
115
+
116
+ 1. `model_name` - Rails model with `airtable_id` column
117
+ 2. `table_name` - AirTable table name (defaults to: `model_name.underscore.pluralize.humanize`)
118
+
119
+ For example:
120
+
121
+ ```ruby
122
+ AirtableSync::CreateRecordJob.perform_now('articles', 1, 'Stories')
123
+ ```
124
+
125
+ Or, if you want to use the default 'articles' table name:
126
+
127
+ ```ruby
128
+ AirtableSync::CreateRecordJob.perform_now('articles', 1)
129
+ ```
130
+
131
+ ### Run the initial sync
132
+
133
+ After setting up which models you want to sync to AirTable, you can run the initial sync for each of the models:
134
+
135
+ ```ruby
136
+ AirtableSync::InitialSyncJob.perform_later('articles')
137
+ ```
138
+
139
+ You can also run this from a Rake task:
140
+
141
+ ```ruby
142
+ bundle exec rails "airtable_sync:initial_sync[articles]"
143
+ ```
144
+
145
+ Quotes are needed in order for this to work in all shells.
146
+
147
+ ### Important note
148
+
149
+ This gem silently "fails" (does nothing) when `airtable_base_id` or `airtable_id` is `nil`! This is not always desired behavior so be aware of that.
150
+
151
+ ## Contributing
152
+
153
+ PRs welcome!
154
+
155
+ To run RuboCop style check and RSpec tests run:
156
+
157
+ ```sh
158
+ bundle exec rake
159
+ ```
160
+
161
+ To run only RuboCop run:
162
+
163
+ ```sh
164
+ bundle exec rails style
165
+ ```
166
+
167
+ To run RSpec tests run:
168
+
169
+ ```sh
170
+ bundle exec spec
171
+ ```
172
+
173
+ ## License
174
+
175
+ 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,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
6
+ load 'rails/tasks/engine.rake'
7
+
8
+ load 'rails/tasks/statistics.rake'
9
+
10
+ require 'bundler/gem_tasks'
11
+
12
+ require 'stylecheck/rake_tasks' unless Rails.env.production?
13
+
14
+ load 'rspec/rails/tasks/rspec.rake'
15
+ task :default do
16
+ Rake::Task['style:rubocop:run'].execute
17
+ Rake::Task['spec'].execute
18
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class CreateRecordJob < ApplicationJob
5
+ # AirTable table name should be in plural form.
6
+ # 'JobListing'.underscore.pluralize.humanize => "Job listings"
7
+ # 'job_listing'.underscore.pluralize.humanize => "Job listings"
8
+ # We can sync Rails model that has different class name than its AirTable table name
9
+ # model_name => Rails model that has airtable_id column
10
+ # table_name => AirTable table name
11
+ # model_name = 'Article'; id = article.id, table_name = 'Posts'
12
+ def perform(model_name, id, table_name = model_name.underscore.pluralize.humanize)
13
+ model_class = model_name.underscore.classify.constantize
14
+ record = model_class.find_by(id:)
15
+ return if record.blank?
16
+ return if record.airtable_base_id.blank?
17
+
18
+ AirtableSync::Api.new(record.airtable_base_id).create_record(record, table_name)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class DestroyRecordJob < ApplicationJob
5
+ def perform(table_name:, airtable_base_id:, airtable_id:)
6
+ return if airtable_base_id.blank?
7
+ return if airtable_id.blank?
8
+
9
+ AirtableSync::Api.new(airtable_base_id).delete_record(table_name, airtable_id)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class InitialSyncJob < ApplicationJob
5
+ def perform(table_name)
6
+ model_class = table_name.underscore.classify.constantize
7
+ model_class.where(airtable_id: nil).find_each do |record|
8
+ next if record.airtable_base_id.blank?
9
+
10
+ client(record.airtable_base_id).create_record(record, table_name)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def client(site_id)
17
+ if @client&.site_id == site_id
18
+ @client
19
+ else
20
+ @client = AirtableSync::Api.new(site_id)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class UpdateRecordJob < ApplicationJob
5
+ # AirTable table name should be in plural form.
6
+ # 'JobListing'.underscore.pluralize.humanize => 'Job listings'
7
+ # 'job_listing'.underscore.pluralize.humanize => 'Job listings'
8
+ def perform(model_name, id, table_name = model_name.underscore.pluralize.humanize)
9
+ model_class = model_name.underscore.classify.constantize
10
+ record = model_class.find_by(id:)
11
+ return if record.blank?
12
+ return if record.airtable_base_id.blank?
13
+ return AirtableSync::CreateRecordJob.perform_now(model_name, id, table_name) if record.airtable_id.blank?
14
+
15
+ AirtableSync::Api.new(record.airtable_base_id).update_record(record, table_name)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Synchronizes any changes to public records to AirTable
4
+ module AirtableSync
5
+ module Callbacks
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ attr_accessor :skip_airtable_sync
10
+
11
+ after_commit :create_airtable_record, on: :create
12
+ after_commit :update_airtable_record, on: :update
13
+ after_commit :destroy_airtable_record, on: :destroy
14
+
15
+ def create_airtable_record
16
+ return if should_skip_sync?
17
+
18
+ AirtableSync::CreateRecordJob.perform_later(self.model_name.collection, self.id)
19
+ end
20
+
21
+ def update_airtable_record
22
+ return if should_skip_sync?
23
+
24
+ AirtableSync::UpdateRecordJob.perform_later(self.model_name.collection, self.id)
25
+ end
26
+
27
+ def destroy_airtable_record
28
+ return if should_skip_sync?
29
+
30
+ # Make sure table name is in the plural form
31
+ table_name = self.model_name.collection.underscore.humanize
32
+
33
+ AirtableSync::DestroyRecordJob.perform_later(table_name:, airtable_base_id:, airtable_id:)
34
+ end
35
+
36
+ private
37
+
38
+ def should_skip_sync? = AirtableSync.configuration.skip_airtable_sync || self.skip_airtable_sync || self.airtable_base_id.blank?
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Synchronizes any changes to public records to AirTable
4
+ module AirtableSync
5
+ module RecordSync
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include AirtableSync::Callbacks
10
+
11
+ # override this method to get more granular, per site customization
12
+ # for example, you could have Site model in your codebase:
13
+ #
14
+ # class Site < ApplicationRecord
15
+ # has_many :articles
16
+ # end
17
+ #
18
+ # class Article < ApplicationRecord
19
+ # belongs_to :site
20
+ #
21
+ # def airtable_base_id
22
+ # self.site.airtable_base_id
23
+ # end
24
+ # end
25
+ #
26
+ def airtable_base_id = AirtableSync.configuration.airtable_base_id
27
+
28
+ # You can customize this to your liking:
29
+ # def as_airtable_json
30
+ # {
31
+ # title: self.title.capitalize,
32
+ # slug: self.title.parameterize,
33
+ # published_at: self.created_at,
34
+ # image: self.image_url
35
+ # }
36
+ # end
37
+ def as_airtable_json = self.as_json
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class Api
5
+ attr_reader :base_id
6
+
7
+ def initialize(base_id)
8
+ @base_id = base_id
9
+ end
10
+
11
+ def get_all_items(table_name:) = table(table_name).all
12
+
13
+ def get_item(table_name, airtable_id) = table(table_name).find(airtable_id)
14
+
15
+ def create_record(record, table_name)
16
+ airtable_record = table(table_name).create(record.as_airtable_json) # rubocop:disable Rails/SaveBang
17
+ record.update_column(:airtable_id, airtable_record.id) # rubocop:disable Rails/SkipsModelValidations
18
+ end
19
+
20
+ def update_record(record, table_name)
21
+ airtable_record = table(table_name).find(record.airtable_id)
22
+ record.as_airtable_json.each { |key, value| airtable_record[key.to_s] = value }
23
+ airtable_record.save # rubocop:disable Rails/SaveBang
24
+ end
25
+
26
+ def delete_record(table_name, airtable_id) = table(table_name).find(airtable_id).destroy
27
+
28
+ private
29
+
30
+ def table(table_name) = Airrecord.table(AirtableSync.configuration.api_key, base_id, table_name)
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class << self
5
+ attr_reader :configuration
6
+
7
+ def configure
8
+ self.configuration ||= Configuration.new
9
+
10
+ self.configuration.skip_airtable_sync = !Rails.env.production?
11
+
12
+ yield(self.configuration)
13
+
14
+ self.configuration.api_key ||= ENV.fetch('AIRTABLE_API_KEY')
15
+ end
16
+
17
+ private
18
+
19
+ attr_writer :configuration
20
+ end
21
+
22
+ class Configuration
23
+ attr_accessor :skip_airtable_sync, :airtable_base_id, :api_key
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ class Railtie < ::Rails::Railtie
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'airtable_sync/version'
4
+ require 'airtable_sync/configuration'
5
+ require 'airtable_sync/engine'
6
+ require 'airrecord'
7
+
8
+ module AirtableSync
9
+ end
10
+
11
+ module Airrecord
12
+ class Client
13
+ def escape(*args) = ERB::Util.url_encode(*args)
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/active_record'
4
+
5
+ module AirtableSync
6
+ module Generators
7
+ class ConnectionGenerator < Rails::Generators::NamedBase
8
+ desc 'Registers ActiveRecord model to sync to AirTable table'
9
+
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ include Rails::Generators::Migration
13
+ def add_migration
14
+ migration_template 'migration.rb.erb', "#{migration_path}/add_airtable_id_to_#{table_name}.rb",
15
+ migration_version:
16
+ end
17
+
18
+ def include_record_sync_in_model_file
19
+ module_snippet = <<~END_OF_INCLUDE.indent(2)
20
+
21
+ include AirtableSync::RecordSync
22
+ END_OF_INCLUDE
23
+
24
+ insert_into_file "app/models/#{name.underscore}.rb", module_snippet, after: / < ApplicationRecord$/
25
+ end
26
+
27
+ def self.next_migration_number(dirname) = ActiveRecord::Generators::Base.next_migration_number(dirname)
28
+
29
+ private
30
+
31
+ def migration_path = ActiveRecord::Migrator.migrations_paths.first
32
+
33
+ def migration_version = "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AirtableSync
4
+ module Generators
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ def add_config_initializer
9
+ template 'airtable_sync.rb', 'config/initializers/airtable_sync.rb'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ AirtableSync.configure do |config|
4
+ # config.api_key = ENV.fetch('AIRTABLE_API_KEY')
5
+ # config.skip_airtable_sync = !Rails.env.production?
6
+ config.airtable_base_id = ENV.fetch('AIRTABLE_BASE_ID')
7
+ end
@@ -0,0 +1,6 @@
1
+ class AddAirtableIdTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :<%= table_name %>, :airtable_id, :string
4
+ add_index :<%= table_name %>, :airtable_id, unique: true
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :airtable_sync do
4
+ desc 'Perform initial sync from Rails to AirTable'
5
+ task :initial_sync, %i[table_name] => :environment do |_task, args|
6
+ AirtableSync::InitialSyncJob.perform_later(args[:table_name])
7
+ end
8
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airtable_sync
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Viktor
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-02-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: airrecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sprockets-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description:
56
+ email:
57
+ - viktor.fonic@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - MIT-LICENSE
63
+ - README.md
64
+ - Rakefile
65
+ - app/jobs/airtable_sync/create_record_job.rb
66
+ - app/jobs/airtable_sync/destroy_record_job.rb
67
+ - app/jobs/airtable_sync/initial_sync_job.rb
68
+ - app/jobs/airtable_sync/update_record_job.rb
69
+ - app/models/concerns/airtable_sync/callbacks.rb
70
+ - app/models/concerns/airtable_sync/record_sync.rb
71
+ - app/services/airtable_sync/api.rb
72
+ - lib/airtable_sync.rb
73
+ - lib/airtable_sync/configuration.rb
74
+ - lib/airtable_sync/engine.rb
75
+ - lib/airtable_sync/railtie.rb
76
+ - lib/airtable_sync/version.rb
77
+ - lib/generators/airtable_sync/connection_generator.rb
78
+ - lib/generators/airtable_sync/install_generator.rb
79
+ - lib/generators/airtable_sync/templates/airtable_sync.rb
80
+ - lib/generators/airtable_sync/templates/migration.rb.erb
81
+ - lib/tasks/airtable_sync.rake
82
+ homepage: https://github.com/vfonic/airtable_sync
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/vfonic/airtable_sync
87
+ source_code_uri: https://github.com/vfonic/airtable_sync
88
+ rubygems_mfa_required: 'true'
89
+ post_install_message:
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.1'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.3.26
105
+ signing_key:
106
+ specification_version: 4
107
+ summary: Keep Rails models in sync with AirTable.
108
+ test_files: []