locallingo 0.2.2 → 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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +19 -4
- data/lib/locallingo/cli.rb +33 -2
- data/lib/locallingo/manager.rb +66 -23
- data/lib/locallingo/providers/ruby_llm.rb +54 -17
- data/lib/locallingo/settings.rb +31 -0
- 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
- data/lib/locallingo.rb +18 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 398a4540431fa5298a1eba1fedecef3c4b7344d2749f935a30882d8899ebcb69
|
|
4
|
+
data.tar.gz: c854c5c26ebee79b0d5663614207e0f75368c973058a5ac320f58ec4e45e3835
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5969d9cac09643f81e6e37ae72ad79003373d1870bbaf37300c0e4c5a95449c6c422176aa6ea1938b66a53889669bbf3c0a0f5525e56d7f0afd0e8c9c2c33c44
|
|
7
|
+
data.tar.gz: 22e5a7d6ff926e04baa3e0de58d7cea8d4993356a3ac931de745dbc8e817298b99b2ff591b4e353e1adc9052dd44307fce4cd957605fcd5527c448e83e8d40f4
|
data/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,36 @@ 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
|
|
32
|
+
- `Locallingo.configure { |c| c.anthropic_api_key = ... }` — gem-level provider
|
|
33
|
+
credentials as Strings or lazy callables, for apps whose keys don't live in
|
|
34
|
+
ENV (Rails credentials, app config objects, vaults). Precedence:
|
|
35
|
+
`Locallingo.configure` → host `RubyLLM.configure` → ENV.
|
|
36
|
+
- `.locallingo.rb` setup file: the CLI loads it from the project root before
|
|
37
|
+
dispatch, so standalone `lingo` runs can configure credentials without
|
|
38
|
+
booting Rails.
|
|
10
39
|
- Initial extraction from the `bin/translate` toolchain into a standalone gem.
|
|
11
40
|
- `lingo` CLI with subcommands: `status`, `translate`, `validate`, `quality`,
|
|
12
41
|
`fix-quality`, `accept-edits`, `hash`, `sync`. Legacy `--flag` forms still work
|
data/README.md
CHANGED
|
@@ -32,8 +32,21 @@ group :development do
|
|
|
32
32
|
end
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
Provider credentials come from ENV (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`)
|
|
36
|
+
or from Ruby, for apps whose keys live elsewhere (Rails credentials, an app
|
|
37
|
+
config object, a vault):
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
Locallingo.configure do |config|
|
|
41
|
+
config.anthropic_api_key = "sk-ant-..." # a String…
|
|
42
|
+
config.openai_api_key = -> { AppConf.openai_key } # …or a lazy callable
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Put that in a `.locallingo.rb` file next to `.locallingo.yml` and the `lingo`
|
|
47
|
+
CLI loads it on start — no Rails boot required. A key set via
|
|
48
|
+
`Locallingo.configure` wins over one set through `RubyLLM.configure`, which
|
|
49
|
+
wins over ENV. Locallingo never stores keys itself.
|
|
37
50
|
|
|
38
51
|
## Configuration
|
|
39
52
|
|
|
@@ -91,8 +104,10 @@ lingo validate --strict # CI gate (exit 1 on strict-tier issues)
|
|
|
91
104
|
lingo validate --strict-all # stricter CI gate
|
|
92
105
|
lingo quality --ai # quality linting (+ optional AI pass)
|
|
93
106
|
lingo fix-quality --locale en # auto-fix fixable issues
|
|
94
|
-
lingo accept-edits --locale de #
|
|
95
|
-
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)
|
|
96
111
|
lingo hash # source-translation fingerprint
|
|
97
112
|
```
|
|
98
113
|
|
data/lib/locallingo/cli.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Locallingo
|
|
|
16
16
|
class CLI
|
|
17
17
|
CLI_NAME = "lingo"
|
|
18
18
|
|
|
19
|
+
# Optional Ruby setup file loaded before dispatch — the hook for apps to
|
|
20
|
+
# configure credentials (via Locallingo.configure or RubyLLM.configure)
|
|
21
|
+
# without booting Rails.
|
|
22
|
+
SETUP_FILENAME = ".locallingo.rb"
|
|
23
|
+
|
|
19
24
|
# subcommand => the legacy flag it replaces
|
|
20
25
|
COMMANDS = {
|
|
21
26
|
"status" => "--status",
|
|
@@ -52,6 +57,7 @@ module Locallingo
|
|
|
52
57
|
def run
|
|
53
58
|
command = resolve_command
|
|
54
59
|
options = parse_options!
|
|
60
|
+
load_setup_file
|
|
55
61
|
config = Locallingo.configuration(root_path: Dir.pwd, package: options[:package])
|
|
56
62
|
dispatch(command, config, options)
|
|
57
63
|
rescue Locallingo::Error => e
|
|
@@ -61,6 +67,13 @@ module Locallingo
|
|
|
61
67
|
|
|
62
68
|
private
|
|
63
69
|
|
|
70
|
+
# An absent file is fine; errors in the file propagate loudly. `load` (not
|
|
71
|
+
# require) so repeated in-process invocations re-execute it.
|
|
72
|
+
def load_setup_file
|
|
73
|
+
path = File.join(Dir.pwd, SETUP_FILENAME)
|
|
74
|
+
load path if File.file?(path)
|
|
75
|
+
end
|
|
76
|
+
|
|
64
77
|
# Determine the subcommand, translating a leading legacy flag (with a
|
|
65
78
|
# deprecation notice) and defaulting to `status`.
|
|
66
79
|
def resolve_command
|
|
@@ -96,6 +109,10 @@ module Locallingo
|
|
|
96
109
|
opts.on("--force-key KEY", "Force re-translation of a specific key") do |v|
|
|
97
110
|
(@options[:force_keys] ||= []) << v
|
|
98
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 }
|
|
99
116
|
opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
|
|
100
117
|
opts.on("-n", "--dry-run", "Show what would be done without changing files") { @options[:dry_run] = true }
|
|
101
118
|
opts.on("--strict", "Fail on strict-tier issues (for CI)") { @options[:strict] = true }
|
|
@@ -172,11 +189,25 @@ module Locallingo
|
|
|
172
189
|
warn "manual_edits validator is disabled in .locallingo.yml — nothing to accept."
|
|
173
190
|
return
|
|
174
191
|
end
|
|
175
|
-
|
|
176
|
-
|
|
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)
|
|
177
197
|
puts "(dry run - no changes made)" if options[:dry_run]
|
|
178
198
|
end
|
|
179
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
|
+
|
|
180
211
|
def cmd_hash(config, options)
|
|
181
212
|
hash = manager(config, options).source_hash
|
|
182
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,11 @@ 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 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
|
|
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
|
|
|
@@ -31,14 +31,14 @@ module Locallingo
|
|
|
31
31
|
@provider = provider.to_sym
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# True when
|
|
35
|
-
#
|
|
36
|
-
# elsewhere) rather than
|
|
34
|
+
# True when a key for the configured provider is found in any source
|
|
35
|
+
# (Locallingo settings, host RubyLLM config, ENV). Unknown providers are
|
|
36
|
+
# assumed configured (RubyLLM may source the key elsewhere) rather than
|
|
37
|
+
# blocking.
|
|
37
38
|
def credentials?
|
|
38
|
-
|
|
39
|
-
return true unless env
|
|
39
|
+
return true unless CREDENTIAL_ENV.key?(provider)
|
|
40
40
|
|
|
41
|
-
!
|
|
41
|
+
!resolved_api_key.nil?
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Raise a precise error when the provider has no credentials.
|
|
@@ -46,8 +46,10 @@ module Locallingo
|
|
|
46
46
|
return if credentials?
|
|
47
47
|
|
|
48
48
|
raise MissingCredentialsError,
|
|
49
|
-
"No credentials for provider #{provider.inspect} " \
|
|
50
|
-
"
|
|
49
|
+
"No credentials for provider #{provider.inspect}. " \
|
|
50
|
+
"Set #{CREDENTIAL_ENV[provider]} in ENV, call " \
|
|
51
|
+
"Locallingo.configure { |c| c.#{provider}_api_key = ... }, or add a " \
|
|
52
|
+
".locallingo.rb setup file at the project root (loaded by the CLI)."
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
# Send +instructions+ (system prompt) + +payload+ (user message, JSON) to
|
|
@@ -70,20 +72,55 @@ module Locallingo
|
|
|
70
72
|
|
|
71
73
|
# RubyLLM does not read provider API keys from ENV on its own, so a
|
|
72
74
|
# standalone CLI run (no Rails initializer to call RubyLLM.configure)
|
|
73
|
-
# would raise "Missing configuration for <provider>".
|
|
74
|
-
#
|
|
75
|
-
#
|
|
75
|
+
# would raise "Missing configuration for <provider>". Push the resolved
|
|
76
|
+
# key into RubyLLM's config: an explicit `Locallingo.configure` key wins
|
|
77
|
+
# (re-resolved every chat so callables stay live), then a key the host
|
|
78
|
+
# app already set, then the ENV fallback.
|
|
76
79
|
def configure_credentials!
|
|
77
|
-
|
|
78
|
-
return unless env
|
|
80
|
+
return unless CREDENTIAL_ENV.key?(provider)
|
|
79
81
|
|
|
80
|
-
setting = "#{provider}_api_key"
|
|
81
82
|
config = ::RubyLLM.config
|
|
83
|
+
setting = key_setting
|
|
82
84
|
return unless config.respond_to?(setting) && config.respond_to?("#{setting}=")
|
|
83
|
-
return unless config.public_send(setting).to_s.strip.empty?
|
|
84
85
|
|
|
85
|
-
key =
|
|
86
|
-
config.public_send(
|
|
86
|
+
key = settings_api_key
|
|
87
|
+
return if key.nil? && !presence(config.public_send(setting)).nil?
|
|
88
|
+
|
|
89
|
+
key ||= env_api_key
|
|
90
|
+
config.public_send("#{setting}=", key) unless key.nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Key resolution across sources, in precedence order. Used by
|
|
94
|
+
# #credentials? as a fail-fast check before any network call.
|
|
95
|
+
def resolved_api_key
|
|
96
|
+
settings_api_key || host_configured_api_key || env_api_key
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def settings_api_key
|
|
100
|
+
Locallingo.settings.api_key_for(provider)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A key the host app set via RubyLLM.configure — only inspected when
|
|
104
|
+
# ruby_llm is already loaded (we never require it just to peek).
|
|
105
|
+
def host_configured_api_key
|
|
106
|
+
return nil unless defined?(::RubyLLM)
|
|
107
|
+
|
|
108
|
+
config = ::RubyLLM.config
|
|
109
|
+
return nil unless config.respond_to?(key_setting)
|
|
110
|
+
|
|
111
|
+
presence(config.public_send(key_setting))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def env_api_key
|
|
115
|
+
env = CREDENTIAL_ENV[provider]
|
|
116
|
+
env ? presence(ENV.fetch(env, "")) : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def key_setting = "#{provider}_api_key"
|
|
120
|
+
|
|
121
|
+
def presence(value)
|
|
122
|
+
key = value.to_s.strip
|
|
123
|
+
key.empty? ? nil : key
|
|
87
124
|
end
|
|
88
125
|
end
|
|
89
126
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
# Code-level gem settings, configured via `Locallingo.configure`. Distinct
|
|
5
|
+
# from Locallingo::Configuration, which loads the `.locallingo.yml` project
|
|
6
|
+
# file — Settings holds what must never live in YAML: provider credentials.
|
|
7
|
+
#
|
|
8
|
+
# Locallingo.configure do |config|
|
|
9
|
+
# config.anthropic_api_key = ENV.fetch("MY_KEY") # a String…
|
|
10
|
+
# config.openai_api_key = -> { Vault.read("openai_key") } # …or a callable
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Callables are resolved lazily on every use (never memoized), so keys can
|
|
14
|
+
# come from sources that aren't ready at configure time or that rotate.
|
|
15
|
+
class Settings
|
|
16
|
+
PROVIDERS = %i[openai anthropic gemini deepseek openrouter].freeze
|
|
17
|
+
|
|
18
|
+
attr_accessor(*PROVIDERS.map { |name| :"#{name}_api_key" })
|
|
19
|
+
|
|
20
|
+
# The usable key for +provider+: callables are called, results stripped,
|
|
21
|
+
# and blank or unknown-provider values come back as nil.
|
|
22
|
+
def api_key_for(provider)
|
|
23
|
+
return nil unless PROVIDERS.include?(provider.to_sym)
|
|
24
|
+
|
|
25
|
+
value = public_send("#{provider}_api_key")
|
|
26
|
+
value = value.call if value.respond_to?(:call)
|
|
27
|
+
key = value.to_s.strip
|
|
28
|
+
key.empty? ? nil : key
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -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
data/lib/locallingo.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "locallingo/version"
|
|
4
|
+
require_relative "locallingo/settings"
|
|
4
5
|
require_relative "locallingo/configuration"
|
|
5
6
|
require_relative "locallingo/json_extraction"
|
|
6
7
|
require_relative "locallingo/key_flattener"
|
|
@@ -35,4 +36,21 @@ module Locallingo
|
|
|
35
36
|
def self.configuration(root_path: Dir.pwd, package: nil)
|
|
36
37
|
Configuration.load(root_path:, package:)
|
|
37
38
|
end
|
|
39
|
+
|
|
40
|
+
# Code-level settings (provider credentials) — see Locallingo::Settings.
|
|
41
|
+
def self.settings
|
|
42
|
+
@settings ||= Settings.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Configure the gem from Ruby — the credentials path for apps whose keys
|
|
46
|
+
# don't live in ENV (call it from a Rails initializer or a `.locallingo.rb`
|
|
47
|
+
# setup file next to `.locallingo.yml`; the CLI loads the latter on start).
|
|
48
|
+
def self.configure
|
|
49
|
+
yield settings
|
|
50
|
+
settings
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.reset_settings!
|
|
54
|
+
@settings = nil
|
|
55
|
+
end
|
|
38
56
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -81,6 +81,7 @@ files:
|
|
|
81
81
|
- lib/locallingo/quality_checker.rb
|
|
82
82
|
- lib/locallingo/reporter.rb
|
|
83
83
|
- lib/locallingo/rubocop.rb
|
|
84
|
+
- lib/locallingo/settings.rb
|
|
84
85
|
- lib/locallingo/state_store.rb
|
|
85
86
|
- lib/locallingo/validators/duplicate_values.rb
|
|
86
87
|
- lib/locallingo/validators/manual_edits.rb
|