diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 19:10:55 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 19:10:55 +0200 |
| commit | 4072efdc912ecb3b79621fb1838434f792e3a531 (patch) | |
| tree | af2cafbec6068a6ee05ac70a064e2ffb2bf369fe /app | |
| parent | 1234371b084356c7542e53d20c9b816332a945d3 (diff) | |
clone_attributes_from: update translations in place, not delete+recreate
Every promotion (autosave creation, draft creation, draft save) ran
every locale's translation through delete-all-then-recreate
unconditionally, giving every locale a fresh created_at/updated_at
regardless of whether its content actually changed. Confirmed via
Node.find(546).draft.translations -- both DE and EN shared one
identical timestamp despite only EN having been edited.
This silently defeated Page.find_with_outdated_translations' whole
staleness comparison: every save reset every locale back into
lockstep, so timestamps could never actually drift apart the way
the feature depends on. The existing test for it only ever passed
by manually backdating a timestamp with record_timestamps = false,
bypassing normal save flow entirely -- not something that happens
through ordinary editing.
Now updates a matching locale's translation only when its own
attributes actually differ, creates one genuinely new, and removes
one genuinely gone from the source -- same end state, but real
per-locale timestamps survive an unrelated save. search_vector is
excluded from both the comparison and the copied attributes: it's
DB-trigger-maintained from title/abstract, not real content, and
comparing a precomputed tsvector risked a false 'changed' from
representation noise alone.
Also reloads the source's translations association, not just
self's -- clone_attributes_from previously only guaranteed a fresh
self, leaving any caller holding a page reference across an earlier
mutation vulnerable to reading a stale cached association.
Diffstat (limited to 'app')
| -rw-r--r-- | app/models/page.rb | 65 |
1 files changed, 53 insertions, 12 deletions
diff --git a/app/models/page.rb b/app/models/page.rb index f796228..e5a8d9d 100644 --- a/app/models/page.rb +++ b/app/models/page.rb | |||
| @@ -176,18 +176,34 @@ class Page < ApplicationRecord | |||
| 176 | return nil unless page | 176 | return nil unless page |
| 177 | 177 | ||
| 178 | self.reload | 178 | self.reload |
| 179 | page.translations.reload | ||
| 179 | 180 | ||
| 180 | # Clone untranslated attributes | 181 | # Clone untranslated attributes |
| 181 | self.tag_list = page.tag_list | 182 | self.tag_list = page.tag_list |
| 182 | self.template_name ||= page.template_name | 183 | self.template_name ||= page.template_name |
| 183 | self.published_at = page.published_at | 184 | self.published_at = page.published_at |
| 184 | 185 | ||
| 185 | # Getting rid of the auto-generated empty translations | 186 | # Clone translated attributes -- update each locale in place rather |
| 186 | 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 | ||
| 187 | 196 | ||
| 188 | # Clone translated attributes | 197 | page.translations.each do |source_translation| |
| 189 | page.translations.each do |translation| | 198 | attrs = source_translation.attributes.except("id", "page_id", "created_at", "updated_at", "search_vector") |
| 190 | self.translations.create!(translation.attributes.except("id", "page_id", "created_at", "updated_at")) | 199 | mine = self.translations.find_by(:locale => source_translation.locale) |
| 200 | |||
| 201 | if mine | ||
| 202 | changed_attrs = attrs.reject { |k, v| mine.public_send(k) == v } | ||
| 203 | mine.update!(changed_attrs) if changed_attrs.any? | ||
| 204 | else | ||
| 205 | self.translations.create!(attrs) | ||
| 206 | end | ||
| 191 | end | 207 | end |
| 192 | 208 | ||
| 193 | # Clone asset references | 209 | # Clone asset references |
| @@ -196,19 +212,28 @@ class Page < ApplicationRecord | |||
| 196 | self.save | 212 | self.save |
| 197 | end | 213 | end |
| 198 | 214 | ||
| 199 | def diff_against other, view: :inline | 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 | |||
| 200 | text_diffs = | 225 | text_diffs = |
| 201 | if view == :side_by_side | 226 | if view == :side_by_side |
| 202 | { | 227 | { |
| 203 | title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), | 228 | title: HtmlWordDiff.side_by_side(their_title.to_s, my_title.to_s), |
| 204 | abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), | 229 | abstract: HtmlWordDiff.side_by_side(their_abstract.to_s, my_abstract.to_s), |
| 205 | body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) | 230 | body: HtmlWordDiff.side_by_side(their_body.to_s, my_body.to_s) |
| 206 | } | 231 | } |
| 207 | else | 232 | else |
| 208 | { | 233 | { |
| 209 | title: HtmlWordDiff.inline(other.title.to_s, title.to_s), | 234 | title: HtmlWordDiff.inline(their_title.to_s, my_title.to_s), |
| 210 | abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), | 235 | abstract: HtmlWordDiff.inline(their_abstract.to_s, my_abstract.to_s), |
| 211 | body: HtmlWordDiff.inline(other.body.to_s, body.to_s) | 236 | body: HtmlWordDiff.inline(their_body.to_s, my_body.to_s) |
| 212 | } | 237 | } |
| 213 | end | 238 | end |
| 214 | 239 | ||
| @@ -219,6 +244,22 @@ class Page < ApplicationRecord | |||
| 219 | ) | 244 | ) |
| 220 | end | 245 | end |
| 221 | 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 | |||
| 222 | def public? | 263 | def public? |
| 223 | published_at.nil? ? true : published_at < Time.now | 264 | published_at.nil? ? true : published_at < Time.now |
| 224 | end | 265 | end |
