summaryrefslogtreecommitdiff
path: root/app/models/node.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/node.rb')
-rw-r--r--app/models/node.rb252
1 files changed, 236 insertions, 16 deletions
diff --git a/app/models/node.rb b/app/models/node.rb
index b41df06..a440c2f 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -4,17 +4,23 @@ class Node < ApplicationRecord
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true 7 has_many :node_actions, :dependent => :nullify
8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true 8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
10 # Autosave pages carry no node_id, so has_many :pages does not cover
11 # them -- this dependent: :destroy is their only cleanup on node destroy.
9 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true 12 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
13
10 has_many :permissions, :dependent => :destroy 14 has_many :permissions, :dependent => :destroy
11 has_many :events, :dependent => :destroy 15 has_many :events, :dependent => :destroy
12 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true 16 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
13 17
14 # Callbacks 18 # Callbacks
15 after_create :initialize_empty_page 19 after_create :initialize_empty_page
16 before_save :check_for_changed_slug 20 before_save :check_for_changed_slug
17 after_save :update_unique_names_of_children 21 after_save :update_unique_names_of_children
22 before_destroy :refuse_destroy_with_children
23 before_destroy :refuse_destroying_trash_node
18 24
19 # Validations 25 # Validations
20 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } 26 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
@@ -22,6 +28,21 @@ class Node < ApplicationRecord
22 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } 28 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
23 validates_presence_of :parent_id, :unless => -> { Node.root.nil? } 29 validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
24 30
31 validate :reserved_slug_stays_reserved
32 validate :no_head_inside_trash
33 validates :default_template_name,
34 :inclusion => { :in => ->(_) { Page.custom_templates } },
35 :allow_blank => true,
36 :if => :default_template_name_changed?
37
38 # Everything outside the Trash subtree, the Trash node included.
39 # Relies on unique_name being authoritative for tree position --
40 # the same trust public routing places in it.
41 scope :not_in_trash, -> {
42 where.not(:unique_name => CccConventions::TRASH_SLUG)
43 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%")
44 }
45
25 # Class methods 46 # Class methods
26 47
27 # Returns a page for a given node. If no revision is supplied, it returns 48 # Returns a page for a given node. If no revision is supplied, it returns
@@ -48,6 +69,19 @@ class Node < ApplicationRecord
48 nil 69 nil
49 end 70 end
50 71
72 # The Trash container node. Lazily self-creating and idempotent, so
73 # every environment acquires it on first touch.
74 # Never call from validations. the positional predicates below
75 # exist for that.
76 def self.trash
77 root.children.find_by(:slug => CccConventions::TRASH_SLUG) ||
78 root.children.create!(:slug => CccConventions::TRASH_SLUG).tap do |node|
79 Globalize.with_locale(I18n.default_locale) do
80 node.draft.update!(:title => "Trash")
81 end
82 end
83 end
84
51 # Instance Methods 85 # Instance Methods
52 86
53 # Acquires (or reaffirms) the editing lock without creating a draft or 87 # Acquires (or reaffirms) the editing lock without creating a draft or
@@ -194,17 +228,23 @@ class Node < ApplicationRecord
194 # Return nil if nothing to publish and no staged changes 228 # Return nil if nothing to publish and no staged changes
195 return nil unless self.draft || staged_slug || staged_parent_id 229 return nil unless self.draft || staged_slug || staged_parent_id
196 230
231 if in_trash? || trash_node?
232 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
233 end
234
235 path_before = self.unique_name
236
197 ActiveRecord::Base.transaction do 237 ActiveRecord::Base.transaction do
198 if self.draft 238 if self.draft
199 previous_title = self.head ? Globalize.with_locale(I18n.default_locale) { self.head.title } : nil 239 outgoing_head = self.head
200 self.head = self.draft 240 self.head = self.draft
201 self.head.published_at ||= Time.now 241 self.head.published_at ||= Time.now
202 self.head.save! 242 self.head.save!
203 self.draft = nil 243 self.draft = nil
204 244
205 new_title = Globalize.with_locale(I18n.default_locale) { self.head.title }
206 NodeAction.record!(:node => self, :page => self.head, :user => current_user, 245 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
207 :action => "publish", :from => previous_title, :to => new_title) 246 :action => "publish", :via => "draft",
247 **NodeAction.head_diff(outgoing_head, self.head))
208 end 248 end
209 249
210 if staged_slug && (staged_slug != slug) 250 if staged_slug && (staged_slug != slug)
@@ -231,20 +271,142 @@ class Node < ApplicationRecord
231 self.reload 271 self.reload
232 self.update_unique_name 272 self.update_unique_name
233 self.send(:update_unique_names_of_children) 273 self.send(:update_unique_names_of_children)
274 if self.unique_name != path_before
275 NodeAction.record!(:node => self, :user => current_user, :action => "move",
276 :path => { "from" => path_before, "to" => self.unique_name })
277 end
234 self.unlock! 278 self.unlock!
235 self 279 self
236 end 280 end
237 end 281 end
238 282
239 def restore_revision! revision 283 def restore_revision! revision, current_user = nil
240 if page = self.pages.find_by_revision(revision) 284 page = self.pages.find_by_revision(revision)
285 return nil unless page
286
287 ActiveRecord::Base.transaction do
288 outgoing_head = self.head
241 self.head = page 289 self.head = page
242 self.save 290 self.save!
243 else 291
244 nil 292 NodeAction.record!(:node => self, :page => page, :user => current_user,
293 :action => "publish", :via => "revision",
294 **NodeAction.head_diff(outgoing_head, page))
295 self
296 end
297 end
298
299
300 # Moves this node and its subtree into the Trash. Demotes every head
301 # in the subtree first (aggregators and search operate on heads
302 # regardless of tree position); where a node has no draft, the former
303 # head becomes its draft so content stays editable and restorable --
304 # otherwise the former head remains a plain revision. One log entry,
305 # at the root, carrying the leaving-public-view snapshot.
306 def trash! current_user = nil
307 return nil if in_trash?
308 raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node?
309
310 ActiveRecord::Base.transaction do
311 path_before = unique_name
312 was_published = head_id.present?
313 final_published_at = head&.published_at
314
315 demoted = 0
316 ([self] + descendants.to_a).each do |node|
317 next unless node.head_id
318 former = node.head
319 node.head = nil
320 node.draft_id = former.id if node.draft_id.nil?
321 node.save!
322 demoted += 1
323 end
324
325 self.reload
326 move_to_child_of(Node.trash)
327 self.reload
328 update_unique_name
329 send(:update_unique_names_of_children)
330 unlock!
331
332 metadata = { :path => { "from" => path_before, "to" => unique_name } }
333 metadata[:was_published] = true if was_published
334 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
335 metadata[:demoted_heads] = demoted if demoted > 0
336
337 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata)
338 self
339 end
340 end
341
342 # Returns the node to the living tree under a chosen parent. The
343 # subtree comes back exactly as it sits in the Trash: all drafts,
344 # nothing published. Republication is a separate, witnessed act
345 # per node.
346 def restore_from_trash! new_parent, current_user = nil
347 return nil unless in_trash?
348
349 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) ||
350 new_parent.trash_node? || new_parent.in_trash?
351 raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node"
352 end
353
354 ActiveRecord::Base.transaction do
355 path_before = unique_name
356 move_to_child_of(new_parent)
357 self.reload
358 update_unique_name
359 send(:update_unique_names_of_children)
360
361 NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash",
362 :path => { "from" => path_before, "to" => unique_name })
363 self
245 end 364 end
246 end 365 end
247 366
367 # Final deletion -- only from inside the Trash. Removes the whole
368 # subtree, deepest first, each node through a real destroy! so every
369 # per-node cascade runs (the categorical difference from the old
370 # delete_all nuke). refuse_destroy_with_children on bare destroy is
371 # untouched
372 # One log entry at the root, per the subtree rule, written before the
373 # rows die.
374 def destroy_from_trash! current_user = nil
375 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash?
376
377 ActiveRecord::Base.transaction do
378 doomed = self_and_descendants_ordered_with_level
379 .sort_by { |_, level| -level }
380 .map(&:first)
381
382 metadata = { :path => unique_name }
383 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
384
385 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata)
386 doomed.each(&:destroy!)
387 end
388 end
389
390 # The most recent trash entry. Its path.from is the restore hint.
391 def last_trash_entry
392 node_actions.where(:action => "trash").order(:occurred_at => :desc, :id => :desc).first
393 end
394
395 # The node's pre-trash parent, if a living node still answers to
396 # that path. Nil when the parent was itself trashed (its unique_name
397 # changed) or deleted; a different node that has since taken over
398 # the path is a legitimate suggestion.
399 def suggested_restore_parent
400 from = last_trash_entry&.metadata&.dig("path", "from")
401 return nil unless from
402
403 parent_path = from.rpartition("/").first
404 return Node.root if parent_path.empty?
405
406 candidate = Node.find_by(:unique_name => parent_path)
407 candidate unless candidate.nil? || candidate.in_trash? || candidate.trash_node?
408 end
409
248 # returns an array with all parts of a unique_name rather than a string 410 # returns an array with all parts of a unique_name rather than a string
249 def unique_path 411 def unique_path
250 unique_name.to_s.split("/") 412 unique_name.to_s.split("/")
@@ -255,9 +417,12 @@ class Node < ApplicationRecord
255 parent.nil? ? [slug] : parent.path_to_root.push(slug) 417 parent.nil? ? [slug] : parent.path_to_root.push(slug)
256 end 418 end
257 419
420 def computed_unique_name
421 path_to_root[1..-1].join("/") # excluding root
422 end
423
258 def current_unique_name 424 def current_unique_name
259 path = path_to_root[1..-1] # excluding root 425 self.unique_name = computed_unique_name
260 self.unique_name = path.join("/")
261 end 426 end
262 427
263 def update_unique_name 428 def update_unique_name
@@ -297,6 +462,20 @@ class Node < ApplicationRecord
297 autosave || draft || head 462 autosave || draft || head
298 end 463 end
299 464
465 def trash_node?
466 parent&.root? && slug == CccConventions::TRASH_SLUG
467 end
468
469 # Inside the Trash subtree. False for the Trash node itself.
470 def in_trash?
471 current = parent
472 while current
473 return true if current.trash_node?
474 current = current.parent
475 end
476 false
477 end
478
300 # Returns immutable node id for all new nodes so that the atom feed entry ids 479 # Returns immutable node id for all new nodes so that the atom feed entry ids
301 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
302 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -337,7 +516,7 @@ class Node < ApplicationRecord
337 end 516 end
338 517
339 def self.drafts_and_autosaves(current_user_id: nil) 518 def self.drafts_and_autosaves(current_user_id: nil)
340 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") 519 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").not_in_trash
341 return scope.order("updated_at DESC") unless current_user_id 520 return scope.order("updated_at DESC") unless current_user_id
342 521
343 scope.order( 522 scope.order(
@@ -345,6 +524,15 @@ class Node < ApplicationRecord
345 ) 524 )
346 end 525 end
347 526
527 # Nodes are never destroyed recursively
528 # Descendants must be removed or reparented individually first.
529 # The Trash feature will be the ordinary path to deletion.
530 def refuse_destroy_with_children
531 return unless children.exists?
532 errors.add(:base, "Cannot destroy a node that still has children")
533 throw :abort
534 end
535
348 def self.recently_changed 536 def self.recently_changed
349 includes(:head).where( 537 includes(:head).where(
350 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", 538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
@@ -401,4 +589,36 @@ class Node < ApplicationRecord
401 end 589 end
402 end 590 end
403 end 591 end
592
593 private
594
595 def reserved_slug_stays_reserved
596 if parent&.root? && !trash_node_already_me?
597 errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG
598 errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG
599 end
600
601 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node?
602 errors.add(:slug, "of the Trash node cannot change") if slug_changed?
603 errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed?
604 errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present?
605 errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present?
606 end
607 end
608
609 def trash_node_already_me?
610 existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG)
611 existing.nil? || existing.id == id
612 end
613
614 def no_head_inside_trash
615 return unless head_id.present?
616 errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node?
617 end
618
619 def refuse_destroying_trash_node
620 return unless trash_node?
621 errors.add(:base, "The Trash node cannot be destroyed")
622 throw :abort
623 end
404end 624end