iron-cms 0.1.3 → 0.2.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 (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 +77 -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 +1 -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 +29 -0
  39. data/lib/iron/version.rb +1 -1
  40. data/lib/iron.rb +1 -0
  41. metadata +17 -1
@@ -0,0 +1,89 @@
1
+ require "csv"
2
+
3
+ module Iron
4
+ class ContentImport::Session
5
+ attr_reader :errors
6
+
7
+ def initialize(archive)
8
+ raise ArgumentError, "Missing import archive" unless archive.present?
9
+
10
+ @archive = archive
11
+ @errors = []
12
+ @registry = ContentImport::Registry.new
13
+ end
14
+
15
+ def run
16
+ ActiveRecord::Base.transaction do
17
+ prepare_entries
18
+ reconstruct_entries
19
+
20
+ Result.new(success: true)
21
+ end
22
+ rescue StandardError => e
23
+ warn e.full_message
24
+ @errors << "Import failed: #{e.message}"
25
+ Result.new(success: false, errors: @errors)
26
+ end
27
+
28
+ private
29
+
30
+ def prepare_entries
31
+ content_manifests.each do |content_type, csv_data|
32
+ entry_builder = ContentImport::EntryBuilder.new(content_type, @registry)
33
+ entry_builder.ensure_entries_exist(csv_data)
34
+ end
35
+ end
36
+
37
+ def reconstruct_entries
38
+ content_manifests.each do |content_type, csv_data|
39
+ reconstructor = ContentImport::FieldReconstructor.new(content_type, @registry, @archive)
40
+ reconstructor.reconstruct_all(csv_data)
41
+ end
42
+ end
43
+
44
+ def content_manifests
45
+ @content_manifests ||= build_content_manifests
46
+ end
47
+
48
+ def build_content_manifests
49
+ @archive.file_paths
50
+ .select { |path| content_manifest?(path) }
51
+ .sort
52
+ .filter_map do |path|
53
+ content_type = find_content_type(path)
54
+
55
+ unless content_type
56
+ @errors << "Unknown content type '#{extract_handle(path)}' in archive"
57
+ next
58
+ end
59
+
60
+ [ content_type, @archive[path] ]
61
+ end
62
+ end
63
+
64
+ def content_manifest?(path)
65
+ path.start_with?("content/") && path.end_with?(".csv")
66
+ end
67
+
68
+ def extract_handle(path)
69
+ ::File.basename(path, ".csv")
70
+ end
71
+
72
+ def find_content_type(path)
73
+ Iron::ContentType.find_by(handle: extract_handle(path))
74
+ end
75
+
76
+ class Result
77
+ attr_reader :errors
78
+
79
+ def initialize(success:, errors: [])
80
+ @success = success
81
+ @errors = errors
82
+ end
83
+
84
+ def success?
85
+ @success
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,15 @@
1
+ module Iron
2
+ class ContentImport
3
+ def self.import(archive)
4
+ new(archive).import
5
+ end
6
+
7
+ def initialize(archive)
8
+ @archive = archive
9
+ end
10
+
11
+ def import
12
+ Session.new(@archive).run
13
+ end
14
+ end
15
+ end
@@ -4,9 +4,10 @@ module Iron
4
4
  delegate :user, to: :session, allow_nil: true
5
5
 
6
6
  attribute :locale
7
+ attribute :account
7
8
 
8
9
  def account
9
- Account.first
10
+ super || (self.account = Account.first)
10
11
  end
11
12
  end
12
13
  end
@@ -0,0 +1,35 @@
1
+ module Iron
2
+ class Entry
3
+ module Portable
4
+ extend ActiveSupport::Concern
5
+
6
+ def to_csv_rows
7
+ rows = []
8
+
9
+ rows.concat(entry_attribute_rows)
10
+
11
+ fields.where(parent_type: nil).includes(:locale, :definition).each do |field|
12
+ rows.concat(field.to_csv_rows)
13
+ end
14
+
15
+ rows
16
+ end
17
+
18
+ private
19
+
20
+ def entry_attribute_rows
21
+ rows = []
22
+
23
+ rows << csv_row("/created_at", "date", created_at.iso8601)
24
+ rows << csv_row("/updated_at", "date", updated_at.iso8601)
25
+ rows << csv_row("/route", "text", route) unless route.nil?
26
+
27
+ rows
28
+ end
29
+
30
+ def csv_row(path, type, value)
31
+ [ to_gid, nil, path, type, value ]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class Entry < ApplicationRecord
3
- include Titlable, Schemable, WebPublishable, Presentable, DeepValidation
3
+ include Titlable, Schemable, WebPublishable, Presentable, DeepValidation, InstanceScoped, Portable
4
4
 
5
5
  belongs_to :creator, class_name: "Iron::User", default: -> { Current.user }
6
6
  has_many :fields, inverse_of: :entry, dependent: :destroy
@@ -0,0 +1,33 @@
1
+ module Iron
2
+ class Field
3
+ module Portable
4
+ extend ActiveSupport::Concern
5
+
6
+ def to_csv_rows
7
+ [ csv_row(field_path, csv_type, csv_value) ]
8
+ end
9
+
10
+ def field_path
11
+ if parent.is_a?(Fields::Block)
12
+ "#{parent.field_path}/#{definition.handle}"
13
+ elsif parent.is_a?(Fields::BlockList)
14
+ "#{parent.field_path}/#{rank}"
15
+ else
16
+ "/fields/#{definition.handle}"
17
+ end
18
+ end
19
+
20
+ def csv_row(path, type, value = nil)
21
+ [ entry.to_gid, locale&.code, path, type, value ]
22
+ end
23
+
24
+ def csv_type
25
+ raise NotImplementedError, "#{self.class} must implement csv_type"
26
+ end
27
+
28
+ def csv_value
29
+ raise NotImplementedError, "#{self.class} must implement csv_value"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,6 +1,6 @@
1
1
  module Iron
2
2
  class Field < ApplicationRecord
3
- include BelongsToEntry
3
+ include BelongsToEntry, Portable
4
4
 
5
5
  TYPES = %w[text_field text_area rich_text_area number file boolean date block block_list reference_list reference].freeze
6
6
 
@@ -6,7 +6,15 @@ module Iron
6
6
  has_rank scoped_by: :parent
7
7
 
8
8
  def block_list
9
- parent.is_a?(Iron::Fields::BlockList) ? parent : nil
9
+ parent.is_a?(Fields::BlockList) ? parent : nil
10
+ end
11
+
12
+ def block_definition
13
+ if definition.is_a?(FieldDefinitions::Block)
14
+ definition.supported_block_definition
15
+ else
16
+ definition
17
+ end
10
18
  end
11
19
 
12
20
  def find_or_build_field(definition)
@@ -14,13 +22,7 @@ module Iron
14
22
  end
15
23
 
16
24
  def value
17
- locale = Iron::Current.locale || Iron::Locale.default
18
-
19
- block_definition = if definition.is_a?(Iron::FieldDefinitions::Block)
20
- definition.supported_block_definition
21
- else
22
- definition
23
- end
25
+ locale = Current.locale || Locale.default
24
26
 
25
27
  result = OpenStruct.new(id: id, _type: block_definition.handle)
26
28
 
@@ -36,5 +38,17 @@ module Iron
36
38
 
37
39
  result
38
40
  end
41
+
42
+ def to_csv_rows
43
+ rows = []
44
+
45
+ rows << csv_row("#{field_path}/_block", "text", block_definition.handle)
46
+
47
+ fields.includes(:locale, :definition).each do |nested_field|
48
+ rows.concat(nested_field.to_csv_rows)
49
+ end
50
+
51
+ rows
52
+ end
39
53
  end
40
54
  end
@@ -12,5 +12,15 @@ module Iron
12
12
  def value
13
13
  blocks.map(&:value)
14
14
  end
15
+
16
+ def to_csv_rows
17
+ rows = []
18
+
19
+ blocks.includes(:fields, :locale, :definition).each do |block|
20
+ rows.concat(block.to_csv_rows)
21
+ end
22
+
23
+ rows
24
+ end
15
25
  end
16
26
  end
@@ -3,5 +3,15 @@ module Iron
3
3
  def value
4
4
  value_boolean
5
5
  end
6
+
7
+ private
8
+
9
+ def csv_type
10
+ "boolean"
11
+ end
12
+
13
+ def csv_value
14
+ value_boolean&.to_s
15
+ end
6
16
  end
7
17
  end
@@ -3,5 +3,15 @@ module Iron
3
3
  def value
4
4
  value_datetime
5
5
  end
6
+
7
+ private
8
+
9
+ def csv_type
10
+ "date"
11
+ end
12
+
13
+ def csv_value
14
+ value_datetime&.iso8601
15
+ end
6
16
  end
7
17
  end
@@ -25,5 +25,13 @@ module Iron
25
25
  errors.add(:base, "must be one of the accepted formats: #{definition.accepted_extensions.join(', ')}")
26
26
  end
27
27
  end
28
+
29
+ def csv_type
30
+ "file"
31
+ end
32
+
33
+ def csv_value
34
+ file.attached? ? file.blob.id : nil
35
+ end
28
36
  end
29
37
  end
@@ -3,5 +3,15 @@ module Iron
3
3
  def value
4
4
  value_float
5
5
  end
6
+
7
+ private
8
+
9
+ def csv_type
10
+ "number"
11
+ end
12
+
13
+ def csv_value
14
+ value_float&.to_s
15
+ end
6
16
  end
7
17
  end
@@ -10,5 +10,15 @@ module Iron
10
10
  )
11
11
  end
12
12
  end
13
+
14
+ private
15
+
16
+ def csv_type
17
+ "reference"
18
+ end
19
+
20
+ def csv_value
21
+ referenced_entry&.to_gid
22
+ end
13
23
  end
14
24
  end
@@ -13,5 +13,19 @@ module Iron
13
13
  )
14
14
  end
15
15
  end
16
+
17
+ def to_csv_rows
18
+ rows = []
19
+
20
+ references.includes(:entry).each do |reference|
21
+ rows << csv_row(
22
+ "#{field_path}/#{reference.rank}",
23
+ "reference",
24
+ reference.entry.to_gid
25
+ )
26
+ end
27
+
28
+ rows
29
+ end
16
30
  end
17
31
  end
@@ -5,5 +5,31 @@ module Iron
5
5
  def value
6
6
  rich_text&.to_s
7
7
  end
8
+
9
+ def to_csv_rows
10
+ rows = []
11
+
12
+ if rich_text.present?
13
+ rows << csv_row("#{field_path}/html", "rich_text", rich_text.body&.to_html)
14
+
15
+ rich_text.body.attachables.each do |attachable|
16
+ next unless attachable.respond_to?(:blob)
17
+
18
+ rows << csv_row("#{field_path}/attachments", "reference", attachable.blob.id)
19
+ end
20
+ end
21
+
22
+ rows
23
+ end
24
+
25
+ private
26
+
27
+ def csv_type
28
+ "rich_text"
29
+ end
30
+
31
+ def csv_value
32
+ rich_text.body&.to_html
33
+ end
8
34
  end
9
35
  end
@@ -3,5 +3,15 @@ module Iron
3
3
  def value
4
4
  value_text
5
5
  end
6
+
7
+ private
8
+
9
+ def csv_type
10
+ "text"
11
+ end
12
+
13
+ def csv_value
14
+ value_text
15
+ end
6
16
  end
7
17
  end
@@ -15,5 +15,13 @@ module Iron
15
15
  errors.add(:base, :blank)
16
16
  end
17
17
  end
18
+
19
+ def csv_type
20
+ "text"
21
+ end
22
+
23
+ def csv_value
24
+ value_string
25
+ end
18
26
  end
19
27
  end
@@ -11,7 +11,7 @@ module Iron
11
11
  end
12
12
 
13
13
  def self.default
14
- Account.first&.default_locale
14
+ Current.account&.default_locale
15
15
  end
16
16
 
17
17
  def default?
@@ -0,0 +1,34 @@
1
+ <% content_for :title, "Import Content" %>
2
+
3
+ <div class="">
4
+ <%= back_button_to "Settings", settings_path %>
5
+ <h1 class="page-title">Import Content</h1>
6
+ <p class="mt-2 text-sm text-stone-500">Upload a content archive exported from another Iron instance.</p>
7
+
8
+ <%= form_with url: import_content_path, multipart: true, class: "mt-8 max-w-2xl", data: { turbo: false } do |form| %>
9
+ <div class="field-group">
10
+ <div class="field">
11
+ <%= form.label :content_file, "Content Archive" %>
12
+ <div>
13
+ <%= form.file_field :content_file,
14
+ accept: ".tar",
15
+ required: true,
16
+ class: "input" %>
17
+ <p class="mt-2 text-sm text-stone-500">Select a .tar file exported using Iron's content export.</p>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="field">
22
+ <label class="text-sm font-medium text-stone-700">Import behaviour</label>
23
+ <p class="text-sm text-stone-500 mt-1">
24
+ Entries with matching IDs and instance tokens are updated. Other records are created.
25
+ </p>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="mt-6 flex items-center gap-x-3">
30
+ <%= form.submit "Import Content", class: "btn" %>
31
+ <%= link_to "Cancel", settings_path, class: "btn", data: { variant: "plain" } %>
32
+ </div>
33
+ <% end %>
34
+ </div>
@@ -35,4 +35,23 @@
35
35
  </div>
36
36
  <% end %>
37
37
  </div>
38
+
39
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6 max-w-96">
40
+ <h2 class="text-lg font-semibold mb-4">Content Management</h2>
41
+ <p class="text-sm text-stone-600 dark:text-stone-400 mb-4">
42
+ Export content entries and their files or import an archive from another instance.
43
+ </p>
44
+ <div class="flex flex-col gap-2">
45
+ <%= link_to "Export Content",
46
+ export_content_path,
47
+ class: "btn",
48
+ data: {
49
+ turbo: false,
50
+ } %>
51
+ <%= link_to "Import Content",
52
+ new_content_path,
53
+ class: "btn",
54
+ data: { variant: "secondary" } %>
55
+ </div>
56
+ </div>
38
57
  </div>
data/config/routes.rb CHANGED
@@ -22,6 +22,11 @@ Iron::Engine.routes.draw do
22
22
  post :import, on: :member
23
23
  end
24
24
 
25
+ resource :content, only: [ :new ] do
26
+ get :export, on: :collection
27
+ post :import, on: :collection
28
+ end
29
+
25
30
  resources :block_definitions do
26
31
  resources :field_definitions, shallow: true, module: :block_definitions
27
32
  end
@@ -0,0 +1,6 @@
1
+ class AddInstanceTokenToIronAccounts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :iron_accounts, :instance_token, :string
4
+ add_index :iron_accounts, :instance_token, unique: true
5
+ end
6
+ end
data/lib/iron/engine.rb CHANGED
@@ -31,6 +31,15 @@ module Iron
31
31
  app.config.active_storage.analyzers.prepend Iron::ImageAnalyzer
32
32
  end
33
33
 
34
+ initializer "iron.global_id_locator" do |app|
35
+ app.config.after_initialize do
36
+ global_id_app = ::GlobalID.app || ::Rails.application.config.try(:global_id)&.app
37
+ next unless global_id_app
38
+
39
+ ::GlobalID::Locator.use(global_id_app, Iron::GlobalID::InstanceScopedLocator.new)
40
+ end
41
+ end
42
+
34
43
  config.to_prepare do
35
44
  ActionView::Base.include(Module.new do
36
45
  def attachment_url(attachment, **options)
@@ -0,0 +1,29 @@
1
+ require "globalid"
2
+
3
+ module Iron
4
+ module GlobalID
5
+ class InstanceScopedLocator < ::GlobalID::Locator::UnscopedLocator
6
+ def locate(gid, options = {})
7
+ return unless instance_matches?(gid)
8
+
9
+ super
10
+ end
11
+
12
+ def locate_many(gids, options = {})
13
+ filtered_gids = gids.select { |candidate| instance_matches?(candidate) }
14
+ return [] if filtered_gids.empty?
15
+
16
+ super(filtered_gids, options)
17
+ end
18
+
19
+ private
20
+
21
+ def instance_matches?(gid)
22
+ instance_token = gid.params[:instance]
23
+ return true if instance_token.blank?
24
+
25
+ Current.account&.instance_token == instance_token
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/iron/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iron
2
- VERSION = "0.1.3"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/iron.rb CHANGED
@@ -2,6 +2,7 @@ require "iron/version"
2
2
  require "iron/engine"
3
3
  require "iron/cva"
4
4
  require "iron/sdk"
5
+ require "iron/global_id/instance_scoped_locator"
5
6
 
6
7
  module Iron
7
8
  # Your code goes here...
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iron-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Massimo De Marchi
@@ -198,6 +198,7 @@ files:
198
198
  - app/controllers/iron/blocks_controller.rb
199
199
  - app/controllers/iron/content_types/field_definitions_controller.rb
200
200
  - app/controllers/iron/content_types_controller.rb
201
+ - app/controllers/iron/contents_controller.rb
201
202
  - app/controllers/iron/entries_controller.rb
202
203
  - app/controllers/iron/field_definitions_controller.rb
203
204
  - app/controllers/iron/first_runs_controller.rb
@@ -239,11 +240,21 @@ files:
239
240
  - app/mailers/iron/application_mailer.rb
240
241
  - app/mailers/passwords_mailer.rb
241
242
  - app/models/concerns/iron/csv_serializable.rb
243
+ - app/models/concerns/iron/instance_scoped.rb
242
244
  - app/models/iron/account.rb
243
245
  - app/models/iron/account/joinable.rb
244
246
  - app/models/iron/application_record.rb
247
+ - app/models/iron/archive.rb
245
248
  - app/models/iron/block_definition.rb
246
249
  - app/models/iron/block_definition/portable.rb
250
+ - app/models/iron/content_export.rb
251
+ - app/models/iron/content_import.rb
252
+ - app/models/iron/content_import/entry_builder.rb
253
+ - app/models/iron/content_import/entry_snapshot.rb
254
+ - app/models/iron/content_import/field_reconstructor.rb
255
+ - app/models/iron/content_import/field_snapshot.rb
256
+ - app/models/iron/content_import/registry.rb
257
+ - app/models/iron/content_import/session.rb
247
258
  - app/models/iron/content_type.rb
248
259
  - app/models/iron/content_type/field_queryable.rb
249
260
  - app/models/iron/content_type/portable.rb
@@ -254,12 +265,14 @@ files:
254
265
  - app/models/iron/current.rb
255
266
  - app/models/iron/entry.rb
256
267
  - app/models/iron/entry/deep_validation.rb
268
+ - app/models/iron/entry/portable.rb
257
269
  - app/models/iron/entry/presentable.rb
258
270
  - app/models/iron/entry/schemable.rb
259
271
  - app/models/iron/entry/titlable.rb
260
272
  - app/models/iron/entry/web_publishable.rb
261
273
  - app/models/iron/field.rb
262
274
  - app/models/iron/field/belongs_to_entry.rb
275
+ - app/models/iron/field/portable.rb
263
276
  - app/models/iron/field_definition.rb
264
277
  - app/models/iron/field_definition/portable.rb
265
278
  - app/models/iron/field_definitions/block.rb
@@ -312,6 +325,7 @@ files:
312
325
  - app/views/iron/content_types/index.html.erb
313
326
  - app/views/iron/content_types/new.html.erb
314
327
  - app/views/iron/content_types/show.html.erb
328
+ - app/views/iron/contents/new.html.erb
315
329
  - app/views/iron/entries/_entry.html.erb
316
330
  - app/views/iron/entries/_entry_option.html.erb
317
331
  - app/views/iron/entries/_form.html.erb
@@ -394,6 +408,7 @@ files:
394
408
  - db/migrate/20250601080146_add_icon_to_iron_content_types.rb
395
409
  - db/migrate/20250609091605_add_web_publishing_to_iron_content_types.rb
396
410
  - db/migrate/20250609091813_add_route_to_iron_entries.rb
411
+ - db/migrate/20250908203158_add_instance_token_to_iron_accounts.rb
397
412
  - lib/generators/iron/pages/pages_generator.rb
398
413
  - lib/generators/iron/pages/templates/pages_controller.rb
399
414
  - lib/generators/iron/pages/templates/show.html.erb
@@ -403,6 +418,7 @@ files:
403
418
  - lib/iron.rb
404
419
  - lib/iron/cva.rb
405
420
  - lib/iron/engine.rb
421
+ - lib/iron/global_id/instance_scoped_locator.rb
406
422
  - lib/iron/image_analyzer.rb
407
423
  - lib/iron/lexorank.rb
408
424
  - lib/iron/lexorank/exceptions.rb