iron-cms 0.1.2 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -0
  3. data/app/assets/builds/iron.css +9 -0
  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/block_definition.rb +8 -0
  9. data/app/models/iron/content_export.rb +73 -0
  10. data/app/models/iron/content_import/entry_builder.rb +77 -0
  11. data/app/models/iron/content_import/entry_snapshot.rb +23 -0
  12. data/app/models/iron/content_import/field_reconstructor.rb +276 -0
  13. data/app/models/iron/content_import/field_snapshot.rb +33 -0
  14. data/app/models/iron/content_import/registry.rb +32 -0
  15. data/app/models/iron/content_import/session.rb +89 -0
  16. data/app/models/iron/content_import.rb +15 -0
  17. data/app/models/iron/content_type.rb +8 -0
  18. data/app/models/iron/current.rb +2 -1
  19. data/app/models/iron/entry/portable.rb +35 -0
  20. data/app/models/iron/entry.rb +1 -1
  21. data/app/models/iron/field/portable.rb +33 -0
  22. data/app/models/iron/field.rb +1 -1
  23. data/app/models/iron/field_definition/portable.rb +12 -6
  24. data/app/models/iron/fields/block.rb +22 -8
  25. data/app/models/iron/fields/block_list.rb +10 -0
  26. data/app/models/iron/fields/boolean.rb +10 -0
  27. data/app/models/iron/fields/date.rb +10 -0
  28. data/app/models/iron/fields/file.rb +8 -0
  29. data/app/models/iron/fields/number.rb +10 -0
  30. data/app/models/iron/fields/reference.rb +10 -0
  31. data/app/models/iron/fields/reference_list.rb +14 -0
  32. data/app/models/iron/fields/rich_text_area.rb +26 -0
  33. data/app/models/iron/fields/text_area.rb +10 -0
  34. data/app/models/iron/fields/text_field.rb +8 -0
  35. data/app/models/iron/locale.rb +1 -1
  36. data/app/models/iron/schema_importer/import_strategy.rb +59 -0
  37. data/app/models/iron/schema_importer/merge_strategy.rb +52 -0
  38. data/app/models/iron/schema_importer/replace_strategy.rb +51 -0
  39. data/app/models/iron/schema_importer/safe_strategy.rb +55 -0
  40. data/app/models/iron/schema_importer.rb +29 -199
  41. data/app/views/iron/contents/new.html.erb +34 -0
  42. data/app/views/iron/settings/show.html.erb +19 -0
  43. data/config/routes.rb +5 -0
  44. data/db/migrate/20250908203158_add_instance_token_to_iron_accounts.rb +6 -0
  45. data/lib/iron/engine.rb +9 -0
  46. data/lib/iron/global_id/instance_scoped_locator.rb +29 -0
  47. data/lib/iron/version.rb +1 -1
  48. data/lib/iron-cms.rb +1 -1
  49. data/lib/iron.rb +1 -0
  50. metadata +21 -1
@@ -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
@@ -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
@@ -11,6 +11,14 @@ module Iron
11
11
 
12
12
  has_many :entries, -> { extending FieldQueryable }, dependent: :destroy
13
13
 
14
+ has_and_belongs_to_many :referencing_field_definitions,
15
+ class_name: "::Iron::FieldDefinition",
16
+ foreign_key: "content_type_id",
17
+ association_foreign_key: "field_definition_id",
18
+ join_table: "iron_content_types_field_definitions"
19
+
20
+ before_destroy { referencing_field_definitions.clear }
21
+
14
22
  class << self
15
23
  def classify_type(type)
16
24
  "Iron::ContentTypes::#{type}"
@@ -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
 
@@ -5,7 +5,7 @@ module Iron
5
5
 
6
6
  class_methods do
7
7
  def csv_headers
8
- %w[parent_type parent_handle handle name type rank metadata supported_block_definitions]
8
+ %w[parent_type parent_handle handle name type rank metadata supported_block_definitions supported_content_types]
9
9
  end
10
10
 
11
11
  def csv_scope
@@ -22,15 +22,21 @@ module Iron
22
22
  type.demodulize.underscore,
23
23
  rank,
24
24
  metadata.to_json,
25
- export_supported_block_definitions
25
+ export_supported_block_definitions,
26
+ export_supported_content_types
26
27
  ]
27
28
  end
28
29
 
29
30
  private
30
31
 
31
- def export_supported_block_definitions
32
- return nil unless respond_to?(:supported_block_definitions)
33
- supported_block_definitions.pluck(:handle).join("|")
34
- end
32
+ def export_supported_block_definitions
33
+ return nil unless respond_to?(:supported_block_definitions)
34
+ supported_block_definitions.pluck(:handle).join("|")
35
+ end
36
+
37
+ def export_supported_content_types
38
+ return nil unless respond_to?(:supported_content_types)
39
+ supported_content_types.pluck(:handle).join("|")
40
+ end
35
41
  end
36
42
  end
@@ -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,59 @@
1
+ module Iron
2
+ class SchemaImporter::ImportStrategy
3
+ def import_block_definition(row)
4
+ raise NotImplementedError
5
+ end
6
+
7
+ def import_content_type(row)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def import_field_definition(row)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ protected
16
+
17
+ def find_parent(parent_type, parent_handle)
18
+ case parent_type
19
+ when "content_type"
20
+ ContentType.find_by(handle: parent_handle)
21
+ when "block"
22
+ BlockDefinition.find_by(handle: parent_handle)
23
+ end
24
+ end
25
+
26
+ def content_type_class(type)
27
+ case type
28
+ when "single" then ContentTypes::Single
29
+ when "collection" then ContentTypes::Collection
30
+ else
31
+ raise "Invalid content type: #{type}. Must be 'single' or 'collection'"
32
+ end
33
+ end
34
+
35
+ def field_definition_class(type)
36
+ "Iron::FieldDefinitions::#{type.camelize}".constantize
37
+ end
38
+
39
+ def parse_metadata(metadata_string)
40
+ return {} if metadata_string.blank?
41
+
42
+ JSON.parse(metadata_string)
43
+ rescue JSON::ParserError
44
+ {}
45
+ end
46
+
47
+ def parse_supported_block_definitions(supported_handles_string)
48
+ return BlockDefinition.none unless supported_handles_string.present?
49
+
50
+ BlockDefinition.where(handle: supported_handles_string.split("|"))
51
+ end
52
+
53
+ def parse_supported_content_types(supported_handles_string)
54
+ return ContentType.none unless supported_handles_string.present?
55
+
56
+ ContentType.where(handle: supported_handles_string.split("|"))
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,52 @@
1
+ module Iron
2
+ class SchemaImporter::MergeStrategy < SchemaImporter::ImportStrategy
3
+ def import_block_definition(row)
4
+ definition = BlockDefinition.find_or_initialize_by(handle: row["handle"])
5
+ definition.update!(
6
+ name: row["name"],
7
+ description: row["description"]
8
+ )
9
+ end
10
+
11
+ def import_content_type(row)
12
+ content_type = ContentType.find_or_initialize_by(handle: row["handle"])
13
+
14
+ content_type = content_type.becomes!(content_type_class(row["type"]))
15
+ content_type.update!(
16
+ name: row["name"],
17
+ description: row["description"],
18
+ icon: row["icon"],
19
+ web_publishing_enabled: row["web_publishing_enabled"] == "true",
20
+ base_path: row["base_path"]
21
+ )
22
+ end
23
+
24
+ def import_field_definition(row)
25
+ raise "Field type is required" unless row["type"].present?
26
+ return unless row["parent_type"].present? && row["parent_handle"].present? && row["handle"].present?
27
+
28
+ parent = find_parent(row["parent_type"], row["parent_handle"])
29
+ return unless parent
30
+
31
+ field_def = parent.field_definitions.find_or_initialize_by(handle: row["handle"])
32
+ field_def = field_def.becomes!(field_definition_class(row["type"]))
33
+
34
+ attributes = {
35
+ name: row["name"],
36
+ rank: row["rank"],
37
+ metadata: parse_metadata(row["metadata"])
38
+ }
39
+
40
+ if field_def.is_a?(FieldDefinitions::Block) || field_def.is_a?(FieldDefinitions::BlockList)
41
+ attributes[:supported_block_definition_ids] = parse_supported_block_definitions(row["supported_block_definitions"]).pluck(:id)
42
+ end
43
+
44
+ if field_def.is_a?(FieldDefinitions::Reference) || field_def.is_a?(FieldDefinitions::ReferenceList)
45
+ attributes[:supported_content_type_ids] = parse_supported_content_types(row["supported_content_types"]).pluck(:id)
46
+ end
47
+
48
+ field_def.assign_attributes(attributes)
49
+ field_def.save!
50
+ end
51
+ end
52
+ end