locallingo 0.3.0 → 0.4.0

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: 398a4540431fa5298a1eba1fedecef3c4b7344d2749f935a30882d8899ebcb69
4
+ data.tar.gz: c854c5c26ebee79b0d5663614207e0f75368c973058a5ac320f58ec4e45e3835
5
5
  SHA512:
6
- metadata.gz: 7820e2f62b6784ae546cd7521303e434a0319099f1ac69fa7471aff9cb786ff3b844f1200ae711eab36df7e058b31742f03de231c5bd95d460889c1027209628
7
- data.tar.gz: b727c0b6f89916f328f3c2b00831263d779b7a7139f478571d7b2e9015326ef8841873da7e72ddd6f963e7f0009dafe9cc4630c1d6883d091bb165672d70fc67
6
+ metadata.gz: 5969d9cac09643f81e6e37ae72ad79003373d1870bbaf37300c0e4c5a95449c6c422176aa6ea1938b66a53889669bbf3c0a0f5525e56d7f0afd0e8c9c2c33c44
7
+ data.tar.gz: 22e5a7d6ff926e04baa3e0de58d7cea8d4993356a3ac931de745dbc8e817298b99b2ff591b4e353e1adc9052dd44307fce4cd957605fcd5527c448e83e8d40f4
data/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+ - `lingo sync` no longer destroys hand-edit protection: it now only refreshes
11
+ each entry's `source_hash` and preserves `target_hash` and `manual: true`
12
+ (the pre-extraction `bin/translate` behavior). Previously a single sync wiped
13
+ both fields for every key, blinding the `manual_edits` validator and causing
14
+ `manual` flags to flip back and forth across branches.
15
+ - `translate --force-key` on a `manual`-flagged key keeps the flag. The value is
16
+ still retranslated on explicit request, but no command removes `manual: true`
17
+ anymore — unprotecting a key requires editing the state JSON by hand.
18
+ - State files whose content is unchanged are no longer rewritten, so operations
19
+ never touch `.i18n-state/` files for unrelated namespaces.
20
+
21
+ ### Changed
22
+ - Unscoped `lingo accept-edits` now accepts only the keys the `manual_edits`
23
+ validator flags (actually hand-edited), instead of stamping `manual: true` on
24
+ every key of the locale. Use the new `--key KEY` (repeatable) for surgical
25
+ accepts, or `--all` for the old blanket behavior (initial adoption).
26
+ - Validator suggestions are scoped and safe to follow verbatim: `manual_edit`
27
+ violations suggest `accept-edits --locale <l> --key <key>`, and `outdated`
28
+ violations on manual keys tell you to update the value by hand and re-accept
29
+ it rather than force-translating over curated text.
30
+
9
31
  ### Added
10
32
  - `Locallingo.configure { |c| c.anthropic_api_key = ... }` — gem-level provider
11
33
  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,11 @@ 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 and prune state
138
+ # for keys that no longer exist. Existing `target_hash`/`manual` fields are
139
+ # preserved — hand-edit protection is never dropped by a sync; use
140
+ # `accept_edits!` to resolve hand-edit drift explicitly. Returns the
141
+ # combined state.
131
142
  def sync_state!
132
143
  source = load_source_translations
133
144
 
@@ -136,18 +147,7 @@ module Locallingo
136
147
  en_state.each_key { |key| en_state.delete(key) unless source.key?(key) }
137
148
  @state.save(config.source_locale, en_state) unless dry_run
138
149
 
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
150
+ config.target_locales.each { |locale| sync_locale_state(source, locale) }
151
151
 
152
152
  combined = { config.source_locale => @state.load(config.source_locale) }
153
153
  config.target_locales.each { |locale| combined[locale] = @state.load(locale) }
@@ -167,6 +167,44 @@ module Locallingo
167
167
  def missing_validator = @missing_validator ||= Validators::Missing.new(cli_name:)
168
168
  def outdated_validator = @outdated_validator ||= Validators::Outdated.new(cli_name:)
169
169
 
170
+ def sync_locale_state(source, locale)
171
+ target = load_locale_translations(locale)
172
+ locale_state = @state.load(locale)
173
+
174
+ target.each_key do |key|
175
+ next unless source[key]
176
+
177
+ existing = locale_state[key]
178
+ entry = existing.is_a?(Hash) ? existing.dup : {}
179
+ entry["source_hash"] = @state.hash(source[key])
180
+ locale_state[key] = entry
181
+ end
182
+ locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) }
183
+ @state.save(locale, locale_state) unless dry_run
184
+ end
185
+
186
+ def keys_to_accept(source, target, locale_state, keys:, all:)
187
+ return keys.select { |key| source.key?(key) && target.key?(key) } if keys.any?
188
+ return target.keys.select { |key| source.key?(key) } if all
189
+
190
+ target.keys.select do |key|
191
+ entry = locale_state[key]
192
+ next false unless source.key?(key) && entry.is_a?(Hash) && !entry["manual"]
193
+
194
+ entry["target_hash"] && entry["target_hash"] != @state.hash(target[key])
195
+ end
196
+ end
197
+
198
+ def ensure_keys_matched!(keys, plans)
199
+ return if keys.empty?
200
+
201
+ matched = plans.flat_map { |_, _, _, accepted| accepted }
202
+ missing = keys - matched
203
+ return if missing.empty?
204
+
205
+ raise Error, "accept-edits: key(s) not found in any target locale: #{missing.join(", ")}"
206
+ end
207
+
170
208
  def translate_locale(source, target_locale, force:, force_keys:)
171
209
  log("Processing #{target_locale}...")
172
210
 
@@ -301,10 +339,15 @@ module Locallingo
301
339
  translations.each_key do |key|
302
340
  next unless source[key]
303
341
 
304
- locale_state[key] = {
342
+ existing = locale_state[key]
343
+ entry = {
305
344
  "source_hash" => @state.hash(source[key]),
306
345
  "target_hash" => @state.hash(translations[key])
307
346
  }
347
+ # A force-keyed retranslation may overwrite a manual value on explicit
348
+ # request, but the protection flag itself must survive.
349
+ entry["manual"] = true if existing.is_a?(Hash) && existing["manual"]
350
+ locale_state[key] = entry
308
351
  end
309
352
  end
310
353
 
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson