iron-cms 0.1.3 → 0.2.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -0
  3. data/app/assets/builds/iron.css +5538 -1
  4. data/app/controllers/iron/contents_controller.rb +33 -0
  5. data/app/models/concerns/iron/instance_scoped.rb +25 -0
  6. data/app/models/iron/account.rb +2 -0
  7. data/app/models/iron/archive.rb +69 -0
  8. data/app/models/iron/content_export.rb +73 -0
  9. data/app/models/iron/content_import/entry_builder.rb +80 -0
  10. data/app/models/iron/content_import/entry_snapshot.rb +23 -0
  11. data/app/models/iron/content_import/field_reconstructor.rb +276 -0
  12. data/app/models/iron/content_import/field_snapshot.rb +33 -0
  13. data/app/models/iron/content_import/registry.rb +32 -0
  14. data/app/models/iron/content_import/session.rb +89 -0
  15. data/app/models/iron/content_import.rb +15 -0
  16. data/app/models/iron/current.rb +2 -1
  17. data/app/models/iron/entry/portable.rb +35 -0
  18. data/app/models/iron/entry.rb +3 -1
  19. data/app/models/iron/field/portable.rb +33 -0
  20. data/app/models/iron/field.rb +1 -1
  21. data/app/models/iron/fields/block.rb +22 -8
  22. data/app/models/iron/fields/block_list.rb +10 -0
  23. data/app/models/iron/fields/boolean.rb +10 -0
  24. data/app/models/iron/fields/date.rb +10 -0
  25. data/app/models/iron/fields/file.rb +8 -0
  26. data/app/models/iron/fields/number.rb +10 -0
  27. data/app/models/iron/fields/reference.rb +10 -0
  28. data/app/models/iron/fields/reference_list.rb +14 -0
  29. data/app/models/iron/fields/rich_text_area.rb +26 -0
  30. data/app/models/iron/fields/text_area.rb +10 -0
  31. data/app/models/iron/fields/text_field.rb +8 -0
  32. data/app/models/iron/locale.rb +1 -1
  33. data/app/views/iron/contents/new.html.erb +34 -0
  34. data/app/views/iron/settings/show.html.erb +19 -0
  35. data/config/routes.rb +5 -0
  36. data/db/migrate/20250908203158_add_instance_token_to_iron_accounts.rb +6 -0
  37. data/lib/iron/engine.rb +9 -0
  38. data/lib/iron/global_id/instance_scoped_locator.rb +31 -0
  39. data/lib/iron/version.rb +1 -1
  40. data/lib/iron.rb +1 -0
  41. metadata +17 -1
@@ -0,0 +1,33 @@
1
+ module Iron
2
+ class ContentsController < ApplicationController
3
+ before_action :ensure_can_administer
4
+
5
+ def export
6
+ send_data ContentExport.export.to_tar,
7
+ filename: "iron-content-#{Date.current}.tar",
8
+ type: "application/x-tar",
9
+ disposition: "attachment"
10
+ end
11
+
12
+ def new
13
+ end
14
+
15
+ def import
16
+ file = params[:content_file]
17
+
18
+ unless file.present?
19
+ redirect_to new_content_path, alert: "Please select a file to import"
20
+ return
21
+ end
22
+
23
+ archive = Archive.from_file(file)
24
+ result = ContentImport.import(archive)
25
+
26
+ if result.success?
27
+ redirect_to settings_path, notice: "Content imported successfully"
28
+ else
29
+ redirect_to new_content_path, alert: result.errors.join(". ")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ # Adds instance token to GlobalIDs to disambiguate between create and update operations
2
+ # during import. When importing data:
3
+ # - Same instance token + existing ID = UPDATE the existing record
4
+ # - Different instance token + any ID = CREATE a new record
5
+ # This allows safe data exchange between Iron instances without ID conflicts.
6
+ module Iron
7
+ module InstanceScoped
8
+ extend ActiveSupport::Concern
9
+
10
+ def to_global_id(options = {})
11
+ super(options.merge(instance: instance))
12
+ end
13
+
14
+ def to_signed_global_id(options = {})
15
+ super(options.merge(instance: instance))
16
+ end
17
+
18
+ alias to_gid to_global_id
19
+ alias to_sgid to_signed_global_id
20
+
21
+ def instance
22
+ @instance ||= Current.account.instance_token
23
+ end
24
+ end
25
+ end
@@ -2,6 +2,8 @@ module Iron
2
2
  class Account < ApplicationRecord
3
3
  include Joinable
4
4
 
5
+ has_secure_token :instance_token
6
+
5
7
  belongs_to :default_locale, class_name: "Iron::Locale"
6
8
 
7
9
  def self.clear_schema!
@@ -0,0 +1,69 @@
1
+ require "rubygems/package"
2
+ require "stringio"
3
+
4
+ module Iron
5
+ class Archive
6
+ attr_reader :files
7
+
8
+ def initialize(tar_content = nil)
9
+ @files = {}
10
+ extract_from_tar(tar_content) if tar_content
11
+ end
12
+
13
+ def self.from_file(file)
14
+ new(file.read)
15
+ end
16
+
17
+ def add_file(path, content)
18
+ @files[path] = content
19
+ self
20
+ end
21
+
22
+ def [](path)
23
+ @files[path]
24
+ end
25
+
26
+ def file_exists?(path)
27
+ @files.key?(path)
28
+ end
29
+
30
+ def file_paths
31
+ @files.keys
32
+ end
33
+
34
+ def to_tar
35
+ tarfile = StringIO.new
36
+
37
+ Gem::Package::TarWriter.new(tarfile) do |tar|
38
+ @files.each do |path, content|
39
+ content_string = content.is_a?(StringIO) ? content.string : content.to_s
40
+ tar.add_file_simple(path, 0644, content_string.bytesize) do |io|
41
+ io.write(content_string)
42
+ end
43
+ end
44
+ end
45
+
46
+ tarfile.string
47
+ end
48
+
49
+ def to_file(path)
50
+ File.open(path, "wb") { |f| f.write(to_tar) }
51
+ end
52
+
53
+ private
54
+
55
+ def extract_from_tar(tar_content)
56
+ tar_io = StringIO.new(tar_content)
57
+
58
+ Gem::Package::TarReader.new(tar_io) do |tar|
59
+ tar.each do |entry|
60
+ if entry.file?
61
+ @files[entry.full_name] = entry.read.force_encoding("UTF-8")
62
+ end
63
+ end
64
+ end
65
+ rescue => e
66
+ raise "Failed to extract tar archive: #{e.message}"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,73 @@
1
+ require "csv"
2
+
3
+ module Iron
4
+ class ContentExport
5
+ def self.export
6
+ new.export
7
+ end
8
+
9
+ def initialize
10
+ @archive = Archive.new
11
+ end
12
+
13
+ def export
14
+ export_entries_by_content_type
15
+ export_files
16
+
17
+ @archive
18
+ end
19
+
20
+ private
21
+
22
+ def export_entries_by_content_type
23
+ ContentType.find_each do |content_type|
24
+ next if content_type.entries.empty?
25
+
26
+ @archive.add_file("content/#{content_type.handle}.csv", csv_for(content_type))
27
+ end
28
+ end
29
+
30
+ def csv_for(content_type)
31
+ CSV.generate do |csv|
32
+ csv << %w[entry_key locale path type value]
33
+
34
+ content_type.entries.find_each do |entry|
35
+ entry.to_csv_rows.each do |row|
36
+ csv << row
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ def export_files
43
+ collect_blob_ids.each do |blob_id|
44
+ blob = ActiveStorage::Blob.find_by(id: blob_id)
45
+ next unless blob
46
+
47
+ begin
48
+ blob.open do |file|
49
+ @archive.add_file("uploads/#{blob_id}", file.read)
50
+ end
51
+ rescue => e
52
+ Rails.logger.warn "Failed to export blob #{blob_id}: #{e.message}"
53
+ end
54
+ end
55
+ end
56
+
57
+ def collect_blob_ids
58
+ ids = Set.new
59
+
60
+ Fields::File.joins(file_attachment: :blob).pluck("active_storage_blobs.id").each do |id|
61
+ ids << id
62
+ end
63
+
64
+ ActionText::RichText.includes(:embeds_attachments).find_each do |rich_text|
65
+ rich_text.embeds_attachments.each do |attachment|
66
+ ids << attachment.blob.id if attachment.blob
67
+ end
68
+ end
69
+
70
+ ids
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,80 @@
1
+ require "csv"
2
+
3
+ module Iron
4
+ class ContentImport::EntryBuilder
5
+ def initialize(content_type, registry)
6
+ @content_type = content_type
7
+ @registry = registry
8
+ end
9
+
10
+ def ensure_entries_exist(csv_data)
11
+ entry_snapshots = parse_entry_snapshots(csv_data)
12
+
13
+ entry_snapshots.each do |_entry_gid, snapshot|
14
+ ensure_entry(snapshot)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def parse_entry_snapshots(csv_data)
21
+ snapshots = {}
22
+ return snapshots unless csv_data.present?
23
+
24
+ CSV.parse(csv_data, headers: true) do |row|
25
+ entry_key = row["entry_key"]
26
+ next if entry_key.blank?
27
+
28
+ snapshot = (snapshots[entry_key] ||= ContentImport::EntrySnapshot.new(entry_key))
29
+ add_row_to_snapshot(snapshot, row)
30
+ end
31
+
32
+ snapshots
33
+ end
34
+
35
+ def add_row_to_snapshot(snapshot, row)
36
+ path = row["path"]
37
+ return if path.blank?
38
+
39
+ if path == "/route"
40
+ snapshot.add_route(row["value"])
41
+ elsif path.start_with?("/fields/")
42
+ locale_code = row["locale"]
43
+ return unless locale_code.present?
44
+
45
+ field_snapshot = ContentImport::FieldSnapshot.new(
46
+ locale_code: locale_code,
47
+ path: path,
48
+ value: row["value"]
49
+ )
50
+ snapshot.add_field_snapshot(field_snapshot)
51
+ end
52
+ end
53
+
54
+ def ensure_entry(snapshot)
55
+ if snapshot.route.present?
56
+ existing_by_route = @content_type.entries.find_by(route: snapshot.route)
57
+ if existing_by_route
58
+ @registry.register_entry(snapshot.gid, existing_by_route)
59
+ return existing_by_route
60
+ end
61
+ end
62
+
63
+ existing_entry = @registry.find_entry(snapshot.gid)
64
+ # Only use the existing entry if it belongs to the correct content type
65
+ if existing_entry && existing_entry.content_type_id == @content_type.id
66
+ return existing_entry
67
+ end
68
+
69
+ new_entry = create_entry
70
+ @registry.register_entry(snapshot.gid, new_entry)
71
+ new_entry
72
+ end
73
+
74
+ def create_entry
75
+ entry = @content_type.entries.build
76
+ entry.save(validate: false)
77
+ entry
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,23 @@
1
+ module Iron
2
+ class ContentImport::EntrySnapshot
3
+ attr_reader :gid, :route, :field_snapshots
4
+
5
+ def initialize(gid)
6
+ @gid = gid
7
+ @route = nil
8
+ @field_snapshots = []
9
+ end
10
+
11
+ def add_route(route)
12
+ @route = route
13
+ end
14
+
15
+ def add_field_snapshot(snapshot)
16
+ @field_snapshots << snapshot
17
+ end
18
+
19
+ def group_field_snapshots_by_locale
20
+ @field_snapshots.group_by(&:locale_code)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,276 @@
1
+ require "csv"
2
+
3
+ module Iron
4
+ class ContentImport::FieldReconstructor
5
+ def initialize(content_type, registry, archive)
6
+ @content_type = content_type
7
+ @registry = registry
8
+ @archive = archive
9
+ end
10
+
11
+ def reconstruct_all(csv_data)
12
+ entry_snapshots = build_entry_snapshots(csv_data)
13
+
14
+ entry_snapshots.each do |entry_gid, snapshot|
15
+ reconstruct_entry(entry_gid, snapshot)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def build_entry_snapshots(csv_data)
22
+ snapshots = {}
23
+ return snapshots unless csv_data.present?
24
+
25
+ CSV.parse(csv_data, headers: true) do |row|
26
+ entry_key = row["entry_key"]
27
+ next if entry_key.blank?
28
+
29
+ snapshot = (snapshots[entry_key] ||= ContentImport::EntrySnapshot.new(entry_key))
30
+ populate_snapshot(snapshot, row)
31
+ end
32
+
33
+ snapshots
34
+ end
35
+
36
+ def populate_snapshot(snapshot, row)
37
+ path = row["path"]
38
+ return if path.blank?
39
+
40
+ if path == "/route"
41
+ snapshot.add_route(row["value"])
42
+ elsif path.start_with?("/fields/")
43
+ add_field_snapshot(snapshot, row)
44
+ end
45
+ end
46
+
47
+ def add_field_snapshot(snapshot, row)
48
+ locale_code = row["locale"]
49
+ return unless locale_code.present?
50
+
51
+ field_snapshot = ContentImport::FieldSnapshot.new(
52
+ locale_code: locale_code,
53
+ path: row["path"],
54
+ value: row["value"]
55
+ )
56
+ snapshot.add_field_snapshot(field_snapshot)
57
+ end
58
+
59
+ def reconstruct_entry(entry_gid, snapshot)
60
+ entry = @registry.find_entry(entry_gid)
61
+ return unless entry
62
+
63
+ update_entry_route(entry, snapshot.route) unless snapshot.route.nil?
64
+ reconstruct_entry_fields(entry, snapshot)
65
+ end
66
+
67
+ def update_entry_route(entry, route)
68
+ entry.update_columns(route: route)
69
+ end
70
+
71
+ def reconstruct_entry_fields(entry, snapshot)
72
+ snapshot.group_field_snapshots_by_locale.each do |locale_code, field_snapshots|
73
+ locale = Iron::Locale.find_by(code: locale_code)
74
+ next unless locale
75
+
76
+ reconstruct_fields_for_locale(entry, field_snapshots, locale)
77
+ end
78
+ end
79
+
80
+ def reconstruct_fields_for_locale(entry, field_snapshots, locale)
81
+ field_groups = field_snapshots
82
+ .select(&:field_path?)
83
+ .group_by(&:field_handle)
84
+
85
+ field_groups.each do |handle, snapshots|
86
+ definition = @content_type.field_definitions.find_by(handle: handle)
87
+ next unless definition
88
+
89
+ field = entry.find_or_build_field(definition, locale)
90
+ reconstruct_field(field, snapshots)
91
+ end
92
+ end
93
+
94
+ def reconstruct_field(field, snapshots)
95
+ case field
96
+ when Iron::Fields::TextField
97
+ field.value_string = snapshots.first.value
98
+ when Iron::Fields::TextArea
99
+ field.value_text = snapshots.first.value
100
+ when Iron::Fields::Number
101
+ field.value_decimal = snapshots.first.value.to_f if snapshots.first.value.present?
102
+ when Iron::Fields::Boolean
103
+ field.value_boolean = ActiveModel::Type::Boolean.new.cast(snapshots.first.value)
104
+ when Iron::Fields::Date
105
+ field.value_datetime = Time.zone.parse(snapshots.first.value) if snapshots.first.value.present?
106
+ when Iron::Fields::File
107
+ attach_file(field, snapshots.first.value)
108
+ when Iron::Fields::Reference
109
+ field.referenced_entry = @registry.find_entry(snapshots.first.value)
110
+ when Iron::Fields::RichTextArea
111
+ reconstruct_rich_text(field, snapshots)
112
+ when Iron::Fields::ReferenceList
113
+ reconstruct_reference_list(field, snapshots)
114
+ when Iron::Fields::Block
115
+ reconstruct_block(field, snapshots)
116
+ when Iron::Fields::BlockList
117
+ reconstruct_block_list(field, snapshots)
118
+ end
119
+
120
+ field.save!(validate: false)
121
+ rescue ArgumentError
122
+ # Handle date parsing errors
123
+ field.save!(validate: false)
124
+ end
125
+
126
+ def attach_file(field, blob_id)
127
+ return unless blob_id.present?
128
+
129
+ data = @archive["uploads/#{blob_id}"]
130
+ return unless data
131
+
132
+ field.file.attach(
133
+ io: StringIO.new(data),
134
+ filename: "imported-#{blob_id}",
135
+ content_type: "application/octet-stream"
136
+ )
137
+ end
138
+
139
+ def reconstruct_rich_text(field, snapshots)
140
+ html_snapshot = snapshots.find { |s| s.path.end_with?("/html") }
141
+
142
+ if html_snapshot&.value.present?
143
+ field.update!(rich_text: html_snapshot.value)
144
+ else
145
+ field.rich_text&.destroy
146
+ end
147
+ end
148
+
149
+ def reconstruct_reference_list(field, snapshots)
150
+ # Group snapshots by rank (the first segment after the field handle)
151
+ field.save!(validate: false) if field.new_record?
152
+
153
+ items = snapshots.filter_map do |snapshot|
154
+ segments = snapshot.field_segments
155
+ next if segments.length < 2
156
+
157
+ rank = segments.last
158
+ entry = @registry.find_entry(snapshot.value)
159
+ next unless entry
160
+
161
+ { rank:, entry: }
162
+ end
163
+
164
+ existing = field.references.index_by(&:rank)
165
+ kept_ids = []
166
+
167
+ items.each do |item|
168
+ reference = existing[item[:rank]] || field.references.build(rank: item[:rank])
169
+ reference.entry = item[:entry]
170
+ reference.save!
171
+ kept_ids << reference.id
172
+ end
173
+
174
+ field.references.where.not(id: kept_ids).destroy_all
175
+ end
176
+
177
+ def reconstruct_block(field, snapshots)
178
+ # Fields::Block IS the block - it doesn't have a separate block relation
179
+ # The field already has the block definition through field.definition
180
+
181
+ # Group snapshots by field handle
182
+ field_snapshots = snapshots.reject { |s| s.path.include?("/_block") }
183
+ reconstruct_block_fields(field, field_snapshots, field.locale)
184
+ end
185
+
186
+ def reconstruct_block_list(field, snapshots)
187
+ # Group snapshots by block rank
188
+ blocks_by_rank = {}
189
+
190
+ snapshots.each do |snapshot|
191
+ segments = snapshot.field_segments
192
+ next if segments.size < 2
193
+
194
+ rank = segments[1]
195
+ blocks_by_rank[rank] ||= []
196
+ blocks_by_rank[rank] << snapshot
197
+ end
198
+
199
+ # Clear all existing blocks first to avoid rank conflicts
200
+ field.blocks.destroy_all
201
+
202
+ blocks_by_rank.each do |rank, block_snapshots|
203
+ # Find block handle
204
+ block_snapshot = block_snapshots.find { |s| s.path.include?("/_block") }
205
+ block_handle = block_snapshot&.value
206
+ next unless block_handle
207
+
208
+ block_definition = Iron::BlockDefinition.find_by(handle: block_handle)
209
+ next unless block_definition
210
+
211
+ block = field.blocks.build(
212
+ rank: rank,
213
+ entry: field.entry,
214
+ locale: field.locale,
215
+ definition: block_definition
216
+ )
217
+
218
+ # Save the block first to establish it in the database
219
+ block.save!(validate: false)
220
+
221
+ # Then reconstruct its fields
222
+ field_snapshots = block_snapshots.reject { |s| s.path.include?("/_block") }
223
+ reconstruct_block_fields(block, field_snapshots, field.locale)
224
+ end
225
+ end
226
+
227
+ def reconstruct_block_fields(block, snapshots, locale)
228
+ # Group snapshots by field handle
229
+ field_groups = {}
230
+
231
+ snapshots.each do |snapshot|
232
+ field_handle = block_child_handle(block, snapshot)
233
+ next unless field_handle
234
+
235
+ field_groups[field_handle] ||= []
236
+ field_groups[field_handle] << snapshot
237
+ end
238
+
239
+ # Get the actual block definition
240
+ block_def = if block.respond_to?(:block_definition)
241
+ block.block_definition
242
+ else
243
+ block.definition
244
+ end
245
+
246
+ # Clear existing fields to avoid rank conflicts
247
+ block.fields.where(locale: locale).destroy_all
248
+
249
+ field_groups.each do |handle, field_snapshots|
250
+ definition = block_def.field_definitions.find_by(handle: handle)
251
+ next unless definition
252
+
253
+ field = block.fields.build(
254
+ type: definition.field_type,
255
+ entry: block.entry,
256
+ definition: definition,
257
+ locale: locale
258
+ )
259
+
260
+ reconstruct_field(field, field_snapshots)
261
+ end
262
+ end
263
+
264
+ def block_child_handle(block, snapshot)
265
+ relative_segments = snapshot.field_segments
266
+ block_path = block.send(:field_path)
267
+
268
+ if block_path.present?
269
+ block_segments = block_path.sub(%r{^/fields/}, "").split("/")
270
+ relative_segments = relative_segments.drop(block_segments.length)
271
+ end
272
+
273
+ relative_segments.first
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,33 @@
1
+ module Iron
2
+ class ContentImport::FieldSnapshot
3
+ attr_reader :locale_code, :path, :value
4
+
5
+ def initialize(locale_code:, path:, value:)
6
+ @locale_code = locale_code
7
+ @path = path
8
+ @value = value
9
+ end
10
+
11
+ def field_handle
12
+ return nil unless field_path?
13
+
14
+ segments[0]
15
+ end
16
+
17
+ def field_segments
18
+ return [] unless field_path?
19
+
20
+ segments
21
+ end
22
+
23
+ def field_path?
24
+ @path&.start_with?("/fields/")
25
+ end
26
+
27
+ private
28
+
29
+ def segments
30
+ @segments ||= @path.sub(%r{^/fields/}, "").split("/")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ module Iron
2
+ class ContentImport::Registry
3
+ def initialize
4
+ @entries_by_original_gid = {}
5
+ end
6
+
7
+ def register_entry(original_gid, entry)
8
+ @entries_by_original_gid[original_gid] = entry
9
+ @entries_by_original_gid[entry.to_gid.to_s] = entry
10
+ end
11
+
12
+ def find_entry(gid_string)
13
+ return nil if gid_string.blank?
14
+
15
+ cached_entry = @entries_by_original_gid[gid_string]
16
+ return cached_entry if cached_entry
17
+
18
+ locate_existing_entry(gid_string)
19
+ end
20
+
21
+ private
22
+
23
+ def locate_existing_entry(gid_string)
24
+ entry = ::GlobalID::Locator.locate(gid_string)
25
+ return nil unless entry.is_a?(Iron::Entry)
26
+
27
+ @entries_by_original_gid[gid_string] = entry
28
+ rescue ActiveRecord::RecordNotFound
29
+ nil
30
+ end
31
+ end
32
+ end