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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -0
- data/README.md +79 -0
- data/app/assets/builds/tailwind.css +1 -1
- data/app/controllers/plum/cp/entries_controller.rb +88 -5
- data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
- data/app/controllers/plum/form_submissions_controller.rb +13 -0
- data/app/controllers/plum/pages_controller.rb +8 -0
- data/app/controllers/plum/theme_assets_controller.rb +2 -0
- data/app/javascript/controllers/plum/write_controller.js +174 -0
- data/app/models/plum/asset.rb +1 -0
- data/app/models/plum/content_type.rb +1 -0
- data/app/models/plum/entry.rb +57 -0
- data/app/models/plum/entry_term.rb +1 -0
- data/app/models/plum/form_definition.rb +1 -0
- data/app/models/plum/global.rb +1 -0
- data/app/models/plum/nav_item.rb +1 -0
- data/app/models/plum/nav_menu.rb +1 -0
- data/app/models/plum/site.rb +2 -0
- data/app/models/plum/site_setting.rb +1 -0
- data/app/models/plum/static_cache_invalidation.rb +30 -0
- data/app/models/plum/taxonomy.rb +1 -0
- data/app/models/plum/term.rb +1 -0
- data/app/services/plum/config_sync.rb +252 -0
- data/app/services/plum/draft_diff.rb +177 -0
- data/app/services/plum/form_renderer.rb +12 -1
- data/app/services/plum/liquid_context.rb +0 -2
- data/app/services/plum/site_archive.rb +367 -0
- data/app/views/layouts/plum/write.html.erb +140 -0
- data/app/views/plum/cp/dashboard/show.html.erb +10 -3
- data/app/views/plum/cp/entries/_form.html.erb +2 -2
- data/app/views/plum/cp/entries/diff.html.erb +40 -0
- data/app/views/plum/cp/entries/edit.html.erb +24 -0
- data/app/views/plum/cp/entries/index.html.erb +3 -0
- data/app/views/plum/cp/entries/write.html.erb +60 -0
- data/config/plum_routes.rb +5 -0
- data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
- data/docs/config-as-code.md +103 -0
- data/docs/plum-cli.md +356 -0
- data/docs/portability.md +40 -0
- data/docs/roadmap.md +71 -69
- data/docs/static-caching.md +163 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +10 -1
- data/lib/plum/static_cache/middleware.rb +61 -0
- data/lib/plum/static_cache.rb +103 -0
- data/lib/plum/version.rb +1 -1
- data/lib/tasks/plum_config.rake +48 -0
- data/lib/tasks/plum_portability.rake +62 -0
- data/lib/tasks/plum_styles.rake +14 -9
- 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
|
-
|
|
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
|