summaryrefslogtreecommitdiff
path: root/app/models/node_action.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/node_action.rb')
-rw-r--r--app/models/node_action.rb132
1 files changed, 132 insertions, 0 deletions
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index f32b5b7..39935c7 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -6,6 +6,82 @@ class NodeAction < ApplicationRecord
6 validates :action, presence: true 6 validates :action, presence: true
7 validates :occurred_at, presence: true 7 validates :occurred_at, presence: true
8 8
9 # == Metadata contract ==
10 #
11 # metadata is written once at creation and never updated. It is the
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.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y} --
15 # never _old/_new suffixes. Optional pairs are written only when the
16 # two sides differ; required keys are always present. All titles and
17 # names are read pinned to I18n.default_locale unless inside a
18 # locale-keyed block.
19 #
20 # Baseline, every entry (written here, not by call sites):
21 # "username" -- actor's login at write time
22 # "human_readable_node_name" -- node title, default locale
23 #
24 # "create":
25 # "title" -- initial title, default locale
26 # "path" -- unique path at creation time. Historical value only:
27 # later renames/moves do NOT update old entries. Never
28 # use as a join key; node_id is the join key while the
29 # node lives.
30 #
31 # "publish" (any promotion of a page to head; the diff against the
32 # outgoing head is computed BEFORE head is re-pointed, over the
33 # union of both pages' locales, by the shared diff function also
34 # used by the backfill):
35 # "via" -- "draft" (ordinary publish) | "revision" (rollback
36 # via restore_revision!). Always written; absent
37 # means a pre-contract entry.
38 # "title" -- pair, always present. "from" is null on a first
39 # publish (no outgoing head).
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"
46 # -- booleans for the default locale, only when true
47 # "translation_diff" -- only when any non-default locale differs:
48 # { "<locale>" => {
49 # "status" -- "added" | "removed" | "changed"
50 # "title" -- pair; "from" null when added,
51 # "to" null when removed
52 # "abstract_changed", "body_changed" -- booleans, only when
53 # true, only for status "changed"
54 # } }
55 #
56 # "move" (reparenting and/or unique-path change; one entry at the
57 # subtree root, descendants get none -- a descendant's own zoomed
58 # view will not show path history):
59 # "path" -- pair
60 #
61 # Reserved for the Trash feature, final shape decided there:
62 # "demote" (via "trash" | "depublish"; carries the leaving-public-
63 # view snapshot: head presence, final published_at), "trash",
64 # "restore_from_trash", "destroy" (final path only; publish-state
65 # snapshot lives on the entry that removed it from public view).
66 # Whether trash logs one entry or a trash+demote pair is decided
67 # with that feature.
68 #
69 # Backfilled entries mirror this vocabulary exactly. Their diff
70 # content is computed, not guessed (consecutive revisions still
71 # exist); only actor (from page.editor) and occurred_at are
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 #
76 # The "locale" column is currently written by no verb (it belonged
77 # to the retired translation_destroy) and is retained for backfill
78 # or future draft-scoped verbs.
79 #
80 # This log records; it does not undo. Reversibility stays in
81 # restore_revision! and the revisions system. No IP, session, or
82 # user agent, ever. Success only -- rejected attempts are not
83 # logged.
84
9 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra) 85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra)
10 create!( 86 create!(
11 :node => node, 87 :node => node,
@@ -23,6 +99,62 @@ class NodeAction < ApplicationRecord
23 ) 99 )
24 end 100 end
25 101
102 # Computes the publish-entry diff between an outgoing head and the
103 # page replacing it, in the exact metadata shape the contract above
104 # specifies. Pure function of its two arguments -- shared verbatim by
105 # publish_draft!, restore_revision!, and the backfill task. Reads
106 # translation rows directly, never locale-dependent accessors, so a
107 # fallback value is never mistaken for real content. Returns
108 # symbol-keyed top level for splatting into record!; nested keys are
109 # strings and jsonb serialization stringifies the rest at write time.
110 def self.head_diff old_page, new_page
111 default = I18n.default_locale
112
113 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
114 diff = { :title => { "from" => title_of.call(old_page),
115 "to" => title_of.call(new_page) } }
116 return diff unless old_page
117
118 old_author, new_author = old_page.user&.login, new_page.user&.login
119 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
120
121 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
122 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
123
124 diff[:template_changed] = true if old_page.template_name != new_page.template_name
125 diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)
126
127 old_t = old_page.translations.find_by(:locale => default)
128 new_t = new_page.translations.find_by(:locale => default)
129 diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
130 diff[:body_changed] = true if old_t&.body != new_t&.body
131
132 locales = (old_page.translated_locales | new_page.translated_locales) - [default]
133 translation_diff = {}
134
135 locales.sort_by(&:to_s).each do |locale|
136 o = old_page.translations.find_by(:locale => locale)
137 n = new_page.translations.find_by(:locale => locale)
138
139 if o.nil?
140 translation_diff[locale.to_s] = { "status" => "added",
141 "title" => { "from" => nil, "to" => n.title } }
142 elsif n.nil?
143 translation_diff[locale.to_s] = { "status" => "removed",
144 "title" => { "from" => o.title, "to" => nil } }
145 elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
146 entry = { "status" => "changed" }
147 entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
148 entry["abstract_changed"] = true if o.abstract != n.abstract
149 entry["body_changed"] = true if o.body != n.body
150 translation_diff[locale.to_s] = entry
151 end
152 end
153
154 diff[:translation_diff] = translation_diff if translation_diff.any?
155 diff
156 end
157
26 def actor_name 158 def actor_name
27 metadata["username"] || "unknown" 159 metadata["username"] || "unknown"
28 end 160 end