diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-09 03:33:55 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-09 03:33:55 +0200 |
| commit | 20c735b0c5a2db9b8984848253ad99332d6211a8 (patch) | |
| tree | a4ae71c53c403c1a7740db624e43fd3163e37ab7 /app | |
| parent | 5203b80f47786b8adc169e80d89cd69159c44b62 (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')
| -rw-r--r-- | app/controllers/assets_controller.rb | 7 | ||||
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 1 | ||||
| -rw-r--r-- | app/models/node.rb | 107 | ||||
| -rw-r--r-- | app/views/layouts/_flash.html.erb | 5 |
4 files changed, 62 insertions, 58 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb index f8a7d1e4..d86370f4 100644 --- a/app/controllers/assets_controller.rb +++ b/app/controllers/assets_controller.rb | |||
| @@ -111,8 +111,10 @@ class AssetsController < ApplicationController | |||
| 111 | flash[:notice] = | 111 | flash[:notice] = |
| 112 | if result[:attached].zero? | 112 | if result[:attached].zero? |
| 113 | t("flash.assets.already_attached", :title => node.title) | 113 | t("flash.assets.already_attached", :title => node.title) |
| 114 | elsif result[:draft_created] | ||
| 115 | t("flash.assets.attached_new_draft", :title => node.title) | ||
| 114 | else | 116 | else |
| 115 | t("flash.assets.attached", :title => node.title) | 117 | t("flash.assets.attached_to_draft", :title => node.title) |
| 116 | end | 118 | end |
| 117 | case result[:headline] | 119 | case result[:headline] |
| 118 | when :set then flash[:notice] += " " + t("flash.common.now_headline") | 120 | when :set then flash[:notice] += " " + t("flash.common.now_headline") |
| @@ -122,5 +124,8 @@ class AssetsController < ApplicationController | |||
| 122 | rescue LockedByAnotherUser | 124 | rescue LockedByAnotherUser |
| 123 | flash[:locked_by] = node.lock_owner&.login | 125 | flash[:locked_by] = node.lock_owner&.login |
| 124 | flash[:locked_node_path] = node_path(node) | 126 | flash[:locked_node_path] = node_path(node) |
| 127 | rescue ActiveRecord::RecordInvalid => e | ||
| 128 | flash[:error] = e.record.errors.full_messages.to_sentence | ||
| 129 | flash[:resolve_node_path] = node_path(node) | ||
| 125 | end | 130 | end |
| 126 | end | 131 | end |
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb index f21fee61..960e28b3 100644 --- a/app/helpers/node_actions_helper.rb +++ b/app/helpers/node_actions_helper.rb | |||
| @@ -14,7 +14,6 @@ module NodeActionsHelper | |||
| 14 | "discard_autosave" => "eraser", | 14 | "discard_autosave" => "eraser", |
| 15 | "destroy_draft" => "eraser", | 15 | "destroy_draft" => "eraser", |
| 16 | "asset_create" => "upload", | 16 | "asset_create" => "upload", |
| 17 | "asset_attach" => "paperclip", | ||
| 18 | "asset_destroy" => "file-x", | 17 | "asset_destroy" => "file-x", |
| 19 | "otp_enroll" => "shield-lock", | 18 | "otp_enroll" => "shield-lock", |
| 20 | "otp_disable" => "shield-off", | 19 | "otp_disable" => "shield-off", |
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 |
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb index b72c377f..2cc7a5ab 100644 --- a/app/views/layouts/_flash.html.erb +++ b/app/views/layouts/_flash.html.erb | |||
| @@ -13,6 +13,11 @@ | |||
| 13 | <%= link_to t(".locked_link"), flash[:locked_node_path] %><%= t(".locked_suffix") %> | 13 | <%= link_to t(".locked_link"), flash[:locked_node_path] %><%= t(".locked_suffix") %> |
| 14 | </span> | 14 | </span> |
| 15 | <% end %> | 15 | <% end %> |
| 16 | <% if flash[:resolve_node_path] %> | ||
| 17 | <span class="warning"> | ||
| 18 | <%= link_to t(".resolve_link"), flash[:resolve_node_path] %> | ||
| 19 | </span> | ||
| 20 | <% end %> | ||
| 16 | <% if flash[:headline_kept_path] %> | 21 | <% if flash[:headline_kept_path] %> |
| 17 | <span class="warning"><%= t(".headline_prefix") %> | 22 | <span class="warning"><%= t(".headline_prefix") %> |
| 18 | <%= link_to t(".headline_link"), flash[:headline_kept_path] %><%= t(".headline_suffix") %> | 23 | <%= link_to t(".headline_link"), flash[:headline_kept_path] %><%= t(".headline_suffix") %> |
