rivet_cms 0.1.2.pre → 0.3.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/CHANGELOG.md +53 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/README.md +173 -10
- data/app/assets/builds/rivet_cms.css +2 -2
- data/app/assets/builds/rivet_cms.js +395 -9505
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
- data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
- data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
- data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
- data/app/controllers/rivet_cms/application_controller.rb +220 -2
- data/app/controllers/rivet_cms/auth_controller.rb +28 -0
- data/app/controllers/rivet_cms/components_controller.rb +93 -2
- data/app/controllers/rivet_cms/content_controller.rb +105 -0
- data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
- data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
- data/app/controllers/rivet_cms/documents_controller.rb +204 -0
- data/app/controllers/rivet_cms/fields_controller.rb +77 -74
- data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
- data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
- data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
- data/app/controllers/rivet_cms/setup_controller.rb +60 -0
- data/app/controllers/rivet_cms/trash_controller.rb +68 -0
- data/app/controllers/rivet_cms/users_controller.rb +117 -0
- data/app/helpers/rivet_cms/application_helper.rb +13 -3
- data/app/helpers/rivet_cms/components_helper.rb +4 -0
- data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
- data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
- data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
- data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
- data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
- data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
- data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
- data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
- data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
- data/app/models/rivet_cms/api_token.rb +64 -0
- data/app/models/rivet_cms/category.rb +20 -0
- data/app/models/rivet_cms/component.rb +10 -0
- data/app/models/rivet_cms/component_instance.rb +27 -0
- data/app/models/rivet_cms/content_type.rb +36 -23
- data/app/models/rivet_cms/content_value.rb +19 -0
- data/app/models/rivet_cms/current.rb +6 -0
- data/app/models/rivet_cms/document.rb +71 -0
- data/app/models/rivet_cms/document_revision.rb +128 -0
- data/app/models/rivet_cms/entry.rb +73 -0
- data/app/models/rivet_cms/entry_collection.rb +41 -0
- data/app/models/rivet_cms/field.rb +203 -61
- data/app/models/rivet_cms/media_asset.rb +109 -0
- data/app/models/rivet_cms/organization.rb +46 -0
- data/app/models/rivet_cms/relation.rb +23 -0
- data/app/models/rivet_cms/user.rb +61 -0
- data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
- data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
- data/app/services/rivet_cms/content_query.rb +158 -0
- data/app/services/rivet_cms/content_validator.rb +150 -0
- data/app/services/rivet_cms/draft_writer.rb +74 -0
- data/app/services/rivet_cms/open_api_generator.rb +173 -221
- data/app/services/rivet_cms/revision_pruner.rb +64 -0
- data/app/services/rivet_cms/webhooks.rb +61 -0
- data/app/views/layouts/rivet_cms/application.html.erb +35 -42
- data/config/routes.rb +68 -45
- data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
- data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
- data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
- data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
- data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
- data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
- data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
- data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
- data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
- data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
- data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
- data/db/seeds/README.md +70 -0
- data/db/seeds/templates/blog.rb +39 -0
- data/db/seeds/templates/events.rb +12 -0
- data/db/seeds/templates/faq.rb +9 -0
- data/db/seeds/templates/pages.rb +35 -0
- data/db/seeds/templates/site_settings.rb +22 -0
- data/db/seeds/templates/team.rb +11 -0
- data/db/seeds/templates/testimonials.rb +11 -0
- data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
- data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
- data/lib/rivet_cms/access_check.rb +12 -0
- data/lib/rivet_cms/audit.rb +43 -0
- data/lib/rivet_cms/engine.rb +28 -22
- data/lib/rivet_cms/hooks.rb +106 -0
- data/lib/rivet_cms/navigation.rb +111 -0
- data/lib/rivet_cms/safe_pattern.rb +40 -0
- data/lib/rivet_cms/seeds.rb +264 -0
- data/lib/rivet_cms/version.rb +1 -1
- data/lib/rivet_cms.rb +289 -32
- data/lib/tasks/rivet_cms_tasks.rake +18 -4
- metadata +138 -98
- data/MIT-LICENSE +0 -20
- data/app/assets/builds/rivet_cms.js.map +0 -7
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
- data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
- data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
- data/app/helpers/rivet_cms/flash_helper.rb +0 -37
- data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
- data/app/javascript/controllers/content_type_form_controller.js +0 -53
- data/app/javascript/controllers/field_layout_controller.js +0 -709
- data/app/javascript/rivet_cms.js +0 -29
- data/app/models/rivet_cms/content.rb +0 -4
- data/app/models/rivet_cms/field_values/base.rb +0 -11
- data/app/models/rivet_cms/field_values/boolean.rb +0 -4
- data/app/models/rivet_cms/field_values/integer.rb +0 -4
- data/app/models/rivet_cms/field_values/string.rb +0 -4
- data/app/models/rivet_cms/field_values/text.rb +0 -4
- data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
- data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
- data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
- data/app/views/rivet_cms/content_types/index.html.erb +0 -151
- data/app/views/rivet_cms/content_types/new.html.erb +0 -19
- data/app/views/rivet_cms/content_types/show.html.erb +0 -147
- data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
- data/app/views/rivet_cms/fields/_form.html.erb +0 -111
- data/app/views/rivet_cms/fields/edit.html.erb +0 -25
- data/app/views/rivet_cms/fields/index.html.erb +0 -126
- data/app/views/rivet_cms/fields/new.html.erb +0 -25
- data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
- data/config/i18n-tasks.yml +0 -178
- data/config/locales/en.yml +0 -14
- data/db/migrate/20250317194359_create_core_tables.rb +0 -90
- data/lib/rivet_cms/routes.rb +0 -49
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module RivetCms
|
|
5
|
+
class ApiToken < ApplicationRecord
|
|
6
|
+
include OrganizationScoped
|
|
7
|
+
|
|
8
|
+
has_prefix_id :tok
|
|
9
|
+
|
|
10
|
+
enum :scope, { published: 0, preview: 1 }
|
|
11
|
+
|
|
12
|
+
validates :name, presence: true
|
|
13
|
+
validates :token_digest, presence: true, uniqueness: true
|
|
14
|
+
validates :token_last4, presence: true
|
|
15
|
+
|
|
16
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
17
|
+
|
|
18
|
+
# Set only on the record returned by generate!; the secret is never stored.
|
|
19
|
+
attr_reader :plaintext
|
|
20
|
+
|
|
21
|
+
def self.digest(raw)
|
|
22
|
+
Digest::SHA256.hexdigest(raw.to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.generate!(name:, scope: :published, organization: RivetCms::Current.organization, expires_at: nil)
|
|
26
|
+
raw = SecureRandom.hex(32)
|
|
27
|
+
token = create!(
|
|
28
|
+
name: name,
|
|
29
|
+
scope: scope,
|
|
30
|
+
organization: organization,
|
|
31
|
+
expires_at: expires_at,
|
|
32
|
+
token_digest: digest(raw),
|
|
33
|
+
token_last4: raw.last(4)
|
|
34
|
+
)
|
|
35
|
+
token.instance_variable_set(:@plaintext, raw)
|
|
36
|
+
token
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Global lookup — org is derived from the token, not scoped by the caller.
|
|
40
|
+
def self.authenticate(raw)
|
|
41
|
+
return nil if raw.blank?
|
|
42
|
+
|
|
43
|
+
token = find_by(token_digest: digest(raw))
|
|
44
|
+
return nil if token.nil? || token.expired?
|
|
45
|
+
|
|
46
|
+
token
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def expired?
|
|
50
|
+
expires_at.present? && expires_at.past?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Throttled so the delivery API doesn't write to this row on every request.
|
|
54
|
+
def touch_used!
|
|
55
|
+
return if last_used_at && last_used_at > 10.minutes.ago
|
|
56
|
+
|
|
57
|
+
update_column(:last_used_at, Time.current)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def masked
|
|
61
|
+
"••••#{token_last4}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class Category < ApplicationRecord
|
|
3
|
+
has_prefix_id :cat
|
|
4
|
+
include OrganizationScoped
|
|
5
|
+
|
|
6
|
+
has_many :components, dependent: :restrict_with_error
|
|
7
|
+
|
|
8
|
+
validates :name, presence: true
|
|
9
|
+
validates :slug, presence: true, uniqueness: { scope: :organization_id }
|
|
10
|
+
validates :system, inclusion: { in: [ true, false ] }
|
|
11
|
+
|
|
12
|
+
scope :system_categories, -> { where(system: true) }
|
|
13
|
+
scope :custom_categories, -> { where(system: false) }
|
|
14
|
+
scope :ordered, -> { order(:position) }
|
|
15
|
+
|
|
16
|
+
def destroyable?
|
|
17
|
+
!system? && components.empty?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
module RivetCms
|
|
2
2
|
class Component < ApplicationRecord
|
|
3
|
+
include Sluggable
|
|
4
|
+
|
|
5
|
+
has_prefix_id :comp
|
|
6
|
+
include OrganizationScoped
|
|
7
|
+
include HasFields
|
|
8
|
+
|
|
9
|
+
belongs_to :category
|
|
10
|
+
|
|
11
|
+
validates :name, presence: true
|
|
12
|
+
validates :slug, uniqueness: { scope: :organization_id }
|
|
3
13
|
end
|
|
4
14
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class ComponentInstance < ApplicationRecord
|
|
3
|
+
include RevisionOwned
|
|
4
|
+
|
|
5
|
+
has_prefix_id :cmpi
|
|
6
|
+
|
|
7
|
+
belongs_to :field
|
|
8
|
+
belongs_to :component
|
|
9
|
+
|
|
10
|
+
has_many :content_values, as: :owner, dependent: :destroy
|
|
11
|
+
has_many :relations, as: :owner, dependent: :destroy
|
|
12
|
+
has_many :component_instances, as: :owner, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
validate :component_in_same_organization
|
|
15
|
+
|
|
16
|
+
scope :ordered, -> { order(:position) }
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def component_in_same_organization
|
|
21
|
+
return if component.nil? || owner_organization_id.nil?
|
|
22
|
+
return if component.organization_id == owner_organization_id
|
|
23
|
+
|
|
24
|
+
errors.add(:component, "must belong to the same organization")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,40 +1,53 @@
|
|
|
1
1
|
module RivetCms
|
|
2
2
|
class ContentType < ApplicationRecord
|
|
3
|
-
|
|
3
|
+
include Sluggable
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
has_prefix_id :ctype
|
|
6
|
+
include OrganizationScoped
|
|
7
|
+
include HasFields
|
|
8
|
+
include SoftDeletable
|
|
9
|
+
|
|
10
|
+
# Deleting a type must never cascade into content. Discarding hides the
|
|
11
|
+
# type and its entries; a real destroy is refused while entries exist, so
|
|
12
|
+
# there is no path from one click to a wiped library.
|
|
13
|
+
has_many :documents, dependent: :restrict_with_error
|
|
14
|
+
# documents is soft-delete scoped, so restrict_with_error cannot see a
|
|
15
|
+
# trashed entry; without this a destroy would slip past it into an FK error.
|
|
16
|
+
before_destroy :restrict_when_entries_remain, prepend: true
|
|
7
17
|
|
|
8
18
|
validates :name, presence: true
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
# The unique index spans removed rows too, so a removed type keeps its slug
|
|
20
|
+
# reserved and restoring one can never collide with something created since.
|
|
21
|
+
validates :slug, uniqueness: { scope: :organization_id }
|
|
22
|
+
validate :slug_not_held_by_removed_type
|
|
23
|
+
validates :single, inclusion: { in: [ true, false ] }
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
scope :singles, -> { where(single: true) }
|
|
26
|
+
scope :collections, -> { where(single: false) }
|
|
16
27
|
|
|
17
28
|
def collection?
|
|
18
|
-
!
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def single?
|
|
22
|
-
is_single
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# For compatibility with existing views
|
|
26
|
-
def is_collection
|
|
27
|
-
!is_single
|
|
29
|
+
!single?
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
private
|
|
31
33
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
+
def restrict_when_entries_remain
|
|
35
|
+
return unless Document.with_discarded.where(content_type_id: id).exists?
|
|
36
|
+
|
|
37
|
+
errors.add(:base, :"restrict_dependent_destroy.has_many", record: Document.model_name.human.downcase.pluralize)
|
|
38
|
+
throw :abort
|
|
34
39
|
end
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
# Rails' uniqueness validator ignores the kept default scope, so a slug held
|
|
42
|
+
# by a removed type does produce "has already been taken". Declared after it
|
|
43
|
+
# so that baffling message can be replaced with one that says what to do.
|
|
44
|
+
def slug_not_held_by_removed_type
|
|
45
|
+
return if slug.blank?
|
|
46
|
+
return if persisted? && !slug_changed?
|
|
47
|
+
return unless self.class.with_discarded.discarded.exists?(organization_id: organization_id, slug: slug)
|
|
48
|
+
|
|
49
|
+
errors.delete(:slug, :taken)
|
|
50
|
+
errors.add(:slug, "belongs to a removed content type; restore that type instead of recreating it")
|
|
38
51
|
end
|
|
39
52
|
end
|
|
40
53
|
end
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
module RivetCms
|
|
2
2
|
class ContentValue < ApplicationRecord
|
|
3
|
+
include RevisionOwned
|
|
4
|
+
include TypedValue
|
|
5
|
+
|
|
6
|
+
has_prefix_id :val
|
|
7
|
+
|
|
8
|
+
belongs_to :field
|
|
9
|
+
belongs_to :media_asset, class_name: "RivetCms::MediaAsset", optional: true
|
|
10
|
+
|
|
11
|
+
validates :field_id, uniqueness: { scope: [ :owner_type, :owner_id ] }
|
|
12
|
+
validate :media_asset_matches_owner_organization
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def media_asset_matches_owner_organization
|
|
17
|
+
return if media_asset.nil? || owner_organization_id.nil?
|
|
18
|
+
return if media_asset.organization_id == owner_organization_id
|
|
19
|
+
|
|
20
|
+
errors.add(:media_asset, "must belong to the same organization")
|
|
21
|
+
end
|
|
3
22
|
end
|
|
4
23
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class Document < ApplicationRecord
|
|
3
|
+
include OrganizationScoped
|
|
4
|
+
include SoftDeletable
|
|
5
|
+
|
|
6
|
+
has_prefix_id :doc
|
|
7
|
+
|
|
8
|
+
belongs_to :content_type
|
|
9
|
+
belongs_to :published_revision, class_name: "RivetCms::DocumentRevision", optional: true
|
|
10
|
+
belongs_to :draft_revision, class_name: "RivetCms::DocumentRevision", optional: true
|
|
11
|
+
has_many :revisions, class_name: "RivetCms::DocumentRevision", dependent: :destroy
|
|
12
|
+
|
|
13
|
+
before_validation :assign_singleton_key
|
|
14
|
+
before_destroy :detach_revisions, prepend: true
|
|
15
|
+
|
|
16
|
+
# Unscoped like ContentType: a trashed entry keeps its slug reserved so
|
|
17
|
+
# restoring it can never collide with something created since.
|
|
18
|
+
validates :slug, presence: true, uniqueness: { scope: :content_type_id }
|
|
19
|
+
# Guarded by slug_changed? so entries created before this rule can still
|
|
20
|
+
# save and publish; the format applies once someone touches the slug.
|
|
21
|
+
validates :slug, format: { with: Sluggable::SLUG_FORMAT, message: "must be lowercase alphanumeric with hyphens" }, if: :slug_changed?
|
|
22
|
+
validates :singleton_key, uniqueness: { scope: :content_type_id }, if: -> { singleton_key.present? }
|
|
23
|
+
validate :slug_not_held_by_trashed_entry
|
|
24
|
+
validate :singleton_not_held_by_trashed_entry
|
|
25
|
+
validate :content_type_in_same_organization
|
|
26
|
+
|
|
27
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
28
|
+
# ContentType is soft-deletable, so a document can outlive its type's
|
|
29
|
+
# visibility; cross-type queries must exclude those or content_type is nil.
|
|
30
|
+
scope :in_visible_types, -> { where(content_type_id: ContentType.select(:id)) }
|
|
31
|
+
scope :search, ->(query) { where("LOWER(#{table_name}.slug) LIKE ?", "%#{sanitize_sql_like(query.downcase)}%") }
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def assign_singleton_key
|
|
36
|
+
self.singleton_key = content_type&.single? ? "singleton" : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def detach_revisions
|
|
40
|
+
update_columns(published_revision_id: nil, draft_revision_id: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Like ContentType, the uniqueness validators ignore the kept scope, so a
|
|
44
|
+
# trashed entry produces "has already been taken". Replace that with a
|
|
45
|
+
# message that points at the trash.
|
|
46
|
+
def slug_not_held_by_trashed_entry
|
|
47
|
+
return if slug.blank?
|
|
48
|
+
return if persisted? && !slug_changed?
|
|
49
|
+
return unless self.class.with_discarded.discarded.exists?(content_type_id: content_type_id, slug: slug)
|
|
50
|
+
|
|
51
|
+
errors.delete(:slug, :taken)
|
|
52
|
+
errors.add(:slug, "belongs to an entry in the trash; restore that entry instead of recreating it")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def singleton_not_held_by_trashed_entry
|
|
56
|
+
return if singleton_key.blank?
|
|
57
|
+
return if persisted? && !singleton_key_changed?
|
|
58
|
+
return unless self.class.with_discarded.discarded.exists?(content_type_id: content_type_id, singleton_key: singleton_key)
|
|
59
|
+
|
|
60
|
+
errors.delete(:singleton_key, :taken)
|
|
61
|
+
errors.add(:base, "This single's entry is in the trash; restore it instead of creating a new one")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def content_type_in_same_organization
|
|
65
|
+
return if content_type.nil? || organization.nil?
|
|
66
|
+
return if content_type.organization == organization
|
|
67
|
+
|
|
68
|
+
errors.add(:content_type, "must belong to the same organization")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class ContentInvalidError < StandardError
|
|
3
|
+
attr_reader :errors
|
|
4
|
+
|
|
5
|
+
def initialize(errors)
|
|
6
|
+
@errors = errors
|
|
7
|
+
super("content is invalid: #{errors.map(&:message).join(', ')}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Raised when content is written or published into a content type that has
|
|
12
|
+
# been removed. Its entries are kept but no longer served, so they must not
|
|
13
|
+
# be edited or republished until the type is restored.
|
|
14
|
+
class RemovedContentTypeError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Raised when a revision is written or published while its entry sits in the
|
|
17
|
+
# trash. Document's kept scope makes revision.document nil, so without this
|
|
18
|
+
# every such path would NoMethodError instead of saying what is wrong.
|
|
19
|
+
class TrashedEntryError < StandardError; end
|
|
20
|
+
|
|
21
|
+
class DocumentRevision < ApplicationRecord
|
|
22
|
+
has_prefix_id :rev
|
|
23
|
+
|
|
24
|
+
belongs_to :document
|
|
25
|
+
# The reference stays optional so deleting a host user doesn't orphan or
|
|
26
|
+
# block revisions; author_name is the durable attribution and is required.
|
|
27
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
28
|
+
|
|
29
|
+
validates :author_name, presence: true
|
|
30
|
+
|
|
31
|
+
has_many :content_values, as: :owner, dependent: :destroy
|
|
32
|
+
has_many :relations, as: :owner, dependent: :destroy
|
|
33
|
+
has_many :component_instances, as: :owner, dependent: :destroy
|
|
34
|
+
|
|
35
|
+
enum :state, { draft: 0, published: 1, archived: 2 }
|
|
36
|
+
|
|
37
|
+
scope :ordered, -> { order(created_at: :desc, id: :desc) }
|
|
38
|
+
|
|
39
|
+
# publisher attributes the snapshot to whoever published it, which is not
|
|
40
|
+
# always the person who last saved the draft.
|
|
41
|
+
def publish!(publisher: nil, publisher_name: nil)
|
|
42
|
+
raise TrashedEntryError, "entry is in the trash; restore it before publishing" if document.nil?
|
|
43
|
+
raise RemovedContentTypeError, "content type was removed; restore it before publishing" unless ContentType.exists?(id: document.content_type_id)
|
|
44
|
+
|
|
45
|
+
validator = ContentValidator.new(self).validate
|
|
46
|
+
raise ContentInvalidError, validator.errors unless validator.valid?
|
|
47
|
+
|
|
48
|
+
snapshot = transaction do
|
|
49
|
+
published = document.revisions.create!(
|
|
50
|
+
locale: locale,
|
|
51
|
+
schema_version: schema_version,
|
|
52
|
+
author: publisher_name.present? ? publisher : author,
|
|
53
|
+
author_name: publisher_name.presence || author_name,
|
|
54
|
+
state: :published,
|
|
55
|
+
published_at: Time.current
|
|
56
|
+
)
|
|
57
|
+
self.class.copy_owned_into(self, published)
|
|
58
|
+
document.update!(published_revision: published)
|
|
59
|
+
# keep_ids protects the source: republishing an old snapshot as a
|
|
60
|
+
# rollback should not destroy the snapshot it rolled back to.
|
|
61
|
+
RevisionPruner.new(document, keep_ids: [ id ]).prune!
|
|
62
|
+
published
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Hooks fire only once the write is durable, even under an outer
|
|
66
|
+
# transaction; the gem requires Rails 7.2+ for this guarantee
|
|
67
|
+
ActiveRecord.after_all_transactions_commit { Hooks.run(:publish, snapshot) }
|
|
68
|
+
snapshot
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Replaces the target draft's owned records with the source's.
|
|
72
|
+
# copy_owned_into alone cannot do this: content values would collide on
|
|
73
|
+
# their per-field uniqueness and relations and component instances would
|
|
74
|
+
# silently duplicate. This is the primitive a revision-history rollback
|
|
75
|
+
# builds on.
|
|
76
|
+
#
|
|
77
|
+
# requires_new opens a savepoint so a failed copy cannot leave the target
|
|
78
|
+
# empty when a caller rescues inside its own transaction. Values for
|
|
79
|
+
# soft-deleted fields are left alone, since the copy cannot restore them
|
|
80
|
+
# and undiscard is expected to recover them.
|
|
81
|
+
def self.restore_owned_into(source, target)
|
|
82
|
+
raise ArgumentError, "cannot restore a revision into itself" if source.id == target.id
|
|
83
|
+
raise ArgumentError, "restore target must be a draft revision" unless target.draft?
|
|
84
|
+
|
|
85
|
+
transaction(requires_new: true) do
|
|
86
|
+
target.content_values.where(field_id: Field.select(:id)).destroy_all
|
|
87
|
+
target.relations.where(field_id: Field.select(:id)).destroy_all
|
|
88
|
+
target.component_instances.where(field_id: Field.select(:id)).destroy_all
|
|
89
|
+
copy_owned_into(source, target)
|
|
90
|
+
end
|
|
91
|
+
target
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Snapshots carry kept-field data only: a soft-deleted field's values may
|
|
95
|
+
# linger on the draft (undiscard recovers them), but copying them would
|
|
96
|
+
# fail the clone's field association, which resolves through Field's
|
|
97
|
+
# kept-only default scope.
|
|
98
|
+
def self.copy_owned_into(source, target)
|
|
99
|
+
source.content_values.where(field_id: Field.select(:id)).find_each do |cv|
|
|
100
|
+
clone = target.content_values.build(field_id: cv.field_id, media_asset_id: cv.media_asset_id)
|
|
101
|
+
ContentValue::VALUE_COLUMNS.each { |col| clone[col] = cv[col] }
|
|
102
|
+
clone.save!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Same shape as the kept-field filter: a trashed target would fail the
|
|
106
|
+
# clone's required belongs_to, so it is dropped rather than raising.
|
|
107
|
+
# For restore_owned_into that drop is permanent: the copied draft loses
|
|
108
|
+
# the link even if the target later leaves the trash. A rollback UI
|
|
109
|
+
# should count these and tell the user, as DraftWriter does.
|
|
110
|
+
source.relations.where(field_id: Field.select(:id), target_document_id: Document.select(:id)).find_each do |relation|
|
|
111
|
+
target.relations.create!(
|
|
112
|
+
field_id: relation.field_id,
|
|
113
|
+
target_document_id: relation.target_document_id,
|
|
114
|
+
position: relation.position
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
source.component_instances.where(field_id: Field.select(:id)).find_each do |instance|
|
|
119
|
+
clone = target.component_instances.create!(
|
|
120
|
+
field_id: instance.field_id,
|
|
121
|
+
component_id: instance.component_id,
|
|
122
|
+
position: instance.position
|
|
123
|
+
)
|
|
124
|
+
copy_owned_into(instance, clone)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Read-only view of one serialized document, as returned by the Ruby content
|
|
3
|
+
# helpers. Field values are reachable as methods (entry.title), via [] for
|
|
4
|
+
# keys that collide with real methods (entry[:slug]), or through #data.
|
|
5
|
+
class Entry
|
|
6
|
+
def initialize(serialized)
|
|
7
|
+
@hash = serialized
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def id
|
|
11
|
+
@hash[:id]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def slug
|
|
15
|
+
@hash[:slug]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def content_type
|
|
19
|
+
@hash[:content_type]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def state
|
|
23
|
+
@hash[:state]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def data
|
|
27
|
+
@hash[:data] || {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
@hash
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def published?
|
|
35
|
+
state.to_s == "published"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def [](key)
|
|
39
|
+
wrap(data[key.to_s])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def key?(key)
|
|
43
|
+
data.key?(key.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def method_missing(name, *args, &block)
|
|
47
|
+
key = name.to_s
|
|
48
|
+
return wrap(data[key]) if args.empty? && block.nil? && data.key?(key)
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def respond_to_missing?(name, include_private = false)
|
|
54
|
+
data.key?(name.to_s) || super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def inspect
|
|
58
|
+
"#<RivetCms::Entry #{content_type}/#{slug} (#{state})>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Populated references become nested entries; everything else (scalars,
|
|
64
|
+
# media hashes, component hashes, shallow refs) passes through untouched.
|
|
65
|
+
def wrap(value)
|
|
66
|
+
case value
|
|
67
|
+
when Hash then value.key?(:data) ? Entry.new(value) : value
|
|
68
|
+
when Array then value.map { |item| wrap(item) }
|
|
69
|
+
else value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Enumerable page of entries with the pagination numbers host views need.
|
|
3
|
+
class EntryCollection
|
|
4
|
+
include Enumerable
|
|
5
|
+
|
|
6
|
+
attr_reader :page, :per_page, :total, :total_pages
|
|
7
|
+
|
|
8
|
+
def initialize(entries, page: 1, per_page: entries.size, total: entries.size, total_pages: 1)
|
|
9
|
+
@entries = entries
|
|
10
|
+
@page = page
|
|
11
|
+
@per_page = per_page
|
|
12
|
+
@total = total
|
|
13
|
+
@total_pages = total_pages
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each(&block)
|
|
17
|
+
@entries.each(&block)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_a
|
|
21
|
+
@entries.dup
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def size
|
|
25
|
+
@entries.size
|
|
26
|
+
end
|
|
27
|
+
alias length size
|
|
28
|
+
|
|
29
|
+
def [](index)
|
|
30
|
+
@entries[index]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def empty?
|
|
34
|
+
@entries.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inspect
|
|
38
|
+
"#<RivetCms::EntryCollection size=#{size} page=#{page}/#{total_pages} total=#{total}>"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|