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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +4 -2
- data/lib/locallingo/cli.rb +20 -2
- data/lib/locallingo/manager.rb +71 -23
- data/lib/locallingo/state_store.rb +7 -3
- data/lib/locallingo/validators/manual_edits.rb +1 -1
- data/lib/locallingo/validators/outdated.rb +14 -1
- data/lib/locallingo/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 566c302b3598359e622f4b6b98a59bf1a8b3487fc81b898ca93a2d0446c31c63
|
|
4
|
+
data.tar.gz: 9961bb74a9f23e2bebdaa4c64913723a195eaac89470a68e3f175b87f2a68e24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 #
|
|
108
|
-
lingo
|
|
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
|
|
data/lib/locallingo/cli.rb
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
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)
|
data/lib/locallingo/manager.rb
CHANGED
|
@@ -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
|
|
101
|
-
# manual flag) so the manual-edits validator stops flagging
|
|
102
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
130
|
-
#
|
|
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
|
|
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.
|
|
44
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/locallingo/version.rb
CHANGED