locallingo 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff4c0e72d7b3eb6d699b7cff03d8e2b8a61efb8a1c94c16cc906f2843b4dc53c
4
- data.tar.gz: c4419498f0f7de3b334efab4c89471eb3a6a27da543992b5171aa3b67760c3d6
3
+ metadata.gz: 566c302b3598359e622f4b6b98a59bf1a8b3487fc81b898ca93a2d0446c31c63
4
+ data.tar.gz: 9961bb74a9f23e2bebdaa4c64913723a195eaac89470a68e3f175b87f2a68e24
5
5
  SHA512:
6
- metadata.gz: 7820e2f62b6784ae546cd7521303e434a0319099f1ac69fa7471aff9cb786ff3b844f1200ae711eab36df7e058b31742f03de231c5bd95d460889c1027209628
7
- data.tar.gz: b727c0b6f89916f328f3c2b00831263d779b7a7139f478571d7b2e9015326ef8841873da7e72ddd6f963e7f0009dafe9cc4630c1d6883d091bb165672d70fc67
6
+ metadata.gz: 441a84936ce9cb8cba5354bfc81ec49de1d498190b81c9020c52df3b4d7fa4ef15aefde5b6d9911e5dabf161227765f93aa75c6e99e677f61febb0dbfbd95a3e
7
+ data.tar.gz: 4d18fed67d04efb9750abb753fee743bd4a206bc9c396e95c882db5d2add9c948782cc65f38bb4b64f9f51018281d7c8af4b4130a37c8b36ddae420cd688f4db
data/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+ - `lingo sync` backfills a missing `target_hash` from the current target value,
11
+ so hand-added translations (written straight into the YAML, never passing
12
+ through `translate` or `accept-edits`) get a baseline and the `manual_edits`
13
+ validator can watch them. An existing `target_hash` is still never
14
+ recomputed — that would silently absorb hand-edit drift.
15
+
16
+ ### Fixed (0.4.0)
17
+ - `lingo sync` no longer destroys hand-edit protection: it now only refreshes
18
+ each entry's `source_hash` and preserves `target_hash` and `manual: true`
19
+ (the pre-extraction `bin/translate` behavior). Previously a single sync wiped
20
+ both fields for every key, blinding the `manual_edits` validator and causing
21
+ `manual` flags to flip back and forth across branches.
22
+ - `translate --force-key` on a `manual`-flagged key keeps the flag. The value is
23
+ still retranslated on explicit request, but no command removes `manual: true`
24
+ anymore — unprotecting a key requires editing the state JSON by hand.
25
+ - State files whose content is unchanged are no longer rewritten, so operations
26
+ never touch `.i18n-state/` files for unrelated namespaces.
27
+
28
+ ### Changed
29
+ - Unscoped `lingo accept-edits` now accepts only the keys the `manual_edits`
30
+ validator flags (actually hand-edited), instead of stamping `manual: true` on
31
+ every key of the locale. Use the new `--key KEY` (repeatable) for surgical
32
+ accepts, or `--all` for the old blanket behavior (initial adoption).
33
+ - Validator suggestions are scoped and safe to follow verbatim: `manual_edit`
34
+ violations suggest `accept-edits --locale <l> --key <key>`, and `outdated`
35
+ violations on manual keys tell you to update the value by hand and re-accept
36
+ it rather than force-translating over curated text.
37
+
9
38
  ### Added
10
39
  - `Locallingo.configure { |c| c.anthropic_api_key = ... }` — gem-level provider
11
40
  credentials as Strings or lazy callables, for apps whose keys don't live in
data/README.md CHANGED
@@ -104,8 +104,10 @@ lingo validate --strict # CI gate (exit 1 on strict-tier issues)
104
104
  lingo validate --strict-all # stricter CI gate
105
105
  lingo quality --ai # quality linting (+ optional AI pass)
106
106
  lingo fix-quality --locale en # auto-fix fixable issues
107
- lingo accept-edits --locale de # mark current translations as intentional
108
- lingo sync # rebuild drift state from current files
107
+ lingo accept-edits --locale de # protect hand-edited translations (drifted keys only)
108
+ lingo accept-edits --key a.b.c # protect a specific key
109
+ lingo accept-edits --all # protect everything (initial adoption)
110
+ lingo sync # refresh drift state (preserves manual flags)
109
111
  lingo hash # source-translation fingerprint
110
112
  ```
111
113
 
@@ -109,6 +109,10 @@ module Locallingo
109
109
  opts.on("--force-key KEY", "Force re-translation of a specific key") do |v|
110
110
  (@options[:force_keys] ||= []) << v
111
111
  end
112
+ opts.on("--key KEY", "accept-edits: accept a specific key (repeatable)") do |v|
113
+ (@options[:keys] ||= []) << v
114
+ end
115
+ opts.on("--all", "accept-edits: mark every translated key as manual") { @options[:all] = true }
112
116
  opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
113
117
  opts.on("-n", "--dry-run", "Show what would be done without changing files") { @options[:dry_run] = true }
114
118
  opts.on("--strict", "Fail on strict-tier issues (for CI)") { @options[:strict] = true }
@@ -185,11 +189,25 @@ module Locallingo
185
189
  warn "manual_edits validator is disabled in .locallingo.yml — nothing to accept."
186
190
  return
187
191
  end
188
- manager(config, options).accept_edits!(locale: options[:locale])
189
- puts "✅ Marked current translations as intentional."
192
+
193
+ results = manager(config, options).accept_edits!(
194
+ locale: options[:locale], keys: options[:keys] || [], all: options.fetch(:all, false)
195
+ )
196
+ report_accepted(results)
190
197
  puts "(dry run - no changes made)" if options[:dry_run]
191
198
  end
192
199
 
200
+ def report_accepted(results)
201
+ total = results.values.sum(&:size)
202
+ if total.zero?
203
+ puts "Nothing to accept — no hand-edited translations found."
204
+ return
205
+ end
206
+
207
+ results.each { |locale, keys| puts " #{locale}: #{keys.size} key(s)" if keys.any? }
208
+ puts "✅ Marked #{total} translation(s) as intentional."
209
+ end
210
+
193
211
  def cmd_hash(config, options)
194
212
  hash = manager(config, options).source_hash
195
213
  options[:format] == :json ? puts(JSON.generate({ hash: })) : puts(hash)
@@ -97,27 +97,35 @@ module Locallingo
97
97
  locales_to_process.each { |target_locale| translate_locale(source, target_locale, force:, force_keys:) }
98
98
  end
99
99
 
100
- # Mark every current target value as intentional (source_hash + target_hash +
101
- # manual flag) so the manual-edits validator stops flagging it.
102
- def accept_edits!(locale: nil)
100
+ # Mark hand-edited target values as intentional (source_hash + target_hash +
101
+ # manual flag) so the manual-edits validator stops flagging them and
102
+ # translate won't overwrite them. Unscoped, it accepts exactly the keys the
103
+ # manual-edits validator flags; `keys:` accepts the named keys regardless of
104
+ # drift; `all: true` marks every translated key (initial adoption). Returns
105
+ # `{ locale => accepted_keys }`.
106
+ def accept_edits!(locale: nil, keys: [], all: false)
103
107
  source = load_source_translations
104
108
  locales = locale ? [locale] : config.target_locales
105
109
 
106
- locales.each do |target_locale|
110
+ plans = locales.map do |target_locale|
107
111
  target = load_locale_translations(target_locale)
108
112
  locale_state = @state.load(target_locale)
113
+ accepted = keys_to_accept(source, target, locale_state, keys:, all:)
114
+ [target_locale, target, locale_state, accepted]
115
+ end
109
116
 
110
- target.each do |key, value|
111
- next unless source[key]
117
+ ensure_keys_matched!(keys, plans)
112
118
 
119
+ plans.each_with_object({}) do |(target_locale, target, locale_state, accepted), results|
120
+ accepted.each do |key|
113
121
  locale_state[key] = {
114
122
  "source_hash" => @state.hash(source[key]),
115
- "target_hash" => @state.hash(value),
123
+ "target_hash" => @state.hash(target[key]),
116
124
  "manual" => true
117
125
  }
118
126
  end
119
-
120
127
  @state.save(target_locale, locale_state) unless dry_run
128
+ results[target_locale] = accepted
121
129
  end
122
130
  end
123
131
 
@@ -126,8 +134,12 @@ module Locallingo
126
134
  format("%08x", Zlib.crc32(load_source_translations.to_json))
127
135
  end
128
136
 
129
- # Rewrite state from the current translation files (initial setup / after
130
- # manual edits). Returns the combined state.
137
+ # Refresh source hashes from the current translation files, backfill a
138
+ # `target_hash` baseline for entries that lack one, and prune state for
139
+ # keys that no longer exist. An existing `target_hash` and the `manual`
140
+ # flag are never touched — hand-edit protection is never dropped by a
141
+ # sync; use `accept_edits!` to resolve hand-edit drift explicitly.
142
+ # Returns the combined state.
131
143
  def sync_state!
132
144
  source = load_source_translations
133
145
 
@@ -136,18 +148,7 @@ module Locallingo
136
148
  en_state.each_key { |key| en_state.delete(key) unless source.key?(key) }
137
149
  @state.save(config.source_locale, en_state) unless dry_run
138
150
 
139
- config.target_locales.each do |locale|
140
- target = load_locale_translations(locale)
141
- locale_state = @state.load(locale)
142
-
143
- target.each_key do |key|
144
- next unless source[key]
145
-
146
- locale_state[key] = { "source_hash" => @state.hash(source[key]) }
147
- end
148
- locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) }
149
- @state.save(locale, locale_state) unless dry_run
150
- end
151
+ config.target_locales.each { |locale| sync_locale_state(source, locale) }
151
152
 
152
153
  combined = { config.source_locale => @state.load(config.source_locale) }
153
154
  config.target_locales.each { |locale| combined[locale] = @state.load(locale) }
@@ -167,6 +168,48 @@ module Locallingo
167
168
  def missing_validator = @missing_validator ||= Validators::Missing.new(cli_name:)
168
169
  def outdated_validator = @outdated_validator ||= Validators::Outdated.new(cli_name:)
169
170
 
171
+ def sync_locale_state(source, locale)
172
+ target = load_locale_translations(locale)
173
+ locale_state = @state.load(locale)
174
+
175
+ target.each do |key, value|
176
+ next unless source[key]
177
+
178
+ existing = locale_state[key]
179
+ entry = existing.is_a?(Hash) ? existing.dup : {}
180
+ entry["source_hash"] = @state.hash(source[key])
181
+ # Backfill a baseline for hand-added translations so the manual-edits
182
+ # validator can watch them; an existing target_hash is never recomputed —
183
+ # that would silently absorb hand-edit drift.
184
+ entry["target_hash"] ||= @state.hash(value)
185
+ locale_state[key] = entry
186
+ end
187
+ locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) }
188
+ @state.save(locale, locale_state) unless dry_run
189
+ end
190
+
191
+ def keys_to_accept(source, target, locale_state, keys:, all:)
192
+ return keys.select { |key| source.key?(key) && target.key?(key) } if keys.any?
193
+ return target.keys.select { |key| source.key?(key) } if all
194
+
195
+ target.keys.select do |key|
196
+ entry = locale_state[key]
197
+ next false unless source.key?(key) && entry.is_a?(Hash) && !entry["manual"]
198
+
199
+ entry["target_hash"] && entry["target_hash"] != @state.hash(target[key])
200
+ end
201
+ end
202
+
203
+ def ensure_keys_matched!(keys, plans)
204
+ return if keys.empty?
205
+
206
+ matched = plans.flat_map { |_, _, _, accepted| accepted }
207
+ missing = keys - matched
208
+ return if missing.empty?
209
+
210
+ raise Error, "accept-edits: key(s) not found in any target locale: #{missing.join(", ")}"
211
+ end
212
+
170
213
  def translate_locale(source, target_locale, force:, force_keys:)
171
214
  log("Processing #{target_locale}...")
172
215
 
@@ -301,10 +344,15 @@ module Locallingo
301
344
  translations.each_key do |key|
302
345
  next unless source[key]
303
346
 
304
- locale_state[key] = {
347
+ existing = locale_state[key]
348
+ entry = {
305
349
  "source_hash" => @state.hash(source[key]),
306
350
  "target_hash" => @state.hash(translations[key])
307
351
  }
352
+ # A force-keyed retranslation may overwrite a manual value on explicit
353
+ # request, but the protection flag itself must survive.
354
+ entry["manual"] = true if existing.is_a?(Hash) && existing["manual"]
355
+ locale_state[key] = entry
308
356
  end
309
357
  end
310
358
 
@@ -40,8 +40,9 @@ module Locallingo
40
40
  "This would cause state loss. Fix the JSON manually or restore from git."
41
41
  end
42
42
 
43
- # Save a locale's state, split back into per-namespace files. Namespace files
44
- # that no longer have keys are removed.
43
+ # Save a locale's state, split back into per-namespace files. Files whose
44
+ # content is unchanged are left untouched so unrelated namespaces never
45
+ # churn in diffs. Namespace files that no longer have keys are removed.
45
46
  def save(locale, locale_state)
46
47
  by_namespace = locale_state.each_with_object({}) do |(key, value), groups|
47
48
  namespace = key.split(".").first
@@ -50,7 +51,10 @@ module Locallingo
50
51
 
51
52
  by_namespace.each do |namespace, keys|
52
53
  state_file = File.join(state_dir, "#{namespace}.#{locale}.json")
53
- File.write(state_file, JSON.pretty_generate(keys.sort.to_h))
54
+ content = JSON.pretty_generate(keys.sort.to_h)
55
+ next if File.exist?(state_file) && File.read(state_file) == content
56
+
57
+ File.write(state_file, content)
54
58
  end
55
59
 
56
60
  Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file|
@@ -30,7 +30,7 @@ module Locallingo
30
30
  type: :manual_edit,
31
31
  locale:,
32
32
  key:,
33
- suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale}"
33
+ suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale} --key #{key}"
34
34
  }
35
35
  end
36
36
  end
@@ -19,7 +19,7 @@ module Locallingo
19
19
  type: :outdated,
20
20
  locale:,
21
21
  key:,
22
- suggestion: "Source changed. Run: #{@cli_name} translate --locale #{locale} --force-key #{key}"
22
+ suggestion: suggestion_for(key, locale, locale_state)
23
23
  }
24
24
  end
25
25
  end
@@ -31,6 +31,19 @@ module Locallingo
31
31
  key if stored && stored != StateStore.hash(value)
32
32
  end
33
33
  end
34
+
35
+ private
36
+
37
+ # Manual keys must not be pushed toward machine retranslation — the value
38
+ # is hand-curated, so the human updates it and re-accepts.
39
+ def suggestion_for(key, locale, locale_state)
40
+ if locale_state.dig(key, "manual")
41
+ "Source changed for a manually-curated key. Update the #{locale} value by hand, " \
42
+ "then run: #{@cli_name} accept-edits --locale #{locale} --key #{key}"
43
+ else
44
+ "Source changed. Run: #{@cli_name} translate --locale #{locale} --force-key #{key}"
45
+ end
46
+ end
34
47
  end
35
48
  end
36
49
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Locallingo
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: locallingo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson