summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-09 03:33:55 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-09 03:33:55 +0200
commit20c735b0c5a2db9b8984848253ad99332d6211a8 (patch)
treea4ae71c53c403c1a7740db624e43fd3163e37ab7 /app/models
parent5203b80f47786b8adc169e80d89cd69159c44b62 (diff)
Attach assets to the draft instead of to every lifecycle row
Node#attach_asset! writes to the node's draft alone, creating one from the head when none is pending. It refuses when another user holds the lock, and when an autosave already exists The asset_attach verb goes with it. create_new_draft names the editor on the draft it creates and the publish entry's asset delta reports the attachment.
Diffstat (limited to 'app/models')
-rw-r--r--app/models/node.rb107
1 files changed, 51 insertions, 56 deletions
diff --git a/app/models/node.rb b/app/models/node.rb
index f5b76b45..a401ed69 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -6,8 +6,8 @@ class Node < ApplicationRecord
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 7
8 # Entries where this node is the primary subject (fast-path column). 8 # Entries where this node is the primary subject (fast-path column).
9 # For a *complete* history -- including subtree trash/destroy entries 9 # For a *complete* history, including subtree trash/destroy entries
10 # recorded at an ancestor -- use participated_actions instead. 10 # recorded at an ancestor, use participated_actions instead.
11 has_many :node_actions, :dependent => :nullify 11 has_many :node_actions, :dependent => :nullify
12 has_many :action_participations, :class_name => "ActionParticipant", :as => :subject 12 has_many :action_participations, :class_name => "ActionParticipant", :as => :subject
13 has_many :participated_actions, :through => :action_participations, :source => :node_action 13 has_many :participated_actions, :through => :action_participations, :source => :node_action
@@ -15,7 +15,7 @@ class Node < ApplicationRecord
15 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true 15 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
16 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true 16 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
17 # Autosave pages carry no node_id, so has_many :pages does not cover 17 # Autosave pages carry no node_id, so has_many :pages does not cover
18 # them -- this dependent: :destroy is their only cleanup on node destroy. 18 # them. This dependent: :destroy is their only cleanup on node destroy.
19 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true 19 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
20 20
21 has_many :events, :dependent => :destroy 21 has_many :events, :dependent => :destroy
@@ -44,8 +44,8 @@ class Node < ApplicationRecord
44 :if => :default_template_name_changed? 44 :if => :default_template_name_changed?
45 45
46 # Everything outside the Trash subtree, the Trash node included. 46 # Everything outside the Trash subtree, the Trash node included.
47 # Relies on unique_name being authoritative for tree position -- 47 # Relies on unique_name being authoritative for tree position.
48 # the same trust public routing places in it. 48 # The same trust public routing places in it.
49 scope :not_in_trash, -> { 49 scope :not_in_trash, -> {
50 where.not(:unique_name => CccConventions::TRASH_SLUG) 50 where.not(:unique_name => CccConventions::TRASH_SLUG)
51 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%") 51 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%")
@@ -93,7 +93,7 @@ class Node < ApplicationRecord
93 # Instance Methods 93 # Instance Methods
94 94
95 # Acquires (or reaffirms) the editing lock without creating a draft or 95 # Acquires (or reaffirms) the editing lock without creating a draft or
96 # an autosave -- both are now deferred until there is real content to 96 # an autosave, both are now deferred until there is real content to
97 # hold. 97 # hold.
98 def lock_for_editing! current_user 98 def lock_for_editing! current_user
99 if self.lock_owner.nil? || self.lock_owner == current_user 99 if self.lock_owner.nil? || self.lock_owner == current_user
@@ -133,11 +133,11 @@ class Node < ApplicationRecord
133 133
134 # Promotes the current autosave into the draft (creating the draft if 134 # Promotes the current autosave into the draft (creating the draft if
135 # none exists yet) and destroys the autosave afterward. This is what 135 # none exists yet) and destroys the autosave afterward. This is what
136 # the explicit "Save" action does; it never creates a new revision -- 136 # the explicit "Save" action does; it never creates a new revision,
137 # same as any other in-place draft edit. The new draft is created via 137 # same as any other in-place draft edit. The new draft is created via
138 # self.pages.create! rather than by repointing the autosave's own 138 # self.pages.create! rather than by repointing the autosave's own
139 # node_id, because acts_as_list assigns the revision number at create 139 # node_id, because acts_as_list assigns the revision number at create
140 # time, scoped to node_id -- a page created with node_id nil and 140 # time, scoped to node_id, a page created with node_id nil and
141 # reassigned afterward would carry a wrong or missing revision number. 141 # reassigned afterward would carry a wrong or missing revision number.
142 def save_draft! current_user 142 def save_draft! current_user
143 assert_locked_by! current_user 143 assert_locked_by! current_user
@@ -178,7 +178,7 @@ class Node < ApplicationRecord
178 178
179 # Which layer-pairs are meaningful to compare right now, given this 179 # Which layer-pairs are meaningful to compare right now, given this
180 # node's actual state. Head vs autosave only shows up when no draft 180 # node's actual state. Head vs autosave only shows up when no draft
181 # sits between them -- with a draft present, autosave is compared 181 # sits between them, with a draft present, autosave is compared
182 # against the draft, never past it straight to head. 182 # against the draft, never past it straight to head.
183 def available_layer_pairs 183 def available_layer_pairs
184 pairs = [] 184 pairs = []
@@ -201,8 +201,8 @@ class Node < ApplicationRecord
201 self.draft.reload 201 self.draft.reload
202 end 202 end
203 203
204 # Discards exactly the topmost non-empty layer -- autosave if present, 204 # Discards exactly the topmost non-empty layer: autosave if present,
205 # else draft -- and reveals whatever's beneath it. Releases the lock 205 # else draft, and reveals whatever's beneath it. Releases the lock
206 # only once nothing is left to protect (no draft survives); leaves it 206 # only once nothing is left to protect (no draft survives); leaves it
207 # alone whenever a draft remains, since #edit still has real content 207 # alone whenever a draft remains, since #edit still has real content
208 # open. 208 # open.
@@ -313,7 +313,7 @@ class Node < ApplicationRecord
313 # Moves this node and its subtree into the Trash. Demotes every head 313 # Moves this node and its subtree into the Trash. Demotes every head
314 # in the subtree first (aggregators and search operate on heads 314 # in the subtree first (aggregators and search operate on heads
315 # regardless of tree position); where a node has no draft, the former 315 # regardless of tree position); where a node has no draft, the former
316 # head becomes its draft so content stays editable and restorable -- 316 # head becomes its draft so content stays editable and restorable,
317 # otherwise the former head remains a plain revision. One log entry, 317 # otherwise the former head remains a plain revision. One log entry,
318 # at the root, carrying the leaving-public-view snapshot. 318 # at the root, carrying the leaving-public-view snapshot.
319 def trash! current_user = nil 319 def trash! current_user = nil
@@ -475,28 +475,17 @@ class Node < ApplicationRecord
475 end 475 end
476 end 476 end
477 477
478 # Attaches an asset to every current lifecycle row -- head, draft and 478 # Attaches an asset to the node's draft, creating one from the head if
479 # autosave -- that does not already carry it. Attachments are page- 479 # none exists.
480 # scoped content (RelatedAsset belongs_to :page; drafts and autosaves
481 # are wholesale clones), so attaching to a single layer is how an
482 # attachment gets silently lost when another layer replaces it at
483 # publish or save. This is the out-of-band counterpart to the
484 # in-editor attach UI; it refuses when someone else holds the editing
485 # lock. Attaching to a head row changes the public page immediately,
486 # by design -- same reasoning as formalizing an already-existing
487 # editorial link.
488 # 480 #
489 # headline is a node-level decision: the flag is set on the newly 481 # Refuses when an autosave exists: it predates this attach and would
490 # created joins only when no current row has a headline yet and the 482 # overwrite the draft at save_draft!, silently dropping the join.
491 # asset is eligible; otherwise the asset is attached plain and the 483 # Refuses when someone else holds the lock, so an editor's work is not
492 # result says why, so the caller can point the editor at the star in 484 # altered under them.
493 # the editor instead.
494 # 485 #
495 # Returns { :attached => n, :already => n, 486 # Returns { :attached => n, :already => n, :draft_created => bool,
496 # :headline => nil | :set | :kept_existing | :not_eligible } 487 # :headline => nil | :set | :kept_existing | :not_eligible }
497 def attach_asset! asset, user:, headline: false 488 def attach_asset! asset, user:, headline: false
498 guard_live_change!(user)
499
500 if in_trash? || trash_node? 489 if in_trash? || trash_node?
501 errors.add(:base, :attach_in_trash) 490 errors.add(:base, :attach_in_trash)
502 raise ActiveRecord::RecordInvalid.new(self) 491 raise ActiveRecord::RecordInvalid.new(self)
@@ -510,37 +499,43 @@ class Node < ApplicationRecord
510 ) 499 )
511 end 500 end
512 501
513 rows = [head, draft, autosave].compact 502 if autosave
514 to_attach = rows.reject { |row| row.related_assets.exists?(:asset_id => asset.id) } 503 errors.add(:base, :attach_with_autosave)
504 raise ActiveRecord::RecordInvalid.new(self)
505 end
515 506
516 headline_state = 507 source = draft || head
517 if headline && to_attach.any? 508 if source && source.related_assets.exists?(:asset_id => asset.id)
518 if !(asset.image? || asset.pdf?) 509 return { :attached => 0,
519 :not_eligible 510 :already => 1,
520 elsif rows.any? { |row| row.headline_asset.present? } 511 :draft_created => false,
521 :kept_existing 512 :headline => nil }
522 else 513 end
523 :set 514
524 end 515 draft_created = draft.nil?
525 end
526 516
527 ActiveRecord::Base.transaction do 517 ActiveRecord::Base.transaction do
528 to_attach.each do |row| 518 create_new_draft(user) if draft_created
529 row.related_assets.create!(:asset => asset, :headline => headline_state == :set) 519 page = draft.reload
530 end
531 520
532 if to_attach.any? 521 headline_state =
533 metadata = { :asset_name => asset.name, 522 if headline
534 :path => asset.upload.url.sub(/\?\d+$/, "") } 523 if !(asset.image? || asset.pdf?)
535 metadata[:headline] = true if headline_state == :set 524 :not_eligible
536 NodeAction.record!(:node => self, :participants => [self, asset], 525 elsif page.headline_asset.present?
537 :user => user, :action => "asset_attach", **metadata) 526 :kept_existing
538 end 527 else
539 end 528 :set
529 end
530 end
531
532 page.related_assets.create!(:asset => asset, :headline => headline_state == :set)
540 533
541 { :attached => to_attach.size, 534 { :attached => 1,
542 :already => rows.size - to_attach.size, 535 :already => 0,
543 :headline => headline_state } 536 :draft_created => draft_created,
537 :headline => headline_state }
538 end
544 end 539 end
545 540
546 def title 541 def title