i18n-keyless-rails 3.7.0 → 3.8.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/README.md +13 -0
- data/SKILL.md +9 -2
- data/lib/i18n_keyless/api_client.rb +5 -1
- data/lib/i18n_keyless/bundle.rb +137 -0
- data/lib/i18n_keyless/config.rb +7 -0
- data/lib/i18n_keyless/dictionary_store.rb +41 -3
- data/lib/i18n_keyless/translator.rb +52 -7
- data/lib/i18n_keyless/version.rb +1 -1
- data/lib/i18n_keyless.rb +1 -0
- data/llms.txt +1 -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: 0b5f7bbea883a39be3f717ee063ceb055b4876f7ce0a8027e15d0df728b9d1fe
|
|
4
|
+
data.tar.gz: afe3e6d4863e6a4867e9a412171bc8e4893def9b9fc0f0aa36ffda03cda13186
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ddf136839855b5e00bcbd685bebb6ce77bfe790454b20dd3f05255114506d10d4adef4e6adecf24dad17feab9fd4e24fc70bae3623d017822e51b226ab4987a
|
|
7
|
+
data.tar.gz: 7222d1ba284586cf64d14ff644e9507050d40c81cc497f00cc104222df6d5226567e92b07011ab89d3710174afc9f9070b6e6430b39a68a3cb142544395054fd
|
data/README.md
CHANGED
|
@@ -104,6 +104,7 @@ end
|
|
|
104
104
|
| `queue` | `I18N_KEYLESS_QUEUE` | none | An ActiveJob queue name: the misses of a request are enqueued as one `I18nKeyless::TranslateMissingKeysJob` instead of being sent after the response. |
|
|
105
105
|
| `logger` | | `Rails.logger` | Where the `i18n-keyless:` warnings go. |
|
|
106
106
|
| `rails_key_pattern` | | see above | The Rails-key rule. `nil`: every string is keyless. |
|
|
107
|
+
| `bundle_path` | `I18N_KEYLESS_BUNDLE_PATH` | none | The directory of a precompiled bundle (`manifest.json` plus `<namespace>/<lang>.json`). Every dictionary it covers is read at boot, never fetched. See "Precompiled bundle". |
|
|
107
108
|
|
|
108
109
|
`I18N_KEYLESS_LANGUAGES` is the list the API translates a new string into, and the list it
|
|
109
110
|
stores as your project's languages (it replaces the previous one, like the `supported` list
|
|
@@ -158,6 +159,18 @@ most `cache_ttl` seconds later. The miss guard and the usage lock live in the ca
|
|
|
158
159
|
with a `:memory_store` they are per process, so use a shared store (Redis, Memcached, the
|
|
159
160
|
database, the file store) in production, as Rails recommends anyway.
|
|
160
161
|
|
|
162
|
+
## Precompiled bundle
|
|
163
|
+
|
|
164
|
+
Export your dictionaries once, at build time, with the MCP `export_bundle` tool or
|
|
165
|
+
`GET /translate/bundle`: a directory holding `manifest.json` and one
|
|
166
|
+
`<namespace>/<lang>.json` per dictionary. Point `bundle_path`
|
|
167
|
+
(`I18N_KEYLESS_BUNDLE_PATH`) at it, for example `Rails.root.join("i18n-keyless")`. Every
|
|
168
|
+
`(namespace, lang)` the manifest covers is then read at boot, into the process and into the
|
|
169
|
+
cache with the manifest's cursor, and never fetched from the API; the cache wins only when
|
|
170
|
+
it already holds a newer copy of the same language (a review that landed after the export).
|
|
171
|
+
Nothing else changes: a pair the manifest does not list is fetched as before, a miss still
|
|
172
|
+
POSTs, and its answer is merged next to the bundled lines.
|
|
173
|
+
|
|
161
174
|
## Limitations
|
|
162
175
|
|
|
163
176
|
- **Plurals.** `t('Il y a %{count} pommes', count: n)` sends one source string and I18n
|
data/SKILL.md
CHANGED
|
@@ -76,10 +76,17 @@ signatures; the `i18nk` helper is mixed into views, controllers, mailers and job
|
|
|
76
76
|
`I18nKeyless.configure { |c| ... }` in an initializer, or the environment. Keys: `enabled`,
|
|
77
77
|
`api_key`, `api_url`, `primary`, `languages`, `namespace`, `cache`, `cache_ttl`,
|
|
78
78
|
`cache_prefix`, `timeout` (10 s), `retry` (`[500, 1500]` ms), `concurrency` (30), `usage`
|
|
79
|
-
(true), `queue`, `logger`, `rails_key_pattern`.
|
|
79
|
+
(true), `queue`, `logger`, `rails_key_pattern`, `bundle_path`.
|
|
80
80
|
Env: `I18N_KEYLESS_ENABLED`, `I18N_KEYLESS_API_KEY`, `I18N_KEYLESS_API_URL`,
|
|
81
81
|
`I18N_KEYLESS_PRIMARY_LANG`, `I18N_KEYLESS_LANGUAGES`, `I18N_KEYLESS_NAMESPACE`,
|
|
82
|
-
`I18N_KEYLESS_CACHE_TTL`, `I18N_KEYLESS_USAGE`, `I18N_KEYLESS_QUEUE
|
|
82
|
+
`I18N_KEYLESS_CACHE_TTL`, `I18N_KEYLESS_USAGE`, `I18N_KEYLESS_QUEUE`,
|
|
83
|
+
`I18N_KEYLESS_BUNDLE_PATH`.
|
|
84
|
+
|
|
85
|
+
Precompiled bundle: export the dictionaries at build time with the MCP `export_bundle`
|
|
86
|
+
tool or `GET /translate/bundle` (a directory: `manifest.json` plus one
|
|
87
|
+
`<namespace>/<lang>.json` per dictionary) and set `bundle_path` to that directory. Every
|
|
88
|
+
pair the manifest covers is read at boot, never fetched; the cache wins only when it is
|
|
89
|
+
newer. A miss still POSTs. Nothing else changes.
|
|
83
90
|
|
|
84
91
|
## Debug
|
|
85
92
|
|
|
@@ -36,7 +36,9 @@ module I18nKeyless
|
|
|
36
36
|
Timeout::Error, SocketError, SystemCallError, EOFError, IOError, OpenSSL::SSL::SSLError
|
|
37
37
|
].freeze
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# `last_refresh`: the API's cursor for the dictionary (`data.lastRefresh`,
|
|
40
|
+
# epoch ms as a string), kept in the cache for the bundle precedence rule.
|
|
41
|
+
Dictionary = Struct.new(:ok, :not_modified, :translations, :etag, :error, :last_refresh, keyword_init: true)
|
|
40
42
|
UsageResult = Struct.new(:ok, :sent, :error, keyword_init: true)
|
|
41
43
|
Outcome = Struct.new(:action, :error, :response, :json, keyword_init: true)
|
|
42
44
|
|
|
@@ -310,6 +312,8 @@ module I18nKeyless
|
|
|
310
312
|
result.translations = translations.is_a?(Hash) ? translations.select { |_, v| v.is_a?(String) } : {}
|
|
311
313
|
etag = response["ETag"].to_s
|
|
312
314
|
result.etag = etag.empty? ? nil : etag
|
|
315
|
+
cursor = json.dig("data", "lastRefresh")
|
|
316
|
+
result.last_refresh = cursor.nil? || cursor.to_s.empty? ? nil : cursor.to_s
|
|
313
317
|
result
|
|
314
318
|
end
|
|
315
319
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module I18nKeyless
|
|
6
|
+
# The precompiled bundle (docs/PROTOCOL.md, sections 4.5 and 7.4): a
|
|
7
|
+
# directory holding `manifest.json` and one `<namespace>/<lang>.json` per
|
|
8
|
+
# dictionary, as the MCP `export_bundle` tool or `GET /translate/bundle`
|
|
9
|
+
# yields it.
|
|
10
|
+
#
|
|
11
|
+
# A (namespace, lang) pair the manifest covers is read from the file instead
|
|
12
|
+
# of fetched, with the manifest's cursor. The two class methods `covers?`
|
|
13
|
+
# and `merge_with_storage` are pure and replayed by
|
|
14
|
+
# conformance/vectors/bundle-seed.json.
|
|
15
|
+
#
|
|
16
|
+
# Seed: { translations: Hash, last_refresh: String|nil }
|
|
17
|
+
# Stored: { translations: Hash, last_refresh: Object, lang: String }
|
|
18
|
+
class Bundle
|
|
19
|
+
attr_reader :path, :manifest
|
|
20
|
+
|
|
21
|
+
def initialize(path, manifest)
|
|
22
|
+
@path = path
|
|
23
|
+
@manifest = manifest
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Reads `manifest.json` from the directory. Nil when no path is configured;
|
|
27
|
+
# nil with one warning when the path holds no readable manifest.
|
|
28
|
+
def self.load(path, logger: nil)
|
|
29
|
+
path = path.to_s.strip.sub(%r{[/\\]+\z}, "")
|
|
30
|
+
return nil if path.empty?
|
|
31
|
+
|
|
32
|
+
manifest = read_json(File.join(path, "manifest.json"))
|
|
33
|
+
unless manifest.is_a?(Hash) && manifest["namespaces"].is_a?(Hash)
|
|
34
|
+
warn_with(logger, "bundle_path \"#{path}\" holds no readable manifest.json: the bundle is ignored")
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
37
|
+
new(path, manifest)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `bundleCovers(manifest, namespace, lang)` of the SDKs: true when the
|
|
41
|
+
# manifest lists `lang` under `namespace`.
|
|
42
|
+
def self.covers?(manifest, namespace, lang)
|
|
43
|
+
return false unless manifest.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
languages = manifest.dig("namespaces", namespace.to_s, "languages")
|
|
46
|
+
languages.is_a?(Array) && languages.include?(lang.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `mergeBundleWithStorage(bundle, stored, lang)` of the SDKs: the bundle is
|
|
50
|
+
# the base. What storage holds is merged on top (its keys win) and its
|
|
51
|
+
# cursor kept ONLY when its cursor is a strictly larger number than the
|
|
52
|
+
# bundle's AND it is in `lang`. A stored cursor that is empty, nil or not a
|
|
53
|
+
# number is never newer.
|
|
54
|
+
def self.merge_with_storage(bundle, stored, lang)
|
|
55
|
+
return bundle if stored.nil? || stored[:lang] != lang
|
|
56
|
+
|
|
57
|
+
stored_cursor = cursor_number(stored[:last_refresh])
|
|
58
|
+
return bundle if stored_cursor.nil?
|
|
59
|
+
|
|
60
|
+
# `Number(x)` of the SDKs: nil and "" are 0, anything else non-numeric is NaN,
|
|
61
|
+
# and nothing is larger than NaN.
|
|
62
|
+
bundle_raw = bundle[:last_refresh]
|
|
63
|
+
bundle_cursor = bundle_raw.nil? || bundle_raw.to_s.empty? ? 0.0 : cursor_number(bundle_raw)
|
|
64
|
+
return bundle if bundle_cursor.nil? || stored_cursor <= bundle_cursor
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
translations: bundle[:translations].merge(stored[:translations] || {}),
|
|
68
|
+
last_refresh: stored[:last_refresh]
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.cursor_number(value)
|
|
73
|
+
return nil if value.nil?
|
|
74
|
+
return value.to_f if value.is_a?(Numeric)
|
|
75
|
+
|
|
76
|
+
text = value.to_s.strip
|
|
77
|
+
return nil if text.empty?
|
|
78
|
+
|
|
79
|
+
Float(text, exception: false)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.read_json(file)
|
|
83
|
+
return nil unless File.file?(file) && File.readable?(file)
|
|
84
|
+
|
|
85
|
+
JSON.parse(File.read(file))
|
|
86
|
+
rescue JSON::ParserError, SystemCallError, IOError, EncodingError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.warn_with(logger, message)
|
|
91
|
+
logger&.warn("i18n-keyless: #{message}")
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# Logging must never take a translation down.
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def covers?(namespace, lang)
|
|
97
|
+
self.class.covers?(manifest, namespace, lang)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The namespaces the manifest lists, in manifest order.
|
|
101
|
+
def namespaces
|
|
102
|
+
manifest["namespaces"].keys.map(&:to_s)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The languages the manifest lists under one namespace.
|
|
106
|
+
def languages(namespace)
|
|
107
|
+
languages = manifest.dig("namespaces", namespace.to_s, "languages")
|
|
108
|
+
languages.is_a?(Array) ? languages.map(&:to_s) : []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Every (namespace, lang) pair the manifest covers, in manifest order.
|
|
112
|
+
def pairs
|
|
113
|
+
namespaces.flat_map { |namespace| languages(namespace).map { |lang| [namespace, lang] } }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The cursor of one namespace (epoch ms as a string), or nil.
|
|
117
|
+
def last_refresh(namespace)
|
|
118
|
+
cursor = manifest.dig("namespaces", namespace.to_s, "lastRefresh")
|
|
119
|
+
cursor.nil? ? nil : cursor.to_s
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One covered dictionary with its cursor, or nil when the pair is not
|
|
123
|
+
# covered or the file is unreadable (logged: the caller falls back to the
|
|
124
|
+
# fetch, like a pair the manifest does not list).
|
|
125
|
+
def seed(namespace, lang, logger: nil)
|
|
126
|
+
return nil unless covers?(namespace, lang)
|
|
127
|
+
|
|
128
|
+
file = File.join(path, namespace.to_s, "#{lang}.json")
|
|
129
|
+
translations = self.class.read_json(file)
|
|
130
|
+
unless translations.is_a?(Hash)
|
|
131
|
+
self.class.warn_with(logger, "bundle file \"#{file}\" is missing or unreadable: #{namespace}/#{lang} is fetched instead")
|
|
132
|
+
return nil
|
|
133
|
+
end
|
|
134
|
+
{ translations: translations.select { |_, v| v.is_a?(String) }, last_refresh: last_refresh(namespace) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/i18n_keyless/config.rb
CHANGED
|
@@ -43,6 +43,12 @@ module I18nKeyless
|
|
|
43
43
|
attr_accessor :logger
|
|
44
44
|
# The Rails-key rule (see DEFAULT_RAILS_KEY_PATTERN). `nil`: every string is keyless.
|
|
45
45
|
attr_accessor :rails_key_pattern
|
|
46
|
+
# The precompiled bundle: a directory holding manifest.json and one
|
|
47
|
+
# <namespace>/<lang>.json per dictionary, as the MCP `export_bundle` tool or
|
|
48
|
+
# GET /translate/bundle yields it (for example Rails.root.join("i18n-keyless")).
|
|
49
|
+
# A dictionary the manifest covers is read at boot, never fetched, unless the
|
|
50
|
+
# cache already holds a newer copy. A miss still POSTs.
|
|
51
|
+
attr_accessor :bundle_path
|
|
46
52
|
|
|
47
53
|
def initialize(env = ENV)
|
|
48
54
|
@enabled = truthy?(env.fetch("I18N_KEYLESS_ENABLED", "true"))
|
|
@@ -61,6 +67,7 @@ module I18nKeyless
|
|
|
61
67
|
@queue = env["I18N_KEYLESS_QUEUE"]
|
|
62
68
|
@logger = nil
|
|
63
69
|
@rails_key_pattern = DEFAULT_RAILS_KEY_PATTERN
|
|
70
|
+
@bundle_path = env["I18N_KEYLESS_BUNDLE_PATH"]
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def enabled?
|
|
@@ -11,7 +11,12 @@ module I18nKeyless
|
|
|
11
11
|
# it is served without asking the API. A stale entry is still served, and
|
|
12
12
|
# revalidated with its ETag after the response (a 304 keeps it as is).
|
|
13
13
|
#
|
|
14
|
-
# Entry: { translations: Hash, etag: String|nil, fetched_at: Integer, failed: Boolean }
|
|
14
|
+
# Entry: { translations: Hash, etag: String|nil, fetched_at: Integer, failed: Boolean, last_refresh: String|nil }
|
|
15
|
+
#
|
|
16
|
+
# `last_refresh` is the API's cursor for the dictionary (epoch ms as a
|
|
17
|
+
# string, PROTOCOL.md section 4.2), or the bundle's when the entry was seeded
|
|
18
|
+
# from one. It is never sent on the wire (the ETag carries freshness here):
|
|
19
|
+
# it decides the precedence between a bundle and the cache (`seed`).
|
|
15
20
|
class DictionaryStore
|
|
16
21
|
# Seconds a failed fetch is remembered before the API is asked again.
|
|
17
22
|
FAILURE_TTL = 60
|
|
@@ -40,8 +45,41 @@ module I18nKeyless
|
|
|
40
45
|
entry[:translations].is_a?(Hash) ? entry : nil
|
|
41
46
|
end
|
|
42
47
|
|
|
43
|
-
def put(lang, namespace, translations, etag, failed: false)
|
|
44
|
-
entry = { translations: translations, etag: etag, fetched_at: Time.now.to_i, failed: failed }
|
|
48
|
+
def put(lang, namespace, translations, etag, failed: false, last_refresh: nil)
|
|
49
|
+
entry = { translations: translations, etag: etag, fetched_at: Time.now.to_i, failed: failed, last_refresh: last_refresh }
|
|
50
|
+
cache.write(key(lang, namespace), entry)
|
|
51
|
+
entry
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Applies one bundle dictionary (PROTOCOL.md 7.4) to the stored entry with
|
|
55
|
+
# the SDKs' precedence (`Bundle.merge_with_storage`): the bundle is the
|
|
56
|
+
# base and the stored slice is merged on top, its cursor kept, only when
|
|
57
|
+
# the cache is strictly newer than the bundle. Otherwise the bundle's lines
|
|
58
|
+
# win, its cursor is stored, and the entry is fresh: no fetch and no
|
|
59
|
+
# revalidation until `ttl` seconds pass. Keys the cache has and the bundle
|
|
60
|
+
# lacks (a merged POST answer) are kept either way. Idempotent: a second
|
|
61
|
+
# call with the same bundle writes nothing.
|
|
62
|
+
#
|
|
63
|
+
# @param seed [Hash] { translations: Hash, last_refresh: String|nil }
|
|
64
|
+
def seed(lang, namespace, seed)
|
|
65
|
+
stored = get(lang, namespace)
|
|
66
|
+
merged = Bundle.merge_with_storage(
|
|
67
|
+
seed,
|
|
68
|
+
stored && { translations: stored[:translations], last_refresh: stored[:last_refresh], lang: lang },
|
|
69
|
+
lang
|
|
70
|
+
)
|
|
71
|
+
translations = (stored ? stored[:translations] : {}).merge(merged[:translations])
|
|
72
|
+
storage_wins = stored && merged[:last_refresh] != seed[:last_refresh]
|
|
73
|
+
if storage_wins
|
|
74
|
+
return stored if translations == stored[:translations]
|
|
75
|
+
|
|
76
|
+
entry = stored.merge(translations: translations)
|
|
77
|
+
else
|
|
78
|
+
return stored if stored && translations == stored[:translations] && stored[:last_refresh] == merged[:last_refresh]
|
|
79
|
+
|
|
80
|
+
# The ETag named what the API answered before the export: it no longer matches this content.
|
|
81
|
+
entry = { translations: translations, etag: nil, fetched_at: Time.now.to_i, failed: false, last_refresh: merged[:last_refresh] }
|
|
82
|
+
end
|
|
45
83
|
cache.write(key(lang, namespace), entry)
|
|
46
84
|
entry
|
|
47
85
|
end
|
|
@@ -49,12 +49,15 @@ module I18nKeyless
|
|
|
49
49
|
default_namespace: config.resolved_namespace,
|
|
50
50
|
queue: config.queue,
|
|
51
51
|
usage_enabled: config.usage?,
|
|
52
|
-
logger: config.resolved_logger
|
|
52
|
+
logger: config.resolved_logger,
|
|
53
|
+
bundle: Bundle.load(config.bundle_path, logger: config.resolved_logger)
|
|
53
54
|
)
|
|
54
55
|
end
|
|
55
56
|
|
|
57
|
+
# @param bundle [Bundle, nil] the precompiled bundle (`bundle_path`): every
|
|
58
|
+
# (namespace, lang) it covers is seeded now, at boot, and never fetched
|
|
56
59
|
def initialize(store:, api:, primary:, languages: [], default_namespace: DEFAULT_NAMESPACE, queue: nil,
|
|
57
|
-
usage_enabled: true, logger: nil)
|
|
60
|
+
usage_enabled: true, logger: nil, bundle: nil)
|
|
58
61
|
@store = store
|
|
59
62
|
@api = api
|
|
60
63
|
@primary = primary
|
|
@@ -63,14 +66,18 @@ module I18nKeyless
|
|
|
63
66
|
@queue = queue.to_s.empty? ? nil : queue.to_s
|
|
64
67
|
@usage_enabled = usage_enabled
|
|
65
68
|
@logger = logger
|
|
69
|
+
@bundle = bundle
|
|
66
70
|
@mutex = Mutex.new
|
|
67
71
|
@loaded = {} # "lang|namespace" => lines loaded in this process
|
|
68
72
|
@misses = {} # Miss#id => Miss
|
|
69
73
|
@revalidate = {} # "lang|namespace" => [lang, namespace]
|
|
70
74
|
@usage = {} # namespace => lookup key => YYYY-MM-DD
|
|
71
75
|
@warned_no_languages = false
|
|
76
|
+
seed_bundle!
|
|
72
77
|
end
|
|
73
78
|
|
|
79
|
+
attr_reader :bundle
|
|
80
|
+
|
|
74
81
|
def usage_enabled?
|
|
75
82
|
@usage_enabled
|
|
76
83
|
end
|
|
@@ -121,18 +128,20 @@ module I18nKeyless
|
|
|
121
128
|
key
|
|
122
129
|
end
|
|
123
130
|
|
|
124
|
-
# Loads the (lang, namespace) dictionary once per process.
|
|
131
|
+
# Loads the (lang, namespace) dictionary once per process. A pair the
|
|
132
|
+
# bundle covers was seeded at boot (`seed_bundle!`); after `I18n.reload!`
|
|
133
|
+
# it is seeded again here, from the file, never fetched.
|
|
125
134
|
def ensure_loaded(lang, namespace)
|
|
126
135
|
id = "#{lang}|#{namespace}"
|
|
127
136
|
@mutex.synchronize do
|
|
128
137
|
return @loaded[id] if @loaded.key?(id)
|
|
129
138
|
|
|
130
|
-
entry = store.get(lang, namespace)
|
|
139
|
+
entry = seed_from_bundle(lang, namespace) || store.get(lang, namespace)
|
|
131
140
|
if entry.nil?
|
|
132
141
|
# First time ever for this language: the one blocking fetch.
|
|
133
142
|
result = api.fetch_dictionary(lang, namespace, nil)
|
|
134
143
|
entry = if result.ok
|
|
135
|
-
store.put(lang, namespace, result.translations, result.etag)
|
|
144
|
+
store.put(lang, namespace, result.translations, result.etag, last_refresh: result.last_refresh)
|
|
136
145
|
else
|
|
137
146
|
store.put(lang, namespace, {}, nil, failed: true)
|
|
138
147
|
end
|
|
@@ -271,17 +280,53 @@ module I18nKeyless
|
|
|
271
280
|
result = api.fetch_dictionary(lang, namespace, entry && entry[:etag])
|
|
272
281
|
unless result.ok
|
|
273
282
|
# Remember the failure briefly, so the next requests do not all retry.
|
|
274
|
-
store.put(lang, namespace, entry ? entry[:translations] : {}, entry && entry[:etag], failed: true
|
|
283
|
+
store.put(lang, namespace, entry ? entry[:translations] : {}, entry && entry[:etag], failed: true,
|
|
284
|
+
last_refresh: entry && entry[:last_refresh])
|
|
275
285
|
return
|
|
276
286
|
end
|
|
277
287
|
if result.not_modified
|
|
278
288
|
store.touch(lang, namespace)
|
|
279
289
|
return
|
|
280
290
|
end
|
|
281
|
-
store.put(lang, namespace, result.translations, result.etag)
|
|
291
|
+
store.put(lang, namespace, result.translations, result.etag, last_refresh: result.last_refresh)
|
|
282
292
|
refresh_loaded(lang, namespace, result.translations)
|
|
283
293
|
end
|
|
284
294
|
|
|
295
|
+
# Boot (PROTOCOL.md 7.4, the node SDK model): every (namespace, lang) the
|
|
296
|
+
# manifest covers is read from its file into this process and into the
|
|
297
|
+
# cache, with the manifest's cursor, so the boot fetch of a covered pair
|
|
298
|
+
# never happens. The primary language is skipped: this gem never looks a
|
|
299
|
+
# primary dictionary up. An unreadable file is logged and left to the lazy
|
|
300
|
+
# path (fetched on first use), never fetched eagerly. Makes no request.
|
|
301
|
+
def seed_bundle!
|
|
302
|
+
return if bundle.nil?
|
|
303
|
+
|
|
304
|
+
@mutex.synchronize do
|
|
305
|
+
bundle.pairs.each do |namespace, lang|
|
|
306
|
+
next if lang == primary || !Locale.lang?(lang)
|
|
307
|
+
|
|
308
|
+
entry = seed_from_bundle(lang, namespace)
|
|
309
|
+
next if entry.nil?
|
|
310
|
+
|
|
311
|
+
id = "#{lang}|#{namespace}"
|
|
312
|
+
@revalidate[id] = [lang, namespace] if store.stale?(entry)
|
|
313
|
+
@loaded[id] = entry[:translations]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# The stored entry after the bundle's dictionary is applied to it (the
|
|
319
|
+
# cache wins only when it is newer), or nil when the bundle does not cover
|
|
320
|
+
# the pair or its file is unreadable. Called with the mutex held.
|
|
321
|
+
def seed_from_bundle(lang, namespace)
|
|
322
|
+
return nil if bundle.nil?
|
|
323
|
+
|
|
324
|
+
seed = bundle.seed(namespace, lang, logger: logger)
|
|
325
|
+
return nil if seed.nil?
|
|
326
|
+
|
|
327
|
+
store.seed(lang, namespace, seed)
|
|
328
|
+
end
|
|
329
|
+
|
|
285
330
|
# Keeps this process's loaded lines current (a long-lived process keeps
|
|
286
331
|
# the dictionaries between requests).
|
|
287
332
|
def refresh_loaded(lang, namespace, lines)
|
data/lib/i18n_keyless/version.rb
CHANGED
|
@@ -4,5 +4,5 @@ module I18nKeyless
|
|
|
4
4
|
# Sent as the `Version` header. The API reads its major to pick the wire
|
|
5
5
|
# dialect: >= 3 means the v3 language codes ("zh-Hans", "cs"). One version
|
|
6
6
|
# for every package and port of the monorepo (scripts/set-version.mjs).
|
|
7
|
-
VERSION = "3.
|
|
7
|
+
VERSION = "3.8.0"
|
|
8
8
|
end
|
data/lib/i18n_keyless.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "i18n_keyless/config"
|
|
|
11
11
|
require_relative "i18n_keyless/locale"
|
|
12
12
|
require_relative "i18n_keyless/miss"
|
|
13
13
|
require_relative "i18n_keyless/api_client"
|
|
14
|
+
require_relative "i18n_keyless/bundle"
|
|
14
15
|
require_relative "i18n_keyless/dictionary_store"
|
|
15
16
|
require_relative "i18n_keyless/translator"
|
|
16
17
|
require_relative "i18n_keyless/backend"
|
data/llms.txt
CHANGED
|
@@ -67,6 +67,7 @@ Rails-key rule (`I18nKeyless.keyless_key?`, `config.rails_key_pattern` = `/\A[a-
|
|
|
67
67
|
| queue | I18N_KEYLESS_QUEUE | nil |
|
|
68
68
|
| logger | | Rails.logger |
|
|
69
69
|
| rails_key_pattern | | `/\A[a-z0-9_]+(\.[a-z0-9_]+)*\z/` (nil: every string is keyless) |
|
|
70
|
+
| bundle_path | I18N_KEYLESS_BUNDLE_PATH | nil: the directory of a precompiled bundle (manifest.json + <namespace>/<lang>.json, from the MCP `export_bundle` tool or GET /translate/bundle); every covered dictionary is read at boot, never fetched; a miss still POSTs |
|
|
70
71
|
|
|
71
72
|
## Locale mapping
|
|
72
73
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: i18n-keyless-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arnaud Ambroselli
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- lib/i18n_keyless.rb
|
|
55
55
|
- lib/i18n_keyless/api_client.rb
|
|
56
56
|
- lib/i18n_keyless/backend.rb
|
|
57
|
+
- lib/i18n_keyless/bundle.rb
|
|
57
58
|
- lib/i18n_keyless/config.rb
|
|
58
59
|
- lib/i18n_keyless/dictionary_store.rb
|
|
59
60
|
- lib/i18n_keyless/helper.rb
|