diff options
Diffstat (limited to 'app/models/page.rb')
| -rw-r--r-- | app/models/page.rb | 203 |
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 | ||
| 253 | end | 388 | end |
