From 2748dc23be86eeb1e4f5e155b8f2191245bb5f5c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 17 Jul 2026 22:48:36 +0200 Subject: Add trash!, restore_from_trash!, and destroy_from_trash! with log entries Also update the node action contract to include the trash related verbs. --- app/models/node.rb | 81 ++++++++++++++++++++++++++++++++++ app/models/node_action.rb | 109 ++++++++++++++++++++++------------------------ 2 files changed, 134 insertions(+), 56 deletions(-) (limited to 'app') diff --git a/app/models/node.rb b/app/models/node.rb index a2e9981..4dae287 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -283,6 +283,87 @@ class Node < ApplicationRecord end end + + # Moves this node and its subtree into the Trash. Demotes every head + # in the subtree first (aggregators and search operate on heads + # regardless of tree position); where a node has no draft, the former + # head becomes its draft so content stays editable and restorable -- + # otherwise the former head remains a plain revision. One log entry, + # at the root, carrying the leaving-public-view snapshot. + def trash! current_user = nil + return nil if in_trash? + raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node? + + ActiveRecord::Base.transaction do + path_before = unique_name + was_published = head_id.present? + final_published_at = head&.published_at + + demoted = 0 + ([self] + descendants.to_a).each do |node| + next unless node.head_id + former = node.head + node.head = nil + node.draft_id = former.id if node.draft_id.nil? + node.save! + demoted += 1 + end + + self.reload + move_to_child_of(Node.trash) + self.reload + update_unique_name + send(:update_unique_names_of_children) + unlock! + + metadata = { :path => { "from" => path_before, "to" => unique_name } } + metadata[:was_published] = true if was_published + metadata[:final_published_at] = final_published_at.iso8601 if final_published_at + metadata[:demoted_heads] = demoted if demoted > 0 + + NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata) + self + end + end + + # Returns the node to the living tree under a chosen parent. The + # subtree comes back exactly as it sits in the Trash: all drafts, + # nothing published. Republication is a separate, witnessed act + # per node. + def restore_from_trash! new_parent, current_user = nil + return nil unless in_trash? + + if new_parent.nil? || new_parent == self || descendants.include?(new_parent) || + new_parent.trash_node? || new_parent.in_trash? + raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node" + end + + ActiveRecord::Base.transaction do + path_before = unique_name + move_to_child_of(new_parent) + self.reload + update_unique_name + send(:update_unique_names_of_children) + + NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash", + :path => { "from" => path_before, "to" => unique_name }) + self + end + end + + # Final deletion. Only from inside the Trash, never with children. + # The log entry is written first, in the same transaction; node_id + # nullifies when the row dies, and the metadata carries what remains. + def destroy_from_trash! current_user = nil + raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash? + + ActiveRecord::Base.transaction do + NodeAction.record!(:node => self, :user => current_user, :action => "destroy", + :path => unique_name) + destroy! + end + end + # returns an array with all parts of a unique_name rather than a string def unique_path unique_name.to_s.split("/") diff --git a/app/models/node_action.rb b/app/models/node_action.rb index 792ae1e..6e09b5b 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb @@ -8,79 +8,76 @@ class NodeAction < ApplicationRecord # == Metadata contract == # - # metadata is written once at creation and never updated. It is the + # metadata is written once at creation, never updated. It is the # single place anything that must survive deletion of the referenced # rows lives; node_id/page_id/user_id are lookup and ordering only. - # All keys are strings. Pairs are always {"from" => x, "to" => y} -- - # never _old/_new suffixes. Optional pairs are written only when the - # two sides differ; required keys are always present. All titles and - # names are read pinned to I18n.default_locale unless inside a - # locale-keyed block. + # All keys are strings. Pairs are always {"from" => x, "to" => y}. + # Optional pairs only when the sides differ; booleans only when + # true; required keys always present. Titles and names are read + # pinned to I18n.default_locale unless inside a locale-keyed block. # # Baseline, every entry (written here, not by call sites): # "username" -- actor's login at write time # "human_readable_node_name" -- node title, default locale # # "create": - # "title" -- initial title, default locale - # "path" -- unique path at creation time. Historical value only: - # later renames/moves do NOT update old entries. Never - # use as a join key; node_id is the join key while the - # node lives. + # "title" -- initial title, flat string (NOT a pair) + # "path" -- unique path at creation, flat string. Historical + # value only; never a join key. # - # "publish" (any promotion of a page to head; the diff against the - # outgoing head is computed BEFORE head is re-pointed, over the - # union of both pages' locales, by the shared diff function also - # used by the backfill): - # "via" -- "draft" (ordinary publish) | "revision" (rollback - # via restore_revision!). Always written; absent - # means a pre-contract entry. - # "title" -- pair, always present. "from" is null on a first - # publish (no outgoing head). - # "author" -- pair, only when the byline (page.user) changed - # "tags" -- pair of arrays, only when changed - # "assets_changed", "template_changed" - # -- booleans, only when true; page_id links to the - # revision for the real diff (never stored here) + # "publish" (any promotion to head; diff computed BEFORE head is + # re-pointed, over the union of both pages' locales, by head_diff -- + # shared with the backfill): + # "via" -- "draft" | "revision" (rollback). Always written; + # absent means a pre-contract entry. + # "title" -- pair, always; "from" null on first publish + # "author" -- pair, when the byline changed (incl. first publish) + # "tags" -- pair of arrays, when changed + # "assets_changed", "template_changed", # "abstract_changed", "body_changed" - # -- booleans for the default locale, only when true - # "translation_diff" -- only when any non-default locale differs: + # -- the last two for the default locale; page_id links + # to the revision for the real diff (never stored) + # "translation_diff" -- only when a non-default locale differs: # { "" => { - # "status" -- "added" | "removed" | "changed" - # "title" -- pair; "from" null when added, - # "to" null when removed - # "abstract_changed", "body_changed" -- booleans, only when - # true, only for status "changed" + # "status" -- "added" | "removed" | "changed" + # "title" -- pair, only when it differs; "from" null when + # added, "to" null when removed + # "abstract_changed", "body_changed" -- status "changed" only # } } # - # "move" (reparenting and/or unique-path change; one entry at the - # subtree root, descendants get none -- a descendant's own zoomed - # view will not show path history): - # "path" -- pair + # "move" (reparent and/or path change; one entry at the subtree + # root, descendants get none): + # "path" -- pair # - # Reserved for the Trash feature, final shape decided there: - # "demote" (via "trash" | "depublish"; carries the leaving-public- - # view snapshot: head presence, final published_at), "trash", - # "restore_from_trash", "destroy" (final path only; publish-state - # snapshot lives on the entry that removed it from public view). - # Whether trash logs one entry or a trash+demote pair is decided - # with that feature. + # "trash" (subtree into the Trash; every head in the subtree is + # demoted first; one entry at the root; snapshots the + # leaving-public-view state, since Trash holds no heads and destroy + # can no longer know): + # "path" -- pair; "from" doubles as the restore hint + # "was_published" -- boolean + # "demoted_heads" -- integer count, only when positive + # "final_published_at" -- ISO8601 string, only when present # - # Backfilled entries mirror this vocabulary exactly. Their diff - # content is computed, not guessed (consecutive revisions still - # exist); only actor (from page.editor) and occurred_at are - # inferred, and inferred_from names the heuristic per entry - # ("from_page_revision", "from_published_at_heuristic"). - # inferred_from null = witnessed live. + # "restore_from_trash" (reparent back to a living node; returns as + # drafts, republication is a separate witnessed act): + # "path" -- pair # - # The "locale" column is currently written by no verb (it belonged - # to the retired translation_destroy) and is retained for backfill - # or future draft-scoped verbs. + # "destroy" (only from inside the Trash, never with children; the + # entry is written in the same transaction before the row dies): + # "path" -- final path, flat string (create-symmetric) # - # This log records; it does not undo. Reversibility stays in - # restore_revision! and the revisions system. No IP, session, or - # user agent, ever. Success only -- rejected attempts are not - # logged. + # Reserved: "demote" (via "trash" | "depublish") for an explicit + # depublish workflow, if ever built. + # + # Backfilled entries mirror this vocabulary; diff content is + # computed, only actor (page.editor) and occurred_at are inferred, + # inferred_from names the heuristic ("from_node_created_at", + # "from_page_revision"). Null = witnessed live. + # + # The "locale" column is written by no verb; retained. + # + # This log records; it does not undo. No IP, session, or user + # agent, ever. Success only. def self.record!(node:, action:, user: nil, page: nil, locale: nil, occurred_at: nil, inferred_from: nil, **extra) -- cgit v1.3