summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/nodes_controller.rb3
-rw-r--r--app/controllers/page_translations_controller.rb3
-rw-r--r--app/controllers/revisions_controller.rb2
-rw-r--r--app/models/node.rb29
-rw-r--r--app/models/node_action.rb132
5 files changed, 157 insertions, 12 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index ed5f8ef..b66ea49 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -50,6 +50,9 @@ class NodesController < ApplicationController
50 @node.draft.save! 50 @node.draft.save!
51 51
52 @node.update!(default_template_name: config[:template]) if config && config[:template] 52 @node.update!(default_template_name: config[:template]) if config && config[:template]
53 NodeAction.record!(:node => @node, :page => @node.draft, :user => current_user,
54 :action => "create",
55 :title => params[:title], :path => @node.unique_name)
53 56
54 redirect_to(edit_node_path(@node)) 57 redirect_to(edit_node_path(@node))
55 else 58 else
diff --git a/app/controllers/page_translations_controller.rb b/app/controllers/page_translations_controller.rb
index be4f488..38a7c4f 100644
--- a/app/controllers/page_translations_controller.rb
+++ b/app/controllers/page_translations_controller.rb
@@ -63,9 +63,6 @@ class PageTranslationsController < ApplicationController
63 draft.translations.where(:locale => @locale).delete_all 63 draft.translations.where(:locale => @locale).delete_all
64 draft.reload 64 draft.reload
65 65
66 NodeAction.record!(:node => @node, :page => draft, :user => current_user,
67 :action => "translation_destroy", :locale => @locale)
68
69 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent." 66 flash[:notice] = "#{@locale.upcase} translation removed from the draft. Publish to make this permanent."
70 redirect_to node_path(@node) 67 redirect_to node_path(@node)
71 rescue LockedByAnotherUser => e 68 rescue LockedByAnotherUser => e
diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb
index c5932a4..c1237a9 100644
--- a/app/controllers/revisions_controller.rb
+++ b/app/controllers/revisions_controller.rb
@@ -49,7 +49,7 @@ class RevisionsController < ApplicationController
49 49
50 def restore 50 def restore
51 page = Page.find(params[:id]) 51 page = Page.find(params[:id])
52 page.node.restore_revision! page.revision 52 page.node.restore_revision! page.revision, current_user
53 flash[:notice] = "Revision #{page.revision} restored" 53 flash[:notice] = "Revision #{page.revision} restored"
54 redirect_to node_path(page.node) 54 redirect_to node_path(page.node)
55 end 55 end
diff --git a/app/models/node.rb b/app/models/node.rb
index b41df06..6ca607d 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -194,17 +194,19 @@ class Node < ApplicationRecord
194 # Return nil if nothing to publish and no staged changes 194 # Return nil if nothing to publish and no staged changes
195 return nil unless self.draft || staged_slug || staged_parent_id 195 return nil unless self.draft || staged_slug || staged_parent_id
196 196
197 path_before = self.unique_name
198
197 ActiveRecord::Base.transaction do 199 ActiveRecord::Base.transaction do
198 if self.draft 200 if self.draft
199 previous_title = self.head ? Globalize.with_locale(I18n.default_locale) { self.head.title } : nil 201 outgoing_head = self.head
200 self.head = self.draft 202 self.head = self.draft
201 self.head.published_at ||= Time.now 203 self.head.published_at ||= Time.now
202 self.head.save! 204 self.head.save!
203 self.draft = nil 205 self.draft = nil
204 206
205 new_title = Globalize.with_locale(I18n.default_locale) { self.head.title }
206 NodeAction.record!(:node => self, :page => self.head, :user => current_user, 207 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
207 :action => "publish", :from => previous_title, :to => new_title) 208 :action => "publish", :via => "draft",
209 **NodeAction.head_diff(outgoing_head, self.head))
208 end 210 end
209 211
210 if staged_slug && (staged_slug != slug) 212 if staged_slug && (staged_slug != slug)
@@ -231,17 +233,28 @@ class Node < ApplicationRecord
231 self.reload 233 self.reload
232 self.update_unique_name 234 self.update_unique_name
233 self.send(:update_unique_names_of_children) 235 self.send(:update_unique_names_of_children)
236 if self.unique_name != path_before
237 NodeAction.record!(:node => self, :user => current_user, :action => "move",
238 :path => { "from" => path_before, "to" => self.unique_name })
239 end
234 self.unlock! 240 self.unlock!
235 self 241 self
236 end 242 end
237 end 243 end
238 244
239 def restore_revision! revision 245 def restore_revision! revision, current_user = nil
240 if page = self.pages.find_by_revision(revision) 246 page = self.pages.find_by_revision(revision)
247 return nil unless page
248
249 ActiveRecord::Base.transaction do
250 outgoing_head = self.head
241 self.head = page 251 self.head = page
242 self.save 252 self.save!
243 else 253
244 nil 254 NodeAction.record!(:node => self, :page => page, :user => current_user,
255 :action => "publish", :via => "revision",
256 **NodeAction.head_diff(outgoing_head, page))
257 self
245 end 258 end
246 end 259 end
247 260
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