plum-cms 0.2.0 → 0.2.2

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +79 -0
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/controllers/plum/cp/entries_controller.rb +88 -5
  6. data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
  7. data/app/controllers/plum/form_submissions_controller.rb +13 -0
  8. data/app/controllers/plum/pages_controller.rb +8 -0
  9. data/app/controllers/plum/theme_assets_controller.rb +2 -0
  10. data/app/javascript/controllers/plum/write_controller.js +174 -0
  11. data/app/models/plum/asset.rb +1 -0
  12. data/app/models/plum/content_type.rb +1 -0
  13. data/app/models/plum/entry.rb +57 -0
  14. data/app/models/plum/entry_term.rb +1 -0
  15. data/app/models/plum/form_definition.rb +1 -0
  16. data/app/models/plum/global.rb +1 -0
  17. data/app/models/plum/nav_item.rb +1 -0
  18. data/app/models/plum/nav_menu.rb +1 -0
  19. data/app/models/plum/site.rb +2 -0
  20. data/app/models/plum/site_setting.rb +1 -0
  21. data/app/models/plum/static_cache_invalidation.rb +30 -0
  22. data/app/models/plum/taxonomy.rb +1 -0
  23. data/app/models/plum/term.rb +1 -0
  24. data/app/services/plum/config_sync.rb +252 -0
  25. data/app/services/plum/draft_diff.rb +177 -0
  26. data/app/services/plum/form_renderer.rb +12 -1
  27. data/app/services/plum/liquid_context.rb +0 -2
  28. data/app/services/plum/site_archive.rb +367 -0
  29. data/app/views/layouts/plum/write.html.erb +140 -0
  30. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  31. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  32. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  33. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  34. data/app/views/plum/cp/entries/index.html.erb +3 -0
  35. data/app/views/plum/cp/entries/write.html.erb +60 -0
  36. data/config/plum_routes.rb +5 -0
  37. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  38. data/docs/config-as-code.md +103 -0
  39. data/docs/plum-cli.md +356 -0
  40. data/docs/portability.md +40 -0
  41. data/docs/roadmap.md +71 -69
  42. data/docs/static-caching.md +163 -0
  43. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  44. data/lib/plum/configuration.rb +12 -1
  45. data/lib/plum/engine.rb +10 -1
  46. data/lib/plum/static_cache/middleware.rb +61 -0
  47. data/lib/plum/static_cache.rb +103 -0
  48. data/lib/plum/version.rb +1 -1
  49. data/lib/tasks/plum_config.rake +48 -0
  50. data/lib/tasks/plum_portability.rake +62 -0
  51. data/lib/tasks/plum_styles.rake +14 -9
  52. metadata +26 -5
@@ -0,0 +1,177 @@
1
+ module Plum
2
+ # Field-by-field comparison between an entry's live content and its working
3
+ # draft, tokenized for git-style rendering (equal / deleted / inserted
4
+ # segments). Rich text is compared as readable text, structured fields as
5
+ # pretty-printed JSON.
6
+ class DraftDiff
7
+ # Beyond this many differing tokens, fall back to a wholesale
8
+ # replaced-block diff instead of an O(n*m) LCS table.
9
+ TOKEN_LIMIT = 1500
10
+
11
+ FieldChange = Struct.new(:handle, :label, :segments, keyword_init: true)
12
+
13
+ def initialize(entry)
14
+ @entry = entry
15
+ @content_type = entry.content_type
16
+ end
17
+
18
+ def changes
19
+ @changes ||= build_changes
20
+ end
21
+
22
+ def any?
23
+ changes.any?
24
+ end
25
+
26
+ # Word-level diff as [[op, text], ...] with op in :eq/:del/:ins.
27
+ # Whitespace is kept as tokens so spacing survives reconstruction.
28
+ def self.word_diff(old_text, new_text)
29
+ a = tokenize(old_text)
30
+ b = tokenize(new_text)
31
+
32
+ prefix = common_prefix_length(a, b)
33
+ suffix = common_suffix_length(a, b, prefix)
34
+ middle_a = a[prefix...(a.length - suffix)]
35
+ middle_b = b[prefix...(b.length - suffix)]
36
+
37
+ segments = []
38
+ segments << [ :eq, a.first(prefix).join ] if prefix.positive?
39
+ segments.concat(diff_middle(middle_a, middle_b))
40
+ segments << [ :eq, a.last(suffix).join ] if suffix.positive?
41
+ merge_segments(segments)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :entry, :content_type
47
+
48
+ def build_changes
49
+ list = []
50
+
51
+ if entry.draft_title.to_s != entry.title.to_s
52
+ list << FieldChange.new(handle: "title", label: "Title",
53
+ segments: self.class.word_diff(entry.title.to_s, entry.draft_title.to_s))
54
+ end
55
+
56
+ live_data = entry.data.to_h
57
+ draft_data = entry.draft_data.to_h["data"] || {}
58
+
59
+ (live_data.keys | draft_data.keys).each do |handle|
60
+ field = field_definition(handle)
61
+ live = normalize(live_data[handle], field)
62
+ draft = normalize(draft_data.key?(handle) ? draft_data[handle] : live_data[handle], field)
63
+ next if live == draft
64
+
65
+ list << FieldChange.new(handle: handle, label: label_for(handle, field),
66
+ segments: self.class.word_diff(live, draft))
67
+ end
68
+
69
+ list
70
+ end
71
+
72
+ def field_definition(handle)
73
+ content_type.fields.find { |field| field["handle"].to_s == handle }
74
+ end
75
+
76
+ def label_for(handle, field)
77
+ field&.dig("label").presence || handle.humanize
78
+ end
79
+
80
+ def normalize(value, field)
81
+ case value
82
+ when nil then ""
83
+ when Hash, Array then JSON.pretty_generate(value)
84
+ when String
85
+ field&.dig("type") == "rich_text" ? html_to_text(value) : value
86
+ else value.to_s
87
+ end
88
+ end
89
+
90
+ def html_to_text(html)
91
+ text = html.gsub(%r{<(br|/p|/h[1-6]|/li|/blockquote|/div|/tr|/figcaption)[^>]*>}i, "\n")
92
+ text = ActionController::Base.helpers.strip_tags(text).to_s
93
+ text.split("\n").map(&:strip).join("\n").gsub(/\n{3,}/, "\n\n").strip
94
+ end
95
+
96
+ class << self
97
+ private
98
+
99
+ def tokenize(text)
100
+ text.to_s.scan(/\S+|\s+/)
101
+ end
102
+
103
+ def common_prefix_length(a, b)
104
+ limit = [ a.length, b.length ].min
105
+ (0...limit).each { |i| return i if a[i] != b[i] }
106
+ limit
107
+ end
108
+
109
+ def common_suffix_length(a, b, prefix)
110
+ limit = [ a.length, b.length ].min - prefix
111
+ (0...limit).each { |i| return i if a[a.length - 1 - i] != b[b.length - 1 - i] }
112
+ limit
113
+ end
114
+
115
+ def diff_middle(a, b)
116
+ return [] if a.empty? && b.empty?
117
+ return [ [ :ins, b.join ] ] if a.empty?
118
+ return [ [ :del, a.join ] ] if b.empty?
119
+ if a.length > TOKEN_LIMIT || b.length > TOKEN_LIMIT
120
+ return [ [ :del, a.join ], [ :ins, b.join ] ]
121
+ end
122
+
123
+ lcs_segments(a, b)
124
+ end
125
+
126
+ # Standard LCS dynamic program with backtracking; inputs are bounded by
127
+ # TOKEN_LIMIT after prefix/suffix trimming.
128
+ def lcs_segments(a, b)
129
+ rows = a.length + 1
130
+ cols = b.length + 1
131
+ table = Array.new(rows) { Array.new(cols, 0) }
132
+
133
+ (a.length - 1).downto(0) do |i|
134
+ (b.length - 1).downto(0) do |j|
135
+ table[i][j] = if a[i] == b[j]
136
+ table[i + 1][j + 1] + 1
137
+ else
138
+ [ table[i + 1][j], table[i][j + 1] ].max
139
+ end
140
+ end
141
+ end
142
+
143
+ segments = []
144
+ i = 0
145
+ j = 0
146
+ while i < a.length && j < b.length
147
+ if a[i] == b[j]
148
+ segments << [ :eq, a[i] ]
149
+ i += 1
150
+ j += 1
151
+ elsif table[i + 1][j] >= table[i][j + 1]
152
+ segments << [ :del, a[i] ]
153
+ i += 1
154
+ else
155
+ segments << [ :ins, b[j] ]
156
+ j += 1
157
+ end
158
+ end
159
+ segments.concat(a[i..].map { |token| [ :del, token ] })
160
+ segments.concat(b[j..].map { |token| [ :ins, token ] })
161
+ segments
162
+ end
163
+
164
+ def merge_segments(segments)
165
+ segments.each_with_object([]) do |(op, text), merged|
166
+ next if text.empty?
167
+
168
+ if merged.last && merged.last[0] == op
169
+ merged.last[1] += text
170
+ else
171
+ merged << [ op, text.dup ]
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -24,11 +24,22 @@ module Plum
24
24
 
25
25
  def hidden_inputs
26
26
  [
27
- hidden_input(form["csrf_param"], form["csrf_token"]),
27
+ honeypot_input,
28
28
  hidden_input("return_to", form["return_to"])
29
29
  ].compact.join("\n ")
30
30
  end
31
31
 
32
+ # Spam trap instead of a CSRF token: tokens are per-session, which would
33
+ # both break under static caching and force a session cookie onto every
34
+ # visitor. Bots fill this in; humans never see it.
35
+ def honeypot_input
36
+ <<~HTML.strip
37
+ <div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
38
+ <input type="text" name="form_submission[website]" tabindex="-1" autocomplete="off">
39
+ </div>
40
+ HTML
41
+ end
42
+
32
43
  def fields_html
33
44
  Array(form["fields"]).map { |field| field_html(field) }.join("\n ")
34
45
  end
@@ -125,8 +125,6 @@ module Plum
125
125
  "handle" => form.handle,
126
126
  "fields" => form.form_fields,
127
127
  "action" => public_form_path(form),
128
- "csrf_token" => form_authenticity_token,
129
- "csrf_param" => "authenticity_token",
130
128
  "return_to" => controller.request.fullpath
131
129
  }
132
130
  end
@@ -0,0 +1,367 @@
1
+ require "json"
2
+ require "zip"
3
+ require "digest"
4
+ require "tempfile"
5
+
6
+ module Plum
7
+ module SiteArchive
8
+ FORMAT = "plum-site"
9
+ VERSION = 1
10
+
11
+ class Error < StandardError; end
12
+ class InvalidArchive < Error; end
13
+
14
+ module_function
15
+
16
+ def dump(site:, path:)
17
+ Exporter.new(site).write(path)
18
+ end
19
+
20
+ def load(path:, name: nil, domain: nil)
21
+ Importer.new(path).import(name: name, domain: domain)
22
+ end
23
+
24
+ class Exporter
25
+ def initialize(site)
26
+ @site = site
27
+ end
28
+
29
+ def write(path)
30
+ destination = Pathname(path).expand_path
31
+ destination.dirname.mkpath
32
+ FileUtils.rm_f(destination)
33
+
34
+ Zip::File.open(destination, create: true) do |zip|
35
+ zip.get_output_stream("manifest.json") { |stream| stream.write(JSON.pretty_generate(manifest)) }
36
+ site.assets.with_attached_file.find_each do |asset|
37
+ next unless asset.file.attached?
38
+
39
+ zip.get_output_stream(asset_path(asset)) do |stream|
40
+ asset.file.blob.open { |file| IO.copy_stream(file, stream) }
41
+ end
42
+ end
43
+ end
44
+ destination
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :site
50
+
51
+ def manifest
52
+ {
53
+ "format" => FORMAT,
54
+ "format_version" => VERSION,
55
+ "plum_version" => Plum::VERSION,
56
+ "exported_at" => Time.current.iso8601,
57
+ "site" => record(site, %w[id name domain theme_name settings theme_settings custom_css]),
58
+ "site_setting" => site.site_setting && record(site.site_setting, site_setting_fields),
59
+ "content_types" => records(site.content_types, %w[id name handle singleton blueprint icon]),
60
+ "fieldsets" => records(site.fieldsets, %w[id name handle fields]),
61
+ "taxonomies" => records(site.taxonomies, %w[id name handle slug]),
62
+ "terms" => records(site.terms, %w[id taxonomy_id name slug position]),
63
+ "assets" => site.assets.with_attached_file.order(:id).map { |asset| asset_record(asset) },
64
+ "entries" => site.entries.order(:id).map { |entry| entry_record(entry) },
65
+ "entry_revisions" => revision_records,
66
+ "globals" => records(site.globals, %w[id name handle data]),
67
+ "nav_menus" => records(site.nav_menus, %w[id name handle]),
68
+ "nav_items" => records(site.nav_items.unscoped.where(site: site), %w[id nav_menu_id parent_id entry_id label url position]),
69
+ "form_definitions" => records(site.form_definitions, %w[id name handle fields notification_email]),
70
+ "form_submissions" => records(site.form_submissions, %w[id form_definition_id data created_at updated_at])
71
+ }.compact
72
+ end
73
+
74
+ def site_setting_fields
75
+ %w[name tagline logo favicon seo_title seo_description theme_name primary_color support_email]
76
+ end
77
+
78
+ def entry_record(entry)
79
+ record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id]).merge(
80
+ "term_ids" => entry.term_ids
81
+ )
82
+ end
83
+
84
+ def revision_records
85
+ site.entries.includes(:revisions).flat_map do |entry|
86
+ entry.revisions.order(:id).map do |revision|
87
+ record(revision, %w[id entry_id editor_name editor_email editor_gid snapshot created_at updated_at])
88
+ end
89
+ end
90
+ end
91
+
92
+ def asset_record(asset)
93
+ record(asset, %w[id alt_text caption folder focal_x focal_y]).merge(
94
+ "filename" => asset.filename,
95
+ "content_type" => asset.content_type,
96
+ "byte_size" => asset.file.byte_size,
97
+ "checksum" => asset.file.blob.checksum,
98
+ "path" => asset_path(asset)
99
+ )
100
+ end
101
+
102
+ def asset_path(asset)
103
+ "assets/#{asset.id}/#{asset.filename.gsub(/[^A-Za-z0-9._-]/, "_")}"
104
+ end
105
+
106
+ def records(scope, fields)
107
+ scope.order(:id).map { |item| record(item, fields) }
108
+ end
109
+
110
+ def record(item, fields)
111
+ item.attributes.slice(*fields)
112
+ end
113
+ end
114
+
115
+ class Importer
116
+ def initialize(path)
117
+ @path = Pathname(path).expand_path
118
+ @maps = Hash.new { |hash, key| hash[key] = {} }
119
+ @uploaded_blobs = []
120
+ end
121
+
122
+ def import(name: nil, domain: nil)
123
+ raise InvalidArchive, "Archive does not exist: #{path}" unless path.file?
124
+
125
+ Zip::File.open(path) do |zip|
126
+ @zip = zip
127
+ @data = parse_manifest(zip)
128
+ validate_manifest!
129
+ ActiveRecord::Base.transaction { import_site(name:, domain:) }
130
+ end
131
+ rescue StandardError => error
132
+ cleanup_uploaded_files
133
+ raise unless error.is_a?(Zip::Error) || error.is_a?(JSON::ParserError)
134
+
135
+ raise InvalidArchive, error.message
136
+ ensure
137
+ @zip = nil
138
+ end
139
+
140
+ private
141
+
142
+ attr_reader :path, :data, :maps, :zip
143
+
144
+ def parse_manifest(zip_file)
145
+ entry = zip_file.find_entry("manifest.json")
146
+ raise InvalidArchive, "Archive is missing manifest.json" unless entry
147
+
148
+ JSON.parse(entry.get_input_stream.read)
149
+ end
150
+
151
+ def validate_manifest!
152
+ raise InvalidArchive, "Not a Plum site archive" unless data["format"] == FORMAT
153
+ raise InvalidArchive, "Unsupported archive version #{data['format_version'].inspect}" unless data["format_version"] == VERSION
154
+ raise InvalidArchive, "Archive is missing site data" unless data["site"].is_a?(Hash)
155
+ end
156
+
157
+ def import_site(name:, domain:)
158
+ source = data.fetch("site")
159
+ @site = Site.create!(
160
+ name: name.presence || source.fetch("name"),
161
+ domain: domain.nil? ? source["domain"] : domain,
162
+ theme_name: source["theme_name"],
163
+ settings: source["settings"] || {},
164
+ theme_settings: source["theme_settings"] || {},
165
+ custom_css: source["custom_css"],
166
+ skip_defaults: true
167
+ )
168
+ maps[:sites][source["id"]] = @site.id
169
+
170
+ import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon])
171
+ import_simple(:fieldsets, Fieldset, %w[name handle fields])
172
+ import_simple(:taxonomies, Taxonomy, %w[name handle slug])
173
+ import_terms
174
+ import_assets
175
+ import_entries
176
+ import_entry_links
177
+ import_globals
178
+ import_navigation
179
+ import_forms
180
+ import_site_setting
181
+ @site
182
+ end
183
+
184
+ def import_simple(key, model, fields)
185
+ Array(data[key.to_s]).each do |source|
186
+ item = model.create!(source.slice(*fields).merge("site_id" => @site.id))
187
+ maps[key][source["id"]] = item.id
188
+ end
189
+ end
190
+
191
+ def import_terms
192
+ Array(data["terms"]).each do |source|
193
+ term = Term.create!(source.slice("name", "slug", "position").merge(
194
+ "site_id" => @site.id,
195
+ "taxonomy_id" => mapped!(:taxonomies, source["taxonomy_id"])
196
+ ))
197
+ maps[:terms][source["id"]] = term.id
198
+ end
199
+ end
200
+
201
+ def import_assets
202
+ Array(data["assets"]).each do |source|
203
+ archive_entry = zip.find_entry(source.fetch("path"))
204
+ raise InvalidArchive, "Archive is missing asset #{source['path']}" unless archive_entry
205
+
206
+ asset = import_asset(source, archive_entry)
207
+ maps[:assets][source["id"]] = asset.id
208
+ end
209
+ end
210
+
211
+ def import_asset(source, archive_entry)
212
+ Tempfile.create([ "plum-asset", File.extname(source.fetch("filename")) ], binmode: true) do |file|
213
+ digest = Digest::MD5.new
214
+ bytes = 0
215
+ input = archive_entry.get_input_stream
216
+ while (chunk = input.read(64 * 1024))
217
+ file.write(chunk)
218
+ digest.update(chunk)
219
+ bytes += chunk.bytesize
220
+ end
221
+ expected_checksum = source["checksum"].to_s
222
+ actual_checksum = [ digest.digest ].pack("m0")
223
+ raise InvalidArchive, "Asset #{source['path']} has an invalid size" if source["byte_size"].present? && bytes != source["byte_size"].to_i
224
+ raise InvalidArchive, "Asset #{source['path']} failed its checksum" if expected_checksum.present? && actual_checksum != expected_checksum
225
+
226
+ file.rewind
227
+ asset = Asset.new(source.slice("alt_text", "caption", "folder", "focal_x", "focal_y").merge("site_id" => @site.id))
228
+ blob = ActiveStorage::Blob.create_and_upload!(
229
+ io: file,
230
+ filename: source.fetch("filename"),
231
+ content_type: source["content_type"]
232
+ )
233
+ @uploaded_blobs << blob
234
+ asset.file.attach(blob)
235
+ asset.save!
236
+ asset
237
+ end
238
+ end
239
+
240
+ def import_entries
241
+ Array(data["entries"]).each do |source|
242
+ entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale").merge(
243
+ "site_id" => @site.id,
244
+ "content_type_id" => mapped!(:content_types, source["content_type_id"])
245
+ ))
246
+ maps[:entries][source["id"]] = entry.id
247
+ end
248
+ end
249
+
250
+ def import_entry_links
251
+ entries_by_id = Array(data["entries"]).index_by { |item| item["id"] }
252
+ entries_by_id.each do |old_id, source|
253
+ entry = Entry.find(mapped!(:entries, old_id))
254
+ fields = entry.content_type.fields
255
+ entry.update_columns(
256
+ data: remap_field_values(source["data"] || {}, fields),
257
+ origin_id: mapped(:entries, source["origin_id"]),
258
+ updated_at: Time.current
259
+ )
260
+ entry.term_ids = Array(source["term_ids"]).filter_map { |id| mapped(:terms, id) }
261
+ end
262
+
263
+ Array(data["entry_revisions"]).each do |source|
264
+ snapshot = source["snapshot"].to_h.deep_dup
265
+ entry = Entry.find(mapped!(:entries, source["entry_id"]))
266
+ snapshot["data"] = remap_field_values(snapshot["data"] || {}, entry.content_type.fields)
267
+ snapshot["term_ids"] = Array(snapshot["term_ids"]).filter_map { |id| mapped(:terms, id) }
268
+ EntryRevision.create!(source.slice("editor_name", "editor_email", "editor_gid", "created_at", "updated_at").merge(
269
+ "site_id" => @site.id, "entry_id" => entry.id, "snapshot" => snapshot
270
+ ))
271
+ end
272
+ end
273
+
274
+ def remap_field_values(values, fields)
275
+ result = values.to_h.deep_dup
276
+ Array(fields).each do |field|
277
+ handle = field["handle"].to_s
278
+ value = result[handle]
279
+ result[handle] = case field["type"]
280
+ when "image" then mapped(:assets, value)
281
+ when "images" then Array(value).filter_map { |id| mapped(:assets, id) }
282
+ when "relationship"
283
+ field["multiple"] ? Array(value).filter_map { |id| mapped(:entries, id) } : mapped(:entries, value)
284
+ when "group" then remap_field_values(value || {}, field["fields"])
285
+ when "repeater" then Array(value).map { |row| remap_field_values(row, field["fields"]) }
286
+ when "blocks" then remap_blocks(value)
287
+ else value
288
+ end
289
+ end
290
+ result
291
+ end
292
+
293
+ def remap_blocks(value)
294
+ library = BlockLibrary.new(@site.theme)
295
+ Array(value).map do |block|
296
+ restored = block.to_h.deep_dup
297
+ definition = library.definition(restored["type"])
298
+ restored["fields"] = remap_field_values(restored["fields"] || {}, definition&.dig("fields") || [])
299
+ restored
300
+ end
301
+ end
302
+
303
+ def import_globals
304
+ Array(data["globals"]).each do |source|
305
+ Global.create!(source.slice("name", "handle", "data").merge("site_id" => @site.id))
306
+ end
307
+ end
308
+
309
+ def import_navigation
310
+ import_simple(:nav_menus, NavMenu, %w[name handle])
311
+ pending = Array(data["nav_items"]).sort_by { |item| item["parent_id"].present? ? 1 : 0 }
312
+ until pending.empty?
313
+ imported = pending.reject! do |source|
314
+ next false if source["parent_id"].present? && mapped(:nav_items, source["parent_id"]).blank?
315
+
316
+ item = NavItem.create!(source.slice("label", "url", "position").merge(
317
+ "site_id" => @site.id,
318
+ "nav_menu_id" => mapped!(:nav_menus, source["nav_menu_id"]),
319
+ "parent_id" => mapped(:nav_items, source["parent_id"]),
320
+ "entry_id" => mapped(:entries, source["entry_id"])
321
+ ))
322
+ maps[:nav_items][source["id"]] = item.id
323
+ true
324
+ end
325
+ raise InvalidArchive, "Navigation contains an invalid parent cycle" unless imported
326
+ end
327
+ end
328
+
329
+ def import_forms
330
+ import_simple(:form_definitions, FormDefinition, %w[name handle fields notification_email])
331
+ Array(data["form_submissions"]).each do |source|
332
+ submission = FormSubmission.new(source.slice("data", "created_at", "updated_at").merge(
333
+ "site_id" => @site.id,
334
+ "form_definition_id" => mapped!(:form_definitions, source["form_definition_id"])
335
+ ))
336
+ submission.save!(validate: false)
337
+ end
338
+ end
339
+
340
+ def import_site_setting
341
+ source = data["site_setting"]
342
+ return SiteSetting.instance(@site) unless source
343
+
344
+ attributes = source.except("id")
345
+ attributes["logo"] = mapped(:assets, source["logo"].to_i)&.to_s if source["logo"].present?
346
+ attributes["favicon"] = mapped(:assets, source["favicon"].to_i)&.to_s if source["favicon"].present?
347
+ SiteSetting.create!(attributes.merge("site_id" => @site.id))
348
+ end
349
+
350
+ def mapped(type, old_id)
351
+ return if old_id.blank?
352
+
353
+ maps[type][old_id] || maps[type][old_id.to_i]
354
+ end
355
+
356
+ def mapped!(type, old_id)
357
+ mapped(type, old_id) || raise(InvalidArchive, "Missing #{type.to_s.singularize} reference #{old_id.inspect}")
358
+ end
359
+
360
+ def cleanup_uploaded_files
361
+ @uploaded_blobs.each { |blob| blob.service.delete(blob.key) }
362
+ rescue StandardError
363
+ nil
364
+ end
365
+ end
366
+ end
367
+ end