summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/concerns/nested_tree.rb86
-rw-r--r--app/models/concerns/rrule_humanizer.rb91
-rw-r--r--app/models/event.rb30
-rw-r--r--app/models/node.rb226
-rw-r--r--app/models/occurrence.rb24
-rw-r--r--app/models/page.rb188
6 files changed, 564 insertions, 81 deletions
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
new file mode 100644
index 0000000..9a2eb0e
--- /dev/null
+++ b/app/models/concerns/nested_tree.rb
@@ -0,0 +1,86 @@
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
13 before_destroy :delete_descendants
14 end
15
16 class_methods do
17 def root
18 roots.first
19 end
20
21 def roots
22 where(parent_id: nil)
23 end
24
25 def valid?
26 # Every non-root row's parent_id must point at a real, existing row.
27 where.not(parent_id: nil).where.missing(:parent).none?
28 end
29 end
30
31 def root?
32 parent_id.nil?
33 end
34
35 def ancestors
36 result = []
37 current = parent
38 while current
39 result << current
40 current = current.parent
41 end
42 result
43 end
44
45 def level
46 ancestors.size
47 end
48
49 def descendants
50 ids = []
51 queue = self.class.where(parent_id: id).pluck(:id)
52 until queue.empty?
53 ids.concat(queue)
54 queue = self.class.where(parent_id: queue).pluck(:id)
55 end
56 self.class.where(id: ids)
57 end
58
59 def self_and_descendants
60 self.class.where(id: [id] + descendants.pluck(:id))
61 end
62
63 def self_and_descendants_ordered_with_level
64 nodes = [self] + descendants.to_a
65 children_by_parent = nodes.group_by(&:parent_id)
66 children_by_parent.each_value { |list| list.sort_by!(&:id) }
67
68 result = []
69 visit = ->(node, level) do
70 result << [node, level]
71 (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) }
72 end
73 visit.call(self, 0)
74 result
75 end
76
77 def move_to_child_of(new_parent)
78 update!(parent_id: new_parent.id)
79 end
80
81 private
82
83 def delete_descendants
84 self.class.where(id: descendants.pluck(:id)).delete_all
85 end
86end
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb
new file mode 100644
index 0000000..8231de8
--- /dev/null
+++ b/app/models/concerns/rrule_humanizer.rb
@@ -0,0 +1,91 @@
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", -1=>"letzten", -2=>"vorletzten" },
19 en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -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 days = byday&.split(",")&.map do |d|
39 if d =~ /^(-?\d+)([A-Z]{2})$/
40 "#{ordinals[$1.to_i]} #{weekdays[$2]}"
41 else
42 weekdays[d]
43 end
44 end
45
46 base =
47 case loc
48 when :de
49 case freq
50 when "WEEKLY"
51 if days
52 if interval == 2
53 adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] }
54 "Alle zwei Wochen #{adverbial.join(' und ')}"
55 else
56 "Jeden #{days.join(' und ')}"
57 end
58 else
59 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich"
60 end
61 when "MONTHLY"
62 days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich"
63 end
64 else
65 case freq
66 when "WEEKLY"
67 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly")
68 when "MONTHLY"
69 days ? "Every #{days.join(' and ')} of the month" : "Monthly"
70 end
71 end
72 return nil unless base
73
74 if bymonth
75 included = bymonth.split(",").map(&:to_i)
76 missing = ((1..12).to_a - included)
77 if missing.size == 1
78 excluded_name = months[missing.first - 1]
79 base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}")
80 end
81 # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently
82 end
83
84 base
85 end
86
87 def self.wday_abbr(time, locale)
88 code = %w[SU MO TU WE TH FR SA][time.wday]
89 (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code]
90 end
91end
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..73eefd1 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -1,13 +1,14 @@
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 belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true
8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true 8 belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true
9 belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
9 has_many :permissions, :dependent => :destroy 10 has_many :permissions, :dependent => :destroy
10 has_one :event, :dependent => :destroy 11 has_many :events, :dependent => :destroy
11 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true 12 belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
12 13
13 # Callbacks 14 # Callbacks
@@ -16,21 +17,11 @@ class Node < ApplicationRecord
16 after_save :update_unique_names_of_children 17 after_save :update_unique_names_of_children
17 18
18 # Validations 19 # Validations
19 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? } 20 validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? }
20 validates_presence_of :slug, :unless => -> { parent_id.nil? } 21 validates_presence_of :slug, :unless => -> { parent_id.nil? }
21 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } 22 validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
22 validates_presence_of :parent_id, :unless => -> { Node.root.nil? } 23 validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
23 24
24 validate :borders # This should never ever happen.
25
26 # Index for Fulltext Search
27 # define_index do
28 # indexes head.translations.title
29 # indexes slug
30 # indexes unique_name
31 # indexes head.translations.body
32 # end
33
34 # Class methods 25 # Class methods
35 26
36 # Returns a page for a given node. If no revision is supplied, it returns 27 # Returns a page for a given node. If no revision is supplied, it returns
@@ -59,6 +50,101 @@ class Node < ApplicationRecord
59 50
60 # Instance Methods 51 # Instance Methods
61 52
53 # Acquires (or reaffirms) the editing lock without creating a draft or
54 # an autosave -- both are now deferred until there is real content to
55 # hold.
56 def lock_for_editing! current_user
57 if self.lock_owner.nil? || self.lock_owner == current_user
58 lock_for! current_user
59 if self.draft
60 self.draft.user = current_user if self.draft.user.nil?
61 self.draft.editor = current_user
62 self.draft.save!
63 end
64 self
65 else
66 raise(
67 LockedByAnotherUser,
68 "Page is locked by another user who is working on it! " \
69 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
70 )
71 end
72 end
73
74 # Creates or updates the autosave buffer from the given attributes.
75 # Autosave rows are never associated to the node via node_id -- they
76 # must never appear in self.pages / the revisions list, which is the
77 # whole reason autosave exists as a separate, unversioned layer.
78 def autosave! attributes, current_user
79 assert_locked_by! current_user
80
81 unless self.autosave
82 self.autosave = Page.create!(:editor => current_user)
83 self.autosave.clone_attributes_from(self.draft || self.head) if self.draft || self.head
84 self.save!
85 end
86 self.autosave.assign_attributes(attributes)
87 self.autosave.save!
88 self.autosave
89 end
90
91 # Promotes the current autosave into the draft (creating the draft if
92 # none exists yet) and destroys the autosave afterward. This is what
93 # the explicit "Save" action does; it never creates a new revision --
94 # same as any other in-place draft edit. The new draft is created via
95 # self.pages.create! rather than by repointing the autosave's own
96 # node_id, because acts_as_list assigns the revision number at create
97 # time, scoped to node_id -- a page created with node_id nil and
98 # reassigned afterward would carry a wrong or missing revision number.
99 def save_draft! current_user
100 assert_locked_by! current_user
101 return unless self.autosave
102
103 if self.draft
104 preserved_published_at = self.draft.published_at
105 self.draft.clone_attributes_from self.autosave
106 self.draft.published_at = preserved_published_at
107 self.draft.user_id = self.autosave.user_id if self.autosave.user_id
108 self.draft.editor = current_user
109 self.draft.save!
110 else
111 empty_page = self.pages.create!
112 empty_page.clone_attributes_from self.autosave
113 empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user)
114 empty_page.editor = current_user
115 empty_page.published_at = self.head.published_at if self.head
116 empty_page.save!
117 self.draft = empty_page
118 self.save!
119 end
120
121 self.autosave.destroy
122 self.autosave_id = nil
123 self.save!
124 self.draft.reload
125 end
126
127 def resolve_page_reference ref
128 case ref.to_s
129 when "head" then head
130 when "draft" then draft
131 when "autosave" then autosave
132 else pages.find_by_revision(ref)
133 end
134 end
135
136 # Which layer-pairs are meaningful to compare right now, given this
137 # node's actual state. Head vs autosave only shows up when no draft
138 # sits between them -- with a draft present, autosave is compared
139 # against the draft, never past it straight to head.
140 def available_layer_pairs
141 pairs = []
142 pairs << [:head, :draft] if head && draft
143 pairs << [:draft, :autosave] if draft && autosave
144 pairs << [:head, :autosave] if head && autosave && !draft
145 pairs
146 end
147
62 def find_or_create_draft current_user 148 def find_or_create_draft current_user
63 self.wipe_draft! 149 self.wipe_draft!
64 if draft && self.lock_owner == current_user 150 if draft && self.lock_owner == current_user
@@ -94,6 +180,36 @@ class Node < ApplicationRecord
94 self.draft.reload 180 self.draft.reload
95 end 181 end
96 182
183 # Discards exactly the topmost non-empty layer -- autosave if present,
184 # else draft -- and reveals whatever's beneath it. Releases the lock
185 # only once nothing is left to protect (no draft survives); leaves it
186 # alone whenever a draft remains, since #edit still has real content
187 # open.
188 def revert! current_user
189 assert_locked_by! current_user
190
191 if self.autosave
192 self.autosave.destroy
193 self.autosave_id = nil
194 self.save!
195 elsif self.draft && self.head
196 self.draft.destroy
197 self.draft_id = nil
198 self.save!
199 end
200
201 self.unlock! unless self.draft
202 self.reload
203 end
204
205 def staged_slug=(value)
206 if head.blank?
207 self.slug = value
208 else
209 super
210 end
211 end
212
97 def publish_draft! 213 def publish_draft!
98 # Return nil if nothing to publish and no staged changes 214 # Return nil if nothing to publish and no staged changes
99 return nil unless self.draft || staged_slug || staged_parent_id 215 return nil unless self.draft || staged_slug || staged_parent_id
@@ -112,6 +228,11 @@ class Node < ApplicationRecord
112 228
113 if staged_parent_id && (staged_parent_id != parent_id) 229 if staged_parent_id && (staged_parent_id != parent_id)
114 new_parent = Node.find(staged_parent_id) 230 new_parent = Node.find(staged_parent_id)
231
232 if new_parent == self || self.descendants.include?(new_parent)
233 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants"
234 end
235
115 self.staged_parent_id = nil 236 self.staged_parent_id = nil
116 self.save! 237 self.save!
117 self.move_to_child_of(new_parent) 238 self.move_to_child_of(new_parent)
@@ -128,11 +249,22 @@ class Node < ApplicationRecord
128 self 249 self
129 end 250 end
130 251
131 # removes a draft and the lock if it is older than a day and still 252 # Releases whatever's stale and abandoned; never anything actively in
132 # identical to head 253 # use. Three independent cases share one rule -- nothing is touched
254 # unless it's been sitting untouched for over a day:
255 # - a lock held with no draft or autosave at all (the editor opened
256 # the page and never actually wrote anything)
257 # - a fresh autosave older than a day, never promoted to a draft
258 # - a draft older than a day that's still identical to head
133 def wipe_draft! 259 def wipe_draft!
260 return if self.autosave && self.autosave.updated_at > 1.day.ago
261
134 unless self.draft 262 unless self.draft
263 return if self.autosave.nil? && self.locked? && self.updated_at > 1.day.ago
264 self.autosave&.destroy
265 self.autosave_id = nil
135 self.unlock! 266 self.unlock!
267 self.save!
136 return 268 return
137 end 269 end
138 return unless self.head 270 return unless self.head
@@ -203,6 +335,10 @@ class Node < ApplicationRecord
203 unique_path.length == 3 && unique_path[0] == "updates" 335 unique_path.length == 3 && unique_path[0] == "updates"
204 end 336 end
205 337
338 def editable_page
339 autosave || draft || head
340 end
341
206 # Returns immutable node id for all new nodes so that the atom feed entry ids 342 # 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. 343 # stay the same eventhough the slug or positions changes.
208 # Can be removed after a year or so ;) 344 # Can be removed after a year or so ;)
@@ -220,12 +356,59 @@ class Node < ApplicationRecord
220 .distinct 356 .distinct
221 end 357 end
222 358
359 # This one is for admin-only views, where finding a draft is the point.
360 # Substring match on whichever of head/draft is present.
361 def self.editor_search(term)
362 words = term.to_s.split(/\s+/).reject(&:blank?)
363 return none if words.empty?
364
365 conditions = []
366 binds = {}
367
368 words.each_with_index do |word, i|
369 key = "term#{i}"
370 binds[key.to_sym] = "%#{sanitize_sql_like(word)}%"
371 conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \
372 "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})"
373 end
374
375 joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id")
376 .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id")
377 .where(conditions.join(" AND "), binds)
378 .distinct
379 end
380
381 def self.drafts_and_autosaves(current_user_id: nil)
382 scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL")
383 return scope.order("updated_at DESC") unless current_user_id
384
385 scope.order(
386 Arel.sql(sanitize_sql_array(["CASE WHEN locking_user_id = ? THEN 0 ELSE 1 END, updated_at DESC", current_user_id]))
387 )
388 end
389
390 def self.recently_changed
391 includes(:head).where(
392 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
393 Time.now, Time.now - 14.days
394 ).order("pages.updated_at desc").references(:head)
395 end
396
223 protected 397 protected
224 def lock_for! current_user 398 def lock_for! current_user
225 self.lock_owner = current_user 399 self.lock_owner = current_user
226 self.save 400 self.save
227 end 401 end
228 402
403 def assert_locked_by! current_user
404 return if self.lock_owner == current_user
405 raise(
406 LockedByAnotherUser,
407 "Page is locked by another user who is working on it! " \
408 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
409 )
410 end
411
229 # Creates an empty page and associates it to the given node. This means 412 # 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 413 # 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 414 # wants to which will not appear on the public page until the author edits
@@ -246,10 +429,13 @@ class Node < ApplicationRecord
246 # Watch out recursion ahead! update_unique_name itself triggers this 429 # Watch out recursion ahead! update_unique_name itself triggers this
247 # after_save callback which invokes update_unique_name on its children. 430 # after_save callback which invokes update_unique_name on its children.
248 # Hopefully until no childrens occur 431 # Hopefully until no childrens occur
432 #
433 # Queries parent_id directly rather than the NestedTree#children
434 # association out of habit from the old awesome_nested_set-avoidance
435 # workaround - no longer strictly necessary now that children is
436 # equally safe, but left as-is since it already works correctly.
249 def update_unique_names_of_children 437 def update_unique_names_of_children
250 unless root? 438 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| 439 Node.where(:parent_id => self.id).each do |child|
254 child.reload 440 child.reload
255 child.update_unique_name 441 child.update_unique_name
@@ -257,10 +443,4 @@ class Node < ApplicationRecord
257 end 443 end
258 end 444 end
259 end 445 end
260
261 def borders
262 if lft && rgt && (lft > rgt)
263 errors.add("Fuck!. lft should never be smaller than rgt")
264 end
265 end
266end 446end
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..e5a8d9d 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -63,7 +63,21 @@ class Page < ApplicationRecord
63 end 63 end
64 end 64 end
65 65
66 scope.order("#{options[:order_by]} #{options[:order_direction]}") 66 if options[:node] && options[:children] == "direct"
67 scope = scope.where(nodes: { parent_id: options[:node].id })
68 elsif options[:node] && options[:children] == "all"
69 scope = scope.where(nodes: { id: options[:node].descendants.pluck(:id) })
70 end
71
72 direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC"
73
74 if options[:order_by] == "title"
75 return scope
76 .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}"))
77 .paginate(:page => page, :per_page => options[:limit])
78 end
79
80 scope.order("#{options[:order_by]} #{direction}")
67 .paginate(:page => page, :per_page => options[:limit]) 81 .paginate(:page => page, :per_page => options[:limit])
68 end 82 end
69 83
@@ -73,6 +87,27 @@ class Page < ApplicationRecord
73 end 87 end
74 end 88 end
75 89
90 def self.non_default_locales
91 I18n.available_locales - [:root, I18n.default_locale]
92 end
93
94 # One row per non-default locale, read from the actual translation
95 # row -- never through the locale-dependent accessor, so a locale
96 # with no real translation yet reports as absent rather than quietly
97 # showing a fallback value borrowed from another locale.
98 def translation_summary
99 Page.non_default_locales.map do |locale|
100 translation = translations.find_by(:locale => locale)
101 {
102 :locale => locale,
103 :exists => translation.present?,
104 :title => translation&.title,
105 :updated_at => translation&.updated_at,
106 :outdated => translation.present? && outdated_translations?(:locale => locale)
107 }
108 end
109 end
110
76 def self.untranslated(options = {:locale => :de}) 111 def self.untranslated(options = {:locale => :de})
77 PageTranslation.all.group_by(&:page_id).select do |k,v| 112 PageTranslation.all.group_by(&:page_id).select do |k,v|
78 v.size == 1 && v.map{|x| x.locale}.include?(options[:locale]) 113 v.size == 1 && v.map{|x| x.locale}.include?(options[:locale])
@@ -128,22 +163,47 @@ class Page < ApplicationRecord
128 "/#{node.unique_name}" 163 "/#{node.unique_name}"
129 end 164 end
130 165
166 def ensure_preview_token!
167 update!(preview_token: SecureRandom.urlsafe_base64(24)) unless preview_token.present?
168 preview_token
169 end
170
171 def revoke_preview_token!
172 update!(preview_token: nil)
173 end
174
131 def clone_attributes_from page 175 def clone_attributes_from page
132 return nil unless page 176 return nil unless page
133 177
134 self.reload 178 self.reload
179 page.translations.reload
135 180
136 # Clone untranslated attributes 181 # Clone untranslated attributes
137 self.tag_list = page.tag_list 182 self.tag_list = page.tag_list
138 self.template_name ||= page.template_name 183 self.template_name ||= page.template_name
139 self.published_at = page.published_at 184 self.published_at = page.published_at
140 185
141 # Getting rid of the auto-generated empty translations 186 # Clone translated attributes -- update each locale in place rather
142 self.translations.delete_all 187 # than delete-and-recreate, so a locale whose content is genuinely
188 # unchanged keeps its real created_at/updated_at instead of looking
189 # freshly touched on every single save (which was silently defeating
190 # Page.find_with_outdated_translations' whole staleness comparison).
191 # search_vector is excluded deliberately: it's DB-trigger-maintained
192 # from title/abstract, not real content, and comparing a precomputed
193 # tsvector risked a false "changed" from representation noise alone.
194 source_locales = page.translations.map(&:locale)
195 self.translations.where.not(:locale => source_locales).destroy_all
196
197 page.translations.each do |source_translation|
198 attrs = source_translation.attributes.except("id", "page_id", "created_at", "updated_at", "search_vector")
199 mine = self.translations.find_by(:locale => source_translation.locale)
143 200
144 # Clone translated attributes 201 if mine
145 page.translations.each do |translation| 202 changed_attrs = attrs.reject { |k, v| mine.public_send(k) == v }
146 self.translations.create!(translation.attributes.except("id", "page_id", "created_at", "updated_at")) 203 mine.update!(changed_attrs) if changed_attrs.any?
204 else
205 self.translations.create!(attrs)
206 end
147 end 207 end
148 208
149 # Clone asset references 209 # Clone asset references
@@ -152,6 +212,54 @@ class Page < ApplicationRecord
152 self.save 212 self.save
153 end 213 end
154 214
215 def diff_against other, view: :inline, locale: nil
216 if locale
217 mine, theirs = translations.find_by(:locale => locale), other.translations.find_by(:locale => locale)
218 my_title, my_abstract, my_body = mine&.title, mine&.abstract, mine&.body
219 their_title, their_abstract, their_body = theirs&.title, theirs&.abstract, theirs&.body
220 else
221 my_title, my_abstract, my_body = title, abstract, body
222 their_title, their_abstract, their_body = other.title, other.abstract, other.body
223 end
224
225 text_diffs =
226 if view == :side_by_side
227 {
228 title: HtmlWordDiff.side_by_side(their_title.to_s, my_title.to_s),
229 abstract: HtmlWordDiff.side_by_side(their_abstract.to_s, my_abstract.to_s),
230 body: HtmlWordDiff.side_by_side(their_body.to_s, my_body.to_s)
231 }
232 else
233 {
234 title: HtmlWordDiff.inline(their_title.to_s, my_title.to_s),
235 abstract: HtmlWordDiff.inline(their_abstract.to_s, my_abstract.to_s),
236 body: HtmlWordDiff.inline(their_body.to_s, my_body.to_s)
237 }
238 end
239
240 text_diffs.merge(
241 tags: { added: tag_list.to_a - other.tag_list.to_a, removed: other.tag_list.to_a - tag_list.to_a },
242 template_name: { from: other.template_name, to: template_name, changed: template_name != other.template_name },
243 assets: { added: assets.to_a - other.assets.to_a, removed: other.assets.to_a - assets.to_a }
244 )
245 end
246
247 def locale_diff_summary other
248 (translated_locales | other.translated_locales).sort_by(&:to_s).map do |locale|
249 mine = translations.find_by(:locale => locale)
250 theirs = other.translations.find_by(:locale => locale)
251 {
252 :locale => locale,
253 :exists_here => mine.present?,
254 :exists_there => theirs.present?,
255 :changed => mine.present? != theirs.present? ||
256 mine&.title != theirs&.title ||
257 mine&.abstract != theirs&.abstract ||
258 mine&.body != theirs&.body
259 }
260 end
261 end
262
155 def public? 263 def public?
156 published_at.nil? ? true : published_at < Time.now 264 published_at.nil? ? true : published_at < Time.now
157 end 265 end
@@ -208,46 +316,60 @@ class Page < ApplicationRecord
208 316
209 end 317 end
210 318
319 # Installs (or re-installs) the trigger that keeps page_translations'
320 # search_vector in sync. Idempotent, safe to call on every boot.
321 # search_vector is populated by a raw Postgres trigger, not anything
322 # Rails' schema dumper can represent -- a database rebuilt from
323 # schema.rb rather than replayed migrations silently loses it.
324 def self.ensure_search_vector_trigger!
325 connection.execute(<<~SQL)
326 CREATE OR REPLACE FUNCTION page_translations_search_vector_update()
327 RETURNS trigger AS $$
328 BEGIN
329 NEW.search_vector := to_tsvector(
330 'simple',
331 coalesce(NEW.title, '') || ' ' ||
332 coalesce(NEW.abstract, '') || ' ' ||
333 coalesce(NEW.body, '')
334 );
335 RETURN NEW;
336 END;
337 $$ LANGUAGE plpgsql;
338
339 CREATE OR REPLACE TRIGGER page_translations_search_vector_trigger
340 BEFORE INSERT OR UPDATE ON page_translations
341 FOR EACH ROW EXECUTE PROCEDURE page_translations_search_vector_update();
342 SQL
343 end
344
211 private 345 private
212 346
213 def set_page_title 347 def set_page_title
214 if title.nil? 348 self.title = "Untitled" if title.nil?
215 title = "Untitled"
216 end
217 end 349 end
218 350
219 def set_template 351 def set_template
220 if node && node.update? 352 return if template_name.present?
221 self.template_name = "update" 353 self.template_name = node&.default_template_name || (node&.update? ? "update" : nil)
222 end
223 end 354 end
224 355
225 def rewrite_links_in_body 356 def rewrite_links_in_body
226 begin 357 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 358
235 links.each do |link| 359 doc = Nokogiri::HTML::DocumentFragment.parse(self.body)
236 unless locales.include? link[:href].slice(1,2).to_sym 360 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 361
243 tmp_body = xml_doc.to_s.gsub(/(\n\<div\>|\<\/div\>\n)/, "") 362 doc.css('a').each do |link|
244 tmp_body.gsub!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>", "") 363 href = link['href']
364 next unless href
365 next if href.start_with?('http://', 'https://')
366 next if href =~ /system\/uploads/
245 367
246 self.body = tmp_body 368 unless locales.include?(href.slice(1, 2)&.to_sym)
369 link['href'] = href.sub(/^\//, "/#{I18n.locale}/")
247 end 370 end
248 rescue
249 nil
250 end 371 end
251 end
252 372
373 self.body = doc.to_html
374 end
253end 375end