summaryrefslogtreecommitdiff
path: root/app/models/page.rb
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-13 23:33:11 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-13 23:33:11 +0200
commit8eab68f2c5a3c126e2fec2ecdfa7ace87ce0937b (patch)
treef446ebc26a7707c7b64a937aa51a155df146c80a /app/models/page.rb
parent42714c697273a7117c6b355fab26c8c35e336ad1 (diff)
parentcdf5d9941ca866d437612d2f863eac6eb0b3db12 (diff)
Merge branch 'erdgeist-revive-events'
Diffstat (limited to 'app/models/page.rb')
-rw-r--r--app/models/page.rb188
1 files changed, 155 insertions, 33 deletions
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