iron-cms 0.11.0 → 0.13.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -20
  3. data/app/assets/builds/iron.css +3 -0
  4. data/app/controllers/concerns/iron/admin_locale.rb +16 -1
  5. data/app/controllers/concerns/iron/authentication.rb +2 -1
  6. data/app/controllers/iron/passwords_controller.rb +2 -1
  7. data/app/controllers/iron/sessions/transfers_controller.rb +1 -1
  8. data/app/controllers/iron/sessions_controller.rb +1 -1
  9. data/app/controllers/iron/users/reactivations_controller.rb +18 -0
  10. data/app/controllers/iron/users/roles_controller.rb +1 -1
  11. data/app/controllers/iron/users_controller.rb +6 -5
  12. data/app/mailers/iron/application_mailer.rb +0 -1
  13. data/app/models/iron/account/export.rb +3 -61
  14. data/app/models/iron/account/import.rb +1 -179
  15. data/app/models/iron/block_definition/importable.rb +6 -12
  16. data/app/models/iron/exporter.rb +75 -0
  17. data/app/models/iron/importer.rb +211 -0
  18. data/app/models/iron/seed.rb +77 -0
  19. data/app/models/iron/user/deactivatable.rb +23 -0
  20. data/app/models/iron/user.rb +1 -9
  21. data/app/views/iron/block_definitions/_empty_state.html.erb +3 -3
  22. data/app/views/iron/block_definitions/edit.html.erb +5 -5
  23. data/app/views/iron/block_definitions/index.html.erb +4 -4
  24. data/app/views/iron/block_definitions/new.html.erb +3 -3
  25. data/app/views/iron/block_definitions/show.html.erb +8 -8
  26. data/app/views/iron/entries/fields/_block.html.erb +3 -3
  27. data/app/views/iron/entries/fields/_file.html.erb +3 -3
  28. data/app/views/iron/field_definitions/block/_form.html.erb +2 -2
  29. data/app/views/iron/field_definitions/block_list/_form.html.erb +1 -1
  30. data/app/views/iron/field_definitions/edit.html.erb +3 -3
  31. data/app/views/iron/field_definitions/file/_form.html.erb +4 -4
  32. data/app/views/iron/field_definitions/layouts/_form.html.erb +1 -1
  33. data/app/views/iron/field_definitions/new.html.erb +3 -3
  34. data/app/views/iron/field_definitions/reference/_form.html.erb +1 -1
  35. data/app/views/iron/field_definitions/reference_list/_form.html.erb +1 -1
  36. data/app/views/iron/field_definitions/text_field/_form.html.erb +3 -3
  37. data/app/views/iron/first_runs/show.html.erb +4 -4
  38. data/app/views/iron/locales/_form.html.erb +1 -1
  39. data/app/views/iron/published_pages/show.html.erb +2 -2
  40. data/app/views/iron/users/index.html.erb +25 -0
  41. data/config/locales/en.yml +101 -1
  42. data/config/locales/it.yml +101 -1
  43. data/config/routes.rb +1 -0
  44. data/db/migrate/20260209215027_add_active_to_iron_users.rb +5 -0
  45. data/lib/iron/version.rb +1 -1
  46. data/lib/iron.rb +0 -1
  47. data/lib/tasks/iron_tasks.rake +10 -19
  48. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4875e891f39ab409c8460006c443ceae03bef9cb9cbd48ea8a0b5603d090405
4
- data.tar.gz: 4a45587b457c4aff648bc7d61d714485ac26682533cdb1c9016d00b27a1c6fab
3
+ metadata.gz: 6f8165107a118a6f3a8dcefe95984760653d8baf16f96653642382fc73ce8aca
4
+ data.tar.gz: 7143fd0c4f78a53e4b945f52005cc1f68e225674667f0f6a8bce8dcb8b77a02d
5
5
  SHA512:
6
- metadata.gz: 9ddcc960d6c7572dcd8d459c883d522d1f25b5f33ef23f75cc7cd1a444e1192864078d2c573bf2f868858582a5316715008314dbe8be3f2acaf68d18f5a8ba6f
7
- data.tar.gz: 299f14dad319f969252baef1b8cc09047b8c6e2a832cde32302c028b6f1dcb51ff562d49c760a3c071d77d654f5718772ef97e6c434c83beef6bd7bbffb0f725
6
+ metadata.gz: e09696ccbfb17bf2a82d515e7297f097a4a6fbdeb8a30626bf1cb1e44818ae9f55f260fa6b7e04acce6707d1d212305318cddf6db545f62d04b37d919191c73f
7
+ data.tar.gz: be54e07e53ca096cb56e66887eab33085f76545716a61dfa7976866303f224867d9766eceaa338a65ecd3182d5b2b7af2003226d98c52d64dd17a68b912cc7e3
data/README.md CHANGED
@@ -96,37 +96,36 @@ For cases where you need a basic responsive image without format optimization:
96
96
 
97
97
  This generates a standard `<img>` with srcset but without modern format variants.
98
98
 
99
- ### Testing
99
+ ### Development Seeding
100
100
 
101
- Iron provides test fixtures to populate your test database with CMS schema and content, eliminating the need to stub the SDK in your tests.
101
+ Iron provides a seeding mechanism to snapshot and restore CMS schema and content, making it easy for teammates to get a consistent development database.
102
102
 
103
- #### Generating Fixtures
103
+ #### Creating a seed
104
104
 
105
- Generate test fixtures from your development database (or production if you prefer):
105
+ After setting up your content types and sample content via the admin UI:
106
106
 
107
107
  ```bash
108
- bin/rails iron:fixtures:generate
108
+ bin/rails iron:seed:dump
109
109
  ```
110
110
 
111
- This creates two files in `test/fixtures/iron/`:
111
+ This exports the full CMS state to `db/seeds/iron.zip`. Commit this file to your repository.
112
112
 
113
- - `schema.tar.gz` - Content types, field definitions, and block definitions
114
- - `content.tar.gz` - All entries and their field data
113
+ #### Loading a seed
115
114
 
116
- Regenerate these fixtures whenever you make changes to your CMS schema or content that you want reflected in tests.
117
-
118
- #### Using Fixtures in Tests
119
-
120
- Load the fixtures in your `test/test_helper.rb`:
115
+ Add this to your `db/seeds.rb`:
121
116
 
122
117
  ```ruby
123
- class ActiveSupport::TestCase
124
- fixtures :all
125
- Iron::TestFixtures.load
126
- end
118
+ Iron::Seed.load
127
119
  ```
128
120
 
129
- The fixtures are loaded once at the start of your test suite. Rails' transactional tests provide test isolation by rolling back changes after each test.
121
+ Then `rails db:prepare` (or `rails db:seed`) will automatically bootstrap the CMS when the database is empty. Loading is skipped if content types already exist.
122
+
123
+ #### Admin credentials
124
+
125
+ The seed creates a default admin user during loading. Configure via environment variables:
126
+
127
+ - `IRON_SEED_EMAIL` (default: `admin@example.com`)
128
+ - `IRON_SEED_PASSWORD` (default: `password`)
130
129
 
131
130
  ## System Requirements
132
131
 
@@ -178,8 +177,8 @@ bin/rails iron:install:migrations
178
177
  Configure the mailer sender address used for system emails (e.g. password resets):
179
178
 
180
179
  ```ruby
181
- # config/initializers/iron.rb
182
- Iron.mailer_sender = "hello@mysite.com"
180
+ # config/environments/production.rb
181
+ config.action_mailer.default_options = { from: "hello@mysite.com" }
183
182
  ```
184
183
 
185
184
  ## Architecture
@@ -3429,6 +3429,9 @@
3429
3429
  .opacity-0 {
3430
3430
  opacity: 0%;
3431
3431
  }
3432
+ .opacity-50 {
3433
+ opacity: 50%;
3434
+ }
3432
3435
  .shadow {
3433
3436
  --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
3434
3437
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -8,7 +8,22 @@ module Iron
8
8
 
9
9
  private
10
10
  def set_admin_locale(&action)
11
- I18n.with_locale(Current.user&.language || :en, &action)
11
+ I18n.with_locale(preferred_admin_language, &action)
12
+ end
13
+
14
+ def preferred_admin_language
15
+ Current.user&.language || browser_admin_language || "en"
16
+ end
17
+
18
+ def browser_admin_language
19
+ browser_language_tags.find { |tag| User::ADMIN_LANGUAGES.include?(tag) }
20
+ end
21
+
22
+ def browser_language_tags
23
+ request.accept_language.to_s.split(",").flat_map { |entry|
24
+ tag = entry.split(";").first.strip.downcase
25
+ [ tag, tag.split("-").first ]
26
+ }.reject(&:blank?).uniq
12
27
  end
13
28
  end
14
29
  end
@@ -33,7 +33,8 @@ module Iron
33
33
 
34
34
 
35
35
  def resume_session
36
- Current.session ||= find_session_by_cookie
36
+ session = find_session_by_cookie
37
+ Current.session ||= session if session&.user&.active?
37
38
  end
38
39
 
39
40
  def find_session_by_cookie
@@ -9,7 +9,7 @@ module Iron
9
9
  end
10
10
 
11
11
  def create
12
- if user = User.find_by(email_address: params[:email_address])
12
+ if user = User.active.find_by(email_address: params[:email_address])
13
13
  Iron::PasswordsMailer.reset(user).deliver_later
14
14
  end
15
15
 
@@ -36,6 +36,7 @@ module Iron
36
36
 
37
37
  def set_user_by_token
38
38
  @user = User.find_by_password_reset_token!(params[:token])
39
+ redirect_to new_password_url, alert: t("iron.passwords.alerts.reset_link_invalid") unless @user.active?
39
40
  rescue ActiveSupport::MessageVerifier::InvalidSignature
40
41
  redirect_to new_password_url, alert: t("iron.passwords.alerts.reset_link_invalid")
41
42
  end
@@ -9,7 +9,7 @@ module Iron
9
9
  end
10
10
 
11
11
  def update
12
- if user = User.find_by_transfer_id(params[:id])
12
+ if (user = User.find_by_transfer_id(params[:id])) && user.active?
13
13
  start_new_session_for user
14
14
  redirect_to after_authentication_url
15
15
  else
@@ -10,7 +10,7 @@ module Iron
10
10
  end
11
11
 
12
12
  def create
13
- if user = User.authenticate_by(params.permit(:email_address, :password))
13
+ if user = User.active.authenticate_by(params.permit(:email_address, :password))
14
14
  start_new_session_for user
15
15
  redirect_to after_authentication_url
16
16
  else
@@ -0,0 +1,18 @@
1
+ module Iron
2
+ module Users
3
+ class ReactivationsController < ApplicationController
4
+ before_action :set_user
5
+ before_action :ensure_can_administer
6
+
7
+ def update
8
+ @user.reactivate
9
+ redirect_to @user, notice: t("iron.users.notifications.reactivated")
10
+ end
11
+
12
+ private
13
+ def set_user
14
+ @user = User.find(params.expect(:user_id))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -14,7 +14,7 @@ module Iron
14
14
 
15
15
  private
16
16
  def set_user
17
- @user = User.find(params.expect(:user_id))
17
+ @user = User.active.find(params.expect(:user_id))
18
18
  end
19
19
  end
20
20
  end
@@ -8,7 +8,8 @@ module Iron
8
8
  before_action :ensure_can_administer, only: %i[ index destroy ]
9
9
 
10
10
  def index
11
- @users = User.all
11
+ @users = User.active
12
+ @deactivated_users = User.where(active: false)
12
13
  end
13
14
 
14
15
  def new
@@ -19,7 +20,7 @@ module Iron
19
20
  end
20
21
 
21
22
  def create
22
- @user = User.new(join_params)
23
+ @user = User.new(join_params.merge(language: preferred_admin_language))
23
24
  if @user.save
24
25
  start_new_session_for @user
25
26
  redirect_to root_url
@@ -31,8 +32,8 @@ module Iron
31
32
  end
32
33
 
33
34
  def destroy
34
- if @user.destroy
35
- redirect_back fallback_location: users_path, notice: t("iron.users.notifications.removed"), status: :see_other
35
+ if @user.deactivate
36
+ redirect_to users_path, notice: t("iron.users.notifications.removed"), status: :see_other
36
37
  else
37
38
  redirect_back fallback_location: users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other
38
39
  end
@@ -40,7 +41,7 @@ module Iron
40
41
 
41
42
  private
42
43
  def set_user
43
- @user = User.find(params.expect(:id))
44
+ @user = User.active.find(params.expect(:id))
44
45
  end
45
46
 
46
47
  def join_params
@@ -1,6 +1,5 @@
1
1
  module Iron
2
2
  class ApplicationMailer < ActionMailer::Base
3
- default from: -> { Iron.mailer_sender }
4
3
  layout "mailer"
5
4
  end
6
5
  end
@@ -17,70 +17,12 @@ module Iron
17
17
  def perform
18
18
  raise ArgumentError, "Nothing selected to export" unless include_schema? || include_content?
19
19
 
20
- zipfile = generate_zip
21
- file.attach(
22
- io: File.open(zipfile.path),
23
- filename: "iron-export-#{id}.zip",
24
- content_type: "application/zip"
25
- )
20
+ zipfile = Tempfile.new([ "export", ".zip" ])
21
+ Exporter.new(zipfile.path, include_schema: include_schema?, include_content: include_content?).export
22
+ file.attach(io: File.open(zipfile.path), filename: "iron-export-#{id}.zip", content_type: "application/zip")
26
23
  ensure
27
24
  zipfile&.close
28
25
  zipfile&.unlink
29
26
  end
30
-
31
- def generate_zip
32
- Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
33
- Zip::File.open(tempfile.path, create: true) do |zip|
34
- add_schema_to_zip(zip) if include_schema?
35
- add_content_to_zip(zip) if include_content?
36
- end
37
- end
38
- end
39
-
40
- def add_schema_to_zip(zip)
41
- zip.get_output_stream("schema.json") { |f| f.write(export_schema_json) }
42
- end
43
-
44
- def add_content_to_zip(zip)
45
- exportable_entries.find_each do |entry|
46
- path = "entries/#{entry.content_type.handle}/#{entry.id}.json"
47
- zip.get_output_stream(path) { |f| f.write(entry.export_json) }
48
-
49
- entry.export_attachments.each do |attachment|
50
- zip.get_output_stream("files/#{attachment[:path]}", compression_method: Zip::Entry::STORED) do |f|
51
- attachment[:blob].download { |chunk| f.write(chunk) }
52
- end
53
- rescue ActiveStorage::FileNotFoundError
54
- # Skip missing files
55
- end
56
- end
57
- end
58
-
59
- def export_schema_json
60
- JSON.pretty_generate(
61
- version: "1.0",
62
- exported_at: Time.current.iso8601,
63
- block_definitions: export_block_definitions,
64
- content_types: export_content_types
65
- )
66
- end
67
-
68
- def export_block_definitions
69
- BlockDefinition
70
- .includes(:field_definitions)
71
- .order(:handle)
72
- .map(&:export_attributes)
73
- end
74
-
75
- def export_content_types
76
- ContentType
77
- .includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
78
- .order(:handle)
79
- .map(&:export_attributes)
80
- end
81
-
82
- def exportable_entries
83
- Entry.includes(:content_type, :creator, fields: [ :locale, :definition ])
84
- end
85
27
  end
86
28
  end
@@ -18,190 +18,12 @@ module Iron
18
18
  def perform
19
19
  raise ArgumentError, "Nothing selected to import" unless include_schema? || include_content?
20
20
 
21
- import_from_zip
22
- end
23
-
24
- def import_from_zip
25
21
  Tempfile.create([ "import", ".zip" ]) do |tempfile|
26
22
  tempfile.binmode
27
23
  tempfile.write(file.download)
28
24
  tempfile.rewind
29
25
 
30
- Zip::File.open(tempfile.path) do |zip|
31
- @zip = zip
32
- @files_dir = extract_files_to_temp_dir(zip)
33
-
34
- import_schema_from_zip if include_schema?
35
- import_content_from_zip if include_content?
36
- ensure
37
- FileUtils.rm_rf(@files_dir) if @files_dir
38
- end
39
- end
40
- end
41
-
42
- def extract_files_to_temp_dir(zip)
43
- dir = Dir.mktmpdir("iron-import")
44
- zip.glob("files/**/*").each do |entry|
45
- next if entry.directory?
46
-
47
- path = File.join(dir, entry.name.sub("files/", ""))
48
- FileUtils.mkdir_p(File.dirname(path))
49
- entry.extract(path)
50
- end
51
- dir
52
- end
53
-
54
- def import_schema_from_zip
55
- return unless @zip.find_entry("schema.json")
56
-
57
- schema_json = @zip.read("schema.json")
58
- schema = parse_json(schema_json)
59
- raise ArgumentError, "Invalid schema JSON format" unless schema
60
-
61
- ActiveRecord::Base.transaction do
62
- import_block_definitions(schema[:block_definitions] || [])
63
- import_content_types(schema[:content_types] || [])
64
- resolve_content_type_references(schema[:content_types] || [])
65
- end
66
- end
67
-
68
- def import_content_from_zip
69
- ActiveRecord::Base.transaction do
70
- id_mapping = {}
71
- entries_data = []
72
-
73
- # First pass: read all entry files and create entries
74
- @zip.glob("entries/**/*.json").each do |zip_entry|
75
- attrs = parse_json(zip_entry.get_input_stream.read)
76
- next unless attrs
77
-
78
- entries_data << attrs
79
- entry = Entry.import_from_attributes(attrs, @files_dir)
80
- id_mapping[attrs[:id]] = entry if entry
81
- end
82
-
83
- # Second pass: resolve references
84
- resolve_references(entries_data, id_mapping)
85
- end
86
- end
87
-
88
- def resolve_references(entries_data, id_mapping)
89
- entries_data.each do |attrs|
90
- entry = id_mapping[attrs[:id]]
91
- next unless entry
92
-
93
- (attrs[:fields] || {}).each do |locale_code, field_data|
94
- locale = Locale.find_by(code: locale_code.to_s)
95
- next unless locale
96
-
97
- resolve_references_in_fields(entry, locale, field_data, id_mapping)
98
- end
99
- end
100
- end
101
-
102
- def resolve_references_in_fields(entry, locale, fields_data, id_mapping, parent: nil)
103
- fields_data.each do |handle, value_data|
104
- next unless value_data.is_a?(Hash)
105
-
106
- case value_data[:type]
107
- when "reference"
108
- resolve_reference_field(entry, locale, handle.to_s, value_data[:value], id_mapping, parent: parent)
109
- when "reference_list"
110
- resolve_reference_list_field(entry, locale, handle.to_s, value_data[:value], id_mapping, parent: parent)
111
- when "block"
112
- resolve_references_in_block(entry, locale, handle.to_s, value_data, id_mapping, parent: parent)
113
- when "block_list"
114
- resolve_references_in_block_list(entry, locale, handle.to_s, value_data, id_mapping, parent: parent)
115
- end
116
- end
117
- end
118
-
119
- def resolve_references_in_block(entry, locale, handle, value_data, id_mapping, parent: nil)
120
- block_field = find_field(entry, locale, handle, parent)
121
- return unless block_field
122
-
123
- resolve_references_in_fields(entry, locale, value_data[:fields] || {}, id_mapping, parent: block_field)
124
- end
125
-
126
- def resolve_references_in_block_list(entry, locale, handle, value_data, id_mapping, parent: nil)
127
- block_list_field = find_field(entry, locale, handle, parent)
128
- return unless block_list_field
129
-
130
- # Reload blocks and get them in rank order (matching import order)
131
- ordered_blocks = block_list_field.blocks.reload.sort_by(&:rank)
132
-
133
- (value_data[:value] || []).each_with_index do |block_data, index|
134
- block = ordered_blocks[index]
135
- next unless block
136
-
137
- resolve_references_in_fields(entry, locale, block_data[:fields] || {}, id_mapping, parent: block)
138
- end
139
- end
140
-
141
- def find_field(entry, locale, handle, parent)
142
- # Reload associations to get freshly persisted fields from the first pass
143
- fields = parent&.fields&.reload || entry.fields.reload
144
- definition_scope = parent.is_a?(Fields::Block) ? parent.block_definition.field_definitions : entry.content_type.field_definitions
145
- definition = definition_scope.find_by(handle: handle)
146
- return unless definition
147
-
148
- fields.find { |f| f.definition_id == definition.id && f.locale_id == locale.id }
149
- end
150
-
151
- def resolve_reference_field(entry, locale, handle, old_entry_id, id_mapping, parent: nil)
152
- return unless old_entry_id
153
-
154
- referenced_entry = id_mapping[old_entry_id]
155
- return unless referenced_entry
156
-
157
- field = find_field(entry, locale, handle, parent)
158
- return unless field
159
-
160
- field.update!(referenced_entry: referenced_entry)
161
- end
162
-
163
- def resolve_reference_list_field(entry, locale, handle, old_entry_ids, id_mapping, parent: nil)
164
- return unless old_entry_ids.is_a?(Array)
165
-
166
- field = find_field(entry, locale, handle, parent)
167
- return unless field
168
-
169
- old_entry_ids.each_with_index do |old_id, index|
170
- referenced_entry = id_mapping[old_id]
171
- next unless referenced_entry
172
-
173
- field.references.create!(entry: referenced_entry, rank: index)
174
- end
175
- end
176
-
177
- def parse_json(content)
178
- JSON.parse(content, symbolize_names: true)
179
- rescue JSON::ParserError
180
- nil
181
- end
182
-
183
- def import_block_definitions(definitions)
184
- definitions.each { |attrs| BlockDefinition.import_from_attributes(attrs) }
185
- end
186
-
187
- def import_content_types(definitions)
188
- definitions.each { |attrs| ContentType.import_from_attributes(attrs) }
189
- end
190
-
191
- def resolve_content_type_references(definitions)
192
- definitions.each do |attrs|
193
- content_type = ContentType.find_by(handle: attrs[:handle])
194
- next unless content_type
195
-
196
- if attrs[:title_field_handle].present?
197
- field = content_type.field_definitions.find_by(handle: attrs[:title_field_handle])
198
- content_type.update!(title_field_definition: field) if field
199
- end
200
-
201
- if attrs[:web_page_title_field_handle].present?
202
- field = content_type.field_definitions.find_by(handle: attrs[:web_page_title_field_handle])
203
- content_type.update!(web_page_title_field_definition: field) if field
204
- end
26
+ Importer.new(tempfile.path, include_schema: include_schema?, include_content: include_content?).import
205
27
  end
206
28
  end
207
29
  end
@@ -4,21 +4,15 @@ module Iron
4
4
 
5
5
  class_methods do
6
6
  def import_from_attributes(attrs)
7
- block_def = find_or_initialize_by(handle: attrs[:handle])
8
- block_def.update!(
9
- name: attrs[:name],
10
- description: attrs[:description]
11
- )
12
-
13
- import_field_definitions(block_def, attrs[:field_definitions] || [])
14
-
15
- block_def
7
+ find_or_initialize_by(handle: attrs[:handle]).tap do |block_def|
8
+ block_def.update!(name: attrs[:name], description: attrs[:description])
9
+ end
16
10
  end
17
11
 
18
- private
12
+ def populate_field_definitions(attrs)
13
+ block_def = find_by!(handle: attrs[:handle])
19
14
 
20
- def import_field_definitions(block_def, field_attrs_list)
21
- field_attrs_list.each do |field_attrs|
15
+ (attrs[:field_definitions] || []).each do |field_attrs|
22
16
  FieldDefinition.import_from_attributes(field_attrs, schemable: block_def)
23
17
  end
24
18
  end
@@ -0,0 +1,75 @@
1
+ module Iron
2
+ class Exporter
3
+ def initialize(path, include_schema: true, include_content: true)
4
+ @path = path.to_s
5
+ @include_schema = include_schema
6
+ @include_content = include_content
7
+ end
8
+
9
+ def export
10
+ Zip::File.open(path, create: true) do |zip|
11
+ write_schema(zip) if schema?
12
+ write_entries(zip) if content?
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :path
19
+
20
+ def schema? = @include_schema
21
+ def content? = @include_content
22
+
23
+ def write_schema(zip)
24
+ zip.get_output_stream("schema.json") { |f| f.write(schema_json) }
25
+ end
26
+
27
+ def write_entries(zip)
28
+ exportable_entries.find_each do |entry|
29
+ path = "entries/#{entry.content_type.handle}/#{entry.id}.json"
30
+ zip.get_output_stream(path) { |f| f.write(entry.export_json) }
31
+
32
+ entry.export_attachments.each do |attachment|
33
+ zip.get_output_stream("files/#{attachment[:path]}", compression_method: Zip::Entry::STORED) do |f|
34
+ attachment[:blob].download { |chunk| f.write(chunk) }
35
+ end
36
+ rescue ActiveStorage::FileNotFoundError
37
+ # Skip missing files
38
+ end
39
+ end
40
+ end
41
+
42
+ def schema_json
43
+ JSON.pretty_generate(
44
+ version: "1.0",
45
+ exported_at: Time.current.iso8601,
46
+ default_locale: Current.account&.default_locale&.code,
47
+ locales: export_locales,
48
+ block_definitions: export_block_definitions,
49
+ content_types: export_content_types
50
+ )
51
+ end
52
+
53
+ def export_locales
54
+ Locale.order(:code).map { |l| { code: l.code, name: l.name } }
55
+ end
56
+
57
+ def export_block_definitions
58
+ BlockDefinition
59
+ .includes(:field_definitions)
60
+ .order(:handle)
61
+ .map(&:export_attributes)
62
+ end
63
+
64
+ def export_content_types
65
+ ContentType
66
+ .includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
67
+ .order(:handle)
68
+ .map(&:export_attributes)
69
+ end
70
+
71
+ def exportable_entries
72
+ Entry.includes(:content_type, :creator, fields: [ :locale, :definition ])
73
+ end
74
+ end
75
+ end