diff options
Diffstat (limited to 'app/models')
| -rw-r--r-- | app/models/concerns/rrule_humanizer.rb | 49 | ||||
| -rw-r--r-- | app/models/node.rb | 93 | ||||
| -rw-r--r-- | app/models/node_action.rb | 170 |
3 files changed, 273 insertions, 39 deletions
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb index 8231de8..07141c1 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb | |||
| @@ -15,8 +15,8 @@ module RruleHumanizer | |||
| 15 | }.freeze | 15 | }.freeze |
| 16 | 16 | ||
| 17 | ORDINAL_NAMES = { | 17 | ORDINAL_NAMES = { |
| 18 | de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, | 18 | de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", 5=>"fünften", -1=>"letzten", -2=>"vorletzten" }, |
| 19 | en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } | 19 | en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -1=>"last", -2=>"second-to-last" } |
| 20 | }.freeze | 20 | }.freeze |
| 21 | 21 | ||
| 22 | MONTH_NAMES = { | 22 | MONTH_NAMES = { |
| @@ -35,7 +35,8 @@ module RruleHumanizer | |||
| 35 | ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] | 35 | ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] |
| 36 | months = MONTH_NAMES[loc] || MONTH_NAMES[:en] | 36 | months = MONTH_NAMES[loc] || MONTH_NAMES[:en] |
| 37 | 37 | ||
| 38 | days = byday&.split(",")&.map do |d| | 38 | byday_values = byday&.split(",") |
| 39 | days = byday_values&.map do |d| | ||
| 39 | if d =~ /^(-?\d+)([A-Z]{2})$/ | 40 | if d =~ /^(-?\d+)([A-Z]{2})$/ |
| 40 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" | 41 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" |
| 41 | else | 42 | else |
| @@ -43,6 +44,32 @@ module RruleHumanizer | |||
| 43 | end | 44 | end |
| 44 | end | 45 | end |
| 45 | 46 | ||
| 47 | excluded_monthly_ordinal = nil | ||
| 48 | excluded_monthly_weekday = nil | ||
| 49 | selected_monthly_ordinals = nil | ||
| 50 | selected_monthly_weekday = nil | ||
| 51 | if freq == "MONTHLY" && byday_values.present? | ||
| 52 | ordinal_days = byday_values.map { |d| d.match(/^([1-5])([A-Z]{2})$/) } | ||
| 53 | if ordinal_days.all? | ||
| 54 | positions = ordinal_days.map { |match| match[1].to_i }.uniq.sort | ||
| 55 | weekdays_in_rule = ordinal_days.map { |match| match[2] }.uniq | ||
| 56 | if weekdays_in_rule.size == 1 | ||
| 57 | selected_monthly_ordinals = positions | ||
| 58 | selected_monthly_weekday = weekdays_in_rule.first | ||
| 59 | missing_positions = (1..5).to_a - positions | ||
| 60 | if positions.size == 4 && missing_positions.size == 1 | ||
| 61 | excluded_monthly_ordinal = missing_positions.first | ||
| 62 | excluded_monthly_weekday = selected_monthly_weekday | ||
| 63 | end | ||
| 64 | end | ||
| 65 | end | ||
| 66 | end | ||
| 67 | |||
| 68 | join_ordinals = lambda do |ordinal_values, conjunction| | ||
| 69 | names = ordinal_values.map { |ordinal| ordinals[ordinal] } | ||
| 70 | names.size > 1 ? "#{names[0..-2].join(', ')} #{conjunction} #{names.last}" : names.first | ||
| 71 | end | ||
| 72 | |||
| 46 | base = | 73 | base = |
| 47 | case loc | 74 | case loc |
| 48 | when :de | 75 | when :de |
| @@ -59,14 +86,26 @@ module RruleHumanizer | |||
| 59 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" | 86 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" |
| 60 | end | 87 | end |
| 61 | when "MONTHLY" | 88 | when "MONTHLY" |
| 62 | days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" | 89 | if excluded_monthly_ordinal |
| 90 | "Jeden #{weekdays[excluded_monthly_weekday]} im Monat, außer dem #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" | ||
| 91 | elsif selected_monthly_ordinals | ||
| 92 | "Jeden #{join_ordinals.call(selected_monthly_ordinals, 'und')} #{weekdays[selected_monthly_weekday]} im Monat" | ||
| 93 | else | ||
| 94 | days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" | ||
| 95 | end | ||
| 63 | end | 96 | end |
| 64 | else | 97 | else |
| 65 | case freq | 98 | case freq |
| 66 | when "WEEKLY" | 99 | when "WEEKLY" |
| 67 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") | 100 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") |
| 68 | when "MONTHLY" | 101 | when "MONTHLY" |
| 69 | days ? "Every #{days.join(' and ')} of the month" : "Monthly" | 102 | if excluded_monthly_ordinal |
| 103 | "Every #{weekdays[excluded_monthly_weekday]} of the month, except the #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" | ||
| 104 | elsif selected_monthly_ordinals | ||
| 105 | "Every #{join_ordinals.call(selected_monthly_ordinals, 'and')} #{weekdays[selected_monthly_weekday]} of the month" | ||
| 106 | else | ||
| 107 | days ? "Every #{days.join(' and ')} of the month" : "Monthly" | ||
| 108 | end | ||
| 70 | end | 109 | end |
| 71 | end | 110 | end |
| 72 | return nil unless base | 111 | return nil unless base |
diff --git a/app/models/node.rb b/app/models/node.rb index 311c5c0..717f5b4 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -170,10 +170,12 @@ class Node < ApplicationRecord | |||
| 170 | self.autosave.destroy | 170 | self.autosave.destroy |
| 171 | self.autosave_id = nil | 171 | self.autosave_id = nil |
| 172 | self.save! | 172 | self.save! |
| 173 | NodeAction.record!(:node => self, :user => current_user, :action => "discard_autosave") | ||
| 173 | elsif self.draft && self.head | 174 | elsif self.draft && self.head |
| 174 | self.draft.destroy | 175 | self.draft.destroy |
| 175 | self.draft_id = nil | 176 | self.draft_id = nil |
| 176 | self.save! | 177 | self.save! |
| 178 | NodeAction.record!(:node => self, :user => current_user, :action => "destroy_draft") | ||
| 177 | end | 179 | end |
| 178 | 180 | ||
| 179 | self.unlock! unless self.draft | 181 | self.unlock! unless self.draft |
| @@ -188,51 +190,71 @@ class Node < ApplicationRecord | |||
| 188 | end | 190 | end |
| 189 | end | 191 | end |
| 190 | 192 | ||
| 191 | def publish_draft! | 193 | def publish_draft! current_user = nil |
| 192 | # Return nil if nothing to publish and no staged changes | 194 | # Return nil if nothing to publish and no staged changes |
| 193 | return nil unless self.draft || staged_slug || staged_parent_id | 195 | return nil unless self.draft || staged_slug || staged_parent_id |
| 194 | 196 | ||
| 195 | if self.draft | 197 | path_before = self.unique_name |
| 196 | self.head = self.draft | ||
| 197 | self.head.published_at ||= Time.now | ||
| 198 | self.head.save! | ||
| 199 | self.draft = nil | ||
| 200 | end | ||
| 201 | 198 | ||
| 202 | if staged_slug && (staged_slug != slug) | 199 | ActiveRecord::Base.transaction do |
| 203 | self.slug = staged_slug | 200 | if self.draft |
| 204 | self.staged_slug = nil | 201 | outgoing_head = self.head |
| 205 | end | 202 | self.head = self.draft |
| 203 | self.head.published_at ||= Time.now | ||
| 204 | self.head.save! | ||
| 205 | self.draft = nil | ||
| 206 | 206 | ||
| 207 | if staged_parent_id && (staged_parent_id != parent_id) | 207 | NodeAction.record!(:node => self, :page => self.head, :user => current_user, |
| 208 | new_parent = Node.find(staged_parent_id) | 208 | :action => "publish", :via => "draft", |
| 209 | **NodeAction.head_diff(outgoing_head, self.head)) | ||
| 210 | end | ||
| 209 | 211 | ||
| 210 | if new_parent == self || self.descendants.include?(new_parent) | 212 | if staged_slug && (staged_slug != slug) |
| 211 | raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" | 213 | self.slug = staged_slug |
| 214 | self.staged_slug = nil | ||
| 212 | end | 215 | end |
| 213 | 216 | ||
| 214 | self.staged_parent_id = nil | 217 | if staged_parent_id && (staged_parent_id != parent_id) |
| 215 | self.save! | 218 | new_parent = Node.find(staged_parent_id) |
| 216 | self.move_to_child_of(new_parent) | 219 | |
| 217 | else | 220 | if new_parent == self || self.descendants.include?(new_parent) |
| 218 | unless self.save | 221 | raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" |
| 219 | raise ActiveRecord::RecordInvalid.new(self) | 222 | end |
| 223 | |||
| 224 | self.staged_parent_id = nil | ||
| 225 | self.save! | ||
| 226 | self.move_to_child_of(new_parent) | ||
| 227 | else | ||
| 228 | unless self.save | ||
| 229 | raise ActiveRecord::RecordInvalid.new(self) | ||
| 230 | end | ||
| 220 | end | 231 | end |
| 221 | end | ||
| 222 | 232 | ||
| 223 | self.reload | 233 | self.reload |
| 224 | self.update_unique_name | 234 | self.update_unique_name |
| 225 | self.send(:update_unique_names_of_children) | 235 | self.send(:update_unique_names_of_children) |
| 226 | self.unlock! | 236 | if self.unique_name != path_before |
| 227 | self | 237 | NodeAction.record!(:node => self, :user => current_user, :action => "move", |
| 238 | :path => { "from" => path_before, "to" => self.unique_name }) | ||
| 239 | end | ||
| 240 | self.unlock! | ||
| 241 | self | ||
| 242 | end | ||
| 228 | end | 243 | end |
| 229 | 244 | ||
| 230 | def restore_revision! revision | 245 | def restore_revision! revision, current_user = nil |
| 231 | 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 | ||
| 232 | self.head = page | 251 | self.head = page |
| 233 | self.save | 252 | self.save! |
| 234 | else | 253 | |
| 235 | 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 | ||
| 236 | end | 258 | end |
| 237 | end | 259 | end |
| 238 | 260 | ||
| @@ -246,9 +268,12 @@ class Node < ApplicationRecord | |||
| 246 | parent.nil? ? [slug] : parent.path_to_root.push(slug) | 268 | parent.nil? ? [slug] : parent.path_to_root.push(slug) |
| 247 | end | 269 | end |
| 248 | 270 | ||
| 271 | def computed_unique_name | ||
| 272 | path = path_to_root[1..-1].join("/") # excluding root | ||
| 273 | end | ||
| 274 | |||
| 249 | def current_unique_name | 275 | def current_unique_name |
| 250 | path = path_to_root[1..-1] # excluding root | 276 | self.unique_name = computed_unique_name |
| 251 | self.unique_name = path.join("/") | ||
| 252 | end | 277 | end |
| 253 | 278 | ||
| 254 | def update_unique_name | 279 | def update_unique_name |
diff --git a/app/models/node_action.rb b/app/models/node_action.rb new file mode 100644 index 0000000..792ae1e --- /dev/null +++ b/app/models/node_action.rb | |||
| @@ -0,0 +1,170 @@ | |||
| 1 | class NodeAction < ApplicationRecord | ||
| 2 | belongs_to :node, optional: true | ||
| 3 | belongs_to :page, optional: true | ||
| 4 | belongs_to :user, optional: true | ||
| 5 | |||
| 6 | validates :action, presence: true | ||
| 7 | validates :occurred_at, presence: true | ||
| 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 | |||
| 85 | def self.record!(node:, action:, user: nil, page: nil, locale: nil, | ||
| 86 | occurred_at: nil, inferred_from: nil, **extra) | ||
| 87 | create!( | ||
| 88 | :node => node, | ||
| 89 | :page => page, | ||
| 90 | :user => user, | ||
| 91 | :action => action, | ||
| 92 | :locale => locale, | ||
| 93 | :occurred_at => occurred_at || Time.now, | ||
| 94 | :inferred_from => inferred_from, | ||
| 95 | :metadata => { | ||
| 96 | "username" => user&.login, | ||
| 97 | "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { | ||
| 98 | node&.head&.title || node&.draft&.title | ||
| 99 | }, | ||
| 100 | }.merge(extra.stringify_keys) | ||
| 101 | ) | ||
| 102 | end | ||
| 103 | |||
| 104 | # Computes the publish-entry diff between an outgoing head and the | ||
| 105 | # page replacing it, in the exact metadata shape the contract above | ||
| 106 | # specifies. Pure function of its two arguments -- shared verbatim by | ||
| 107 | # publish_draft!, restore_revision!, and the backfill task. Reads | ||
| 108 | # translation rows directly, never locale-dependent accessors, so a | ||
| 109 | # fallback value is never mistaken for real content. Returns | ||
| 110 | # symbol-keyed top level for splatting into record!; nested keys are | ||
| 111 | # strings and jsonb serialization stringifies the rest at write time. | ||
| 112 | def self.head_diff old_page, new_page | ||
| 113 | default = I18n.default_locale | ||
| 114 | |||
| 115 | title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title } | ||
| 116 | diff = { :title => { "from" => title_of.call(old_page), | ||
| 117 | "to" => title_of.call(new_page) } } | ||
| 118 | unless old_page | ||
| 119 | diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user | ||
| 120 | return diff | ||
| 121 | end | ||
| 122 | |||
| 123 | old_author, new_author = old_page.user&.login, new_page.user&.login | ||
| 124 | diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author | ||
| 125 | |||
| 126 | old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort | ||
| 127 | diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags | ||
| 128 | |||
| 129 | diff[:template_changed] = true if old_page.template_name != new_page.template_name | ||
| 130 | diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id) | ||
| 131 | |||
| 132 | old_t = old_page.translations.find_by(:locale => default) | ||
| 133 | new_t = new_page.translations.find_by(:locale => default) | ||
| 134 | diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract | ||
| 135 | diff[:body_changed] = true if old_t&.body != new_t&.body | ||
| 136 | |||
| 137 | locales = (old_page.translated_locales | new_page.translated_locales) - [default] | ||
| 138 | translation_diff = {} | ||
| 139 | |||
| 140 | locales.sort_by(&:to_s).each do |locale| | ||
| 141 | o = old_page.translations.find_by(:locale => locale) | ||
| 142 | n = new_page.translations.find_by(:locale => locale) | ||
| 143 | |||
| 144 | if o.nil? | ||
| 145 | translation_diff[locale.to_s] = { "status" => "added", | ||
| 146 | "title" => { "from" => nil, "to" => n.title } } | ||
| 147 | elsif n.nil? | ||
| 148 | translation_diff[locale.to_s] = { "status" => "removed", | ||
| 149 | "title" => { "from" => o.title, "to" => nil } } | ||
| 150 | elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body | ||
| 151 | entry = { "status" => "changed" } | ||
| 152 | entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title | ||
| 153 | entry["abstract_changed"] = true if o.abstract != n.abstract | ||
| 154 | entry["body_changed"] = true if o.body != n.body | ||
| 155 | translation_diff[locale.to_s] = entry | ||
| 156 | end | ||
| 157 | end | ||
| 158 | |||
| 159 | diff[:translation_diff] = translation_diff if translation_diff.any? | ||
| 160 | diff | ||
| 161 | end | ||
| 162 | |||
| 163 | def actor_name | ||
| 164 | metadata["username"] || "unknown" | ||
| 165 | end | ||
| 166 | |||
| 167 | def subject_name | ||
| 168 | metadata["human_readable_node_name"] || node&.unique_name || "deleted node" | ||
| 169 | end | ||
| 170 | end | ||
