diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/controllers/nodes_controller.rb | 3 | ||||
| -rw-r--r-- | app/controllers/page_translations_controller.rb | 3 | ||||
| -rw-r--r-- | app/controllers/revisions_controller.rb | 2 | ||||
| -rw-r--r-- | app/models/node.rb | 29 | ||||
| -rw-r--r-- | app/models/node_action.rb | 132 |
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 |
