summaryrefslogtreecommitdiff
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
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.
-rw-r--r--app/models/page.rb65
-rw-r--r--test/models/page_test.rb99
2 files changed, 152 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
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index bcddc89..24bc7c5 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -177,6 +177,56 @@ class PageTest < ActiveSupport::TestCase
177 assert first_page.published_at.present? 177 assert first_page.published_at.present?
178 end 178 end
179 179
180 test "clone_attributes_from preserves an unchanged locale's original timestamp" do
181 n = Node.root.children.create!(:slug => "clone_preserve_timestamp_test")
182 source = n.draft
183 Globalize.with_locale(:de) { source.update!(:title => "Deutscher Titel") }
184 Globalize.with_locale(:en) { source.update!(:title => "English Title") }
185
186 target = Page.create!
187 target.clone_attributes_from(source)
188 original_en_updated_at = target.translations.find_by(:locale => :en).updated_at
189
190 Globalize.with_locale(:de) { source.update!(:title => "Deutscher Titel (bearbeitet)") }
191 target.clone_attributes_from(source)
192
193 en_translation = target.translations.find_by(:locale => :en)
194 assert_equal "English Title", en_translation.title
195 assert_equal original_en_updated_at, en_translation.updated_at
196 end
197
198 test "clone_attributes_from gives a genuinely changed locale a fresh timestamp" do
199 n = Node.root.children.create!(:slug => "clone_fresh_timestamp_test")
200 source = n.draft
201 Globalize.with_locale(:de) { source.update!(:title => "Erste Version") }
202
203 target = Page.create!
204 target.clone_attributes_from(source)
205 original_de_updated_at = target.translations.find_by(:locale => :de).updated_at
206
207 Globalize.with_locale(:de) { source.update!(:title => "Zweite Version") }
208 target.clone_attributes_from(source)
209
210 de_translation = target.translations.find_by(:locale => :de)
211 assert_equal "Zweite Version", de_translation.title
212 assert_operator de_translation.updated_at, :>, original_de_updated_at
213 end
214
215 test "clone_attributes_from removes a locale no longer present in the source" do
216 n = Node.root.children.create!(:slug => "clone_removed_locale_test")
217 source = n.draft
218 Globalize.with_locale(:en) { source.update!(:title => "English Title") }
219
220 target = Page.create!
221 target.clone_attributes_from(source)
222 assert_includes target.translations.map(&:locale), :en
223
224 source.translations.where(:locale => :en).delete_all
225 target.clone_attributes_from(source)
226
227 assert_not_includes target.reload.translations.map(&:locale), :en
228 end
229
180 def test_diff_against_inline_keeps_tags_and_marks_only_the_changed_word 230 def test_diff_against_inline_keeps_tags_and_marks_only_the_changed_word
181 n = Node.root.children.create! :slug => "diff_against_test" 231 n = Node.root.children.create! :slug => "diff_against_test"
182 d = n.find_or_create_draft @user1 232 d = n.find_or_create_draft @user1
@@ -271,4 +321,53 @@ class PageTest < ActiveSupport::TestCase
271 assert_equal [added_asset], diff[:assets][:added] 321 assert_equal [added_asset], diff[:assets][:added]
272 assert_equal [removed_asset], diff[:assets][:removed] 322 assert_equal [removed_asset], diff[:assets][:removed]
273 end 323 end
324
325 test "diff_against with an explicit locale compares that locale's own translation on each side" do
326 n = Node.root.children.create!(:slug => "diff_locale_test")
327 d = n.find_or_create_draft(@user1)
328 Globalize.with_locale(:en) { d.update!(:title => "Old English") }
329 d.save!
330 n.publish_draft!
331
332 d2 = n.find_or_create_draft(@user1)
333 Globalize.with_locale(:en) { d2.update!(:title => "New English") }
334 d2.save!
335
336 diff = d2.diff_against(n.head, :locale => :en)
337
338 assert_match "<del>Old</del>", diff[:title]
339 assert_match "<ins>New</ins>", diff[:title]
340 end
341
342 test "diff_against with an explicit locale ignores content in other locales entirely" do
343 n = Node.root.children.create!(:slug => "diff_locale_isolation_test")
344 d = n.find_or_create_draft(@user1)
345 d.save!
346 n.publish_draft!
347
348 d2 = n.find_or_create_draft(@user1)
349 Globalize.with_locale(:de) { d2.update!(:title => "Nur Deutsch geƤndert") }
350 d2.save!
351
352 diff = d2.diff_against(n.head, :locale => :en)
353
354 assert_no_match(/Deutsch/, diff[:title])
355 end
356
357 test "locale_diff_summary flags a locale that only exists on one side as changed" do
358 n = Node.root.children.create!(:slug => "diff_locale_summary_test")
359 d = n.find_or_create_draft(@user1)
360 d.save!
361 n.publish_draft!
362
363 d2 = n.find_or_create_draft(@user1)
364 Globalize.with_locale(:en) { d2.update!(:title => "New English translation") }
365 d2.save!
366
367 summary = d2.locale_diff_summary(n.head)
368 en_entry = summary.find { |s| s[:locale] == :en }
369
370 assert en_entry[:changed]
371 refute en_entry[:exists_there]
372 end
274end 373end