summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/node.rb81
-rw-r--r--app/models/node_action.rb109
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)