i18n-keyless-rails 3.5.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 +7 -0
- data/LICENSE.md +21 -0
- data/README.md +237 -0
- data/SKILL.md +106 -0
- data/lib/i18n-keyless-rails.rb +4 -0
- data/lib/i18n_keyless/api_client.rb +355 -0
- data/lib/i18n_keyless/backend.rb +52 -0
- data/lib/i18n_keyless/config.rb +125 -0
- data/lib/i18n_keyless/dictionary_store.rb +151 -0
- data/lib/i18n_keyless/helper.rb +21 -0
- data/lib/i18n_keyless/locale.rb +103 -0
- data/lib/i18n_keyless/middleware.rb +30 -0
- data/lib/i18n_keyless/miss.rb +44 -0
- data/lib/i18n_keyless/railtie.rb +31 -0
- data/lib/i18n_keyless/translate_missing_keys_job.rb +11 -0
- data/lib/i18n_keyless/translator.rb +300 -0
- data/lib/i18n_keyless/version.rb +8 -0
- data/lib/i18n_keyless.rb +129 -0
- data/llms.txt +101 -0
- metadata +95 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module I18nKeyless
|
|
9
|
+
# The three routes of the i18n-keyless wire format this gem uses, with the
|
|
10
|
+
# network policy of the SDKs (conformance/vectors/backoff.json and
|
|
11
|
+
# retry-decision.json): a per-attempt timeout, three attempts with fixed
|
|
12
|
+
# backoff delays on a network error, a timeout, a 429, a 5xx or an unparsable
|
|
13
|
+
# 200 body; no retry on any other status; nothing ever raised.
|
|
14
|
+
#
|
|
15
|
+
# Usage analytics (POST /translate/last-used-translations) follow the node
|
|
16
|
+
# SDK: the cumulative map is POSTed, at most once every 10 s.
|
|
17
|
+
class ApiClient
|
|
18
|
+
# Sent as the `Version` header (the wire dialect: v3 language codes).
|
|
19
|
+
VERSION = I18nKeyless::VERSION
|
|
20
|
+
|
|
21
|
+
# Sent as the `sdk` header. `rails` is registered on the API as a server
|
|
22
|
+
# label, counted like `node`: a server sends no `unique_id`, the API counts
|
|
23
|
+
# it by its source connection, which the client cannot shape.
|
|
24
|
+
SDK = "rails"
|
|
25
|
+
|
|
26
|
+
DEFAULT_URL = "https://api.i18n-keyless.com"
|
|
27
|
+
|
|
28
|
+
ACTION_PARSE_BODY = "parse-body"
|
|
29
|
+
ACTION_NOT_MODIFIED = "not-modified"
|
|
30
|
+
ACTION_FAIL = "fail"
|
|
31
|
+
ACTION_RETRY = "retry"
|
|
32
|
+
|
|
33
|
+
# A network error or a timeout is transient and retried; any other
|
|
34
|
+
# exception ends the call now.
|
|
35
|
+
TRANSIENT_ERRORS = [
|
|
36
|
+
Timeout::Error, SocketError, SystemCallError, EOFError, IOError, OpenSSL::SSL::SSLError
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
Dictionary = Struct.new(:ok, :not_modified, :translations, :etag, :error, keyword_init: true)
|
|
40
|
+
UsageResult = Struct.new(:ok, :sent, :error, keyword_init: true)
|
|
41
|
+
Outcome = Struct.new(:action, :error, :response, :json, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
attr_reader :api_key, :api_url, :timeout, :retry_delays, :concurrency
|
|
44
|
+
# The backoff sleep, `->(ms) { sleep(ms / 1000.0) }`. Tests replace it.
|
|
45
|
+
attr_accessor :sleeper
|
|
46
|
+
|
|
47
|
+
# @param retry_delays [Array<Integer>] milliseconds between attempts (two entries: three attempts)
|
|
48
|
+
# @param sleeper [#call] receives a number of milliseconds; replaced in tests
|
|
49
|
+
def initialize(api_key:, api_url: DEFAULT_URL, timeout: 10, retry_delays: [500, 1500], concurrency: 30,
|
|
50
|
+
logger: nil, sleeper: nil)
|
|
51
|
+
@api_key = api_key.to_s
|
|
52
|
+
@api_url = api_url.to_s.sub(%r{/+\z}, "")
|
|
53
|
+
@api_url = DEFAULT_URL if @api_url.empty?
|
|
54
|
+
@timeout = timeout
|
|
55
|
+
@retry_delays = retry_delays.map(&:to_i)
|
|
56
|
+
@concurrency = [concurrency.to_i, 1].max
|
|
57
|
+
@logger = logger
|
|
58
|
+
@sleeper = sleeper || ->(ms) { sleep(ms / 1000.0) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def max_attempts
|
|
62
|
+
retry_delays.length + 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The delay after a failed attempt (1-based), or nil when there is no next attempt.
|
|
66
|
+
def delay_after(failed_attempt)
|
|
67
|
+
retry_delays[failed_attempt - 1]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# GET /translate/{lang}: the whole dictionary of one language, or a 304
|
|
71
|
+
# when the ETag still matches.
|
|
72
|
+
def fetch_dictionary(lang, namespace, etag, last_refresh = "")
|
|
73
|
+
result = Dictionary.new(ok: false, not_modified: false, translations: {}, etag: nil, error: nil)
|
|
74
|
+
url = dictionary_url(lang, namespace, etag, last_refresh)
|
|
75
|
+
outcome = call { request(:get, url, etag: etag) }
|
|
76
|
+
if outcome.action == ACTION_NOT_MODIFIED
|
|
77
|
+
result.ok = true
|
|
78
|
+
result.not_modified = true
|
|
79
|
+
return result
|
|
80
|
+
end
|
|
81
|
+
if outcome.action != ACTION_PARSE_BODY
|
|
82
|
+
result.error = outcome.error
|
|
83
|
+
warn("fetch all translations error: #{outcome.error}")
|
|
84
|
+
return result
|
|
85
|
+
end
|
|
86
|
+
dictionary_from(outcome.json, outcome.response, result)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# POST /translate/last-used-translations: the cumulative usage map. An
|
|
90
|
+
# empty map is never sent. Same network policy as every other call.
|
|
91
|
+
def send_usage(primary, usage_by_namespace)
|
|
92
|
+
return UsageResult.new(ok: false, sent: false, error: nil) if usage_by_namespace.empty? || api_key.empty?
|
|
93
|
+
|
|
94
|
+
body = { "primaryLanguage" => primary, "translationsUsageByNamespace" => usage_by_namespace }
|
|
95
|
+
outcome = call { request(:post, "#{api_url}/translate/last-used-translations", body: body) }
|
|
96
|
+
if outcome.action != ACTION_PARSE_BODY
|
|
97
|
+
warn("send translations usage error: #{outcome.error}")
|
|
98
|
+
return UsageResult.new(ok: false, sent: true, error: outcome.error)
|
|
99
|
+
end
|
|
100
|
+
json = outcome.json
|
|
101
|
+
warn(json["message"].to_s) if json["message"].to_s != ""
|
|
102
|
+
unless json["ok"]
|
|
103
|
+
error = (json["error"] || "not ok").to_s
|
|
104
|
+
warn("send translations usage error: #{error}")
|
|
105
|
+
return UsageResult.new(ok: false, sent: true, error: error)
|
|
106
|
+
end
|
|
107
|
+
UsageResult.new(ok: true, sent: true, error: nil)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# POST /translate for every miss, at most `concurrency` at a time. Failed
|
|
111
|
+
# attempts are retried together, one backoff sleep per round.
|
|
112
|
+
#
|
|
113
|
+
# @param misses [Array<Miss>]
|
|
114
|
+
# @param languages [Array<String>] the configured languages (the primary is added)
|
|
115
|
+
# @return [Hash{String => Hash{String => String}, nil}] translations by language, keyed by miss id; nil when the call failed
|
|
116
|
+
def translate(misses, primary, languages)
|
|
117
|
+
results = {}
|
|
118
|
+
pending = {}
|
|
119
|
+
misses.each do |miss|
|
|
120
|
+
pending[miss.id] = miss
|
|
121
|
+
results[miss.id] = nil
|
|
122
|
+
end
|
|
123
|
+
errors = {}
|
|
124
|
+
attempt = 1
|
|
125
|
+
while attempt <= max_attempts && !pending.empty?
|
|
126
|
+
retry_next = {}
|
|
127
|
+
pending.values.each_slice(concurrency) do |chunk|
|
|
128
|
+
responses = post_chunk(chunk, primary, languages)
|
|
129
|
+
chunk.each do |miss|
|
|
130
|
+
outcome = outcome_of(responses.fetch(miss.id) { RuntimeError.new("no response") })
|
|
131
|
+
errors[miss.id] = outcome.error
|
|
132
|
+
if outcome.action == ACTION_PARSE_BODY
|
|
133
|
+
json = self.class.decode_json(outcome.response)
|
|
134
|
+
if json.nil?
|
|
135
|
+
errors[miss.id] = "invalid JSON"
|
|
136
|
+
retry_next[miss.id] = miss
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
results[miss.id] = translation_from(json, miss)
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
if outcome.action == ACTION_RETRY
|
|
143
|
+
retry_next[miss.id] = miss
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
# fail (or a 304 that makes no sense on a POST): give up on this miss now
|
|
147
|
+
warn("translate error for \"#{miss.key}\": #{outcome.error}")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
pending = retry_next
|
|
151
|
+
sleep_after(attempt) unless pending.empty?
|
|
152
|
+
attempt += 1
|
|
153
|
+
end
|
|
154
|
+
pending.each_value do |miss|
|
|
155
|
+
warn("translate error for \"#{miss.key}\": #{errors[miss.id] || 'unknown error'}")
|
|
156
|
+
end
|
|
157
|
+
results
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The body of one POST /translate (conformance/vectors/translate-request.json).
|
|
161
|
+
def translate_body(miss, primary, languages)
|
|
162
|
+
body = {
|
|
163
|
+
"key" => miss.key,
|
|
164
|
+
"context" => miss.context,
|
|
165
|
+
# The default namespace is omitted on the wire, like the SDKs do.
|
|
166
|
+
"namespace" => miss.namespace == Translator::DEFAULT_NAMESPACE ? nil : miss.namespace,
|
|
167
|
+
# The configured list plus the primary, never the locale that missed: the
|
|
168
|
+
# API stores this list as the project's languages (the react SDK sends its
|
|
169
|
+
# required `supported` list the same way).
|
|
170
|
+
"languages" => (Array(languages) + [primary]).uniq,
|
|
171
|
+
"primaryLanguage" => primary
|
|
172
|
+
}
|
|
173
|
+
body.reject { |_, value| value.nil? || value == "" }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# What one attempt's answer does to the call. Statuses follow
|
|
177
|
+
# conformance/vectors/retry-decision.json; `error` is the reason phrase
|
|
178
|
+
# when non-empty, else `HTTP <code>`.
|
|
179
|
+
def self.decide(status, reason = nil)
|
|
180
|
+
status = status.to_i
|
|
181
|
+
error = reason.to_s.empty? ? "HTTP #{status}" : reason.to_s
|
|
182
|
+
return Outcome.new(action: ACTION_PARSE_BODY, error: "") if status == 200
|
|
183
|
+
return Outcome.new(action: ACTION_NOT_MODIFIED, error: "") if status == 304
|
|
184
|
+
return Outcome.new(action: ACTION_RETRY, error: error) if status == 429 || status >= 500
|
|
185
|
+
|
|
186
|
+
Outcome.new(action: ACTION_FAIL, error: error)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A network error or a timeout is transient; the SDKs spell a timeout `timeout`.
|
|
190
|
+
def self.error_for(exception)
|
|
191
|
+
return "timeout" if exception.is_a?(Timeout::Error) || exception.message =~ /timed out|timeout/i
|
|
192
|
+
|
|
193
|
+
message = exception.message.to_s
|
|
194
|
+
message.empty? ? exception.class.name : message
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.transient?(exception)
|
|
198
|
+
TRANSIENT_ERRORS.any? { |klass| exception.is_a?(klass) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.decode_json(response)
|
|
202
|
+
json = JSON.parse(response.body.to_s)
|
|
203
|
+
json.is_a?(Hash) ? json : nil
|
|
204
|
+
rescue JSON::ParserError, TypeError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The URL of a bulk fetch (conformance/vectors/dictionary-request.json).
|
|
209
|
+
# With an ETag in hand, freshness travels in If-None-Match and the URL
|
|
210
|
+
# stays stable, so shared HTTP caches can hold it. Without one, the delta
|
|
211
|
+
# cursor travels as `last_refresh`: this gem keeps no cursor and sends it
|
|
212
|
+
# empty, which asks for the whole dictionary.
|
|
213
|
+
def dictionary_url(lang, namespace, etag, last_refresh = "")
|
|
214
|
+
# The default namespace is omitted from the query so a plain install
|
|
215
|
+
# hits the exact same URL as the SDKs.
|
|
216
|
+
namespace_query = namespace == Translator::DEFAULT_NAMESPACE ? "" : "&namespace=#{encode(namespace)}"
|
|
217
|
+
query = if etag
|
|
218
|
+
namespace_query.empty? ? "" : "?#{namespace_query[1..]}"
|
|
219
|
+
else
|
|
220
|
+
"?last_refresh=#{last_refresh.nil? ? 'null' : last_refresh}#{namespace_query}"
|
|
221
|
+
end
|
|
222
|
+
"#{api_url}/translate/#{lang}#{query}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The headers every request carries.
|
|
226
|
+
def headers
|
|
227
|
+
{
|
|
228
|
+
"Content-Type" => "application/json",
|
|
229
|
+
"Accept" => "application/json",
|
|
230
|
+
"Authorization" => "Bearer #{api_key}",
|
|
231
|
+
"Version" => VERSION,
|
|
232
|
+
"sdk" => SDK
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
# One call with the shared network policy: up to `max_attempts` attempts,
|
|
239
|
+
# a backoff sleep after each failed one. Ends with `parse-body` (and the
|
|
240
|
+
# decoded JSON), `not-modified`, or `fail` with the last error.
|
|
241
|
+
def call
|
|
242
|
+
error = ""
|
|
243
|
+
(1..max_attempts).each do |attempt|
|
|
244
|
+
outcome = begin
|
|
245
|
+
outcome_of(yield)
|
|
246
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- nothing ever raises out of a translation
|
|
247
|
+
raise if e.is_a?(NoMemoryError) || e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
248
|
+
|
|
249
|
+
outcome_of(e)
|
|
250
|
+
end
|
|
251
|
+
error = outcome.error
|
|
252
|
+
return outcome if outcome.action == ACTION_NOT_MODIFIED
|
|
253
|
+
|
|
254
|
+
if outcome.action == ACTION_PARSE_BODY
|
|
255
|
+
json = self.class.decode_json(outcome.response)
|
|
256
|
+
if json
|
|
257
|
+
outcome.json = json
|
|
258
|
+
return outcome
|
|
259
|
+
end
|
|
260
|
+
# An unparsable 200 body is a failed attempt, retried like a 5xx.
|
|
261
|
+
outcome.action = ACTION_RETRY
|
|
262
|
+
error = "invalid JSON"
|
|
263
|
+
end
|
|
264
|
+
break if outcome.action == ACTION_FAIL
|
|
265
|
+
|
|
266
|
+
sleep_after(attempt)
|
|
267
|
+
end
|
|
268
|
+
Outcome.new(action: ACTION_FAIL, error: error, response: nil, json: nil)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @return [Hash{String => Net::HTTPResponse, Exception}] keyed by miss id
|
|
272
|
+
def post_chunk(chunk, primary, languages)
|
|
273
|
+
threads = chunk.map do |miss|
|
|
274
|
+
Thread.new do
|
|
275
|
+
Thread.current.report_on_exception = false
|
|
276
|
+
[miss.id, begin
|
|
277
|
+
request(:post, "#{api_url}/translate", body: translate_body(miss, primary, languages))
|
|
278
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
279
|
+
raise if e.is_a?(NoMemoryError) || e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
280
|
+
|
|
281
|
+
e
|
|
282
|
+
end]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
threads.to_h(&:value)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def outcome_of(answer)
|
|
289
|
+
if answer.is_a?(Exception)
|
|
290
|
+
return Outcome.new(
|
|
291
|
+
action: self.class.transient?(answer) ? ACTION_RETRY : ACTION_FAIL,
|
|
292
|
+
error: self.class.error_for(answer),
|
|
293
|
+
response: nil
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
outcome = self.class.decide(answer.code, answer.message)
|
|
297
|
+
outcome.response = answer
|
|
298
|
+
outcome
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def dictionary_from(json, response, result)
|
|
302
|
+
unless json["ok"]
|
|
303
|
+
result.error = (json["error"] || "not ok").to_s
|
|
304
|
+
warn("fetch all translations error: #{result.error}")
|
|
305
|
+
return result
|
|
306
|
+
end
|
|
307
|
+
warn(json["message"].to_s) if json["message"].to_s != ""
|
|
308
|
+
translations = json.dig("data", "translations")
|
|
309
|
+
result.ok = true
|
|
310
|
+
result.translations = translations.is_a?(Hash) ? translations.select { |_, v| v.is_a?(String) } : {}
|
|
311
|
+
etag = response["ETag"].to_s
|
|
312
|
+
result.etag = etag.empty? ? nil : etag
|
|
313
|
+
result
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def translation_from(json, miss)
|
|
317
|
+
unless json["ok"]
|
|
318
|
+
warn("translate error for \"#{miss.key}\": #{json['error'] || 'not ok'}")
|
|
319
|
+
return nil
|
|
320
|
+
end
|
|
321
|
+
warn(json["message"].to_s) if json["message"].to_s != ""
|
|
322
|
+
translation = json.dig("data", "translation")
|
|
323
|
+
translation.is_a?(Hash) ? translation.select { |_, v| v.is_a?(String) } : {}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def request(method, url, body: nil, etag: nil)
|
|
327
|
+
uri = URI.parse(url)
|
|
328
|
+
req = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
|
|
329
|
+
headers.each { |name, value| req[name] = value }
|
|
330
|
+
req["If-None-Match"] = etag if etag
|
|
331
|
+
req.body = JSON.generate(body) if body
|
|
332
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
333
|
+
http.use_ssl = uri.scheme == "https"
|
|
334
|
+
http.open_timeout = timeout
|
|
335
|
+
http.read_timeout = timeout
|
|
336
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
337
|
+
http.start { |connection| connection.request(req) }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def encode(value)
|
|
341
|
+
URI.encode_www_form_component(value).gsub("+", "%20")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def sleep_after(failed_attempt)
|
|
345
|
+
delay = delay_after(failed_attempt)
|
|
346
|
+
@sleeper.call(delay) if delay && delay.positive?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def warn(message)
|
|
350
|
+
@logger&.warn("i18n-keyless: #{message}")
|
|
351
|
+
rescue StandardError
|
|
352
|
+
# Logging must never take a translation down.
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "i18n"
|
|
4
|
+
|
|
5
|
+
module I18nKeyless
|
|
6
|
+
# The I18n backend. Chained AFTER the application's own backend
|
|
7
|
+
# (`I18n::Backend::Chain.new(I18n.backend, I18nKeyless::Backend.new)`), so a
|
|
8
|
+
# key found in `config/locales/*.yml` wins and only what the YAML files do
|
|
9
|
+
# not have reaches the API.
|
|
10
|
+
#
|
|
11
|
+
# `I18n.t("Welcome to our app")` therefore resolves through i18n-keyless,
|
|
12
|
+
# while `I18n.t("users.index.title")` or `t(:hello)` stay Rails keys (see
|
|
13
|
+
# `I18nKeyless.keyless_key?`) and are never sent.
|
|
14
|
+
#
|
|
15
|
+
# `context:` and `namespace:` travel as I18n options:
|
|
16
|
+
# `t("8 heures", context: "duration")` is stored as `8 heures__duration`.
|
|
17
|
+
class Backend
|
|
18
|
+
include I18n::Backend::Base
|
|
19
|
+
|
|
20
|
+
def available_locales
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialized?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reload!
|
|
29
|
+
I18nKeyless.translator.reset_loaded! if I18nKeyless.enabled?
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def eager_load!
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def store_translations(_locale, _data, _options = {}); end
|
|
38
|
+
|
|
39
|
+
def translations(*)
|
|
40
|
+
{}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def lookup(locale, key, scope = [], options = {})
|
|
46
|
+
return nil unless I18nKeyless.enabled?
|
|
47
|
+
return nil unless key.is_a?(String) && (scope.nil? || Array(scope).empty?) && I18nKeyless.keyless_key?(key)
|
|
48
|
+
|
|
49
|
+
I18nKeyless.translator.lookup(locale.to_s, key, context: options[:context], namespace: options[:namespace])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nKeyless
|
|
4
|
+
# The configuration, with the same names and defaults as the Laravel port.
|
|
5
|
+
# Every value is read from the environment first (`I18N_KEYLESS_*`), then
|
|
6
|
+
# overridden by `I18nKeyless.configure { |c| ... }`.
|
|
7
|
+
class Config
|
|
8
|
+
# A key matching this is a Rails key (`hello`, `users.index.title`): it is
|
|
9
|
+
# left to the YAML files. Everything else is a keyless source string.
|
|
10
|
+
DEFAULT_RAILS_KEY_PATTERN = /\A[a-z0-9_]+(\.[a-z0-9_]+)*\z/
|
|
11
|
+
|
|
12
|
+
FALSE_VALUES = %w[false 0 off no].freeze
|
|
13
|
+
|
|
14
|
+
# `false` switches the gem off: Rails behaves as without it.
|
|
15
|
+
attr_accessor :enabled
|
|
16
|
+
# Your project's key. Without it the gem stays inactive.
|
|
17
|
+
attr_accessor :api_key
|
|
18
|
+
# The official service, or your own backend / proxy speaking the same wire format.
|
|
19
|
+
attr_accessor :api_url
|
|
20
|
+
# The language the source strings are written in. Default: I18n.default_locale.
|
|
21
|
+
attr_accessor :primary
|
|
22
|
+
# REQUIRED for translation: every language the app serves ("en,fr,es" or an array).
|
|
23
|
+
attr_accessor :languages
|
|
24
|
+
# The i18n-keyless namespace of the `t()` strings. Default "default".
|
|
25
|
+
attr_accessor :namespace
|
|
26
|
+
# An ActiveSupport::Cache::Store. Default: Rails.cache, else a MemoryStore.
|
|
27
|
+
attr_accessor :cache
|
|
28
|
+
# Seconds a dictionary is served without asking the API. Default 3600.
|
|
29
|
+
attr_accessor :cache_ttl
|
|
30
|
+
# Prefix of every cache key the gem writes.
|
|
31
|
+
attr_accessor :cache_prefix
|
|
32
|
+
# HTTP timeout in seconds, per attempt.
|
|
33
|
+
attr_accessor :timeout
|
|
34
|
+
# Backoff in milliseconds between retries: two entries, two retries.
|
|
35
|
+
attr_accessor :retry
|
|
36
|
+
# Maximum POST /translate requests in flight at once.
|
|
37
|
+
attr_accessor :concurrency
|
|
38
|
+
# Usage analytics (the date each string was last served), like the node SDK.
|
|
39
|
+
attr_accessor :usage
|
|
40
|
+
# An ActiveJob queue name: misses are sent from a TranslateMissingKeysJob instead of after the response.
|
|
41
|
+
attr_accessor :queue
|
|
42
|
+
# Where warnings go. Default: Rails.logger, else STDERR.
|
|
43
|
+
attr_accessor :logger
|
|
44
|
+
# The Rails-key rule (see DEFAULT_RAILS_KEY_PATTERN). `nil`: every string is keyless.
|
|
45
|
+
attr_accessor :rails_key_pattern
|
|
46
|
+
|
|
47
|
+
def initialize(env = ENV)
|
|
48
|
+
@enabled = truthy?(env.fetch("I18N_KEYLESS_ENABLED", "true"))
|
|
49
|
+
@api_key = env["I18N_KEYLESS_API_KEY"]
|
|
50
|
+
@api_url = env["I18N_KEYLESS_API_URL"]
|
|
51
|
+
@primary = env["I18N_KEYLESS_PRIMARY_LANG"]
|
|
52
|
+
@languages = env["I18N_KEYLESS_LANGUAGES"]
|
|
53
|
+
@namespace = env["I18N_KEYLESS_NAMESPACE"]
|
|
54
|
+
@cache = nil
|
|
55
|
+
@cache_ttl = Integer(env.fetch("I18N_KEYLESS_CACHE_TTL", 3600), exception: false) || 3600
|
|
56
|
+
@cache_prefix = "i18n-keyless"
|
|
57
|
+
@timeout = 10
|
|
58
|
+
@retry = [500, 1500]
|
|
59
|
+
@concurrency = 30
|
|
60
|
+
@usage = truthy?(env.fetch("I18N_KEYLESS_USAGE", "true"))
|
|
61
|
+
@queue = env["I18N_KEYLESS_QUEUE"]
|
|
62
|
+
@logger = nil
|
|
63
|
+
@rails_key_pattern = DEFAULT_RAILS_KEY_PATTERN
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def enabled?
|
|
67
|
+
truthy?(enabled) && !api_key.to_s.strip.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The primary language as an i18n-keyless code: the configured one, else
|
|
71
|
+
# I18n.default_locale mapped, else "en".
|
|
72
|
+
def resolved_primary
|
|
73
|
+
Locale.to_lang(primary&.to_s) || Locale.to_lang(I18n.default_locale.to_s) || "en"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The configured languages as i18n-keyless codes, deduplicated. A comma
|
|
77
|
+
# separated string ("en,fr,es") or an array of locales.
|
|
78
|
+
def resolved_languages
|
|
79
|
+
list = languages
|
|
80
|
+
list = list.split(",") if list.is_a?(String)
|
|
81
|
+
Array(list).filter_map { |tag| Locale.to_lang(tag.to_s) }.uniq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolved_api_url
|
|
85
|
+
url = api_url.to_s.strip.sub(%r{/+\z}, "")
|
|
86
|
+
url.empty? ? ApiClient::DEFAULT_URL : url
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolved_namespace
|
|
90
|
+
value = namespace.to_s
|
|
91
|
+
value.empty? ? Translator::DEFAULT_NAMESPACE : value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolved_cache
|
|
95
|
+
return cache if cache
|
|
96
|
+
return ::Rails.cache if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
|
|
97
|
+
|
|
98
|
+
@memory_store ||= ActiveSupport::Cache::MemoryStore.new
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolved_logger
|
|
102
|
+
return logger if logger
|
|
103
|
+
return ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
104
|
+
|
|
105
|
+
@stderr_logger ||= Logger.new($stderr)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolved_retry
|
|
109
|
+
Array(self.retry).map(&:to_i)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def usage?
|
|
113
|
+
truthy?(usage)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def truthy?(value)
|
|
119
|
+
return value if value == true || value == false
|
|
120
|
+
return false if value.nil?
|
|
121
|
+
|
|
122
|
+
!FALSE_VALUES.include?(value.to_s.strip.downcase) && !value.to_s.strip.empty?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
|
|
5
|
+
module I18nKeyless
|
|
6
|
+
# The per-language dictionaries in an ActiveSupport cache store (any: memory,
|
|
7
|
+
# file, Redis, Memcached, the database), plus the cross-request guard that
|
|
8
|
+
# keeps one miss from being POSTed by every request.
|
|
9
|
+
#
|
|
10
|
+
# A dictionary entry is stored forever: `ttl` is not its lifetime but the time
|
|
11
|
+
# it is served without asking the API. A stale entry is still served, and
|
|
12
|
+
# revalidated with its ETag after the response (a 304 keeps it as is).
|
|
13
|
+
#
|
|
14
|
+
# Entry: { translations: Hash, etag: String|nil, fetched_at: Integer, failed: Boolean }
|
|
15
|
+
class DictionaryStore
|
|
16
|
+
# Seconds a failed fetch is remembered before the API is asked again.
|
|
17
|
+
FAILURE_TTL = 60
|
|
18
|
+
|
|
19
|
+
# Minimum seconds between two usage POSTs, across every process (the node SDK's debounce).
|
|
20
|
+
USAGE_FLUSH_SECONDS = 10
|
|
21
|
+
|
|
22
|
+
attr_reader :cache, :prefix, :ttl, :api_key_hash
|
|
23
|
+
|
|
24
|
+
def initialize(cache:, prefix:, ttl:, api_key_hash:)
|
|
25
|
+
@cache = cache
|
|
26
|
+
@prefix = prefix
|
|
27
|
+
@ttl = [ttl.to_i, 0].max
|
|
28
|
+
@api_key_hash = api_key_hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.hash_key(api_key)
|
|
32
|
+
Digest::SHA1.hexdigest(api_key.to_s)[0, 8]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get(lang, namespace)
|
|
36
|
+
entry = cache.read(key(lang, namespace))
|
|
37
|
+
return nil unless entry.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
entry = entry.transform_keys(&:to_sym)
|
|
40
|
+
entry[:translations].is_a?(Hash) ? entry : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def put(lang, namespace, translations, etag, failed: false)
|
|
44
|
+
entry = { translations: translations, etag: etag, fetched_at: Time.now.to_i, failed: failed }
|
|
45
|
+
cache.write(key(lang, namespace), entry)
|
|
46
|
+
entry
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# After a 304: same dictionary, same ETag, fresh again.
|
|
50
|
+
def touch(lang, namespace)
|
|
51
|
+
entry = get(lang, namespace)
|
|
52
|
+
return if entry.nil?
|
|
53
|
+
|
|
54
|
+
entry[:fetched_at] = Time.now.to_i
|
|
55
|
+
entry[:failed] = false
|
|
56
|
+
cache.write(key(lang, namespace), entry)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Adds freshly translated lines to a stored dictionary (after POST /translate),
|
|
60
|
+
# and marks it stale so the next request revalidates with the API.
|
|
61
|
+
def merge(lang, namespace, lines)
|
|
62
|
+
entry = get(lang, namespace) || { translations: {}, etag: nil, fetched_at: 0, failed: false }
|
|
63
|
+
entry[:translations] = entry[:translations].merge(lines)
|
|
64
|
+
entry[:fetched_at] = 0
|
|
65
|
+
cache.write(key(lang, namespace), entry)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def mark_stale(lang, namespace)
|
|
69
|
+
entry = get(lang, namespace)
|
|
70
|
+
return if entry.nil?
|
|
71
|
+
|
|
72
|
+
entry[:fetched_at] = 0
|
|
73
|
+
cache.write(key(lang, namespace), entry)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stale?(entry)
|
|
77
|
+
max_age = entry[:failed] ? [FAILURE_TTL, ttl].min : ttl
|
|
78
|
+
(Time.now.to_i - entry[:fetched_at].to_i) > max_age
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Claims a miss for this process: true when nobody POSTed it during the last
|
|
82
|
+
# `ttl` seconds. Atomic on stores that honour `unless_exist` (Redis,
|
|
83
|
+
# Memcached, the database store, the file store, the memory store).
|
|
84
|
+
def claim_miss(miss)
|
|
85
|
+
return true if ttl.zero?
|
|
86
|
+
|
|
87
|
+
cache.write(miss_key(miss), 1, expires_in: ttl, unless_exist: true) ? true : false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# After a failed POST: let a later request try again.
|
|
91
|
+
def release_miss(miss)
|
|
92
|
+
cache.delete(miss_key(miss))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The cumulative usage map, `{ namespace => { "key__context" => "YYYY-MM-DD" } }`,
|
|
96
|
+
# never cleared (the node SDK keeps it for the life of the process; here it
|
|
97
|
+
# lives in the cache for the life of the cache).
|
|
98
|
+
def usage
|
|
99
|
+
usage = cache.read(usage_key)
|
|
100
|
+
usage.is_a?(Hash) ? usage : {}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Merges freshly recorded dates into the stored map. True when a date
|
|
104
|
+
# changed (a new key, or a key seen on a new day).
|
|
105
|
+
def merge_usage(recorded)
|
|
106
|
+
usage = self.usage
|
|
107
|
+
changed = false
|
|
108
|
+
recorded.each do |namespace, keys|
|
|
109
|
+
keys.each do |key, date|
|
|
110
|
+
next if usage.dig(namespace, key) == date
|
|
111
|
+
|
|
112
|
+
(usage[namespace] ||= {})[key] = date
|
|
113
|
+
changed = true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
if changed
|
|
117
|
+
cache.write(usage_key, usage)
|
|
118
|
+
cache.write("#{usage_key}:dirty", true)
|
|
119
|
+
end
|
|
120
|
+
changed
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# True while the stored map holds changes the API has not received.
|
|
124
|
+
def usage_dirty?
|
|
125
|
+
cache.read("#{usage_key}:dirty") ? true : false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def clear_usage_dirty
|
|
129
|
+
cache.delete("#{usage_key}:dirty")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Claims the right to POST usage now: false when a POST left less than 10 s ago.
|
|
133
|
+
def claim_usage_slot
|
|
134
|
+
cache.write("#{usage_key}:lock", 1, expires_in: USAGE_FLUSH_SECONDS, unless_exist: true) ? true : false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def usage_key
|
|
138
|
+
"#{prefix}:#{api_key_hash}:usage"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def key(lang, namespace)
|
|
142
|
+
"#{prefix}:#{api_key_hash}:dict:#{namespace}:#{lang}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def miss_key(miss)
|
|
148
|
+
"#{prefix}:#{api_key_hash}:miss:#{Digest::SHA1.hexdigest(miss.id)}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|