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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/COPYING +674 -0
  4. data/COPYING.LESSER +165 -0
  5. data/README.md +173 -10
  6. data/app/assets/builds/rivet_cms.css +2 -2
  7. data/app/assets/builds/rivet_cms.js +395 -9505
  8. data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
  9. data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
  10. data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
  11. data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
  12. data/app/controllers/rivet_cms/application_controller.rb +220 -2
  13. data/app/controllers/rivet_cms/auth_controller.rb +28 -0
  14. data/app/controllers/rivet_cms/components_controller.rb +93 -2
  15. data/app/controllers/rivet_cms/content_controller.rb +105 -0
  16. data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
  17. data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
  18. data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
  19. data/app/controllers/rivet_cms/documents_controller.rb +204 -0
  20. data/app/controllers/rivet_cms/fields_controller.rb +77 -74
  21. data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
  22. data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
  23. data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
  24. data/app/controllers/rivet_cms/setup_controller.rb +60 -0
  25. data/app/controllers/rivet_cms/trash_controller.rb +68 -0
  26. data/app/controllers/rivet_cms/users_controller.rb +117 -0
  27. data/app/helpers/rivet_cms/application_helper.rb +13 -3
  28. data/app/helpers/rivet_cms/components_helper.rb +4 -0
  29. data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
  30. data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
  31. data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
  32. data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
  33. data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
  34. data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
  35. data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
  36. data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
  37. data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
  38. data/app/models/rivet_cms/api_token.rb +64 -0
  39. data/app/models/rivet_cms/category.rb +20 -0
  40. data/app/models/rivet_cms/component.rb +10 -0
  41. data/app/models/rivet_cms/component_instance.rb +27 -0
  42. data/app/models/rivet_cms/content_type.rb +36 -23
  43. data/app/models/rivet_cms/content_value.rb +19 -0
  44. data/app/models/rivet_cms/current.rb +6 -0
  45. data/app/models/rivet_cms/document.rb +71 -0
  46. data/app/models/rivet_cms/document_revision.rb +128 -0
  47. data/app/models/rivet_cms/entry.rb +73 -0
  48. data/app/models/rivet_cms/entry_collection.rb +41 -0
  49. data/app/models/rivet_cms/field.rb +203 -61
  50. data/app/models/rivet_cms/media_asset.rb +109 -0
  51. data/app/models/rivet_cms/organization.rb +46 -0
  52. data/app/models/rivet_cms/relation.rb +23 -0
  53. data/app/models/rivet_cms/user.rb +61 -0
  54. data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
  55. data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
  56. data/app/services/rivet_cms/content_query.rb +158 -0
  57. data/app/services/rivet_cms/content_validator.rb +150 -0
  58. data/app/services/rivet_cms/draft_writer.rb +74 -0
  59. data/app/services/rivet_cms/open_api_generator.rb +173 -221
  60. data/app/services/rivet_cms/revision_pruner.rb +64 -0
  61. data/app/services/rivet_cms/webhooks.rb +61 -0
  62. data/app/views/layouts/rivet_cms/application.html.erb +35 -42
  63. data/config/routes.rb +68 -45
  64. data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
  65. data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
  66. data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
  69. data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
  70. data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
  71. data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
  72. data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
  73. data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
  74. data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
  75. data/db/seeds/README.md +70 -0
  76. data/db/seeds/templates/blog.rb +39 -0
  77. data/db/seeds/templates/events.rb +12 -0
  78. data/db/seeds/templates/faq.rb +9 -0
  79. data/db/seeds/templates/pages.rb +35 -0
  80. data/db/seeds/templates/site_settings.rb +22 -0
  81. data/db/seeds/templates/team.rb +11 -0
  82. data/db/seeds/templates/testimonials.rb +11 -0
  83. data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
  84. data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
  85. data/lib/rivet_cms/access_check.rb +12 -0
  86. data/lib/rivet_cms/audit.rb +43 -0
  87. data/lib/rivet_cms/engine.rb +28 -22
  88. data/lib/rivet_cms/hooks.rb +106 -0
  89. data/lib/rivet_cms/navigation.rb +111 -0
  90. data/lib/rivet_cms/safe_pattern.rb +40 -0
  91. data/lib/rivet_cms/seeds.rb +264 -0
  92. data/lib/rivet_cms/version.rb +1 -1
  93. data/lib/rivet_cms.rb +289 -32
  94. data/lib/tasks/rivet_cms_tasks.rake +18 -4
  95. metadata +138 -98
  96. data/MIT-LICENSE +0 -20
  97. data/app/assets/builds/rivet_cms.js.map +0 -7
  98. data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
  99. data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
  100. data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
  101. data/app/helpers/rivet_cms/flash_helper.rb +0 -37
  102. data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
  103. data/app/javascript/controllers/content_type_form_controller.js +0 -53
  104. data/app/javascript/controllers/field_layout_controller.js +0 -709
  105. data/app/javascript/rivet_cms.js +0 -29
  106. data/app/models/rivet_cms/content.rb +0 -4
  107. data/app/models/rivet_cms/field_values/base.rb +0 -11
  108. data/app/models/rivet_cms/field_values/boolean.rb +0 -4
  109. data/app/models/rivet_cms/field_values/integer.rb +0 -4
  110. data/app/models/rivet_cms/field_values/string.rb +0 -4
  111. data/app/models/rivet_cms/field_values/text.rb +0 -4
  112. data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
  113. data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
  114. data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
  115. data/app/views/rivet_cms/content_types/index.html.erb +0 -151
  116. data/app/views/rivet_cms/content_types/new.html.erb +0 -19
  117. data/app/views/rivet_cms/content_types/show.html.erb +0 -147
  118. data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
  119. data/app/views/rivet_cms/fields/_form.html.erb +0 -111
  120. data/app/views/rivet_cms/fields/edit.html.erb +0 -25
  121. data/app/views/rivet_cms/fields/index.html.erb +0 -126
  122. data/app/views/rivet_cms/fields/new.html.erb +0 -25
  123. data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
  124. data/config/i18n-tasks.yml +0 -178
  125. data/config/locales/en.yml +0 -14
  126. data/db/migrate/20250317194359_create_core_tables.rb +0 -90
  127. 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
- has_prefix_id :content_type, minimum_length: RivetCms.configuration.prefixed_ids_length, salt: RivetCms.configuration.prefixed_ids_salt, alphabet: RivetCms.configuration.prefixed_ids_alphabet
3
+ include Sluggable
4
4
 
5
- has_many :contents, dependent: :destroy
6
- has_many :fields, dependent: :destroy
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
- validates :slug, presence: true
10
- validates :slug, format: { with: /\A[a-z0-9\-]+\z/, message: "can only contain lowercase letters, numbers, and hyphens" }
11
-
12
- # Default to collection type (is_single = false)
13
- after_initialize :set_default_type, if: :new_record?
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
- after_commit :invalidate_api_docs_cache
25
+ scope :singles, -> { where(single: true) }
26
+ scope :collections, -> { where(single: false) }
16
27
 
17
28
  def collection?
18
- !is_single
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 set_default_type
33
- self.is_single = false if self.is_single.nil?
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
- def invalidate_api_docs_cache
37
- Rails.cache.delete("rivet_cms_api_docs")
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,6 @@
1
+ module RivetCms
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :organization
4
+ attribute :user
5
+ end
6
+ 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