1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
|
class NodeAction < ApplicationRecord
belongs_to :node, optional: true
belongs_to :page, optional: true
belongs_to :user, optional: true
has_many :action_participants, -> { order(:id) }, :dependent => :destroy
validates :action, presence: true
validates :occurred_at, presence: true
# == Metadata contract ==
#
# 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}.
# 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, flat string (NOT a pair)
# "path" -- unique path at creation, flat string. Historical
# value only; never a join key.
#
# "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
# "external_url" -- pair, when a chapter's homepage changed
# "assets" -- {"added" => [asset names], "removed" => [asset names]},
# keys only when any; a delta, not a pair. The event IS
# the delta, full sets would bloat every entry. Changed
# assets are participants of the entry. Replaces the
# legacy "assets_changed" boolean, which witnessed
# pre-contract entries still carry and the renderer keeps
# understanding. Assets destroyed since leave no trace in
# regenerated deltas, their joins died with them.
# "assets_reordered" -- boolean, set unchanged but gallery order not
# "template_changed", "abstract_changed", "body_changed"
# -- 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:
# { "<locale>" => {
# "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" (reparent and/or path change; one entry at the subtree
# root, descendants get none):
# "path" -- pair
#
# "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
#
# "restore_from_trash" (reparent back to a living node; returns as
# drafts, republication is a separate witnessed act):
# "path" -- pair
#
# "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)
# "destroyed_descendants" -- integer, only when positive; one entry
# at the root, per the subtree rule.
#
# "asset_create" (witnessed upload; participants: the asset alone):
# "asset_name", "content_type", "path" -- flat strings
#
# "asset_destroy" (witnessed asset deletion; the files were publicly
# reachable, so an entry is always written. Destruction is refused
# while the asset is attached to any current row, so no node is ever
# affected: node column nil, the asset the sole participant):
# "asset_name" -- flat string
# "content_type" -- flat string
# "path" -- public original path, flat string
#
# "otp_enroll" / "otp_disable" / "otp_reset" (second-factor
# lifecycle) and "user_create" / "user_deactivate" /
# "user_reactivate" (account lifecycle). Node column nil;
# participants: the affected User, a User-typed subject.
# otp_disable is self-service; otp_reset and all three account
# verbs are an administrator acting on someone else, so actor and
# participant differ:
# "redaktion_grant" / "redaktion_revoke" / "admin_grant" /
# "admin_revoke" -- role changes. Both pairs come from
# User#grant_* / #revoke_*, so the roles form reaches them through
# update_roles! rather than writing the attribute: witnessing is the
# reason the form does not touch roles directly. Alumni changes record
# as user_deactivate / user_reactivate, not as a role verb.
# "target_login" -- flat string, the affected account's login
#
# "event_create" / "event_update" / "event_destroy" (calendar
# entries; participants: the Event and, when it has one, its Node,
# which also fills the node column. Deliberately not gated --
# events reach chapter pages and widgets, never the feeds, and
# protection here follows emission, not position).
#
# Events carry no revisions, so the log is their only history:
# every entry holds a full snapshot of the state it produced, and
# walking an event's entries reconstructs it. Times are ISO 8601
# and the rrule is raw, never humanised -- entries are read in both
# locales and event_schedule_text resolves that at render time.
# "event_title" -- flat string; the title, else the node's
# unique_name, else "#<id>"
# "start_time", "end_time" -- ISO 8601, when set
# "allday" -- boolean
# "rrule" -- raw RRULE, when set
# "location", "url" -- flat strings, when set
# "event_tags" -- array of names, sorted, always. Named apart
# from the node verbs' "tags", which is a pair,
# so one renderer cannot mistake the other.
# "path" -- the node's unique_name, when it has a node
#
# On "event_update" only, and only when something changed -- an
# update that changes nothing records no entry at all:
# "changes" -- {field => pair}, node_id resolved to paths
# under "node_path", tag_list under "tags",
# times as ISO 8601
# "description_changed" -- boolean; prose, flagged not quoted,
# as abstract_changed and body_changed are
#
# 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: nil, participants: [], action:, user: nil, page: nil,
locale: nil, occurred_at: nil, inferred_from: nil, **extra)
participants = participants.presence || [node].compact
raise ArgumentError, "NodeAction.record! needs at least one participant" if participants.empty?
primary_node = node || (participants.first if participants.first.is_a?(Node))
create!(
:node => primary_node,
:page => page,
:user => user,
:action => action,
:locale => locale,
:occurred_at => occurred_at || Time.now,
:inferred_from => inferred_from,
:metadata => {
"username" => user&.login,
"human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
primary_node&.head&.title || primary_node&.draft&.title
},
}.merge(extra.stringify_keys)
).tap do |na|
participants.each { |subject| na.action_participants.create!(:subject => subject) }
end
end
# Computes the publish-entry diff between an outgoing head and the
# page replacing it, in the exact metadata shape the contract above
# specifies. Pure function of its two arguments -- shared verbatim by
# publish_draft!, restore_revision!, and the backfill task. Reads
# translation rows directly, never locale-dependent accessors, so a
# fallback value is never mistaken for real content. Returns
# symbol-keyed top level for splatting into record!; nested keys are
# strings and jsonb serialization stringifies the rest at write time.
def self.head_diff old_page, new_page
default = I18n.default_locale
title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
diff = { :title => { "from" => title_of.call(old_page),
"to" => title_of.call(new_page) } }
unless old_page
diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
diff[:external_url] = { "from" => nil, "to" => new_page.external_url } if new_page.external_url.present?
return diff
end
old_author, new_author = old_page.user&.login, new_page.user&.login
diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
old_url, new_url = old_page.external_url, new_page.external_url
diff[:external_url] = { "from" => old_url, "to" => new_url } if old_url != new_url
old_redirect, new_redirect = old_page.redirect, new_page.redirect
diff[:redirect] = { "from" => old_redirect, "to" => new_redirect } if old_redirect != new_redirect
old_target, new_target = old_page.redirect_node&.unique_name, new_page.redirect_node&.unique_name
diff[:redirect_target] = { "from" => old_target, "to" => new_target } if old_target != new_target
old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
diff[:template_changed] = true if old_page.template_name != new_page.template_name
old_assets, new_assets = old_page.assets.to_a, new_page.assets.to_a
added, removed = new_assets - old_assets, old_assets - new_assets
if added.any? || removed.any?
assets = {}
assets["added"] = added.map { |a| a.name.presence || a.upload_file_name } if added.any?
assets["removed"] = removed.map { |a| a.name.presence || a.upload_file_name } if removed.any?
diff[:assets] = assets
elsif old_assets.map(&:id) != new_assets.map(&:id)
diff[:assets_reordered] = true
end
old_t = old_page.translations.find_by(:locale => default)
new_t = new_page.translations.find_by(:locale => default)
diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
diff[:body_changed] = true if old_t&.body != new_t&.body
locales = (old_page.translated_locales | new_page.translated_locales) - [default]
translation_diff = {}
locales.sort_by(&:to_s).each do |locale|
o = old_page.translations.find_by(:locale => locale)
n = new_page.translations.find_by(:locale => locale)
if o.nil?
translation_diff[locale.to_s] = { "status" => "added",
"title" => { "from" => nil, "to" => n.title } }
elsif n.nil?
translation_diff[locale.to_s] = { "status" => "removed",
"title" => { "from" => o.title, "to" => nil } }
elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
entry = { "status" => "changed" }
entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
entry["abstract_changed"] = true if o.abstract != n.abstract
entry["body_changed"] = true if o.body != n.body
translation_diff[locale.to_s] = entry
end
end
diff[:translation_diff] = translation_diff if translation_diff.any?
diff
end
# The asset records added or removed between an outgoing head and its
# replacement -- the participant complement to head_diff's "assets"
# names. Empty on first publish, mirroring head_diff, which records
# no asset delta when everything is new.
def self.changed_assets old_page, new_page
return [] unless old_page
old_a, new_a = old_page.assets.to_a, new_page.assets.to_a
(new_a - old_a) | (old_a - new_a)
end
def actor_name
metadata["username"] || "unknown"
end
def subject_name
metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
end
def diff_link_params
prev = NodeAction.where(node_id: node_id, action: "publish")
.where("id < ?", id)
.order(id: :desc).first
return nil unless prev&.page && page
{ start_revision: prev.page.revision, end_revision: page.revision }
end
end
|