From c357a1132d88cc1ebe21cb44b7dc02bdf5988103 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 16 Jul 2026 19:13:33 +0200 Subject: [PATCH] fix(sync): backfill missing target_hash so hand-added translations get drift protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translations written straight into the locale YAML (never passing through translate or accept-edits) ended up as bare {source_hash} entries — invisible to the manual_edits validator. sync now records target_hash from the current value when the entry has none; an existing target_hash is never recomputed, so hand-edit drift stays visible. --- CHANGELOG.md | 7 +++++++ docs/app/views/docs/pages/commands.rb | 7 ++++--- docs/app/views/docs/pages/drift_state.rb | 10 ++++++---- lib/locallingo/manager.rb | 17 ++++++++++------ spec/locallingo/manager_spec.rb | 25 ++++++++++++++++++++++-- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad2f4f..b63edb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- `lingo sync` backfills a missing `target_hash` from the current target value, + so hand-added translations (written straight into the YAML, never passing + through `translate` or `accept-edits`) get a baseline and the `manual_edits` + validator can watch them. An existing `target_hash` is still never + recomputed — that would silently absorb hand-edit drift. + +### Fixed (0.4.0) - `lingo sync` no longer destroys hand-edit protection: it now only refreshes each entry's `source_hash` and preserves `target_hash` and `manual: true` (the pre-extraction `bin/translate` behavior). Previously a single sync wiped diff --git a/docs/app/views/docs/pages/commands.rb b/docs/app/views/docs/pages/commands.rb index 490fd57..ce53f59 100644 --- a/docs/app/views/docs/pages/commands.rb +++ b/docs/app/views/docs/pages/commands.rb @@ -96,9 +96,10 @@ def sync Refreshes the `.i18n-state/` drift state from the current locale files — for initial setup on an existing app, or after editing source strings, so `validate` doesn't report spurious "outdated" keys. It updates each key's - `source_hash` and prunes entries whose keys were removed; `target_hash` - and `manual` flags are always preserved, so hand-edit protection never - depends on when sync last ran. + `source_hash`, backfills a `target_hash` baseline for entries that lack + one (hand-added translations), and prunes entries whose keys were + removed. An existing `target_hash` and all `manual` flags are always + preserved, so hand-edit protection never depends on when sync last ran. MD end end diff --git a/docs/app/views/docs/pages/drift_state.rb b/docs/app/views/docs/pages/drift_state.rb index c5ebd7a..12defcf 100644 --- a/docs/app/views/docs/pages/drift_state.rb +++ b/docs/app/views/docs/pages/drift_state.rb @@ -91,10 +91,12 @@ def syncing md <<~'MD' Adopting locallingo on an existing app, or editing a batch of source strings, can leave the state out of step with the files. `lingo sync` - refreshes each key's `source_hash` from the current translations and - prunes entries for removed keys, so nothing reads as spuriously outdated. - It never touches `target_hash` or `manual` — hand-edit drift stays - visible until you resolve it with `accept-edits`. + refreshes each key's `source_hash` from the current translations, records + a `target_hash` baseline for entries that have none (translations added + straight to the YAML by hand), and prunes entries for removed keys, so + nothing reads as spuriously outdated. It never rewrites an existing + `target_hash` or any `manual` flag — hand-edit drift stays visible until + you resolve it with `accept-edits`. MD DocsUI::Callout(:note) do plain "Run " diff --git a/lib/locallingo/manager.rb b/lib/locallingo/manager.rb index 1131648..0d76f22 100644 --- a/lib/locallingo/manager.rb +++ b/lib/locallingo/manager.rb @@ -134,11 +134,12 @@ def source_hash format("%08x", Zlib.crc32(load_source_translations.to_json)) end - # Refresh source hashes from the current translation files and prune state - # for keys that no longer exist. Existing `target_hash`/`manual` fields are - # preserved — hand-edit protection is never dropped by a sync; use - # `accept_edits!` to resolve hand-edit drift explicitly. Returns the - # combined state. + # Refresh source hashes from the current translation files, backfill a + # `target_hash` baseline for entries that lack one, and prune state for + # keys that no longer exist. An existing `target_hash` and the `manual` + # flag are never touched — hand-edit protection is never dropped by a + # sync; use `accept_edits!` to resolve hand-edit drift explicitly. + # Returns the combined state. def sync_state! source = load_source_translations @@ -171,12 +172,16 @@ def sync_locale_state(source, locale) target = load_locale_translations(locale) locale_state = @state.load(locale) - target.each_key do |key| + target.each do |key, value| next unless source[key] existing = locale_state[key] entry = existing.is_a?(Hash) ? existing.dup : {} entry["source_hash"] = @state.hash(source[key]) + # Backfill a baseline for hand-added translations so the manual-edits + # validator can watch them; an existing target_hash is never recomputed — + # that would silently absorb hand-edit drift. + entry["target_hash"] ||= @state.hash(value) locale_state[key] = entry end locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) } diff --git a/spec/locallingo/manager_spec.rb b/spec/locallingo/manager_spec.rb index d6b859a..d65fe3d 100644 --- a/spec/locallingo/manager_spec.rb +++ b/spec/locallingo/manager_spec.rb @@ -173,7 +173,7 @@ end end - it "leaves bare entries bare (no target_hash or manual invented)" do + it "backfills a missing target_hash from the current value without inventing manual" do with_app( config: { "target_locales" => %w[de] }, locales: { @@ -186,7 +186,28 @@ described_class.new(config: config_for(root)).sync_state! entry = read_state(root, "greeting.de.json").fetch("greeting.hi") - expect(entry).to eq("source_hash" => Locallingo::StateStore.hash("Hello")) + expect(entry).to eq( + "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => Locallingo::StateStore.hash("Hallo") + ) + end + end + + it "records a full entry for keys with no state at all" do + with_app( + config: { "target_locales" => %w[de] }, + locales: { + "en" => { "greeting" => { "hi" => "Hello" } }, + "de" => { "greeting" => { "hi" => "Hallo" } } + } + ) do |root| + described_class.new(config: config_for(root)).sync_state! + + entry = read_state(root, "greeting.de.json").fetch("greeting.hi") + expect(entry).to eq( + "source_hash" => Locallingo::StateStore.hash("Hello"), + "target_hash" => Locallingo::StateStore.hash("Hallo") + ) end end