summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/concerns/file_attachment.rb9
-rw-r--r--app/models/concerns/nested_tree.rb78
-rw-r--r--app/models/concerns/rrule_humanizer.rb130
-rw-r--r--app/models/event.rb30
-rw-r--r--app/models/node.rb514
-rw-r--r--app/models/node_action.rb169
-rw-r--r--app/models/occurrence.rb24
-rw-r--r--app/models/page.rb203
-rw-r--r--app/models/user.rb31
9 files changed, 1044 insertions, 144 deletions
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index 0e99fa2..613a6f0 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -23,9 +23,10 @@ module FileAttachment
23 extend ActiveSupport::Concern 23 extend ActiveSupport::Concern
24 24
25 STYLES = { 25 STYLES = {
26 medium: { geometry: "300x300>", format: nil }, 26 medium: { args: ["-resize", "300x300>"] },
27 thumb: { geometry: "100x100>", format: nil }, 27 thumb: { args: ["-resize", "100x100>"] },
28 headline: { geometry: "460x250!", format: nil } 28 headline: { args: ["-resize", "460x250^", "-gravity", "center", "-extent", "460x250"] },
29 large: { args: ["-resize", "1600x1600>"] }
29 }.freeze 30 }.freeze
30 31
31 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze 32 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze
@@ -80,7 +81,7 @@ module FileAttachment
80 STYLES.each do |style, options| 81 STYLES.each do |style, options|
81 dest_path = file_path(style) 82 dest_path = file_path(style)
82 FileUtils.mkdir_p(File.dirname(dest_path)) 83 FileUtils.mkdir_p(File.dirname(dest_path))
83 system("magick", original_path, "-resize", options[:geometry], dest_path) 84 system("magick", original_path, *options[:args], dest_path)
84 end 85 end
85 end 86 end
86 87
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
new file mode 100644
index 0000000..befcdb3
--- /dev/null
+++ b/app/models/concerns/nested_tree.rb
@@ -0,0 +1,78 @@
1# app/models/concerns/nested_tree.rb
2#
3# Minimal parent_id-based replacement for the tree-traversal subset of
4# awesome_nested_set actually used by this app (descendants, ancestors,
5# level, root, move_to_child_of)
6module NestedTree
7 extend ActiveSupport::Concern
8
9 included do
10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children
11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent
12 end
13
14 class_methods do
15 def root
16 roots.first
17 end
18
19 def roots
20 where(parent_id: nil)
21 end
22
23 def valid?
24 # Every non-root row's parent_id must point at a real, existing row.
25 where.not(parent_id: nil).where.missing(:parent).none?
26 end
27 end
28
29 def root?
30 parent_id.nil?
31 end
32
33 def ancestors
34 result = []
35 current = parent
36 while current
37 result << current
38 current = current.parent
39 end
40 result
41 end
42
43 def level
44 ancestors.size
45 end
46
47 def descendants
48 ids = []
49 queue = self.class.where(parent_id: id).pluck(:id)
50 until queue.empty?
51 ids.concat(queue)
52 queue = self.class.where(parent_id: queue).pluck(:id)
53 end
54 self.class.where(id: ids)
55 end
56
57 def self_and_descendants
58 self.class.where(id: [id] + descendants.pluck(:id))
59 end
60
61 def self_and_descendants_ordered_with_level
62 nodes = [self] + descendants.to_a
63 children_by_parent = nodes.group_by(&:parent_id)
64 children_by_parent.each_value { |list| list.sort_by!(&:id) }
65
66 result = []
67 visit = ->(node, level) do
68 result << [node, level]
69 (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) }
70 end
71 visit.call(self, 0)
72 result
73 end
74
75 def move_to_child_of(new_parent)
76 update!(parent_id: new_parent.id)
77 end
78end
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb
new file mode 100644
index 0000000..07141c1
--- /dev/null
+++ b/app/models/concerns/rrule_humanizer.rb
@@ -0,0 +1,130 @@
1module RruleHumanizer
2 extend ActiveSupport::Concern
3
4 WEEKDAY_NAMES = {
5 de: { "MO"=>"Montag","TU"=>"Dienstag","WE"=>"Mittwoch","TH"=>"Donnerstag","FR"=>"Freitag","SA"=>"Samstag","SU"=>"Sonntag" },
6 en: { "MO"=>"Monday","TU"=>"Tuesday","WE"=>"Wednesday","TH"=>"Thursday","FR"=>"Friday","SA"=>"Saturday","SU"=>"Sunday" }
7 }.freeze
8
9 WEEKDAY_NAMES_ADVERBIAL = {
10 de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" }
11 }.freeze
12
13 WEEKDAY_NAMES_ABBR = {
14 de: { "MO"=>"Mo","TU"=>"Di","WE"=>"Mi","TH"=>"Do","FR"=>"Fr","SA"=>"Sa","SU"=>"So" }
15 }.freeze
16
17 ORDINAL_NAMES = {
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", 5=>"fifth", -1=>"last", -2=>"second-to-last" }
20 }.freeze
21
22 MONTH_NAMES = {
23 de: %w[Januar Februar März April Mai Juni Juli August September Oktober November Dezember],
24 en: %w[January February March April May June July August September October November December]
25 }.freeze
26
27 def humanize_rrule(locale = I18n.locale)
28 return nil if rrule.blank?
29 parts = Hash[rrule.split(";").map { |p| p.split("=", 2) }]
30 return nil if parts["COUNT"] || parts["UNTIL"] # old one-off data, don't guess
31
32 freq, interval, byday, bymonth = parts["FREQ"], parts["INTERVAL"].to_i, parts["BYDAY"], parts["BYMONTH"]
33 loc = locale.to_sym
34 weekdays = WEEKDAY_NAMES[loc] || WEEKDAY_NAMES[:en]
35 ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en]
36 months = MONTH_NAMES[loc] || MONTH_NAMES[:en]
37
38 byday_values = byday&.split(",")
39 days = byday_values&.map do |d|
40 if d =~ /^(-?\d+)([A-Z]{2})$/
41 "#{ordinals[$1.to_i]} #{weekdays[$2]}"
42 else
43 weekdays[d]
44 end
45 end
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
73 base =
74 case loc
75 when :de
76 case freq
77 when "WEEKLY"
78 if days
79 if interval == 2
80 adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] }
81 "Alle zwei Wochen #{adverbial.join(' und ')}"
82 else
83 "Jeden #{days.join(' und ')}"
84 end
85 else
86 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich"
87 end
88 when "MONTHLY"
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
96 end
97 else
98 case freq
99 when "WEEKLY"
100 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly")
101 when "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
109 end
110 end
111 return nil unless base
112
113 if bymonth
114 included = bymonth.split(",").map(&:to_i)
115 missing = ((1..12).to_a - included)
116 if missing.size == 1
117 excluded_name = months[missing.first - 1]
118 base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}")
119 end
120 # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently
121 end
122
123 base
124 end
125
126 def self.wday_abbr(time, locale)
127 code = %w[SU MO TU WE TH FR SA][time.wday]
128 (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code]
129 end
130end
diff --git a/app/models/event.rb b/app/models/event.rb
index 94a22e3..b8651a8 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -1,25 +1,27 @@
1class Event < ApplicationRecord 1class Event < ApplicationRecord
2 2 include RruleHumanizer
3 # Associations 3
4 4 belongs_to :node, optional: true
5 has_many :occurrences 5 has_many :occurrences, dependent: :destroy
6 belongs_to :node 6 acts_as_taggable_on :tags
7 7
8 # Callbacks 8 validates :title, presence: true, unless: -> { node_id.present? }
9 9
10 after_save :generate_occurences 10 after_save :generate_occurrences
11 11
12 # Instance Methods
13
14 def occurrences_in_range start_time, end_time 12 def occurrences_in_range start_time, end_time
15 self.occurrences.where( 13 self.occurrences.where(
16 "start_time > ? AND end_time < ?", 14 "start_time > ? AND end_time < ?",
17 start_time, end_time 15 start_time, end_time
18 ) 16 )
19 end 17 end
18
19 def display_title
20 title.presence || node&.head&.title || "Untitled event"
21 end
20 22
21 private 23 private
22 def generate_occurences 24 def generate_occurrences
23 Occurrence.generate self 25 Occurrence.generate self
24 end 26 end
25end 27end
diff --git a/app/models/node.rb b/app/models/node.rb
index 92ecc12..b5b18a1 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -1,35 +1,47 @@
1class Node < ApplicationRecord 1class Node < ApplicationRecord
2 # Mixins and Plugins 2 # Mixins and Plugins
3 acts_as_nested_set 3 include NestedTree
4 4
5 # Associations 5 # Associations
6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy 6 has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
7 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true 7 has_many :node_actions, :dependent => :nullify
8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true 8 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true
9 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true
10 # Autosave pages carry no node_id, so has_many :pages does not cover
11 # them -- this dependent: :destroy is their only cleanup on node destroy.
12 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
13
9 has_many :permissions, :dependent => :destroy 14 has_many :permissions, :dependent => :destroy
10 has_one :event, :dependent => :destroy 15 has_many :events, :dependent => :destroy
11 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true 16 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
12 17
13 # Callbacks 18 # Callbacks
14 after_create :initialize_empty_page 19 after_create :initialize_empty_page
15 before_save :check_for_changed_slug 20 before_save :check_for_changed_slug
16 after_save :update_unique_names_of_children 21 after_save :update_unique_names_of_children
22 before_destroy :refuse_destroy_with_children
23 before_destroy :refuse_destroying_trash_node
17 24
18 # Validations 25 # Validations
19 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? } 26 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
20 validates_presence_of :slug, :unless => -> { parent_id.nil? } 27 validates_presence_of :slug, :unless => -> { parent_id.nil? }
21 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } 28 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
22 validates_presence_of :parent_id, :unless => -> { Node.root.nil? } 29 validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
23 30
24 validate :borders # This should never ever happen. 31 validate :reserved_slug_stays_reserved
32 validate :no_head_inside_trash
33 validates :default_template_name,
34 :inclusion => { :in => ->(_) { Page.custom_templates } },
35 :allow_nil => true,
36 :if => :default_template_name_changed?
25 37
26 # Index for Fulltext Search 38 # Everything outside the Trash subtree, the Trash node included.
27 # define_index do 39 # Relies on unique_name being authoritative for tree position --
28 # indexes head.translations.title 40 # the same trust public routing places in it.
29 # indexes slug 41 scope :not_in_trash, -> {
30 # indexes unique_name 42 where.not(:unique_name => CccConventions::TRASH_SLUG)
31 # indexes head.translations.body 43 .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%")
32 # end 44 }
33 45
34 # Class methods 46 # Class methods
35 47
@@ -57,30 +69,116 @@ class Node < ApplicationRecord
57 nil 69 nil
58 end 70 end
59 71
72 # The Trash container node. Lazily self-creating and idempotent, so
73 # every environment acquires it on first touch.
74 # Never call from validations. the positional predicates below
75 # exist for that.
76 def self.trash
77 root.children.find_by(:slug => CccConventions::TRASH_SLUG) ||
78 root.children.create!(:slug => CccConventions::TRASH_SLUG).tap do |node|
79 Globalize.with_locale(I18n.default_locale) do
80 node.draft.update!(:title => "Trash")
81 end
82 end
83 end
84
60 # Instance Methods 85 # Instance Methods
61 86
62 def find_or_create_draft current_user 87 # Acquires (or reaffirms) the editing lock without creating a draft or
63 self.wipe_draft! 88 # an autosave -- both are now deferred until there is real content to
64 if draft && self.lock_owner == current_user 89 # hold.
65 draft 90 def lock_for_editing! current_user
66 elsif draft && self.lock_owner.nil? 91 if self.lock_owner.nil? || self.lock_owner == current_user
67 lock_for! current_user 92 lock_for! current_user
68 draft.user = current_user if draft.user.nil? 93 if self.draft
69 draft.editor = current_user 94 self.draft.user = current_user if self.draft.user.nil?
70 draft.save 95 self.draft.editor = current_user
71 draft 96 self.draft.save!
72 elsif draft && self.lock_owner != current_user 97 end
98 self
99 else
73 raise( 100 raise(
74 LockedByAnotherUser, 101 LockedByAnotherUser,
75 "Page is locked by another user who is working on it! " \ 102 "Page is locked by another user who is working on it! " \
76 "Last modification: #{draft.updated_at.to_fs(:db)}" 103 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
77 ) 104 )
105 end
106 end
107
108 # Creates or updates the autosave buffer from the given attributes.
109 # Autosave rows are never associated to the node via node_id -- they
110 # must never appear in self.pages / the revisions list, which is the
111 # whole reason autosave exists as a separate, unversioned layer.
112 def autosave! attributes, current_user
113 assert_locked_by! current_user
114
115 unless self.autosave
116 self.autosave = Page.create!(:editor => current_user)
117 self.autosave.clone_attributes_from(self.draft || self.head) if self.draft || self.head
118 self.save!
119 end
120 self.autosave.assign_attributes(attributes)
121 self.autosave.save!
122 self.autosave
123 end
124
125 # Promotes the current autosave into the draft (creating the draft if
126 # none exists yet) and destroys the autosave afterward. This is what
127 # the explicit "Save" action does; it never creates a new revision --
128 # same as any other in-place draft edit. The new draft is created via
129 # self.pages.create! rather than by repointing the autosave's own
130 # node_id, because acts_as_list assigns the revision number at create
131 # time, scoped to node_id -- a page created with node_id nil and
132 # reassigned afterward would carry a wrong or missing revision number.
133 def save_draft! current_user
134 assert_locked_by! current_user
135 return unless self.autosave
136
137 if self.draft
138 preserved_published_at = self.draft.published_at
139 self.draft.clone_attributes_from self.autosave
140 self.draft.published_at = preserved_published_at
141 self.draft.user_id = self.autosave.user_id if self.autosave.user_id
142 self.draft.editor = current_user
143 self.draft.save!
78 else 144 else
79 lock_for! current_user 145 empty_page = self.pages.create!
80 create_new_draft current_user 146 empty_page.clone_attributes_from self.autosave
147 empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user)
148 empty_page.editor = current_user
149 empty_page.published_at = self.head.published_at if self.head
150 empty_page.save!
151 self.draft = empty_page
152 self.save!
153 end
154
155 self.autosave.destroy
156 self.autosave_id = nil
157 self.save!
158 self.draft.reload
159 end
160
161 def resolve_page_reference ref
162 case ref.to_s
163 when "head" then head
164 when "draft" then draft
165 when "autosave" then autosave
166 else pages.find_by_revision(ref)
81 end 167 end
82 end 168 end
83 169
170 # Which layer-pairs are meaningful to compare right now, given this
171 # node's actual state. Head vs autosave only shows up when no draft
172 # sits between them -- with a draft present, autosave is compared
173 # against the draft, never past it straight to head.
174 def available_layer_pairs
175 pairs = []
176 pairs << [:head, :draft] if head && draft
177 pairs << [:draft, :autosave] if draft && autosave
178 pairs << [:head, :autosave] if head && autosave && !draft
179 pairs
180 end
181
84 def create_new_draft user 182 def create_new_draft user
85 empty_page = self.pages.create! 183 empty_page = self.pages.create!
86 empty_page.user = (self.head ? self.head.user : user) 184 empty_page.user = (self.head ? self.head.user : user)
@@ -94,67 +192,221 @@ class Node < ApplicationRecord
94 self.draft.reload 192 self.draft.reload
95 end 193 end
96 194
97 def publish_draft! 195 # Discards exactly the topmost non-empty layer -- autosave if present,
196 # else draft -- and reveals whatever's beneath it. Releases the lock
197 # only once nothing is left to protect (no draft survives); leaves it
198 # alone whenever a draft remains, since #edit still has real content
199 # open.
200 def revert! current_user
201 assert_locked_by! current_user
202
203 if self.autosave
204 self.autosave.destroy
205 self.autosave_id = nil
206 self.save!
207 NodeAction.record!(:node => self, :user => current_user, :action => "discard_autosave")
208 elsif self.draft && self.head
209 self.draft.destroy
210 self.draft_id = nil
211 self.save!
212 NodeAction.record!(:node => self, :user => current_user, :action => "destroy_draft")
213 end
214
215 self.unlock! unless self.draft
216 self.reload
217 end
218
219 def staged_slug=(value)
220 if head.blank?
221 self.slug = value
222 else
223 super
224 end
225 end
226
227 def publish_draft! current_user = nil
98 # Return nil if nothing to publish and no staged changes 228 # Return nil if nothing to publish and no staged changes
99 return nil unless self.draft || staged_slug || staged_parent_id 229 return nil unless self.draft || staged_slug || staged_parent_id
100 230
101 if self.draft 231 if in_trash? || trash_node?
102 self.head = self.draft 232 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
103 self.head.published_at ||= Time.now
104 self.head.save!
105 self.draft = nil
106 end 233 end
107 234
108 if staged_slug && (staged_slug != slug) 235 path_before = self.unique_name
109 self.slug = staged_slug 236
110 self.staged_slug = nil 237 ActiveRecord::Base.transaction do
238 if self.draft
239 outgoing_head = self.head
240 self.head = self.draft
241 self.head.published_at ||= Time.now
242 self.head.save!
243 self.draft = nil
244
245 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
246 :action => "publish", :via => "draft",
247 **NodeAction.head_diff(outgoing_head, self.head))
248 end
249
250 if staged_slug && (staged_slug != slug)
251 self.slug = staged_slug
252 self.staged_slug = nil
253 end
254
255 if staged_parent_id && (staged_parent_id != parent_id)
256 new_parent = Node.find(staged_parent_id)
257
258 if new_parent == self || self.descendants.include?(new_parent)
259 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants"
260 end
261
262 self.staged_parent_id = nil
263 self.save!
264 self.move_to_child_of(new_parent)
265 else
266 unless self.save
267 raise ActiveRecord::RecordInvalid.new(self)
268 end
269 end
270
271 self.reload
272 self.update_unique_name
273 self.send(:update_unique_names_of_children)
274 if self.unique_name != path_before
275 NodeAction.record!(:node => self, :user => current_user, :action => "move",
276 :path => { "from" => path_before, "to" => self.unique_name })
277 end
278 self.unlock!
279 self
111 end 280 end
281 end
112 282
113 if staged_parent_id && (staged_parent_id != parent_id) 283 def restore_revision! revision, current_user = nil
114 new_parent = Node.find(staged_parent_id) 284 page = self.pages.find_by_revision(revision)
115 self.staged_parent_id = nil 285 return nil unless page
286
287 ActiveRecord::Base.transaction do
288 outgoing_head = self.head
289 self.head = page
116 self.save! 290 self.save!
117 self.move_to_child_of(new_parent) 291
118 else 292 NodeAction.record!(:node => self, :page => page, :user => current_user,
119 unless self.save 293 :action => "publish", :via => "revision",
120 raise ActiveRecord::RecordInvalid.new(self) 294 **NodeAction.head_diff(outgoing_head, page))
121 end 295 self
122 end 296 end
297 end
123 298
124 self.reload 299
125 self.update_unique_name 300 # Moves this node and its subtree into the Trash. Demotes every head
126 self.send(:update_unique_names_of_children) 301 # in the subtree first (aggregators and search operate on heads
127 self.unlock! 302 # regardless of tree position); where a node has no draft, the former
128 self 303 # head becomes its draft so content stays editable and restorable --
304 # otherwise the former head remains a plain revision. One log entry,
305 # at the root, carrying the leaving-public-view snapshot.
306 def trash! current_user = nil
307 return nil if in_trash?
308 raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node?
309
310 ActiveRecord::Base.transaction do
311 path_before = unique_name
312 was_published = head_id.present?
313 final_published_at = head&.published_at
314
315 demoted = 0
316 ([self] + descendants.to_a).each do |node|
317 next unless node.head_id
318 former = node.head
319 node.head = nil
320 node.draft_id = former.id if node.draft_id.nil?
321 node.save!
322 demoted += 1
323 end
324
325 self.reload
326 move_to_child_of(Node.trash)
327 self.reload
328 update_unique_name
329 send(:update_unique_names_of_children)
330 unlock!
331
332 metadata = { :path => { "from" => path_before, "to" => unique_name } }
333 metadata[:was_published] = true if was_published
334 metadata[:final_published_at] = final_published_at.iso8601 if final_published_at
335 metadata[:demoted_heads] = demoted if demoted > 0
336
337 NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata)
338 self
339 end
129 end 340 end
130 341
131 # removes a draft and the lock if it is older than a day and still 342 # Returns the node to the living tree under a chosen parent. The
132 # identical to head 343 # subtree comes back exactly as it sits in the Trash: all drafts,
133 def wipe_draft! 344 # nothing published. Republication is a separate, witnessed act
134 unless self.draft 345 # per node.
135 self.unlock! 346 def restore_from_trash! new_parent, current_user = nil
136 return 347 return nil unless in_trash?
348
349 if new_parent.nil? || new_parent == self || descendants.include?(new_parent) ||
350 new_parent.trash_node? || new_parent.in_trash?
351 raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node"
137 end 352 end
138 return unless self.head
139 return unless self.draft.updated_at < 1.day.ago
140 return if self.head.has_changes_to? self.draft
141 353
142 self.draft.destroy 354 ActiveRecord::Base.transaction do
143 self.draft_id = nil 355 path_before = unique_name
144 self.unlock! 356 move_to_child_of(new_parent)
145 self.save! 357 self.reload
146 self.reload 358 update_unique_name
359 send(:update_unique_names_of_children)
360
361 NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash",
362 :path => { "from" => path_before, "to" => unique_name })
363 self
364 end
147 end 365 end
148 366
149 def restore_revision! revision 367 # Final deletion -- only from inside the Trash. Removes the whole
150 if page = self.pages.find_by_revision(revision) 368 # subtree, deepest first, each node through a real destroy! so every
151 self.head = page 369 # per-node cascade runs (the categorical difference from the old
152 self.save 370 # delete_all nuke). refuse_destroy_with_children on bare destroy is
153 else 371 # untouched
154 nil 372 # One log entry at the root, per the subtree rule, written before the
373 # rows die.
374 def destroy_from_trash! current_user = nil
375 raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash?
376
377 ActiveRecord::Base.transaction do
378 doomed = self_and_descendants_ordered_with_level
379 .sort_by { |_, level| -level }
380 .map(&:first)
381
382 metadata = { :path => unique_name }
383 metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1
384
385 NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata)
386 doomed.each(&:destroy!)
155 end 387 end
156 end 388 end
157 389
390 # The most recent trash entry. Its path.from is the restore hint.
391 def last_trash_entry
392 node_actions.where(:action => "trash").order(:occurred_at => :desc, :id => :desc).first
393 end
394
395 # The node's pre-trash parent, if a living node still answers to
396 # that path. Nil when the parent was itself trashed (its unique_name
397 # changed) or deleted; a different node that has since taken over
398 # the path is a legitimate suggestion.
399 def suggested_restore_parent
400 from = last_trash_entry&.metadata&.dig("path", "from")
401 return nil unless from
402
403 parent_path = from.rpartition("/").first
404 return Node.root if parent_path.empty?
405
406 candidate = Node.find_by(:unique_name => parent_path)
407 candidate unless candidate.nil? || candidate.in_trash? || candidate.trash_node?
408 end
409
158 # returns an array with all parts of a unique_name rather than a string 410 # returns an array with all parts of a unique_name rather than a string
159 def unique_path 411 def unique_path
160 unique_name.to_s.split("/") 412 unique_name.to_s.split("/")
@@ -165,9 +417,12 @@ class Node < ApplicationRecord
165 parent.nil? ? [slug] : parent.path_to_root.push(slug) 417 parent.nil? ? [slug] : parent.path_to_root.push(slug)
166 end 418 end
167 419
420 def computed_unique_name
421 path_to_root[1..-1].join("/") # excluding root
422 end
423
168 def current_unique_name 424 def current_unique_name
169 path = path_to_root[1..-1] # excluding root 425 self.unique_name = computed_unique_name
170 self.unique_name = path.join("/")
171 end 426 end
172 427
173 def update_unique_name 428 def update_unique_name
@@ -203,6 +458,24 @@ class Node < ApplicationRecord
203 unique_path.length == 3 && unique_path[0] == "updates" 458 unique_path.length == 3 && unique_path[0] == "updates"
204 end 459 end
205 460
461 def editable_page
462 autosave || draft || head
463 end
464
465 def trash_node?
466 parent&.root? && slug == CccConventions::TRASH_SLUG
467 end
468
469 # Inside the Trash subtree. False for the Trash node itself.
470 def in_trash?
471 current = parent
472 while current
473 return true if current.trash_node?
474 current = current.parent
475 end
476 false
477 end
478
206 # Returns immutable node id for all new nodes so that the atom feed entry ids 479 # Returns immutable node id for all new nodes so that the atom feed entry ids
207 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
208 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -220,12 +493,68 @@ class Node < ApplicationRecord
220 .distinct 493 .distinct
221 end 494 end
222 495
496 # This one is for admin-only views, where finding a draft is the point.
497 # Substring match on whichever of head/draft is present.
498 def self.editor_search(term)
499 words = term.to_s.split(/\s+/).reject(&:blank?)
500 return none if words.empty?
501
502 conditions = []
503 binds = {}
504
505 words.each_with_index do |word, i|
506 key = "term#{i}"
507 binds[key.to_sym] = "%#{sanitize_sql_like(word)}%"
508 conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \
509 "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})"
510 end
511
512 joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id")
513 .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id")
514 .where(conditions.join(" AND "), binds)
515 .distinct
516 end
517
518 def self.drafts_and_autosaves(current_user_id: nil)
519 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").not_in_trash
520 return scope.order("updated_at DESC") unless current_user_id
521
522 scope.order(
523 Arel.sql(sanitize_sql_array(["CASE WHEN locking_user_id = ? THEN 0 ELSE 1 END, updated_at DESC", current_user_id]))
524 )
525 end
526
527 # Nodes are never destroyed recursively
528 # Descendants must be removed or reparented individually first.
529 # The Trash feature will be the ordinary path to deletion.
530 def refuse_destroy_with_children
531 return unless children.exists?
532 errors.add(:base, "Cannot destroy a node that still has children")
533 throw :abort
534 end
535
536 def self.recently_changed
537 includes(:head).where(
538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
539 Time.now, Time.now - 14.days
540 ).order("pages.updated_at desc").references(:head)
541 end
542
223 protected 543 protected
224 def lock_for! current_user 544 def lock_for! current_user
225 self.lock_owner = current_user 545 self.lock_owner = current_user
226 self.save 546 self.save
227 end 547 end
228 548
549 def assert_locked_by! current_user
550 return if self.lock_owner == current_user
551 raise(
552 LockedByAnotherUser,
553 "Page is locked by another user who is working on it! " \
554 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
555 )
556 end
557
229 # Creates an empty page and associates it to the given node. This means 558 # Creates an empty page and associates it to the given node. This means
230 # freshly created node has an empty draft. A user can create nodes as he 559 # freshly created node has an empty draft. A user can create nodes as he
231 # wants to which will not appear on the public page until the author edits 560 # wants to which will not appear on the public page until the author edits
@@ -246,10 +575,13 @@ class Node < ApplicationRecord
246 # Watch out recursion ahead! update_unique_name itself triggers this 575 # Watch out recursion ahead! update_unique_name itself triggers this
247 # after_save callback which invokes update_unique_name on its children. 576 # after_save callback which invokes update_unique_name on its children.
248 # Hopefully until no childrens occur 577 # Hopefully until no childrens occur
578 #
579 # Queries parent_id directly rather than the NestedTree#children
580 # association out of habit from the old awesome_nested_set-avoidance
581 # workaround - no longer strictly necessary now that children is
582 # equally safe, but left as-is since it already works correctly.
249 def update_unique_names_of_children 583 def update_unique_names_of_children
250 unless root? 584 unless root?
251 # Use parent_id-based traversal instead of lft/rgt descendants
252 # due to awesome_nested_set not refreshing parent lft/rgt in memory
253 Node.where(:parent_id => self.id).each do |child| 585 Node.where(:parent_id => self.id).each do |child|
254 child.reload 586 child.reload
255 child.update_unique_name 587 child.update_unique_name
@@ -258,9 +590,35 @@ class Node < ApplicationRecord
258 end 590 end
259 end 591 end
260 592
261 def borders 593 private
262 if lft && rgt && (lft > rgt) 594
263 errors.add("Fuck!. lft should never be smaller than rgt") 595 def reserved_slug_stays_reserved
596 if parent&.root? && !trash_node_already_me?
597 errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG
598 errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG
264 end 599 end
600
601 if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node?
602 errors.add(:slug, "of the Trash node cannot change") if slug_changed?
603 errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed?
604 errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present?
605 errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present?
606 end
607 end
608
609 def trash_node_already_me?
610 existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG)
611 existing.nil? || existing.id == id
612 end
613
614 def no_head_inside_trash
615 return unless head_id.present?
616 errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node?
617 end
618
619 def refuse_destroying_trash_node
620 return unless trash_node?
621 errors.add(:base, "The Trash node cannot be destroyed")
622 throw :abort
265 end 623 end
266end 624end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
new file mode 100644
index 0000000..13bd5ba
--- /dev/null
+++ b/app/models/node_action.rb
@@ -0,0 +1,169 @@
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, 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 # Optional pairs only when the sides differ; booleans only when
16 # true; required keys always present. Titles and names are read
17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 #
19 # Baseline, every entry (written here, not by call sites):
20 # "username" -- actor's login at write time
21 # "human_readable_node_name" -- node title, default locale
22 #
23 # "create":
24 # "title" -- initial title, flat string (NOT a pair)
25 # "path" -- unique path at creation, flat string. Historical
26 # value only; never a join key.
27 #
28 # "publish" (any promotion to head; diff computed BEFORE head is
29 # re-pointed, over the union of both pages' locales, by head_diff --
30 # shared with the backfill):
31 # "via" -- "draft" | "revision" (rollback). Always written;
32 # absent means a pre-contract entry.
33 # "title" -- pair, always; "from" null on first publish
34 # "author" -- pair, when the byline changed (incl. first publish)
35 # "tags" -- pair of arrays, when changed
36 # "assets_changed", "template_changed",
37 # "abstract_changed", "body_changed"
38 # -- the last two for the default locale; page_id links
39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
41 # { "<locale>" => {
42 # "status" -- "added" | "removed" | "changed"
43 # "title" -- pair, only when it differs; "from" null when
44 # added, "to" null when removed
45 # "abstract_changed", "body_changed" -- status "changed" only
46 # } }
47 #
48 # "move" (reparent and/or path change; one entry at the subtree
49 # root, descendants get none):
50 # "path" -- pair
51 #
52 # "trash" (subtree into the Trash; every head in the subtree is
53 # demoted first; one entry at the root; snapshots the
54 # leaving-public-view state, since Trash holds no heads and destroy
55 # can no longer know):
56 # "path" -- pair; "from" doubles as the restore hint
57 # "was_published" -- boolean
58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
60 #
61 # "restore_from_trash" (reparent back to a living node; returns as
62 # drafts, republication is a separate witnessed act):
63 # "path" -- pair
64 #
65 # "destroy" (only from inside the Trash, never with children; the
66 # entry is written in the same transaction before the row dies):
67 # "path" -- final path, flat string (create-symmetric)
68 # "destroyed_descendants" -- integer, only when positive; one entry
69 # at the root, per the subtree rule.
70 #
71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
72 # depublish workflow, if ever built.
73 #
74 # Backfilled entries mirror this vocabulary; diff content is
75 # computed, only actor (page.editor) and occurred_at are inferred,
76 # inferred_from names the heuristic ("from_node_created_at",
77 # "from_page_revision"). Null = witnessed live.
78 #
79 # The "locale" column is written by no verb; retained.
80 #
81 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only.
83
84 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
85 occurred_at: nil, inferred_from: nil, **extra)
86 create!(
87 :node => node,
88 :page => page,
89 :user => user,
90 :action => action,
91 :locale => locale,
92 :occurred_at => occurred_at || Time.now,
93 :inferred_from => inferred_from,
94 :metadata => {
95 "username" => user&.login,
96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
97 node&.head&.title || node&.draft&.title
98 },
99 }.merge(extra.stringify_keys)
100 )
101 end
102
103 # Computes the publish-entry diff between an outgoing head and the
104 # page replacing it, in the exact metadata shape the contract above
105 # specifies. Pure function of its two arguments -- shared verbatim by
106 # publish_draft!, restore_revision!, and the backfill task. Reads
107 # translation rows directly, never locale-dependent accessors, so a
108 # fallback value is never mistaken for real content. Returns
109 # symbol-keyed top level for splatting into record!; nested keys are
110 # strings and jsonb serialization stringifies the rest at write time.
111 def self.head_diff old_page, new_page
112 default = I18n.default_locale
113
114 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
115 diff = { :title => { "from" => title_of.call(old_page),
116 "to" => title_of.call(new_page) } }
117 unless old_page
118 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
119 return diff
120 end
121
122 old_author, new_author = old_page.user&.login, new_page.user&.login
123 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
124
125 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
126 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
127
128 diff[:template_changed] = true if old_page.template_name != new_page.template_name
129 diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)
130
131 old_t = old_page.translations.find_by(:locale => default)
132 new_t = new_page.translations.find_by(:locale => default)
133 diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
134 diff[:body_changed] = true if old_t&.body != new_t&.body
135
136 locales = (old_page.translated_locales | new_page.translated_locales) - [default]
137 translation_diff = {}
138
139 locales.sort_by(&:to_s).each do |locale|
140 o = old_page.translations.find_by(:locale => locale)
141 n = new_page.translations.find_by(:locale => locale)
142
143 if o.nil?
144 translation_diff[locale.to_s] = { "status" => "added",
145 "title" => { "from" => nil, "to" => n.title } }
146 elsif n.nil?
147 translation_diff[locale.to_s] = { "status" => "removed",
148 "title" => { "from" => o.title, "to" => nil } }
149 elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
150 entry = { "status" => "changed" }
151 entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
152 entry["abstract_changed"] = true if o.abstract != n.abstract
153 entry["body_changed"] = true if o.body != n.body
154 translation_diff[locale.to_s] = entry
155 end
156 end
157
158 diff[:translation_diff] = translation_diff if translation_diff.any?
159 diff
160 end
161
162 def actor_name
163 metadata["username"] || "unknown"
164 end
165
166 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end
169end
diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb
index 3baf447..c6a2380 100644
--- a/app/models/occurrence.rb
+++ b/app/models/occurrence.rb
@@ -1,19 +1,19 @@
1# TODO Make a gem out of the c wrapper
2require 'chaos_calendar' 1require 'chaos_calendar'
3 2
4class Occurrence < ApplicationRecord 3class Occurrence < ApplicationRecord
5 4
6 # Associations 5 # Associations
7 6
8 belongs_to :node 7 belongs_to :node, optional: true
9 belongs_to :event 8 belongs_to :event
10 9
11 # Class Methods 10 # Class Methods
12 11
13 def self.find_in_range start_time, end_time 12 def self.find_in_range start_time, end_time
14 includes(:node) 13 joins(:event)
15 .where("start_time > ? AND end_time < ?", start_time, end_time) 14 .includes(:node)
16 .order("start_time") 15 .where("occurrences.start_time > ? AND occurrences.end_time < ?", start_time, end_time)
16 .order("occurrences.start_time")
17 end 17 end
18 18
19 def self.find_next 19 def self.find_next
@@ -27,6 +27,7 @@ class Occurrence < ApplicationRecord
27 # event are then calculated and created. 27 # event are then calculated and created.
28 def self.generate event 28 def self.generate event
29 self.where(:event_id => event.id).delete_all 29 self.where(:event_id => event.id).delete_all
30 return if event.start_time.nil?
30 31
31 node = event.node 32 node = event.node
32 duration = (event.end_time - event.start_time) 33 duration = (event.end_time - event.start_time)
@@ -36,7 +37,7 @@ class Occurrence < ApplicationRecord
36 self.create( 37 self.create(
37 :start_time => occurrence, 38 :start_time => occurrence,
38 :end_time => (occurrence + duration), 39 :end_time => (occurrence + duration),
39 :node_id => node.id, 40 :node_id => node&.id,
40 :event_id => event.id 41 :event_id => event.id
41 ) 42 )
42 end 43 end
@@ -49,10 +50,11 @@ class Occurrence < ApplicationRecord
49 # Return value is always an array of Time objects. 50 # Return value is always an array of Time objects.
50 def self.generate_dates event 51 def self.generate_dates event
51 if event.rrule && !event.rrule.empty? 52 if event.rrule && !event.rrule.empty?
52 ChaosCalendar::occurrences( 53 ChaosCalendar::occurrences_for_timezone(
53 event.start_time, 54 event.start_time,
54 (Time.now + 5.years), 55 (Time.now + 5.years),
55 event.rrule 56 event.rrule,
57 Time.zone.tzinfo.identifier
56 ) 58 )
57 else 59 else
58 [event.start_time] 60 [event.start_time]
@@ -64,7 +66,7 @@ class Occurrence < ApplicationRecord
64 # Instance Methods 66 # Instance Methods
65 67
66 def summary 68 def summary
67 node.head.title 69 node&.head&.title || event.display_title
68 end 70 end
69 71
70end 72end
diff --git a/app/models/page.rb b/app/models/page.rb
index e6baf20..c4d66fc 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -11,11 +11,21 @@ class Page < ApplicationRecord
11 11
12 translates :title, :abstract, :body # Globalize2 12 translates :title, :abstract, :body # Globalize2
13 13
14 # Template names render as filesystem paths; only names actually
15 # present in the public template directory are acceptable. Validated
16 # only on change so legacy rows whose template file has since
17 # vanished stay saveable -- valid_template already falls back to
18 # standard_template for those at render time.
19 validates :template_name,
20 :inclusion => { :in => ->(_) { Page.custom_templates } },
21 :allow_nil => true,
22 :if => :template_name_changed?
23
14 # Associations 24 # Associations
15 belongs_to :node, optional: true 25 belongs_to :node, optional: true
16 belongs_to :user, optional: true 26 belongs_to :user, optional: true
17 belongs_to :editor, :class_name => "User", optional: true 27 belongs_to :editor, :class_name => "User", optional: true
18 has_many :related_assets 28 has_many :related_assets, :dependent => :destroy
19 has_many :assets, -> { order("position ASC") }, :through => :related_assets 29 has_many :assets, -> { order("position ASC") }, :through => :related_assets
20 30
21 # Named scopes 31 # Named scopes
@@ -63,7 +73,24 @@ class Page < ApplicationRecord
63 end 73 end
64 end 74 end
65 75
66 scope.order("#{options[:order_by]} #{options[:order_direction]}") 76 if options[:node] && options[:children] == "direct"
77 scope = scope.where(nodes: { parent_id: options[:node].id })
78 elsif options[:node] && options[:children] == "all"
79 scope = scope.where(nodes: { id: options[:node].descendants.pluck(:id) })
80 end
81
82 direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC"
83
84 if options[:order_by] == "title"
85 return scope
86 .order(Arel.sql("(SELECT pt.title FROM page_translations pt WHERE pt.page_id = pages.id AND pt.locale = #{ActiveRecord::Base.connection.quote(I18n.locale.to_s)}) #{direction}"))
87 .paginate(:page => page, :per_page => options[:limit])
88 end
89
90 column = options[:order_by].to_s.sub(/\Apages\./, "")
91 column = "id" unless %w[id published_at created_at updated_at].include?(column)
92
93 scope.order("pages.#{column} #{direction}")
67 .paginate(:page => page, :per_page => options[:limit]) 94 .paginate(:page => page, :per_page => options[:limit])
68 end 95 end
69 96
@@ -73,6 +100,27 @@ class Page < ApplicationRecord
73 end 100 end
74 end 101 end
75 102
103 def self.non_default_locales
104 I18n.available_locales - [:root, I18n.default_locale]
105 end
106
107 # One row per non-default locale, read from the actual translation
108 # row -- never through the locale-dependent accessor, so a locale
109 # with no real translation yet reports as absent rather than quietly
110 # showing a fallback value borrowed from another locale.
111 def translation_summary
112 Page.non_default_locales.map do |locale|
113 translation = translations.find_by(:locale => locale)
114 {
115 :locale => locale,
116 :exists => translation.present?,
117 :title => translation&.title,
118 :updated_at => translation&.updated_at,
119 :outdated => translation.present? && outdated_translations?(:locale => locale)
120 }
121 end
122 end
123
76 def self.untranslated(options = {:locale => :de}) 124 def self.untranslated(options = {:locale => :de})
77 PageTranslation.all.group_by(&:page_id).select do |k,v| 125 PageTranslation.all.group_by(&:page_id).select do |k,v|
78 v.size == 1 && v.map{|x| x.locale}.include?(options[:locale]) 126 v.size == 1 && v.map{|x| x.locale}.include?(options[:locale])
@@ -128,22 +176,47 @@ class Page < ApplicationRecord
128 "/#{node.unique_name}" 176 "/#{node.unique_name}"
129 end 177 end
130 178
179 def ensure_preview_token!
180 update!(preview_token: SecureRandom.urlsafe_base64(24)) unless preview_token.present?
181 preview_token
182 end
183
184 def revoke_preview_token!
185 update!(preview_token: nil)
186 end
187
131 def clone_attributes_from page 188 def clone_attributes_from page
132 return nil unless page 189 return nil unless page
133 190
134 self.reload 191 self.reload
192 page.translations.reload
135 193
136 # Clone untranslated attributes 194 # Clone untranslated attributes
137 self.tag_list = page.tag_list 195 self.tag_list = page.tag_list
138 self.template_name ||= page.template_name 196 self.template_name ||= page.template_name
139 self.published_at = page.published_at 197 self.published_at = page.published_at
140 198
141 # Getting rid of the auto-generated empty translations 199 # Clone translated attributes -- update each locale in place rather
142 self.translations.delete_all 200 # than delete-and-recreate, so a locale whose content is genuinely
201 # unchanged keeps its real created_at/updated_at instead of looking
202 # freshly touched on every single save (which was silently defeating
203 # Page.find_with_outdated_translations' whole staleness comparison).
204 # search_vector is excluded deliberately: it's DB-trigger-maintained
205 # from title/abstract, not real content, and comparing a precomputed
206 # tsvector risked a false "changed" from representation noise alone.
207 source_locales = page.translations.map(&:locale)
208 self.translations.where.not(:locale => source_locales).destroy_all
143 209
144 # Clone translated attributes 210 page.translations.each do |source_translation|
145 page.translations.each do |translation| 211 attrs = source_translation.attributes.except("id", "page_id", "created_at", "updated_at", "search_vector")
146 self.translations.create!(translation.attributes.except("id", "page_id", "created_at", "updated_at")) 212 mine = self.translations.find_by(:locale => source_translation.locale)
213
214 if mine
215 changed_attrs = attrs.reject { |k, v| mine.public_send(k) == v }
216 mine.update!(changed_attrs) if changed_attrs.any?
217 else
218 self.translations.create!(attrs)
219 end
147 end 220 end
148 221
149 # Clone asset references 222 # Clone asset references
@@ -152,6 +225,54 @@ class Page < ApplicationRecord
152 self.save 225 self.save
153 end 226 end
154 227
228 def diff_against other, view: :inline, locale: nil
229 if locale
230 mine, theirs = translations.find_by(:locale => locale), other.translations.find_by(:locale => locale)
231 my_title, my_abstract, my_body = mine&.title, mine&.abstract, mine&.body
232 their_title, their_abstract, their_body = theirs&.title, theirs&.abstract, theirs&.body
233 else
234 my_title, my_abstract, my_body = title, abstract, body
235 their_title, their_abstract, their_body = other.title, other.abstract, other.body
236 end
237
238 text_diffs =
239 if view == :side_by_side
240 {
241 title: HtmlWordDiff.side_by_side(their_title.to_s, my_title.to_s),
242 abstract: HtmlWordDiff.side_by_side(their_abstract.to_s, my_abstract.to_s),
243 body: HtmlWordDiff.side_by_side(their_body.to_s, my_body.to_s)
244 }
245 else
246 {
247 title: HtmlWordDiff.inline(their_title.to_s, my_title.to_s),
248 abstract: HtmlWordDiff.inline(their_abstract.to_s, my_abstract.to_s),
249 body: HtmlWordDiff.inline(their_body.to_s, my_body.to_s)
250 }
251 end
252
253 text_diffs.merge(
254 tags: { added: tag_list.to_a - other.tag_list.to_a, removed: other.tag_list.to_a - tag_list.to_a },
255 template_name: { from: other.template_name, to: template_name, changed: template_name != other.template_name },
256 assets: { added: assets.to_a - other.assets.to_a, removed: other.assets.to_a - assets.to_a }
257 )
258 end
259
260 def locale_diff_summary other
261 (translated_locales | other.translated_locales).sort_by(&:to_s).map do |locale|
262 mine = translations.find_by(:locale => locale)
263 theirs = other.translations.find_by(:locale => locale)
264 {
265 :locale => locale,
266 :exists_here => mine.present?,
267 :exists_there => theirs.present?,
268 :changed => mine.present? != theirs.present? ||
269 mine&.title != theirs&.title ||
270 mine&.abstract != theirs&.abstract ||
271 mine&.body != theirs&.body
272 }
273 end
274 end
275
155 def public? 276 def public?
156 published_at.nil? ? true : published_at < Time.now 277 published_at.nil? ? true : published_at < Time.now
157 end 278 end
@@ -208,46 +329,60 @@ class Page < ApplicationRecord
208 329
209 end 330 end
210 331
332 # Installs (or re-installs) the trigger that keeps page_translations'
333 # search_vector in sync. Idempotent, safe to call on every boot.
334 # search_vector is populated by a raw Postgres trigger, not anything
335 # Rails' schema dumper can represent -- a database rebuilt from
336 # schema.rb rather than replayed migrations silently loses it.
337 def self.ensure_search_vector_trigger!
338 connection.execute(<<~SQL)
339 CREATE OR REPLACE FUNCTION page_translations_search_vector_update()
340 RETURNS trigger AS $$
341 BEGIN
342 NEW.search_vector := to_tsvector(
343 'simple',
344 coalesce(NEW.title, '') || ' ' ||
345 coalesce(NEW.abstract, '') || ' ' ||
346 coalesce(NEW.body, '')
347 );
348 RETURN NEW;
349 END;
350 $$ LANGUAGE plpgsql;
351
352 CREATE OR REPLACE TRIGGER page_translations_search_vector_trigger
353 BEFORE INSERT OR UPDATE ON page_translations
354 FOR EACH ROW EXECUTE PROCEDURE page_translations_search_vector_update();
355 SQL
356 end
357
211 private 358 private
212 359
213 def set_page_title 360 def set_page_title
214 if title.nil? 361 self.title = "Untitled" if title.nil?
215 title = "Untitled"
216 end
217 end 362 end
218 363
219 def set_template 364 def set_template
220 if node && node.update? 365 return if template_name.present?
221 self.template_name = "update" 366 self.template_name = node&.default_template_name || (node&.update? ? "update" : nil)
222 end
223 end 367 end
224 368
225 def rewrite_links_in_body 369 def rewrite_links_in_body
226 begin 370 return unless self.body
227 if self.body
228 tmp_body = "<div>#{self.body}</div>"
229 xml_string = XML::Parser.string( tmp_body )
230 xml_doc = xml_string.parse
231 links = xml_doc.find("//a[not(starts-with(@href, 'http://'))]")
232 links = links.reject { |l| l[:href] =~ /system\/uploads/ }
233 locales = I18n.available_locales.reject {|l| l == :root}
234 371
235 links.each do |link| 372 doc = Nokogiri::HTML::DocumentFragment.parse(self.body)
236 unless locales.include? link[:href].slice(1,2).to_sym 373 locales = I18n.available_locales.reject { |l| l == :root }
237 unless link[:href] =~ /sytem\/uploads/
238 link[:href] = link[:href].sub(/^\//, "/#{I18n.locale}/")
239 end
240 end
241 end
242 374
243 tmp_body = xml_doc.to_s.gsub(/(\n\<div\>|\<\/div\>\n)/, "") 375 doc.css('a').each do |link|
244 tmp_body.gsub!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>", "") 376 href = link['href']
377 next unless href
378 next if href.start_with?('http://', 'https://')
379 next if href =~ /system\/uploads/
245 380
246 self.body = tmp_body 381 unless locales.include?(href.slice(1, 2)&.to_sym)
382 link['href'] = href.sub(/^\//, "/#{I18n.locale}/")
247 end 383 end
248 rescue
249 nil
250 end 384 end
251 end
252 385
386 self.body = doc.to_html
387 end
253end 388end
diff --git a/app/models/user.rb b/app/models/user.rb
index 92ac33a..5e47ae7 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,10 @@
1require 'digest/sha1' 1require 'digest/sha1'
2 2
3class User < ApplicationRecord 3class User < ApplicationRecord
4 has_secure_password(validations: false)
5
6 alias_method :authenticate_bcrypt, :authenticate
7
4 # Mixins and Plugins 8 # Mixins and Plugins
5 include Authentication 9 include Authentication
6 include Authentication::ByPassword 10 include Authentication::ByPassword
@@ -23,9 +27,30 @@ class User < ApplicationRecord
23 27
24 # Authenticates a user by their login name and unencrypted password. Returns the user or nil. 28 # Authenticates a user by their login name and unencrypted password. Returns the user or nil.
25 def self.authenticate(login, password) 29 def self.authenticate(login, password)
26 return nil if login.blank? || password.blank? 30 return if login.blank? || password.blank?
27 u = find_by_login(login) # need to get the salt 31
28 u && u.authenticated?(password) ? u : nil 32 user = find_by(login: login)
33 return unless user
34
35 user&.authenticate(password)
36 end
37
38 def authenticate(password)
39 if password_digest.present?
40 return self if authenticate_bcrypt(password)
41 return nil
42 end
43
44 return nil unless authenticate_legacy(password)
45
46 transaction do
47 self.password = password
48 self.crypted_password = nil
49 self.salt = nil
50 save!(validate: false)
51 end
52
53 self
29 end 54 end
30 55
31 # TODO: Do we really want to have downcase logins only? 56 # TODO: Do we really want to have downcase logins only?