summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-13 19:10:55 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-13 19:10:55 +0200
commit4072efdc912ecb3b79621fb1838434f792e3a531 (patch)
treeaf2cafbec6068a6ee05ac70a064e2ffb2bf369fe /app
parent1234371b084356c7542e53d20c9b816332a945d3 (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.rb65
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