summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/concerns/rrule_humanizer.rb49
-rw-r--r--app/models/node.rb93
-rw-r--r--app/models/node_action.rb170
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 @@
1class 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
170end