copy_tuner_client 1.2.1 → 1.2.3
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/.vscode/settings.json +3 -0
- data/CLAUDE.md +5 -0
- data/lib/copy_tuner_client/dotted_hash.rb +22 -4
- data/lib/copy_tuner_client/i18n_backend.rb +24 -18
- data/lib/copy_tuner_client/version.rb +1 -1
- data/spec/copy_tuner_client/dotted_hash_spec.rb +83 -9
- data/spec/copy_tuner_client/i18n_backend_spec.rb +18 -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: f671e8161b117c8d8d46b5ae56810ccd0e2fc88dbe9d35ab2175d303c89df11c
|
|
4
|
+
data.tar.gz: 5cf906cd134cda913900d2e15e970a094b23f01a03f9914c746b0e2da3ee6c26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84f11e2d259e496f0638b3b8914cc3f88ceda67c6ae1e497f37192fd55347b0f8948abe115c5b91d33ee8feb3acdfb7994fd0af0b4a3118a64ee763dfc458b89
|
|
7
|
+
data.tar.gz: 97f296346a8eff7bb3b09ae4b6de6757973ec859e771492962031440626df13548dc25705ebcb355a14e94038444d1223b325c49caff3a1d87f3ec034bde1561
|
data/.vscode/settings.json
CHANGED
data/CLAUDE.md
CHANGED
|
@@ -18,6 +18,11 @@ CopyTuner の Ruby クライアント gem。Rails アプリの I18n を CopyTune
|
|
|
18
18
|
- Rack middleware `RequestSync` / `CopyrayMiddleware` — 開発環境でのリクエスト毎同期とオーバーレイ
|
|
19
19
|
Rails 統合は engine.rb のイニシャライザ経由(ヘルパー/SimpleForm フック、アセット precompile)。
|
|
20
20
|
|
|
21
|
+
## 開発スタイル
|
|
22
|
+
- **RED/TDD で進める**: 実装前に必ず失敗するテストを書き、テストが RED になることを確認してから実装する
|
|
23
|
+
- 新機能・バグ修正ともに「テスト追加 → RED 確認 → 実装 → GREEN」のサイクルを守る
|
|
24
|
+
- テストを書かずに実装を先行させない
|
|
25
|
+
|
|
21
26
|
## Gotchas
|
|
22
27
|
- **フロントエンドは `src/*.ts` を編集する。`app/assets/*` は Vite のビルド成果物なので直接編集しない**
|
|
23
28
|
(vite.config.ts が `src/main.ts` → `app/assets/javascripts/copytuner.js` を出力)。
|
|
@@ -3,7 +3,9 @@ module CopyTunerClient
|
|
|
3
3
|
def to_h(dotted_hash)
|
|
4
4
|
hash = {}
|
|
5
5
|
dotted_hash.to_h.transform_keys(&:to_s).sort.each do |key, value|
|
|
6
|
-
|
|
6
|
+
# Rails i18n標準との互換性のため、特定のキーを適切な型に変換
|
|
7
|
+
converted_value = convert_value_type(key, value)
|
|
8
|
+
_hash = key.split('.').reverse.inject(converted_value) { |memo, _key| { _key => memo } }
|
|
7
9
|
hash.deep_merge!(_hash)
|
|
8
10
|
end
|
|
9
11
|
hash
|
|
@@ -16,8 +18,8 @@ module CopyTunerClient
|
|
|
16
18
|
all_keys.each_with_index do |key, index|
|
|
17
19
|
prefix = "#{key}."
|
|
18
20
|
conflict_keys = ((index + 1)..Float::INFINITY)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
.take_while { |i| all_keys[i]&.start_with?(prefix) }
|
|
22
|
+
.map { |i| all_keys[i] }
|
|
21
23
|
|
|
22
24
|
if conflict_keys.present?
|
|
23
25
|
results[key] = conflict_keys
|
|
@@ -27,6 +29,22 @@ module CopyTunerClient
|
|
|
27
29
|
results
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def convert_value_type(key, value)
|
|
35
|
+
return value unless value.is_a?(String)
|
|
36
|
+
|
|
37
|
+
# Rails i18n標準で数値型として扱われるキー
|
|
38
|
+
if key.end_with?('.precision')
|
|
39
|
+
value.to_i
|
|
40
|
+
# Rails i18n標準で真偽値として扱われるキー
|
|
41
|
+
elsif key.end_with?('.significant', '.strip_insignificant_zeros')
|
|
42
|
+
value == 'true'
|
|
43
|
+
else
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module_function :to_h, :conflict_keys, :convert_value_type # rubocop:disable Style/AccessModifierDeclarations
|
|
31
49
|
end
|
|
32
50
|
end
|
|
@@ -21,12 +21,13 @@ module CopyTunerClient
|
|
|
21
21
|
@cache = cache
|
|
22
22
|
@tree_cache = nil
|
|
23
23
|
@cache_version = nil
|
|
24
|
-
end
|
|
24
|
+
end
|
|
25
|
+
|
|
25
26
|
#
|
|
26
27
|
# @return [Object] the translated key (usually a String)
|
|
27
28
|
def translate(locale, key, options = {})
|
|
28
29
|
# I18nの標準処理に任せる(内部でlookupが呼ばれる)
|
|
29
|
-
content = super
|
|
30
|
+
content = super
|
|
30
31
|
|
|
31
32
|
return content if content.nil? || content.is_a?(Hash)
|
|
32
33
|
|
|
@@ -43,8 +44,9 @@ module CopyTunerClient
|
|
|
43
44
|
# @return [Array<String>] available locales
|
|
44
45
|
def available_locales
|
|
45
46
|
return @available_locales if defined?(@available_locales)
|
|
47
|
+
|
|
46
48
|
cached_locales = cache.keys.map { |key| key.split('.').first }
|
|
47
|
-
@available_locales = (cached_locales + super).uniq.map
|
|
49
|
+
@available_locales = (cached_locales + super).uniq.map(&:to_sym)
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
# Stores the given translations.
|
|
@@ -62,24 +64,26 @@ module CopyTunerClient
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
65
|
-
def lookup(locale, key, scope = [], options = {})
|
|
67
|
+
def lookup(locale, key, scope = [], options = {}) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
|
|
66
68
|
return nil if !key.is_a?(String) && !key.is_a?(Symbol)
|
|
67
69
|
|
|
68
70
|
parts = I18n.normalize_keys(locale, key, scope, options[:separator])
|
|
69
71
|
key_with_locale = parts.join('.')
|
|
70
72
|
key_without_locale = parts[1..].join('.')
|
|
71
73
|
|
|
72
|
-
if CopyTunerClient::configuration.ignored_keys.include?(key_without_locale)
|
|
73
|
-
CopyTunerClient::configuration.ignored_key_handler.call(IgnoredKey.new("Ignored key: #{key_without_locale}"))
|
|
74
|
-
end
|
|
75
|
-
|
|
76
74
|
# NOTE: local_first_key_regexp にマッチするキーは copy_tuner キャッシュをスキップし、
|
|
77
75
|
# ローカル config/locales(I18n::Backend::Simple)を優先する。段階的にローカルへ移行するための仕組み。
|
|
78
76
|
# ローカルに無い場合は nil(未訳)のまま返し、copy_tuner へのフォールバックも空キー登録も行わない(完全分離)。
|
|
77
|
+
# ignored_keys より先に評価することで、両方にマッチするキーでも確実にローカルへ委譲する。
|
|
79
78
|
if local_first_key?(key_without_locale)
|
|
80
79
|
return super
|
|
81
80
|
end
|
|
82
81
|
|
|
82
|
+
config = CopyTunerClient.configuration
|
|
83
|
+
if config.ignored_keys.include?(key_without_locale)
|
|
84
|
+
config.ignored_key_handler.call(IgnoredKey.new("Ignored key: #{key_without_locale}"))
|
|
85
|
+
end
|
|
86
|
+
|
|
83
87
|
# NOTE: ハッシュ化した場合に削除されるキーに対応するため、最初に完全一致をチェック(旧クライアントの動作を維持)
|
|
84
88
|
# 例: `en.test.key` が `en.test.key.conflict` のように別のキーで上書きされている場合の対応
|
|
85
89
|
exact_match = cache[key_with_locale]
|
|
@@ -87,10 +91,9 @@ module CopyTunerClient
|
|
|
87
91
|
return exact_match
|
|
88
92
|
end
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# return tree_result if tree_result
|
|
94
|
+
ensure_tree_cache_current
|
|
95
|
+
tree_result = lookup_in_tree_cache(parts)
|
|
96
|
+
return tree_result if tree_result
|
|
94
97
|
|
|
95
98
|
content = super
|
|
96
99
|
|
|
@@ -114,7 +117,9 @@ module CopyTunerClient
|
|
|
114
117
|
def lookup_in_tree_cache(keys)
|
|
115
118
|
return nil if @tree_cache.nil?
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
# NOTE: keys は I18n.normalize_keys 済みの配列で、数字だけのセグメントは Integer になっている
|
|
121
|
+
# (例: "...body_temperature.36.5" は [..., 36, 5] に分割される)。Integer#to_sym は無いため to_s を経由する。
|
|
122
|
+
symbol_keys = keys.map { |k| k.to_s.to_sym }
|
|
118
123
|
begin
|
|
119
124
|
result = @tree_cache.dig(*symbol_keys)
|
|
120
125
|
result.is_a?(Hash) ? result : nil
|
|
@@ -146,21 +151,22 @@ module CopyTunerClient
|
|
|
146
151
|
end
|
|
147
152
|
|
|
148
153
|
def default(locale, object, subject, options = {})
|
|
149
|
-
content = super
|
|
154
|
+
content = super
|
|
150
155
|
return content if !object.is_a?(String) && !object.is_a?(Symbol)
|
|
151
156
|
|
|
152
157
|
if content.respond_to?(:to_str)
|
|
153
158
|
parts = I18n.normalize_keys(locale, object, options[:scope], options[:separator])
|
|
154
159
|
# NOTE: ActionView::Helpers::TranslationHelper#translate wraps default String in an Array
|
|
155
160
|
# NOTE: local_first キーのアップロード抑止は Cache#[]= 側に集約している
|
|
156
|
-
|
|
157
|
-
key = parts.join('.')
|
|
158
|
-
cache[key] = content.to_str
|
|
159
|
-
end
|
|
161
|
+
cache[parts.join('.')] = content.to_str if default_string_subject?(subject)
|
|
160
162
|
end
|
|
161
163
|
content
|
|
162
164
|
end
|
|
163
165
|
|
|
166
|
+
def default_string_subject?(subject)
|
|
167
|
+
subject.is_a?(String) || (subject.is_a?(Array) && subject.size == 1 && subject.first.is_a?(String))
|
|
168
|
+
end
|
|
169
|
+
|
|
164
170
|
attr_reader :cache
|
|
165
171
|
end
|
|
166
172
|
end
|
|
@@ -4,25 +4,25 @@ describe CopyTunerClient::DottedHash do
|
|
|
4
4
|
describe ".to_h" do
|
|
5
5
|
subject { CopyTunerClient::DottedHash.to_h(dotted_hash) }
|
|
6
6
|
|
|
7
|
-
context '
|
|
7
|
+
context '空のキーの場合' do
|
|
8
8
|
let(:dotted_hash) { {} }
|
|
9
9
|
|
|
10
10
|
it { is_expected.to eq({}) }
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
context '
|
|
13
|
+
context '1階層のキーの場合' do
|
|
14
14
|
let(:dotted_hash) { { 'key' => 'test value', other_key: 'other value' } }
|
|
15
15
|
|
|
16
16
|
it { is_expected.to eq({ 'key' => 'test value', 'other_key' => 'other value' }) }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
context '
|
|
19
|
+
context 'キーと値の配列の場合' do
|
|
20
20
|
let(:dotted_hash) { [['key', 'test value'], ['other_key', 'other value']] }
|
|
21
21
|
|
|
22
22
|
it { is_expected.to eq({ 'key' => 'test value', 'other_key' => 'other value' }) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
context "
|
|
25
|
+
context "複数階層のblurbキーの場合" do
|
|
26
26
|
let(:dotted_hash) do
|
|
27
27
|
{
|
|
28
28
|
'en.test.key' => 'en test value',
|
|
@@ -31,7 +31,7 @@ describe CopyTunerClient::DottedHash do
|
|
|
31
31
|
}
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
it do
|
|
34
|
+
it "正しくネストされたハッシュに変換されること" do
|
|
35
35
|
is_expected.to eq({
|
|
36
36
|
'en' => {
|
|
37
37
|
'test' => {
|
|
@@ -48,7 +48,7 @@ describe CopyTunerClient::DottedHash do
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
context "
|
|
51
|
+
context "キーの競合がある場合" do
|
|
52
52
|
let(:dotted_hash) do
|
|
53
53
|
{
|
|
54
54
|
'en.test' => 'invalid value',
|
|
@@ -58,12 +58,86 @@ describe CopyTunerClient::DottedHash do
|
|
|
58
58
|
|
|
59
59
|
it { is_expected.to eq({ 'en' => { 'test' => { 'key' => 'en test value' } } }) }
|
|
60
60
|
end
|
|
61
|
+
|
|
62
|
+
context "Rails i18nの数値precisionキーの場合" do
|
|
63
|
+
let(:dotted_hash) do
|
|
64
|
+
{
|
|
65
|
+
'en.number.currency.format.precision' => '2',
|
|
66
|
+
'en.number.format.precision' => '3',
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "precision値を整数に変換する" do
|
|
71
|
+
is_expected.to eq({
|
|
72
|
+
'en' => {
|
|
73
|
+
'number' => {
|
|
74
|
+
'currency' => {
|
|
75
|
+
'format' => {
|
|
76
|
+
'precision' => 2,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
'format' => {
|
|
80
|
+
'precision' => 3,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context "Rails i18nのbooleanキーの場合" do
|
|
89
|
+
let(:dotted_hash) do
|
|
90
|
+
{
|
|
91
|
+
'en.number.currency.format.significant' => 'false',
|
|
92
|
+
'en.number.format.strip_insignificant_zeros' => 'true',
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "boolean値を実際の真偽値に変換する" do
|
|
97
|
+
is_expected.to eq({
|
|
98
|
+
'en' => {
|
|
99
|
+
'number' => {
|
|
100
|
+
'currency' => {
|
|
101
|
+
'format' => {
|
|
102
|
+
'significant' => false,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
'format' => {
|
|
106
|
+
'strip_insignificant_zeros' => true,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
context "Rails i18n以外で似たパターンを含むキーの場合" do
|
|
115
|
+
let(:dotted_hash) do
|
|
116
|
+
{
|
|
117
|
+
'en.custom.precision' => 'custom_value',
|
|
118
|
+
'en.other.significant_value' => 'true',
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "Rails i18nパターンで終わるキーのみ変換する" do
|
|
123
|
+
is_expected.to eq({
|
|
124
|
+
'en' => {
|
|
125
|
+
'custom' => {
|
|
126
|
+
'precision' => 0, # .precision suffix triggers conversion
|
|
127
|
+
},
|
|
128
|
+
'other' => {
|
|
129
|
+
'significant_value' => 'true', # no conversion for non-exact match
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
end
|
|
134
|
+
end
|
|
61
135
|
end
|
|
62
136
|
|
|
63
137
|
describe ".conflict_keys" do
|
|
64
138
|
subject { CopyTunerClient::DottedHash.conflict_keys(dotted_hash) }
|
|
65
139
|
|
|
66
|
-
context '
|
|
140
|
+
context '有効なキーの場合' do
|
|
67
141
|
let(:dotted_hash) do
|
|
68
142
|
{
|
|
69
143
|
'ja.hoge.test' => 'test',
|
|
@@ -74,7 +148,7 @@ describe CopyTunerClient::DottedHash do
|
|
|
74
148
|
it { is_expected.to eq({}) }
|
|
75
149
|
end
|
|
76
150
|
|
|
77
|
-
context '
|
|
151
|
+
context '無効なキーの場合' do
|
|
78
152
|
let(:dotted_hash) do
|
|
79
153
|
{
|
|
80
154
|
'ja.hoge.test' => 'test',
|
|
@@ -85,7 +159,7 @@ describe CopyTunerClient::DottedHash do
|
|
|
85
159
|
}
|
|
86
160
|
end
|
|
87
161
|
|
|
88
|
-
it do
|
|
162
|
+
it "競合するキーが正しく検出されること" do
|
|
89
163
|
is_expected.to eq({
|
|
90
164
|
'ja.fuga.test' => %w[ja.fuga.test.hoge],
|
|
91
165
|
'ja.hoge.test' => %w[ja.hoge.test.fuga ja.hoge.test.hoge],
|
|
@@ -244,7 +244,7 @@ describe 'CopyTunerClient::I18nBackend' do
|
|
|
244
244
|
end
|
|
245
245
|
|
|
246
246
|
# NOTE: 色々考慮する必要があることが分かったため暫定対応として、ツリーキャッシュを使用しないようにしている
|
|
247
|
-
|
|
247
|
+
describe 'ツリー構造のlookup' do # rubocop:disable Metrics/BlockLength
|
|
248
248
|
subject { build_backend }
|
|
249
249
|
|
|
250
250
|
context '完全一致が存在する場合' do
|
|
@@ -425,6 +425,23 @@ describe 'CopyTunerClient::I18nBackend' do
|
|
|
425
425
|
result = subject.translate('ja', 'hoge.hello', default: nil)
|
|
426
426
|
expect(result).to be_nil
|
|
427
427
|
end
|
|
428
|
+
|
|
429
|
+
it '数値セグメントを含むキー(数値enum)でクラッシュしないこと' do
|
|
430
|
+
# NOTE: enumerize の Float range など、normalize_keys が末尾を Integer に分割するキー。
|
|
431
|
+
# exact_match(完全一致)を回避するため別キーを格納し、tree_cache 探索経路を必ず通す。
|
|
432
|
+
cache['ja.dummy'] = 'dummy'
|
|
433
|
+
|
|
434
|
+
expect {
|
|
435
|
+
subject.translate('ja', 'enumerize.infection_control.body_temperature.36.5', default: nil)
|
|
436
|
+
}.not_to raise_error
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
it '数値セグメントキーが存在しても通常キーのlookupが壊れないこと' do
|
|
440
|
+
cache['ja.views.hoge'] = 'normal'
|
|
441
|
+
|
|
442
|
+
expect { subject.translate('ja', 'enumerize.body_temperature.36.5', default: nil) }.not_to raise_error
|
|
443
|
+
expect(subject.translate('ja', 'views.hoge')).to eq('normal')
|
|
444
|
+
end
|
|
428
445
|
end
|
|
429
446
|
end
|
|
430
447
|
|