summaryrefslogtreecommitdiff
path: root/app/models/page.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/page.rb')
-rw-r--r--app/models/page.rb203
1 files changed, 169 insertions, 34 deletions
diff --git a/app/models/page.rb b/app/models/page.rb
index e6baf20..f33d88d 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_blank => 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