iron-cms 0.17.2 → 0.18.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.
- checksums.yaml +4 -4
- data/README.md +88 -4
- data/app/assets/builds/iron.css +255 -106
- data/app/assets/tailwind/iron/application.css +1 -0
- data/app/assets/tailwind/iron/components/file-upload.css +26 -0
- data/app/assets/tailwind/iron/lexxy.css +111 -87
- data/app/controllers/concerns/iron/schema_editing.rb +19 -0
- data/app/controllers/iron/api/schema/base_controller.rb +25 -0
- data/app/controllers/iron/api/schema/block_definitions_controller.rb +49 -0
- data/app/controllers/iron/api/schema/content_types_controller.rb +64 -0
- data/app/controllers/iron/api/schema/field_definitions_controller.rb +88 -0
- data/app/controllers/iron/api/schema/locales_controller.rb +52 -0
- data/app/controllers/iron/application_controller.rb +1 -1
- data/app/controllers/iron/block_definitions_controller.rb +1 -0
- data/app/controllers/iron/content_types_controller.rb +1 -0
- data/app/controllers/iron/field_definitions_controller.rb +2 -1
- data/app/controllers/iron/first_runs_controller.rb +1 -1
- data/app/controllers/iron/locales_controller.rb +1 -0
- data/app/controllers/iron/sessions_controller.rb +1 -1
- data/app/helpers/iron/avatar_helper.rb +24 -2
- data/app/javascript/iron/controllers/file_upload_controller.js +38 -7
- data/app/models/iron/account.rb +1 -1
- data/app/models/iron/api/openapi_spec.rb +37 -15
- data/app/models/iron/block_definition/exportable.rb +1 -1
- data/app/models/iron/block_definition.rb +3 -1
- data/app/models/iron/content.rb +176 -0
- data/app/models/iron/content_type/exportable.rb +3 -1
- data/app/models/iron/content_type/importable.rb +1 -1
- data/app/models/iron/content_type.rb +6 -1
- data/app/models/iron/entry/content_assignable.rb +10 -2
- data/app/models/iron/exporter.rb +1 -26
- data/app/models/iron/field/length_constrained.rb +17 -0
- data/app/models/iron/field/validatable.rb +16 -0
- data/app/models/iron/field.rb +1 -1
- data/app/models/iron/field_definition/exportable.rb +3 -4
- data/app/models/iron/field_definition/importable.rb +16 -9
- data/app/models/iron/field_definition/ranked.rb +1 -1
- data/app/models/iron/field_definition/searchable.rb +2 -0
- data/app/models/iron/field_definition/validatable.rb +40 -0
- data/app/models/iron/field_definition/validations.rb +175 -0
- data/app/models/iron/field_definition.rb +1 -1
- data/app/models/iron/field_definitions/date.rb +2 -0
- data/app/models/iron/field_definitions/file.rb +3 -11
- data/app/models/iron/field_definitions/number.rb +2 -0
- data/app/models/iron/field_definitions/reference.rb +2 -0
- data/app/models/iron/field_definitions/rich_text_area.rb +1 -0
- data/app/models/iron/field_definitions/text_area.rb +2 -0
- data/app/models/iron/field_definitions/text_field.rb +1 -9
- data/app/models/iron/fields/block.rb +5 -1
- data/app/models/iron/fields/block_list.rb +5 -1
- data/app/models/iron/fields/date.rb +19 -2
- data/app/models/iron/fields/file.rb +5 -3
- data/app/models/iron/fields/number.rb +28 -1
- data/app/models/iron/fields/reference.rb +2 -0
- data/app/models/iron/fields/rich_text_area.rb +2 -0
- data/app/models/iron/fields/text_area.rb +4 -0
- data/app/models/iron/fields/text_field.rb +9 -5
- data/app/models/iron/importer.rb +1 -54
- data/app/models/iron/locale.rb +2 -0
- data/app/models/iron/schema/auto_dumpable.rb +26 -0
- data/app/models/iron/schema/diff.rb +194 -0
- data/app/models/iron/schema/validation.rb +214 -0
- data/app/models/iron/schema.rb +282 -0
- data/app/models/iron/seed.rb +5 -3
- data/app/models/iron/system.rb +7 -0
- data/app/models/iron/user/deactivatable.rb +5 -0
- data/app/models/iron/user.rb +18 -1
- data/app/views/iron/api/fields/_rich_text_area.json.jbuilder +2 -2
- data/app/views/iron/api/schema/block_definitions/_block_definition.json.jbuilder +4 -0
- data/app/views/iron/api/schema/block_definitions/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/block_definitions/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/content_types/_content_type.json.jbuilder +5 -0
- data/app/views/iron/api/schema/content_types/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/content_types/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/field_definitions/_field_definition.json.jbuilder +7 -0
- data/app/views/iron/api/schema/field_definitions/show.json.jbuilder +1 -0
- data/app/views/iron/api/schema/locales/_locale.json.jbuilder +4 -0
- data/app/views/iron/api/schema/locales/index.json.jbuilder +1 -0
- data/app/views/iron/api/schema/locales/show.json.jbuilder +1 -0
- data/app/views/iron/block_definitions/_empty_state.html.erb +5 -3
- data/app/views/iron/block_definitions/_form.html.erb +3 -1
- data/app/views/iron/block_definitions/edit.html.erb +19 -17
- data/app/views/iron/block_definitions/index.html.erb +7 -3
- data/app/views/iron/block_definitions/show.html.erb +14 -8
- data/app/views/iron/content_types/_content_type.html.erb +1 -1
- data/app/views/iron/content_types/_empty_state.html.erb +5 -3
- data/app/views/iron/content_types/_form.html.erb +12 -8
- data/app/views/iron/content_types/edit.html.erb +19 -17
- data/app/views/iron/content_types/index.html.erb +7 -3
- data/app/views/iron/content_types/show.html.erb +14 -8
- data/app/views/iron/entries/_empty_state.html.erb +1 -1
- data/app/views/iron/entries/edit.html.erb +1 -1
- data/app/views/iron/entries/entry.html.erb +1 -1
- data/app/views/iron/entries/fields/_block.html.erb +4 -0
- data/app/views/iron/entries/fields/_block_list.html.erb +2 -0
- data/app/views/iron/entries/fields/_boolean.html.erb +1 -0
- data/app/views/iron/entries/fields/_date.html.erb +3 -2
- data/app/views/iron/entries/fields/_field_errors.html.erb +7 -0
- data/app/views/iron/entries/fields/_field_label.html.erb +8 -0
- data/app/views/iron/entries/fields/_file.html.erb +11 -19
- data/app/views/iron/entries/fields/_number.html.erb +6 -2
- data/app/views/iron/entries/fields/_reference.html.erb +2 -1
- data/app/views/iron/entries/fields/_reference_list.html.erb +2 -0
- data/app/views/iron/entries/fields/_rich_text_area.html.erb +2 -1
- data/app/views/iron/entries/fields/_text_area.html.erb +6 -2
- data/app/views/iron/entries/fields/_text_field.html.erb +5 -12
- data/app/views/iron/field_definitions/_field_definition.html.erb +51 -29
- data/app/views/iron/field_definitions/date/_form.html.erb +5 -1
- data/app/views/iron/field_definitions/edit.html.erb +10 -8
- data/app/views/iron/field_definitions/file/_form.html.erb +6 -0
- data/app/views/iron/field_definitions/new.html.erb +5 -3
- data/app/views/iron/field_definitions/number/_form.html.erb +23 -1
- data/app/views/iron/field_definitions/reference/_form.html.erb +5 -0
- data/app/views/iron/field_definitions/rich_text_area/_form.html.erb +5 -0
- data/app/views/iron/field_definitions/text_area/_form.html.erb +23 -0
- data/app/views/iron/field_definitions/text_field/_form.html.erb +20 -1
- data/app/views/iron/home/_content_types.html.erb +1 -1
- data/app/views/iron/locales/_form.html.erb +5 -3
- data/app/views/iron/locales/edit.html.erb +1 -1
- data/app/views/iron/locales/index.html.erb +12 -6
- data/app/views/iron/shared/_schema_lock_badge.html.erb +19 -0
- data/config/locales/en.yml +13 -1
- data/config/locales/it.yml +18 -1
- data/config/routes.rb +11 -0
- data/db/migrate/20260612131538_create_iron_systems.rb +9 -0
- data/exe/iron +5 -0
- data/lib/generators/iron/agents/agents_generator.rb +52 -0
- data/lib/generators/iron/agents/templates/AGENTS.md +24 -0
- data/lib/generators/iron/agents/templates/SKILL.md +423 -0
- data/lib/generators/iron/install/install_generator.rb +118 -0
- data/lib/generators/iron/install/templates/iron_release.rake +5 -0
- data/lib/generators/iron/install/templates/schema.json +12 -0
- data/lib/generators/iron/install/templates/seeds.rb +13 -0
- data/lib/generators/iron/pages/pages_generator.rb +5 -0
- data/lib/generators/iron/pages/templates/pages_controller.rb +1 -1
- data/lib/generators/iron/pages/templates/show.html.erb +1 -1
- data/lib/install/template.rb +9 -0
- data/lib/iron/cli.rb +43 -0
- data/lib/iron/version.rb +1 -1
- data/lib/tasks/iron_content.rake +82 -0
- data/lib/tasks/iron_schema.rake +45 -0
- metadata +62 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module Iron
|
|
2
2
|
module AvatarHelper
|
|
3
|
+
SYSTEM_AVATAR_CLASSES = "bg-sky-100 text-sky-600 dark:bg-sky-500/20 dark:text-sky-400"
|
|
4
|
+
|
|
3
5
|
def avatar(user, options = {})
|
|
4
6
|
variant = options.delete(:variant) || "circle"
|
|
5
7
|
alt = options.delete(:alt) || ""
|
|
@@ -9,10 +11,31 @@ module Iron
|
|
|
9
11
|
"inline-grid shrink-0 align-middle [--avatar-radius:20%] [--ring-opacity:20%] *:col-start-1 *:row-start-1",
|
|
10
12
|
"outline outline-1 -outline-offset-1 outline-black/(--ring-opacity) dark:outline-white/(--ring-opacity)",
|
|
11
13
|
# Add the correct border radius
|
|
12
|
-
variant == "square" ? "rounded-(--avatar-radius) *:rounded-(--avatar-radius)" : "rounded-full *:rounded-full"
|
|
14
|
+
variant == "square" ? "rounded-(--avatar-radius) *:rounded-(--avatar-radius)" : "rounded-full *:rounded-full",
|
|
15
|
+
# The system user wears the accent treatment instead of the caller's palette
|
|
16
|
+
user.iron_system? ? SYSTEM_AVATAR_CLASSES : nil
|
|
13
17
|
)
|
|
14
18
|
|
|
15
19
|
tag.span class: css_class, data: { slot: "avatar" } do
|
|
20
|
+
user.iron_system? ? system_glyph : initials_glyph(user, alt)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def user_icon(user, **options)
|
|
25
|
+
user.iron_system? ? system_icon(**options) : icon("circle-user", **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def system_icon(**options)
|
|
29
|
+
icon "bot", **options
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def system_glyph
|
|
35
|
+
system_icon class: "size-full p-[22%]"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initials_glyph(user, alt)
|
|
16
39
|
tag.svg class: "size-full select-none fill-current p-[5%] text-[48px] font-medium uppercase",
|
|
17
40
|
viewbox: "0 0 100 100",
|
|
18
41
|
"aria-hidden": alt.present? ? nil : true do
|
|
@@ -30,6 +53,5 @@ module Iron
|
|
|
30
53
|
])
|
|
31
54
|
end
|
|
32
55
|
end
|
|
33
|
-
end
|
|
34
56
|
end
|
|
35
57
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
3
|
export default class FileUploadController extends Controller {
|
|
4
|
-
static targets = ["preview", "placeholder", "previewWrapper"];
|
|
4
|
+
static targets = ["preview", "placeholder", "previewWrapper", "filename"];
|
|
5
5
|
|
|
6
6
|
connect() {
|
|
7
7
|
if (this.hasPreviewTarget) {
|
|
@@ -16,19 +16,27 @@ export default class FileUploadController extends Controller {
|
|
|
16
16
|
const file = event.target.files[0];
|
|
17
17
|
if (!file) return;
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
const kind = this.#kindOf(file.type);
|
|
20
|
+
|
|
21
|
+
if (kind === "image") {
|
|
22
|
+
if (this.hasPreviewTarget) {
|
|
23
|
+
this.previewTarget.src = URL.createObjectURL(file);
|
|
24
|
+
}
|
|
25
|
+
} else if (this.hasFilenameTarget) {
|
|
26
|
+
this.filenameTarget.textContent = file.name;
|
|
24
27
|
}
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
this.#markFileKind(kind);
|
|
30
|
+
this.#revealPreview();
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
reset() {
|
|
29
34
|
if (this.hasPreviewTarget) {
|
|
30
35
|
this.previewTarget.src = this.originalSrc;
|
|
31
36
|
}
|
|
37
|
+
if (this.hasPreviewWrapperTarget) {
|
|
38
|
+
delete this.previewWrapperTarget.dataset.fileKind;
|
|
39
|
+
}
|
|
32
40
|
if (this.originalHidden) {
|
|
33
41
|
this.previewElement?.classList.add("hidden");
|
|
34
42
|
if (this.hasPlaceholderTarget) {
|
|
@@ -42,4 +50,27 @@ export default class FileUploadController extends Controller {
|
|
|
42
50
|
if (this.hasPreviewTarget) return this.previewTarget;
|
|
43
51
|
return null;
|
|
44
52
|
}
|
|
53
|
+
|
|
54
|
+
// Private
|
|
55
|
+
|
|
56
|
+
#markFileKind(kind) {
|
|
57
|
+
if (this.hasPreviewWrapperTarget) {
|
|
58
|
+
this.previewWrapperTarget.dataset.fileKind = kind;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#revealPreview() {
|
|
63
|
+
if (this.hasPlaceholderTarget) {
|
|
64
|
+
this.placeholderTarget.classList.add("hidden");
|
|
65
|
+
}
|
|
66
|
+
this.previewElement?.classList.remove("hidden");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#kindOf(contentType) {
|
|
70
|
+
if (contentType.startsWith("image/")) return "image";
|
|
71
|
+
if (contentType === "application/pdf") return "pdf";
|
|
72
|
+
if (contentType.startsWith("audio/")) return "audio";
|
|
73
|
+
if (contentType.startsWith("video/")) return "video";
|
|
74
|
+
return "file";
|
|
75
|
+
}
|
|
45
76
|
}
|
data/app/models/iron/account.rb
CHANGED
|
@@ -208,7 +208,7 @@ module Iron
|
|
|
208
208
|
},
|
|
209
209
|
ValidationErrors: {
|
|
210
210
|
type: "object",
|
|
211
|
-
description: "Field
|
|
211
|
+
description: "Field path to error messages mapping. Paths may be nested, e.g. `sections[0].title`.",
|
|
212
212
|
additionalProperties: { type: "array", items: { type: "string" } }
|
|
213
213
|
},
|
|
214
214
|
FileObject: {
|
|
@@ -275,7 +275,7 @@ module Iron
|
|
|
275
275
|
|
|
276
276
|
ct.field_definitions.each do |fd|
|
|
277
277
|
content_properties[fd.handle.to_sym] = field_write_schema(fd)
|
|
278
|
-
required_fields << fd.handle if fd.
|
|
278
|
+
required_fields << fd.handle if fd.required?
|
|
279
279
|
end
|
|
280
280
|
|
|
281
281
|
content_schema = { type: "object", properties: content_properties }
|
|
@@ -307,7 +307,7 @@ module Iron
|
|
|
307
307
|
properties[fd.handle.to_sym] = field_write_schema(fd)
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
-
{ type: "object", properties: properties, required: [ "_type" ] }
|
|
310
|
+
{ type: "object", properties: properties, required: [ "_type" ] + bd.field_definitions.select(&:required?).map(&:handle) }
|
|
311
311
|
end
|
|
312
312
|
|
|
313
313
|
# --- Field type mapping ---
|
|
@@ -315,12 +315,12 @@ module Iron
|
|
|
315
315
|
def field_read_schema(fd)
|
|
316
316
|
case fd
|
|
317
317
|
when FieldDefinitions::TextField
|
|
318
|
-
schema = { type: [ "string", "null" ] }
|
|
319
|
-
schema[:enum] = fd.allowed_values + [ nil ] if fd.
|
|
318
|
+
schema = { type: [ "string", "null" ] }.merge(length_bounds(fd))
|
|
319
|
+
schema[:enum] = fd.allowed_values + [ nil ] if fd.allowed_values.present?
|
|
320
320
|
schema
|
|
321
|
-
when FieldDefinitions::TextArea then { type: [ "string", "null" ] }
|
|
321
|
+
when FieldDefinitions::TextArea then { type: [ "string", "null" ] }.merge(length_bounds(fd))
|
|
322
322
|
when FieldDefinitions::RichTextArea then { oneOf: [ { "$ref": "#/components/schemas/RichText" }, { type: "null" } ] }
|
|
323
|
-
when FieldDefinitions::Number then { type: [ "number", "null" ] }
|
|
323
|
+
when FieldDefinitions::Number then { type: [ "number", "null" ] }.merge(number_bounds(fd))
|
|
324
324
|
when FieldDefinitions::Boolean then { type: [ "boolean", "null" ] }
|
|
325
325
|
when FieldDefinitions::Date then { type: [ "string", "null" ], format: "date-time" }
|
|
326
326
|
when FieldDefinitions::File then { oneOf: [ { "$ref": "#/components/schemas/FileObject" }, { type: "null" } ] }
|
|
@@ -338,16 +338,16 @@ module Iron
|
|
|
338
338
|
def field_write_schema(fd)
|
|
339
339
|
case fd
|
|
340
340
|
when FieldDefinitions::TextField
|
|
341
|
-
schema = { type:
|
|
342
|
-
schema[:enum] = fd.allowed_values + [ nil ] if fd.
|
|
341
|
+
schema = { type: write_type(fd, "string") }.merge(length_bounds(fd))
|
|
342
|
+
schema[:enum] = fd.required? ? fd.allowed_values : fd.allowed_values + [ nil ] if fd.allowed_values.present?
|
|
343
343
|
schema
|
|
344
|
-
when FieldDefinitions::TextArea then { type:
|
|
345
|
-
when FieldDefinitions::RichTextArea then { type:
|
|
346
|
-
when FieldDefinitions::Number then { type:
|
|
344
|
+
when FieldDefinitions::TextArea then { type: write_type(fd, "string") }.merge(length_bounds(fd))
|
|
345
|
+
when FieldDefinitions::RichTextArea then { type: write_type(fd, "string"), description: "HTML content" }
|
|
346
|
+
when FieldDefinitions::Number then { type: write_type(fd, "number") }.merge(number_bounds(fd))
|
|
347
347
|
when FieldDefinitions::Boolean then { type: [ "boolean", "null" ] }
|
|
348
|
-
when FieldDefinitions::Date then { type:
|
|
349
|
-
when FieldDefinitions::File then
|
|
350
|
-
when FieldDefinitions::Reference then
|
|
348
|
+
when FieldDefinitions::Date then { type: write_type(fd, "string"), format: "date-time" }
|
|
349
|
+
when FieldDefinitions::File then nullable_unless_required(fd, type: "string", description: upload_token_description(fd))
|
|
350
|
+
when FieldDefinitions::Reference then nullable_unless_required(fd, type: "integer", description: "Entry ID")
|
|
351
351
|
when FieldDefinitions::ReferenceList then { type: "array", items: { type: "integer", description: "Entry ID" } }
|
|
352
352
|
when FieldDefinitions::Block
|
|
353
353
|
bd = fd.supported_block_definition
|
|
@@ -358,6 +358,28 @@ module Iron
|
|
|
358
358
|
end
|
|
359
359
|
end
|
|
360
360
|
|
|
361
|
+
def write_type(fd, type)
|
|
362
|
+
fd.required? ? type : [ type, "null" ]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def nullable_unless_required(fd, schema)
|
|
366
|
+
fd.required? ? schema : { oneOf: [ schema, { type: "null" } ] }
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def length_bounds(fd)
|
|
370
|
+
{ minLength: fd.min_length, maxLength: fd.max_length }.compact
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def number_bounds(fd)
|
|
374
|
+
{ minimum: fd.min, maximum: fd.max }.compact
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def upload_token_description(fd)
|
|
378
|
+
return "Upload token (signed_id)" if fd.accepted_extensions.blank?
|
|
379
|
+
|
|
380
|
+
"Upload token (signed_id). Accepted file types: #{fd.accepted_extensions.join(', ')}"
|
|
381
|
+
end
|
|
382
|
+
|
|
361
383
|
# --- Helpers ---
|
|
362
384
|
|
|
363
385
|
def content_types
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Iron
|
|
2
2
|
class BlockDefinition < ApplicationRecord
|
|
3
|
-
include Exportable, Importable
|
|
3
|
+
include Exportable, Importable, Schema::AutoDumpable
|
|
4
4
|
|
|
5
5
|
has_many :field_definitions, -> { ranked }, as: :schemable, dependent: :destroy
|
|
6
6
|
|
|
@@ -14,5 +14,7 @@ module Iron
|
|
|
14
14
|
|
|
15
15
|
validates :name, presence: true
|
|
16
16
|
validates :handle, presence: true
|
|
17
|
+
|
|
18
|
+
def required? = false
|
|
17
19
|
end
|
|
18
20
|
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require "open-uri"
|
|
2
|
+
|
|
3
|
+
module Iron
|
|
4
|
+
class Content
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class InvalidContent < Error
|
|
8
|
+
attr_reader :errors
|
|
9
|
+
|
|
10
|
+
def initialize(errors)
|
|
11
|
+
@errors = errors
|
|
12
|
+
super(errors.map { |path, messages| "#{path} #{Array(messages).join(", ")}" }.join("; "))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def list(handle, locale: nil)
|
|
18
|
+
as_system(locale) do
|
|
19
|
+
content_type(handle).entries.order(:id).map { |entry| render_entry(entry) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get(handle, id: nil, route: nil, locale: nil)
|
|
24
|
+
as_system(locale) do
|
|
25
|
+
render_entry(locate_entry(content_type(handle), id:, route:))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create(handle, content, route: nil, locale: nil)
|
|
30
|
+
type = content_type(handle)
|
|
31
|
+
raise Error, "#{handle} is a single — use update" if type.single?
|
|
32
|
+
|
|
33
|
+
as_system(locale) do
|
|
34
|
+
entry = type.entries.build
|
|
35
|
+
entry.route = route if route
|
|
36
|
+
write_entry(entry, content)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def update(handle, content, id: nil, route: nil, locale: nil)
|
|
41
|
+
type = content_type(handle)
|
|
42
|
+
|
|
43
|
+
as_system(locale) do
|
|
44
|
+
write_entry(entry_for_update(type, id:, route:), content)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delete(handle, id: nil, route: nil)
|
|
49
|
+
as_system(nil) do
|
|
50
|
+
entry = locate_entry(content_type(handle), id:, route:)
|
|
51
|
+
purge_file_attachments(entry)
|
|
52
|
+
entry.destroy!
|
|
53
|
+
{ "deleted" => true, "id" => entry.id }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def as_system(locale_code, &block)
|
|
60
|
+
Current.set(user: User.system, locale: resolve_locale(locale_code)) do
|
|
61
|
+
ActiveStorage::Current.set(url_options: storage_url_options, &block)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_locale(code)
|
|
66
|
+
code.present? ? Locale.find_by!(code:) : Locale.default
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def storage_url_options
|
|
70
|
+
Rails.application.routes.default_url_options.presence || { host: "localhost", port: 3000 }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def content_type(handle)
|
|
74
|
+
ContentType.find_by!(handle:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def locate_entry(content_type, id:, route:)
|
|
78
|
+
if id
|
|
79
|
+
content_type.entries.find(id)
|
|
80
|
+
elsif route
|
|
81
|
+
content_type.entries.find_by!(route:)
|
|
82
|
+
else
|
|
83
|
+
singleton_entry(content_type)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def singleton_entry(content_type)
|
|
88
|
+
raise Error, "#{content_type.handle} is a collection — pass an id or route" unless content_type.single?
|
|
89
|
+
|
|
90
|
+
content_type.entry.tap do |entry|
|
|
91
|
+
raise ActiveRecord::RecordNotFound, "#{content_type.handle} has no entry yet" unless entry.persisted?
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def entry_for_update(content_type, id:, route:)
|
|
96
|
+
if id.nil? && route.nil? && content_type.single?
|
|
97
|
+
content_type.entry
|
|
98
|
+
else
|
|
99
|
+
locate_entry(content_type, id:, route:)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write_entry(entry, content)
|
|
104
|
+
content ||= {}
|
|
105
|
+
raise Error, "payload must be a JSON object of field handles" unless content.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
entry.assign_content(ingest_files(content))
|
|
108
|
+
raise InvalidContent, entry.content_errors unless entry.save
|
|
109
|
+
|
|
110
|
+
render_entry(entry)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def purge_file_attachments(entry)
|
|
114
|
+
entry.fields.each do |field|
|
|
115
|
+
field.file.purge if field.is_a?(Fields::File) && field.file.attached?
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render_entry(entry)
|
|
120
|
+
JSON.parse(Api::BaseController.render(partial: "iron/api/entry", formats: [ :json ], locals: { entry: }))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def ingest_files(content)
|
|
124
|
+
case content
|
|
125
|
+
when Hash
|
|
126
|
+
if file_directive?(content)
|
|
127
|
+
uploaded_blob(file_source(content)).signed_id
|
|
128
|
+
else
|
|
129
|
+
content.transform_values { |value| ingest_files(value) }
|
|
130
|
+
end
|
|
131
|
+
when Array
|
|
132
|
+
content.map { |value| ingest_files(value) }
|
|
133
|
+
else
|
|
134
|
+
content
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def file_directive?(hash)
|
|
139
|
+
hash.key?("_file") || hash.key?(:_file)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def file_source(hash)
|
|
143
|
+
hash["_file"] || hash[:_file]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def uploaded_blob(source)
|
|
147
|
+
unless source.is_a?(String) && source.present?
|
|
148
|
+
raise Error, '"_file" must be a file path or URL string'
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
source.match?(%r{\Ahttps?://}i) ? downloaded_blob(source) : local_blob(source)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def downloaded_blob(url)
|
|
155
|
+
URI.open(url) do |io|
|
|
156
|
+
ActiveStorage::Blob.create_and_upload!(io:, filename: File.basename(URI.parse(url).path))
|
|
157
|
+
end
|
|
158
|
+
rescue OpenURI::HTTPError, SocketError, URI::InvalidURIError, Timeout::Error, Errno::ECONNREFUSED => error
|
|
159
|
+
raise Error, "could not download #{url}: #{error.message}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def local_blob(path)
|
|
163
|
+
file = Pathname.new(path)
|
|
164
|
+
file = Rails.root.join(file) if file.relative?
|
|
165
|
+
|
|
166
|
+
file.open do |io|
|
|
167
|
+
ActiveStorage::Blob.create_and_upload!(io:, filename: file.basename.to_s)
|
|
168
|
+
end
|
|
169
|
+
rescue Errno::ENOENT
|
|
170
|
+
raise Error, "file not found: #{path} (paths are relative to the app root)"
|
|
171
|
+
rescue Errno::EACCES, Errno::EISDIR => error
|
|
172
|
+
raise Error, "could not read file #{path}: #{error.message}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -2,6 +2,8 @@ module Iron
|
|
|
2
2
|
module ContentType::Exportable
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
# Blank attributes and false flags are omitted: schema files stay canonical
|
|
6
|
+
# and hand-written files don't churn under the development auto-dump.
|
|
5
7
|
def export_attributes
|
|
6
8
|
{
|
|
7
9
|
handle:,
|
|
@@ -14,7 +16,7 @@ module Iron
|
|
|
14
16
|
title_field_handle: title_field_definition&.handle,
|
|
15
17
|
web_page_title_field_handle: web_page_title_field_definition&.handle,
|
|
16
18
|
field_definitions: field_definitions.ranked.map(&:export_attributes)
|
|
17
|
-
}
|
|
19
|
+
}.compact_blank
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module Iron
|
|
2
2
|
class ContentType < ApplicationRecord
|
|
3
|
-
include Titlable, WebPublishable, Exportable, Importable
|
|
3
|
+
include Titlable, WebPublishable, Exportable, Importable, Schema::AutoDumpable
|
|
4
4
|
|
|
5
5
|
TYPES = %w[Single Collection].freeze
|
|
6
|
+
DEFAULT_ICON = "file-text"
|
|
6
7
|
|
|
7
8
|
validates :name, presence: true
|
|
8
9
|
validates :handle, presence: true
|
|
@@ -36,6 +37,10 @@ module Iron
|
|
|
36
37
|
type.demodulize
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
def display_icon
|
|
41
|
+
icon.presence.then { |name| IconCatalog.include?(name) ? name : nil } || DEFAULT_ICON
|
|
42
|
+
end
|
|
43
|
+
|
|
39
44
|
def single?
|
|
40
45
|
is_a?(ContentTypes::Single)
|
|
41
46
|
end
|
|
@@ -5,7 +5,11 @@ module Iron
|
|
|
5
5
|
def assign_content(content)
|
|
6
6
|
content_type.field_definitions.each do |definition|
|
|
7
7
|
raw_value = Field.content_fetch(content, definition.handle)
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
if raw_value == Field::CONTENT_MISSING
|
|
10
|
+
find_or_build_field(definition, Current.locale) if definition.required?
|
|
11
|
+
next
|
|
12
|
+
end
|
|
9
13
|
|
|
10
14
|
field = find_or_build_field(definition, Current.locale)
|
|
11
15
|
field.content_value = raw_value
|
|
@@ -17,7 +21,11 @@ module Iron
|
|
|
17
21
|
|
|
18
22
|
errors.each do |error|
|
|
19
23
|
next if error.type == :fields_invalid
|
|
20
|
-
|
|
24
|
+
|
|
25
|
+
attribute = error.attribute.to_s
|
|
26
|
+
next if attribute == "fields" || attribute.start_with?("fields.")
|
|
27
|
+
|
|
28
|
+
(details[attribute] ||= []) << error.message
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
collect_field_error_details(fields.select { |f| f.parent.nil? }, nil, details)
|
data/app/models/iron/exporter.rb
CHANGED
|
@@ -40,32 +40,7 @@ module Iron
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def schema_json
|
|
43
|
-
|
|
44
|
-
version: "1.0",
|
|
45
|
-
exported_at: Time.current.iso8601,
|
|
46
|
-
default_locale: Current.account&.default_locale&.code,
|
|
47
|
-
locales: export_locales,
|
|
48
|
-
block_definitions: export_block_definitions,
|
|
49
|
-
content_types: export_content_types
|
|
50
|
-
)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def export_locales
|
|
54
|
-
Locale.order(:code).map { |l| { code: l.code, name: l.name } }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def export_block_definitions
|
|
58
|
-
BlockDefinition
|
|
59
|
-
.includes(:field_definitions)
|
|
60
|
-
.order(:handle)
|
|
61
|
-
.map(&:export_attributes)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def export_content_types
|
|
65
|
-
ContentType
|
|
66
|
-
.includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
|
|
67
|
-
.order(:handle)
|
|
68
|
-
.map(&:export_attributes)
|
|
43
|
+
Schema.to_json
|
|
69
44
|
end
|
|
70
45
|
|
|
71
46
|
def exportable_entries
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Field::LengthConstrained
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
validate :value_within_length_bounds
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
def value_within_length_bounds
|
|
11
|
+
return if value.blank?
|
|
12
|
+
|
|
13
|
+
errors.add(:base, :too_short, count: definition.min_length) if definition.min_length && value.length < definition.min_length
|
|
14
|
+
errors.add(:base, :too_long, count: definition.max_length) if definition.max_length && value.length > definition.max_length
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Field::Validatable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
validate :enforce_required
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def filled? = true
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
def enforce_required
|
|
13
|
+
errors.add(:base, :blank) if definition&.required? && !filled?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/app/models/iron/field.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Iron
|
|
2
2
|
class Field < ApplicationRecord
|
|
3
|
-
include BelongsToEntry
|
|
3
|
+
include BelongsToEntry, Validatable
|
|
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
|
CONTENT_MISSING = Object.new.freeze
|
|
@@ -7,15 +7,14 @@ module Iron
|
|
|
7
7
|
handle:,
|
|
8
8
|
name:,
|
|
9
9
|
type: type_handle,
|
|
10
|
-
|
|
11
|
-
metadata:
|
|
10
|
+
metadata: metadata.compact_blank
|
|
12
11
|
}.tap do |attrs|
|
|
13
12
|
if respond_to?(:supported_block_definitions)
|
|
14
|
-
attrs[:supported_block_definitions] = supported_block_definitions.pluck(:handle)
|
|
13
|
+
attrs[:supported_block_definitions] = supported_block_definitions.pluck(:handle).sort
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
if respond_to?(:supported_content_types)
|
|
18
|
-
attrs[:supported_content_types] = supported_content_types.pluck(:handle)
|
|
17
|
+
attrs[:supported_content_types] = supported_content_types.pluck(:handle).sort
|
|
19
18
|
end
|
|
20
19
|
end.compact_blank
|
|
21
20
|
end
|
|
@@ -7,11 +7,11 @@ module Iron
|
|
|
7
7
|
raise ArgumentError, "Field type is required" unless attrs[:type].present?
|
|
8
8
|
|
|
9
9
|
field_def = schemable.field_definitions.find_or_initialize_by(handle: attrs[:handle])
|
|
10
|
+
discard_stored_values(field_def, attrs[:type])
|
|
10
11
|
field_def = field_def.becomes!(classify_type(attrs[:type]).constantize)
|
|
11
12
|
|
|
12
13
|
field_def.assign_attributes(
|
|
13
14
|
name: attrs[:name],
|
|
14
|
-
rank: attrs[:rank],
|
|
15
15
|
metadata: attrs[:metadata] || {}
|
|
16
16
|
)
|
|
17
17
|
|
|
@@ -21,19 +21,26 @@ module Iron
|
|
|
21
21
|
field_def
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# Omitted lists clear the supported definitions: the file is authoritative.
|
|
25
|
+
# Handles declared later in the same file resolve in Schema.import's second
|
|
26
|
+
# pass, once every block definition and content type exists.
|
|
26
27
|
def assign_supported_definitions(field_def, attrs)
|
|
27
|
-
if field_def.respond_to?(:supported_block_definition_ids=)
|
|
28
|
-
|
|
29
|
-
field_def.supported_block_definition_ids = block_defs.pluck(:id)
|
|
28
|
+
if field_def.respond_to?(:supported_block_definition_ids=)
|
|
29
|
+
field_def.supported_block_definition_ids = BlockDefinition.where(handle: attrs[:supported_block_definitions] || []).pluck(:id)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
if field_def.respond_to?(:supported_content_type_ids=)
|
|
33
|
-
|
|
34
|
-
field_def.supported_content_type_ids = content_types.pluck(:id)
|
|
32
|
+
if field_def.respond_to?(:supported_content_type_ids=)
|
|
33
|
+
field_def.supported_content_type_ids = ContentType.where(handle: attrs[:supported_content_types] || []).pluck(:id)
|
|
35
34
|
end
|
|
36
35
|
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Changing a field's type discards its stored values: rows written under
|
|
40
|
+
# the old field class would otherwise linger as split-brain content.
|
|
41
|
+
def discard_stored_values(field_def, type)
|
|
42
|
+
field_def.fields.destroy_all if field_def.persisted? && field_def.type != classify_type(type)
|
|
43
|
+
end
|
|
37
44
|
end
|
|
38
45
|
end
|
|
39
46
|
end
|
|
@@ -19,6 +19,8 @@ module Iron
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def searchable=(value)
|
|
22
|
+
raise ActiveModel::UnknownAttributeError.new(self, "searchable") unless SEARCHABLE_TYPES.include?(self.class.name)
|
|
23
|
+
|
|
22
24
|
self.metadata = (metadata || {}).merge("searchable" => ActiveModel::Type::Boolean.new.cast(value))
|
|
23
25
|
end
|
|
24
26
|
|