iron-cms 0.5.2 → 0.7.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/iron.css +682 -388
  3. data/app/assets/tailwind/iron/application.css +1 -0
  4. data/app/assets/tailwind/iron/components/button.css +0 -7
  5. data/app/assets/tailwind/iron/components/checkbox.css +21 -0
  6. data/app/assets/tailwind/iron/components/form.css +1 -1
  7. data/app/assets/tailwind/iron/lexxy.css +165 -51
  8. data/app/controllers/iron/account/exports_controller.rb +26 -0
  9. data/app/controllers/iron/account/imports_controller.rb +27 -0
  10. data/app/helpers/iron/form_builder.rb +7 -0
  11. data/app/javascript/iron/controllers/local_preference_controller.js +62 -0
  12. data/app/jobs/iron/export_job.rb +9 -0
  13. data/app/jobs/iron/import_job.rb +9 -0
  14. data/app/models/concerns/iron/broadcastable.rb +9 -0
  15. data/app/models/concerns/iron/processable.rb +34 -0
  16. data/app/models/iron/account/export.rb +86 -0
  17. data/app/models/iron/account/import.rb +208 -0
  18. data/app/models/iron/block_definition/exportable.rb +14 -0
  19. data/app/models/iron/block_definition/importable.rb +27 -0
  20. data/app/models/iron/block_definition.rb +1 -1
  21. data/app/models/iron/content_type/exportable.rb +20 -0
  22. data/app/models/iron/content_type/importable.rb +32 -0
  23. data/app/models/iron/content_type.rb +1 -1
  24. data/app/models/iron/current.rb +6 -3
  25. data/app/models/iron/entry/exportable.rb +49 -0
  26. data/app/models/iron/entry/importable.rb +181 -0
  27. data/app/models/iron/entry.rb +1 -1
  28. data/app/models/iron/field.rb +9 -1
  29. data/app/models/iron/field_definition/exportable.rb +23 -0
  30. data/app/models/iron/field_definition/importable.rb +39 -0
  31. data/app/models/iron/field_definition.rb +1 -1
  32. data/app/models/iron/fields/block.rb +34 -0
  33. data/app/models/iron/fields/block_list.rb +8 -0
  34. data/app/models/iron/fields/boolean.rb +4 -0
  35. data/app/models/iron/fields/date.rb +4 -0
  36. data/app/models/iron/fields/file.rb +16 -0
  37. data/app/models/iron/fields/number.rb +4 -0
  38. data/app/models/iron/fields/reference.rb +4 -0
  39. data/app/models/iron/fields/reference_list.rb +4 -0
  40. data/app/models/iron/fields/rich_text_area.rb +32 -0
  41. data/app/models/iron/fields/text_area.rb +4 -0
  42. data/app/models/iron/fields/text_field.rb +4 -0
  43. data/app/models/iron/user.rb +2 -0
  44. data/app/views/iron/account/exports/index.html.erb +43 -0
  45. data/app/views/iron/account/exports/new.html.erb +39 -0
  46. data/app/views/iron/account/exports/show.html.erb +40 -0
  47. data/app/views/iron/account/imports/index.html.erb +43 -0
  48. data/app/views/iron/account/imports/new.html.erb +52 -0
  49. data/app/views/iron/account/imports/show.html.erb +37 -0
  50. data/app/views/iron/content_types/index.html.erb +1 -8
  51. data/app/views/iron/entries/fields/_block.html.erb +23 -10
  52. data/app/views/iron/entries/fields/_file.html.erb +3 -3
  53. data/app/views/iron/settings/show.html.erb +4 -11
  54. data/app/views/layouts/iron/application.html.erb +14 -0
  55. data/config/routes.rb +3 -9
  56. data/db/migrate/20251209103109_create_iron_account_exports.rb +13 -0
  57. data/db/migrate/20251209103110_create_iron_account_imports.rb +13 -0
  58. data/lib/iron/version.rb +1 -1
  59. data/lib/iron.rb +1 -1
  60. metadata +41 -28
  61. data/app/controllers/iron/contents_controller.rb +0 -33
  62. data/app/controllers/iron/schemas_controller.rb +0 -32
  63. data/app/models/concerns/iron/csv_serializable.rb +0 -28
  64. data/app/models/iron/archive.rb +0 -69
  65. data/app/models/iron/block_definition/portable.rb +0 -20
  66. data/app/models/iron/content_export.rb +0 -73
  67. data/app/models/iron/content_import/entry_builder.rb +0 -80
  68. data/app/models/iron/content_import/entry_snapshot.rb +0 -23
  69. data/app/models/iron/content_import/field_reconstructor.rb +0 -276
  70. data/app/models/iron/content_import/field_snapshot.rb +0 -33
  71. data/app/models/iron/content_import/registry.rb +0 -32
  72. data/app/models/iron/content_import/session.rb +0 -89
  73. data/app/models/iron/content_import.rb +0 -15
  74. data/app/models/iron/content_type/portable.rb +0 -30
  75. data/app/models/iron/entry/portable.rb +0 -35
  76. data/app/models/iron/field/portable.rb +0 -33
  77. data/app/models/iron/field_definition/portable.rb +0 -42
  78. data/app/models/iron/schema_archive.rb +0 -71
  79. data/app/models/iron/schema_exporter.rb +0 -15
  80. data/app/models/iron/schema_importer/import_strategy.rb +0 -59
  81. data/app/models/iron/schema_importer/merge_strategy.rb +0 -52
  82. data/app/models/iron/schema_importer/replace_strategy.rb +0 -51
  83. data/app/models/iron/schema_importer/safe_strategy.rb +0 -55
  84. data/app/models/iron/schema_importer.rb +0 -108
  85. data/app/views/iron/contents/new.html.erb +0 -34
  86. data/app/views/iron/schemas/new.html.erb +0 -57
  87. data/lib/iron/test_fixtures.rb +0 -50
@@ -7,6 +7,7 @@
7
7
 
8
8
  @import './components/input.css';
9
9
  @import './components/textarea.css';
10
+ @import './components/checkbox.css';
10
11
  @import './components/button.css';
11
12
  @import './components/badge.css';
12
13
  @import './components/dropdown.css';
@@ -115,10 +115,3 @@
115
115
  }
116
116
  }
117
117
  }
118
-
119
- /* Legacy support - map old btn class to button-primary button-md */
120
- @utility btn {
121
- @layer components {
122
- @apply button-primary button-md;
123
- }
124
- }
@@ -0,0 +1,21 @@
1
+ @utility checkbox {
2
+ @apply grid size-4 grid-cols-1;
3
+
4
+ & input {
5
+ @apply col-start-1 row-start-1 appearance-none rounded-sm border border-stone-300 bg-white;
6
+ @apply checked:border-sky-600 checked:bg-sky-600;
7
+ @apply focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600;
8
+ @apply disabled:border-stone-300 disabled:bg-stone-100 disabled:checked:bg-stone-100;
9
+ @apply dark:border-white/10 dark:bg-white/5;
10
+ @apply dark:checked:border-sky-500 dark:checked:bg-sky-500;
11
+ @apply dark:focus-visible:outline-sky-500;
12
+ @apply dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10;
13
+ @apply forced-colors:appearance-auto;
14
+ }
15
+
16
+ & svg {
17
+ @apply pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center;
18
+ @apply stroke-white opacity-0 group-has-checked/checkbox:opacity-100;
19
+ @apply group-has-disabled/checkbox:stroke-stone-950/25 dark:group-has-disabled/checkbox:stroke-white/25;
20
+ }
21
+ }
@@ -8,7 +8,7 @@
8
8
  @apply input;
9
9
  }
10
10
  input[type="submit"], input[type="button"], button:not(el-select button) {
11
- @apply btn;
11
+ @apply button-primary;
12
12
  }
13
13
 
14
14
  label + div {
@@ -3,6 +3,29 @@
3
3
  * Matches the design system with proper light/dark mode support
4
4
  */
5
5
 
6
+ /* Highlight color variables for text and background highlighting */
7
+ :root {
8
+ --highlight-1: rgb(136, 118, 38);
9
+ --highlight-2: rgb(185, 94, 6);
10
+ --highlight-3: rgb(207, 0, 0);
11
+ --highlight-4: rgb(216, 28, 170);
12
+ --highlight-5: rgb(144, 19, 254);
13
+ --highlight-6: rgb(5, 98, 185);
14
+ --highlight-7: rgb(17, 138, 15);
15
+ --highlight-8: rgb(148, 82, 22);
16
+ --highlight-9: rgb(102, 102, 102);
17
+
18
+ --highlight-bg-1: rgba(229, 223, 6, 0.3);
19
+ --highlight-bg-2: rgba(255, 185, 87, 0.3);
20
+ --highlight-bg-3: rgba(255, 118, 118, 0.3);
21
+ --highlight-bg-4: rgba(248, 137, 216, 0.3);
22
+ --highlight-bg-5: rgba(190, 165, 255, 0.3);
23
+ --highlight-bg-6: rgba(124, 192, 252, 0.3);
24
+ --highlight-bg-7: rgba(140, 255, 129, 0.3);
25
+ --highlight-bg-8: rgba(221, 170, 123, 0.3);
26
+ --highlight-bg-9: rgba(200, 200, 200, 0.3);
27
+ }
28
+
6
29
  @layer components {
7
30
  /* Editor container */
8
31
  lexxy-editor {
@@ -31,7 +54,8 @@
31
54
  }
32
55
 
33
56
  /* Toolbar buttons */
34
- lexxy-toolbar button {
57
+ lexxy-toolbar button,
58
+ lexxy-toolbar summary {
35
59
  @apply inline-flex items-center justify-center;
36
60
  @apply h-8 w-8 rounded-md;
37
61
  @apply text-stone-700 dark:text-stone-200;
@@ -42,12 +66,14 @@
42
66
  }
43
67
 
44
68
  lexxy-toolbar button[aria-pressed="true"],
45
- lexxy-toolbar button.active {
69
+ lexxy-toolbar button.active,
70
+ lexxy-toolbar details[open] > summary {
46
71
  @apply bg-stone-200 text-stone-900;
47
72
  @apply dark:bg-white/15 dark:text-white;
48
73
  }
49
74
 
50
- lexxy-toolbar button svg {
75
+ lexxy-toolbar button svg,
76
+ lexxy-toolbar summary svg {
51
77
  @apply size-5 text-inherit;
52
78
  fill: currentColor;
53
79
  }
@@ -66,71 +92,159 @@
66
92
  @apply pointer-events-none;
67
93
  }
68
94
 
69
- /* Link dialog/popover */
70
- lexxy-editor [data-lexxy-link-editor] {
71
- @apply flex items-center gap-2 p-2;
95
+ /* Toolbar dropdowns (shared container styles) */
96
+ .lexxy-editor__toolbar-dropdown {
97
+ @apply relative select-none;
98
+ }
99
+
100
+ .lexxy-editor__toolbar-dropdown > summary {
101
+ list-style: none;
102
+ @apply cursor-pointer;
103
+ }
104
+
105
+ .lexxy-editor__toolbar-dropdown > summary::-webkit-details-marker {
106
+ display: none;
107
+ }
108
+
109
+ .lexxy-editor__toolbar-dropdown > summary::marker {
110
+ display: none;
111
+ content: "";
112
+ }
113
+
114
+ .lexxy-editor__toolbar-dropdown-content {
115
+ --dropdown-padding: --spacing(3);
116
+ --dropdown-gap: --spacing(1.5);
117
+
118
+ @apply absolute z-10;
119
+ @apply flex gap-(--dropdown-gap) p-(--dropdown-padding);
72
120
  @apply bg-white dark:bg-stone-800;
73
- @apply border border-stone-200 dark:border-white/10;
74
- @apply rounded-lg shadow-lg;
75
- @apply focus-visible:outline-2 focus-visible:outline-indigo-600;
121
+ @apply border-2 border-sky-200 dark:border-sky-500/30;
122
+ @apply rounded-md;
123
+ @apply text-stone-900 dark:text-white;
124
+ @apply top-8 start-0;
125
+ @apply max-w-[40ch];
126
+ @apply rounded-ss-none;
76
127
  }
77
128
 
78
- lexxy-editor [data-lexxy-link-editor] input {
79
- @apply px-2 py-1 text-sm rounded;
80
- @apply bg-white dark:bg-white/5;
129
+ .lexxy-editor__toolbar-dropdown:is([open]) .lexxy-editor__toolbar-button {
130
+ @apply bg-sky-100 dark:bg-sky-500/20;
131
+ @apply rounded-ee-none rounded-es-none;
132
+ }
133
+
134
+ .lexxy-editor__toolbar-dropdown:is([open]) .lexxy-editor__toolbar-button:hover {
135
+ @apply bg-sky-100 dark:bg-sky-500/20;
136
+ }
137
+
138
+ /* Responsive dropdown in overflow menu */
139
+ [overflowing] .lexxy-editor__toolbar-dropdown {
140
+ @apply static;
141
+ }
142
+
143
+ [overflowing] .lexxy-editor__toolbar-dropdown-content {
144
+ --dropdown-padding: --spacing(2);
145
+ @apply end-(--dropdown-padding) start-(--dropdown-padding);
146
+ }
147
+
148
+ /* Link dropdown */
149
+ lexxy-link-dropdown {
150
+ @apply flex-1;
151
+ }
152
+
153
+ lexxy-link-dropdown > * {
154
+ @apply flex-1;
155
+ }
156
+
157
+ lexxy-link-dropdown .lexxy-editor__toolbar-dropdown-actions {
158
+ @apply flex flex-1 gap-2 mt-2;
159
+ @apply text-sm;
160
+ }
161
+
162
+ lexxy-link-dropdown input[type="url"] {
163
+ @apply w-full rounded-md px-2;
81
164
  @apply border border-stone-300 dark:border-white/10;
165
+ @apply bg-white dark:bg-stone-900;
82
166
  @apply text-stone-900 dark:text-white;
83
167
  @apply placeholder:text-stone-400 dark:placeholder:text-stone-500;
84
- @apply focus-visible:outline-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500;
168
+ @apply focus-visible:outline-2 focus-visible:outline-sky-600 dark:focus-visible:outline-sky-500;
169
+ @apply leading-6 min-w-[40ch] box-border;
85
170
  }
86
171
 
87
- lexxy-editor [data-lexxy-link-editor] button {
88
- @apply px-2 py-1 text-sm font-medium rounded;
89
- @apply bg-indigo-600 text-white;
90
- @apply hover:bg-indigo-500;
172
+ [overflowing] lexxy-link-dropdown input[type="url"] {
173
+ @apply min-w-0;
91
174
  }
92
175
 
93
- /* Floating link dialog */
94
- lexxy-link-dialog dialog {
95
- @apply m-0 p-3;
96
- @apply rounded-lg border border-stone-200 bg-white shadow-lg;
97
- @apply dark:border-white/10 dark:bg-stone-800;
98
- min-width: 18rem;
176
+ lexxy-link-dropdown .lexxy-editor__toolbar-dropdown-actions button {
177
+ @apply w-full justify-center;
178
+ @apply button-secondary button-sm;
99
179
  }
100
180
 
101
- lexxy-link-dialog form {
102
- @apply flex flex-col gap-3;
181
+ /* Highlight/Color dropdown */
182
+ lexxy-highlight-dropdown {
183
+ @apply flex flex-col;
103
184
  }
104
185
 
105
- lexxy-link-dialog button {
106
- @apply inline-flex items-center justify-center;
107
- @apply size-auto min-w-0;
186
+ lexxy-highlight-dropdown [data-button-group] {
187
+ @apply flex flex-row gap-(--dropdown-gap);
188
+ @apply justify-start;
108
189
  }
109
190
 
110
- lexxy-link-dialog input[type="url"] {
111
- @apply w-full rounded-md;
112
- @apply border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900;
113
- @apply placeholder:text-stone-400;
114
- @apply focus-visible:outline-2 focus-visible:outline-indigo-600;
115
- @apply dark:border-white/10 dark:bg-stone-900 dark:text-white dark:placeholder:text-stone-500 dark:focus-visible:outline-indigo-500;
191
+ lexxy-highlight-dropdown [data-button-group] button {
192
+ --button-size: --spacing(8);
193
+ @apply aspect-square;
194
+ @apply w-(--button-size) min-w-(--button-size) max-w-(--button-size);
116
195
  }
117
196
 
118
- lexxy-link-dialog .lexxy-dialog-actions {
119
- @apply flex items-center justify-end gap-2;
197
+ lexxy-highlight-dropdown [data-button-group] button::after {
198
+ content: "Aa";
199
+ @apply absolute inset-0;
200
+ @apply self-center;
201
+ @apply inline-block;
202
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
120
203
  }
121
204
 
122
- lexxy-link-dialog .lexxy-dialog-actions button {
123
- @apply inline-flex items-center justify-center gap-1;
124
- @apply rounded-md px-3 py-1.5 text-sm font-semibold;
125
- @apply text-white bg-indigo-600 hover:bg-indigo-500;
126
- @apply shadow-sm;
127
- @apply focus-visible:outline-2 focus-visible:outline-indigo-500;
205
+ lexxy-highlight-dropdown button {
206
+ --button-size: --spacing(8);
207
+ @apply flex-1 relative;
208
+ @apply text-stone-900 dark:text-white;
209
+ @apply min-h-(--button-size);
210
+ }
211
+
212
+ lexxy-highlight-dropdown button:hover {
213
+ @apply opacity-80;
214
+ }
215
+
216
+ lexxy-highlight-dropdown button[aria-pressed="true"] {
217
+ @apply ring-2 ring-inset ring-current;
218
+ }
219
+
220
+ lexxy-highlight-dropdown button[aria-pressed="true"]::after {
221
+ content: "✓";
222
+ }
223
+
224
+ lexxy-highlight-dropdown .lexxy-editor__toolbar-dropdown-reset {
225
+ @apply mt-2 w-full;
226
+ @apply button-secondary button-sm;
227
+ }
228
+
229
+ lexxy-highlight-dropdown .lexxy-editor__toolbar-dropdown-reset[disabled] {
230
+ @apply hidden;
231
+ }
232
+
233
+ /* Responsive highlight dropdown */
234
+ [overflowing] lexxy-highlight-dropdown {
235
+ @apply w-fit;
236
+ }
237
+
238
+ [overflowing] lexxy-highlight-dropdown [data-button-group] {
239
+ @apply flex-wrap;
240
+ }
241
+
242
+ [overflowing] lexxy-highlight-dropdown [data-button-group] button {
243
+ --button-size: --spacing(6);
128
244
  }
129
245
 
130
- lexxy-link-dialog .lexxy-dialog-actions button[value="unlink"] {
131
- @apply text-stone-800 bg-stone-100 hover:bg-stone-200;
132
- @apply border border-stone-200;
133
- @apply dark:text-white dark:bg-white/10 dark:border-white/20 dark:hover:bg-white/15;
246
+ [overflowing] lexxy-highlight-dropdown [data-button-group] button::after {
247
+ @apply text-sm;
134
248
  }
135
249
 
136
250
  /* Attachment upload progress */
@@ -145,12 +259,12 @@
145
259
  }
146
260
 
147
261
  lexxy-editor .attachment__progress::-webkit-progress-value {
148
- @apply bg-indigo-600;
262
+ @apply bg-sky-600;
149
263
  @apply transition-all duration-200;
150
264
  }
151
265
 
152
266
  lexxy-editor .attachment__progress::-moz-progress-bar {
153
- @apply bg-indigo-600;
267
+ @apply bg-sky-600;
154
268
  }
155
269
 
156
270
  lexxy-editor .attachment__progress[value="100"] {
@@ -201,7 +315,7 @@
201
315
  /* Selected attachment state */
202
316
  lexxy-editor [data-trix-mutable] img,
203
317
  lexxy-editor .attachment.selected img {
204
- @apply ring-2 ring-indigo-500;
318
+ @apply ring-2 ring-sky-500;
205
319
  }
206
320
  }
207
321
 
@@ -233,9 +347,9 @@
233
347
  }
234
348
 
235
349
  .lexxy-content a {
236
- @apply text-indigo-600 dark:text-indigo-400;
350
+ @apply text-sky-600 dark:text-sky-400;
237
351
  @apply underline underline-offset-2;
238
- @apply hover:text-indigo-500 dark:hover:text-indigo-300;
352
+ @apply hover:text-sky-500 dark:hover:text-sky-300;
239
353
  }
240
354
 
241
355
  .lexxy-content h1 {
@@ -0,0 +1,26 @@
1
+ module Iron
2
+ class Account::ExportsController < ApplicationController
3
+ def index
4
+ @exports = Account::Export.order(created_at: :desc)
5
+ end
6
+
7
+ def new
8
+ @export = Account::Export.new
9
+ end
10
+
11
+ def create
12
+ @export = Account::Export.create!(export_params)
13
+ @export.process_later
14
+ redirect_to @export, notice: "Export started"
15
+ end
16
+
17
+ def show
18
+ @export = Account::Export.find(params[:id])
19
+ end
20
+
21
+ private
22
+ def export_params
23
+ params.require(:account_export).permit(:include_schema, :include_content)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ module Iron
2
+ class Account::ImportsController < ApplicationController
3
+ def index
4
+ @imports = Account::Import.order(created_at: :desc)
5
+ end
6
+
7
+ def new
8
+ @import = Account::Import.new
9
+ end
10
+
11
+ def create
12
+ @import = Account::Import.create!(import_params)
13
+ @import.file.attach(params[:account_import][:file])
14
+ @import.process_later
15
+ redirect_to @import, notice: "Import started"
16
+ end
17
+
18
+ def show
19
+ @import = Account::Import.find(params[:id])
20
+ end
21
+
22
+ private
23
+ def import_params
24
+ params.require(:account_import).permit(:include_schema, :include_content)
25
+ end
26
+ end
27
+ end
@@ -40,6 +40,13 @@ module Iron
40
40
  super(method, text, options, &block)
41
41
  end
42
42
 
43
+ def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
44
+ @template.content_tag(:div, class: "group/checkbox checkbox") do
45
+ super(method, options, checked_value, unchecked_value) +
46
+ @template.icon("check")
47
+ end
48
+ end
49
+
43
50
  def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
44
51
  selected_value = object.send(method) rescue nil
45
52
 
@@ -0,0 +1,62 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Persists element state to localStorage automatically.
5
+ *
6
+ * Listens for changes and saves the element's property value to localStorage.
7
+ * Use with `Iron.preference.apply(id, property)` to restore state on page load.
8
+ *
9
+ * @example Basic usage (auto-detects event and property)
10
+ * <details id="my-panel" data-controller="local-preference">
11
+ * ...
12
+ * </details>
13
+ * <script>Iron.preference.apply("my-panel", "open")</script>
14
+ *
15
+ * @example With custom property
16
+ * <input id="my-input" data-controller="local-preference" data-local-preference-property-value="value">
17
+ *
18
+ * @example With key prefix (useful for scoping)
19
+ * <details id="panel" data-controller="local-preference" data-local-preference-key-prefix-value="settings:">
20
+ *
21
+ * @values {string} [property] - Element property to persist. Auto-detected if not set:
22
+ * - "open" for <details>
23
+ * - "checked" for checkbox/radio inputs
24
+ * - "value" for other inputs, selects, textareas
25
+ * @values {string} [keyPrefix=""] - Prefix for the localStorage key
26
+ */
27
+ export default class extends Controller {
28
+ static values = {
29
+ property: String,
30
+ keyPrefix: { type: String, default: "" }
31
+ }
32
+
33
+ connect() {
34
+ this.element.addEventListener(this.event, this.save)
35
+ }
36
+
37
+ disconnect() {
38
+ this.element.removeEventListener(this.event, this.save)
39
+ }
40
+
41
+ save = () => {
42
+ const key = this.keyPrefixValue + this.element.id
43
+ localStorage.setItem(Iron.preference.storageKey(key), this.element[this.property])
44
+ }
45
+
46
+ get event() {
47
+ const el = this.element
48
+ if (el.tagName === "DETAILS") return "toggle"
49
+ if (el.tagName === "TEXTAREA") return "input"
50
+ if (el.tagName === "INPUT" && el.type !== "checkbox" && el.type !== "radio") return "input"
51
+ return "change"
52
+ }
53
+
54
+ get property() {
55
+ if (this.hasPropertyValue) return this.propertyValue
56
+
57
+ const el = this.element
58
+ if (el.tagName === "DETAILS") return "open"
59
+ if (el.type === "checkbox" || el.type === "radio") return "checked"
60
+ return "value"
61
+ }
62
+ }
@@ -0,0 +1,9 @@
1
+ module Iron
2
+ class ExportJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(export)
6
+ export.process
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Iron
2
+ class ImportJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(import)
6
+ Current.set(user: import.user) { import.process }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Iron
2
+ module Broadcastable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ broadcasts_refreshes
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ module Iron
2
+ module Processable
3
+ extend ActiveSupport::Concern
4
+
5
+ RETENTION_LIMIT = 5
6
+
7
+ included do
8
+ enum :status, %w[pending processing completed failed].index_by(&:itself), default: :pending
9
+
10
+ scope :recent, -> { order(created_at: :desc).limit(RETENTION_LIMIT) }
11
+ end
12
+
13
+ class_methods do
14
+ def cleanup
15
+ where.not(id: recent.select(:id)).destroy_all
16
+ end
17
+ end
18
+
19
+ def process
20
+ processing!
21
+ perform
22
+ update!(status: :completed, completed_at: Time.current)
23
+ self.class.cleanup
24
+ rescue => e
25
+ update!(status: :failed, error_message: e.message)
26
+ raise
27
+ end
28
+
29
+ private
30
+ def perform
31
+ raise NotImplementedError
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,86 @@
1
+ module Iron
2
+ class Account::Export < ApplicationRecord
3
+ include Processable, Broadcastable
4
+
5
+ belongs_to :user, class_name: "Iron::User", default: -> { Current.user }
6
+ has_one_attached :file
7
+
8
+ def process_later
9
+ ExportJob.perform_later(self)
10
+ end
11
+
12
+ def title
13
+ created_at.strftime("%b %-d, %Y at %-l:%M %p")
14
+ end
15
+
16
+ private
17
+ def perform
18
+ raise ArgumentError, "Nothing selected to export" unless include_schema? || include_content?
19
+
20
+ zipfile = generate_zip
21
+ file.attach(
22
+ io: File.open(zipfile.path),
23
+ filename: "iron-export-#{id}.zip",
24
+ content_type: "application/zip"
25
+ )
26
+ ensure
27
+ zipfile&.close
28
+ zipfile&.unlink
29
+ end
30
+
31
+ def generate_zip
32
+ Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
33
+ Zip::File.open(tempfile.path, create: true) do |zip|
34
+ add_schema_to_zip(zip) if include_schema?
35
+ add_content_to_zip(zip) if include_content?
36
+ end
37
+ end
38
+ end
39
+
40
+ def add_schema_to_zip(zip)
41
+ zip.get_output_stream("schema.json") { |f| f.write(export_schema_json) }
42
+ end
43
+
44
+ def add_content_to_zip(zip)
45
+ exportable_entries.find_each do |entry|
46
+ path = "entries/#{entry.content_type.handle}/#{entry.id}.json"
47
+ zip.get_output_stream(path) { |f| f.write(entry.export_json) }
48
+
49
+ entry.export_attachments.each do |attachment|
50
+ zip.get_output_stream("files/#{attachment[:path]}", compression_method: Zip::Entry::STORED) do |f|
51
+ attachment[:blob].download { |chunk| f.write(chunk) }
52
+ end
53
+ rescue ActiveStorage::FileNotFoundError
54
+ # Skip missing files
55
+ end
56
+ end
57
+ end
58
+
59
+ def export_schema_json
60
+ JSON.pretty_generate(
61
+ version: "1.0",
62
+ exported_at: Time.current.iso8601,
63
+ block_definitions: export_block_definitions,
64
+ content_types: export_content_types
65
+ )
66
+ end
67
+
68
+ def export_block_definitions
69
+ BlockDefinition
70
+ .includes(:field_definitions)
71
+ .order(:handle)
72
+ .map(&:export_attributes)
73
+ end
74
+
75
+ def export_content_types
76
+ ContentType
77
+ .includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
78
+ .order(:handle)
79
+ .map(&:export_attributes)
80
+ end
81
+
82
+ def exportable_entries
83
+ Entry.includes(:content_type, :creator, fields: [ :locale, :definition ])
84
+ end
85
+ end
86
+ end