diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/models/node.rb | 81 | ||||
| -rw-r--r-- | app/models/node_action.rb | 109 |
2 files changed, 134 insertions, 56 deletions
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 | |||
| 283 | end | 283 | end |
| 284 | end | 284 | end |
| 285 | 285 | ||
| 286 | |||
| 287 | # Moves this node and its subtree into the Trash. Demotes every head | ||
| 288 | # in the subtree first (aggregators and search operate on heads | ||
| 289 | # regardless of tree position); where a node has no draft, the former | ||
| 290 | # head becomes its draft so content stays editable and restorable -- | ||
| 291 | # otherwise the former head remains a plain revision. One log entry, | ||
| 292 | # at the root, carrying the leaving-public-view snapshot. | ||
| 293 | def trash! current_user = nil | ||
| 294 | return nil if in_trash? | ||
| 295 | raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node? | ||
| 296 | |||
| 297 | ActiveRecord::Base.transaction do | ||
| 298 | path_before = unique_name | ||
| 299 | was_published = head_id.present? | ||
| 300 | final_published_at = head&.published_at | ||
| 301 | |||
| 302 | demoted = 0 | ||
| 303 | ([self] + descendants.to_a).each do |node| | ||
| 304 | next unless node.head_id | ||
| 305 | former = node.head | ||
| 306 | node.head = nil | ||
| 307 | node.draft_id = former.id if node.draft_id.nil? | ||
| 308 | node.save! | ||
| 309 | demoted += 1 | ||
| 310 | end | ||
| 311 | |||
| 312 | self.reload | ||
| 313 | move_to_child_of(Node.trash) | ||
| 314 | self.reload | ||
| 315 | update_unique_name | ||
| 316 | send(:update_unique_names_of_children) | ||
| 317 | unlock! | ||
| 318 | |||
| 319 | metadata = { :path => { "from" => path_before, "to" => unique_name } } | ||
| 320 | metadata[:was_published] = true if was_published | ||
| 321 | metadata[:final_published_at] = final_published_at.iso8601 if final_published_at | ||
| 322 | metadata[:demoted_heads] = demoted if demoted > 0 | ||
| 323 | |||
| 324 | NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata) | ||
| 325 | self | ||
| 326 | end | ||
| 327 | end | ||
| 328 | |||
| 329 | # Returns the node to the living tree under a chosen parent. The | ||
| 330 | # subtree comes back exactly as it sits in the Trash: all drafts, | ||
| 331 | # nothing published. Republication is a separate, witnessed act | ||
| 332 | # per node. | ||
| 333 | def restore_from_trash! new_parent, current_user = nil | ||
| 334 | return nil unless in_trash? | ||
| 335 | |||
| 336 | if new_parent.nil? || new_parent == self || descendants.include?(new_parent) || | ||
| 337 | new_parent.trash_node? || new_parent.in_trash? | ||
| 338 | raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node" | ||
| 339 | end | ||
| 340 | |||
| 341 | ActiveRecord::Base.transaction do | ||
| 342 | path_before = unique_name | ||
| 343 | move_to_child_of(new_parent) | ||
| 344 | self.reload | ||
| 345 | update_unique_name | ||
| 346 | send(:update_unique_names_of_children) | ||
| 347 | |||
| 348 | NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash", | ||
| 349 | :path => { "from" => path_before, "to" => unique_name }) | ||
| 350 | self | ||
| 351 | end | ||
| 352 | end | ||
| 353 | |||
| 354 | # Final deletion. Only from inside the Trash, never with children. | ||
| 355 | # The log entry is written first, in the same transaction; node_id | ||
| 356 | # nullifies when the row dies, and the metadata carries what remains. | ||
| 357 | def destroy_from_trash! current_user = nil | ||
| 358 | raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash? | ||
| 359 | |||
| 360 | ActiveRecord::Base.transaction do | ||
| 361 | NodeAction.record!(:node => self, :user => current_user, :action => "destroy", | ||
| 362 | :path => unique_name) | ||
| 363 | destroy! | ||
| 364 | end | ||
| 365 | end | ||
| 366 | |||
| 286 | # returns an array with all parts of a unique_name rather than a string | 367 | # returns an array with all parts of a unique_name rather than a string |
| 287 | def unique_path | 368 | def unique_path |
| 288 | unique_name.to_s.split("/") | 369 | 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 | |||
| 8 | 8 | ||
| 9 | # == Metadata contract == | 9 | # == Metadata contract == |
| 10 | # | 10 | # |
| 11 | # metadata is written once at creation and never updated. It is the | 11 | # metadata is written once at creation, never updated. It is the |
| 12 | # single place anything that must survive deletion of the referenced | 12 | # single place anything that must survive deletion of the referenced |
| 13 | # rows lives; node_id/page_id/user_id are lookup and ordering only. | 13 | # rows lives; node_id/page_id/user_id are lookup and ordering only. |
| 14 | # All keys are strings. Pairs are always {"from" => x, "to" => y} -- | 14 | # All keys are strings. Pairs are always {"from" => x, "to" => y}. |
| 15 | # never _old/_new suffixes. Optional pairs are written only when the | 15 | # Optional pairs only when the sides differ; booleans only when |
| 16 | # two sides differ; required keys are always present. All titles and | 16 | # true; required keys always present. Titles and names are read |
| 17 | # names are read pinned to I18n.default_locale unless inside a | 17 | # pinned to I18n.default_locale unless inside a locale-keyed block. |
| 18 | # locale-keyed block. | ||
| 19 | # | 18 | # |
| 20 | # Baseline, every entry (written here, not by call sites): | 19 | # Baseline, every entry (written here, not by call sites): |
| 21 | # "username" -- actor's login at write time | 20 | # "username" -- actor's login at write time |
| 22 | # "human_readable_node_name" -- node title, default locale | 21 | # "human_readable_node_name" -- node title, default locale |
| 23 | # | 22 | # |
| 24 | # "create": | 23 | # "create": |
| 25 | # "title" -- initial title, default locale | 24 | # "title" -- initial title, flat string (NOT a pair) |
| 26 | # "path" -- unique path at creation time. Historical value only: | 25 | # "path" -- unique path at creation, flat string. Historical |
| 27 | # later renames/moves do NOT update old entries. Never | 26 | # value only; never a join key. |
| 28 | # use as a join key; node_id is the join key while the | ||
| 29 | # node lives. | ||
| 30 | # | 27 | # |
| 31 | # "publish" (any promotion of a page to head; the diff against the | 28 | # "publish" (any promotion to head; diff computed BEFORE head is |
| 32 | # outgoing head is computed BEFORE head is re-pointed, over the | 29 | # re-pointed, over the union of both pages' locales, by head_diff -- |
| 33 | # union of both pages' locales, by the shared diff function also | 30 | # shared with the backfill): |
| 34 | # used by the backfill): | 31 | # "via" -- "draft" | "revision" (rollback). Always written; |
| 35 | # "via" -- "draft" (ordinary publish) | "revision" (rollback | 32 | # absent means a pre-contract entry. |
| 36 | # via restore_revision!). Always written; absent | 33 | # "title" -- pair, always; "from" null on first publish |
| 37 | # means a pre-contract entry. | 34 | # "author" -- pair, when the byline changed (incl. first publish) |
| 38 | # "title" -- pair, always present. "from" is null on a first | 35 | # "tags" -- pair of arrays, when changed |
| 39 | # publish (no outgoing head). | 36 | # "assets_changed", "template_changed", |
| 40 | # "author" -- pair, only when the byline (page.user) changed | ||
| 41 | # "tags" -- pair of arrays, only when changed | ||
| 42 | # "assets_changed", "template_changed" | ||
| 43 | # -- booleans, only when true; page_id links to the | ||
| 44 | # revision for the real diff (never stored here) | ||
| 45 | # "abstract_changed", "body_changed" | 37 | # "abstract_changed", "body_changed" |
| 46 | # -- booleans for the default locale, only when true | 38 | # -- the last two for the default locale; page_id links |
| 47 | # "translation_diff" -- only when any non-default locale differs: | 39 | # to the revision for the real diff (never stored) |
| 40 | # "translation_diff" -- only when a non-default locale differs: | ||
| 48 | # { "<locale>" => { | 41 | # { "<locale>" => { |
| 49 | # "status" -- "added" | "removed" | "changed" | 42 | # "status" -- "added" | "removed" | "changed" |
| 50 | # "title" -- pair; "from" null when added, | 43 | # "title" -- pair, only when it differs; "from" null when |
| 51 | # "to" null when removed | 44 | # added, "to" null when removed |
| 52 | # "abstract_changed", "body_changed" -- booleans, only when | 45 | # "abstract_changed", "body_changed" -- status "changed" only |
| 53 | # true, only for status "changed" | ||
| 54 | # } } | 46 | # } } |
| 55 | # | 47 | # |
| 56 | # "move" (reparenting and/or unique-path change; one entry at the | 48 | # "move" (reparent and/or path change; one entry at the subtree |
| 57 | # subtree root, descendants get none -- a descendant's own zoomed | 49 | # root, descendants get none): |
| 58 | # view will not show path history): | 50 | # "path" -- pair |
| 59 | # "path" -- pair | ||
| 60 | # | 51 | # |
| 61 | # Reserved for the Trash feature, final shape decided there: | 52 | # "trash" (subtree into the Trash; every head in the subtree is |
| 62 | # "demote" (via "trash" | "depublish"; carries the leaving-public- | 53 | # demoted first; one entry at the root; snapshots the |
| 63 | # view snapshot: head presence, final published_at), "trash", | 54 | # leaving-public-view state, since Trash holds no heads and destroy |
| 64 | # "restore_from_trash", "destroy" (final path only; publish-state | 55 | # can no longer know): |
| 65 | # snapshot lives on the entry that removed it from public view). | 56 | # "path" -- pair; "from" doubles as the restore hint |
| 66 | # Whether trash logs one entry or a trash+demote pair is decided | 57 | # "was_published" -- boolean |
| 67 | # with that feature. | 58 | # "demoted_heads" -- integer count, only when positive |
| 59 | # "final_published_at" -- ISO8601 string, only when present | ||
| 68 | # | 60 | # |
| 69 | # Backfilled entries mirror this vocabulary exactly. Their diff | 61 | # "restore_from_trash" (reparent back to a living node; returns as |
| 70 | # content is computed, not guessed (consecutive revisions still | 62 | # drafts, republication is a separate witnessed act): |
| 71 | # exist); only actor (from page.editor) and occurred_at are | 63 | # "path" -- pair |
| 72 | # inferred, and inferred_from names the heuristic per entry | ||
| 73 | # ("from_page_revision", "from_published_at_heuristic"). | ||
| 74 | # inferred_from null = witnessed live. | ||
| 75 | # | 64 | # |
| 76 | # The "locale" column is currently written by no verb (it belonged | 65 | # "destroy" (only from inside the Trash, never with children; the |
| 77 | # to the retired translation_destroy) and is retained for backfill | 66 | # entry is written in the same transaction before the row dies): |
| 78 | # or future draft-scoped verbs. | 67 | # "path" -- final path, flat string (create-symmetric) |
| 79 | # | 68 | # |
| 80 | # This log records; it does not undo. Reversibility stays in | 69 | # Reserved: "demote" (via "trash" | "depublish") for an explicit |
| 81 | # restore_revision! and the revisions system. No IP, session, or | 70 | # depublish workflow, if ever built. |
| 82 | # user agent, ever. Success only -- rejected attempts are not | 71 | # |
| 83 | # logged. | 72 | # Backfilled entries mirror this vocabulary; diff content is |
| 73 | # computed, only actor (page.editor) and occurred_at are inferred, | ||
| 74 | # inferred_from names the heuristic ("from_node_created_at", | ||
| 75 | # "from_page_revision"). Null = witnessed live. | ||
| 76 | # | ||
| 77 | # The "locale" column is written by no verb; retained. | ||
| 78 | # | ||
| 79 | # This log records; it does not undo. No IP, session, or user | ||
| 80 | # agent, ever. Success only. | ||
| 84 | 81 | ||
| 85 | def self.record!(node:, action:, user: nil, page: nil, locale: nil, | 82 | def self.record!(node:, action:, user: nil, page: nil, locale: nil, |
| 86 | occurred_at: nil, inferred_from: nil, **extra) | 83 | occurred_at: nil, inferred_from: nil, **extra) |
