i18n-tasks 1.0.12 → 1.0.13
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +25 -2
- data/Rakefile +1 -1
- data/config/locales/en.yml +8 -0
- data/config/locales/ru.yml +8 -0
- data/i18n-tasks.gemspec +5 -4
- data/lib/i18n/tasks/cli.rb +8 -8
- data/lib/i18n/tasks/command/commands/missing.rb +2 -2
- data/lib/i18n/tasks/configuration.rb +2 -1
- data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +7 -1
- data/lib/i18n/tasks/data/tree/traversal.rb +2 -2
- data/lib/i18n/tasks/key_pattern_matching.rb +2 -0
- data/lib/i18n/tasks/references.rb +3 -3
- data/lib/i18n/tasks/reports/base.rb +1 -1
- data/lib/i18n/tasks/reports/terminal.rb +1 -1
- data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +4 -4
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +1 -0
- data/lib/i18n/tasks/translation.rb +4 -1
- data/lib/i18n/tasks/translators/base_translator.rb +16 -11
- data/lib/i18n/tasks/translators/deepl_translator.rb +32 -9
- data/lib/i18n/tasks/translators/google_translator.rb +1 -1
- data/lib/i18n/tasks/translators/openai_translator.rb +100 -0
- data/lib/i18n/tasks/used_keys.rb +3 -3
- data/lib/i18n/tasks/version.rb +1 -1
- data/templates/config/i18n-tasks.yml +6 -4
- data/templates/minitest/i18n_test.rb +6 -6
- metadata +38 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 12e19e4d7fe61bd20e000b0b6e4db5bdf233443e8e8cf965b62e634bdfa3e5a2
|
4
|
+
data.tar.gz: 3dab05338d079c4defad590466ab2985faf43245d616f86af4c212003831a6e4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2cf99aec1c6d5738c4a71964a7ce67e70c8ade3150042c4e381fef19b9eaa82cf6638979bf97c6e9941f119304973f9e6f3fd31b3bab0fd635520344448b5587
|
7
|
+
data.tar.gz: 2de5ab47951585b298e43bece202f3ad885ae8eb03edb2b6e860b30e3172ea213947509b9543bbf31df06ef03079ff1e7cfd1b7a32cd964428d9f9612fd7e171
|
data/README.md
CHANGED
@@ -24,7 +24,7 @@ i18n-tasks can be used with any project using the ruby [i18n gem][i18n-gem] (def
|
|
24
24
|
Add i18n-tasks to the Gemfile:
|
25
25
|
|
26
26
|
```ruby
|
27
|
-
gem 'i18n-tasks', '~> 1.0.
|
27
|
+
gem 'i18n-tasks', '~> 1.0.13'
|
28
28
|
```
|
29
29
|
|
30
30
|
Copy the default [configuration file](#configuration):
|
@@ -117,6 +117,17 @@ $ i18n-tasks translate-missing --backend=yandex
|
|
117
117
|
$ i18n-tasks translate-missing --from=en es fr
|
118
118
|
```
|
119
119
|
|
120
|
+
### OpenAI Translate missing keys
|
121
|
+
|
122
|
+
Translate missing values with OpenAI ([more below on the API key](#openai-translation-config)).
|
123
|
+
|
124
|
+
```console
|
125
|
+
$ i18n-tasks translate-missing --backend=openai
|
126
|
+
|
127
|
+
# accepts from and locales options:
|
128
|
+
$ i18n-tasks translate-missing --from=en es fr
|
129
|
+
```
|
130
|
+
|
120
131
|
### Find usages
|
121
132
|
|
122
133
|
See where the keys are used with `i18n-tasks find`:
|
@@ -335,6 +346,7 @@ A special syntax similar to file glob patterns is used throughout i18n-tasks to
|
|
335
346
|
|:------------:|:----------------------------------------------------------|
|
336
347
|
| `*` | matches everything |
|
337
348
|
| `:` | matches a single key |
|
349
|
+
| `*:` | matches part of a single key |
|
338
350
|
| `{a, b.c}` | match any in set, can use `:` and `*`, match is captured |
|
339
351
|
|
340
352
|
Example of usage:
|
@@ -423,6 +435,17 @@ translation:
|
|
423
435
|
yandex_api_key: <Yandex API key>
|
424
436
|
```
|
425
437
|
|
438
|
+
<a name="openai-translation-config"></a>
|
439
|
+
### OpenAI Translate
|
440
|
+
|
441
|
+
`i18n-tasks translate-missing` requires a OpenAI API key, get it at [OpenAI](https://openai.com/).
|
442
|
+
|
443
|
+
```yaml
|
444
|
+
# config/i18n-tasks.yml
|
445
|
+
translation:
|
446
|
+
openai_api_key: <OpenAI API key>
|
447
|
+
```
|
448
|
+
|
426
449
|
## Interactive console
|
427
450
|
|
428
451
|
`i18n-tasks irb` starts an IRB session in i18n-tasks context. Type `guide` for more information.
|
@@ -440,7 +463,7 @@ Custom tasks can be added easily, see the examples [on the wiki](https://github.
|
|
440
463
|
|
441
464
|
- Install dependencies using `bundle install`
|
442
465
|
- Run tests using `bundle exec rspec`
|
443
|
-
- Install [Overcommit](overcommit) by running `overcommit --install`
|
466
|
+
- Install [Overcommit](https://github.com/sds/overcommit) by running `overcommit --install`
|
444
467
|
|
445
468
|
## Skip Overcommit-hooks
|
446
469
|
|
data/Rakefile
CHANGED
data/config/locales/en.yml
CHANGED
@@ -96,6 +96,8 @@ en:
|
|
96
96
|
Setup DeepL Pro API key via DEEPL_AUTH_KEY environment variable or translation.deepl_api_key
|
97
97
|
in config/i18n-tasks.yml. Get the key at https://www.deepl.com/pro.
|
98
98
|
no_results: DeepL returned no results.
|
99
|
+
specific_target_missing: You must supply a specific variant for the given target language
|
100
|
+
e.g. en-us instead of en.
|
99
101
|
google_translate:
|
100
102
|
errors:
|
101
103
|
no_api_key: >-
|
@@ -110,6 +112,12 @@ en:
|
|
110
112
|
missing:
|
111
113
|
details_title: Value in other locales or source
|
112
114
|
none: No translations are missing.
|
115
|
+
openai_translate:
|
116
|
+
errors:
|
117
|
+
no_api_key: >-
|
118
|
+
Set OpenAI API key via OPENAI_API_KEY environment variable or translation.openai_api_key
|
119
|
+
in config/i18n-tasks.yml. Get the key at https://openai.com/.
|
120
|
+
no_results: OpenAI returned no results.
|
113
121
|
remove_unused:
|
114
122
|
confirm:
|
115
123
|
one: "%{count} translation will be removed from %{locales}."
|
data/config/locales/ru.yml
CHANGED
@@ -94,6 +94,8 @@ ru:
|
|
94
94
|
Задайте ключ API DeepL через переменную окружения DEEPL_AUTH_KEY или translation.deepl_api_key
|
95
95
|
Получите ключ через https://www.deepl.com/pro.
|
96
96
|
no_results: DeepL не дал результатов.
|
97
|
+
specific_target_missing: You must supply a specific variant for the given target language
|
98
|
+
e.g. en-us instead of en.
|
97
99
|
google_translate:
|
98
100
|
errors:
|
99
101
|
no_api_key: >-
|
@@ -109,6 +111,12 @@ ru:
|
|
109
111
|
missing:
|
110
112
|
details_title: На других языках или в коде
|
111
113
|
none: Всё переведено.
|
114
|
+
openai_translate:
|
115
|
+
errors:
|
116
|
+
no_api_key: |-
|
117
|
+
Установить ключ API Яндекса с помощью переменной среды OPENAI_API_KEY или translation.openai_api_key
|
118
|
+
в config / i18n-tasks.yml. Получите ключ по адресу https://openai.com/.
|
119
|
+
no_results: Яндекс не дал результатов.
|
112
120
|
remove_unused:
|
113
121
|
confirm:
|
114
122
|
few: Переводы (%{count}) будут удалены из %{locales}.
|
data/i18n-tasks.gemspec
CHANGED
@@ -4,7 +4,7 @@ lib = File.expand_path('lib', __dir__)
|
|
4
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
5
|
require 'i18n/tasks/version'
|
6
6
|
|
7
|
-
Gem::Specification.new do |s|
|
7
|
+
Gem::Specification.new do |s|
|
8
8
|
s.name = 'i18n-tasks'
|
9
9
|
s.version = I18n::Tasks::VERSION
|
10
10
|
s.authors = ['glebm']
|
@@ -35,7 +35,6 @@ Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
|
|
35
35
|
s.files = `git ls-files`.split($/)
|
36
36
|
s.files -= s.files.grep(%r{^(doc/|\.|spec/)}) + %w[CHANGES.md config/i18n-tasks.yml Gemfile]
|
37
37
|
s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } - %w[i18n-tasks.cmd]
|
38
|
-
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
39
38
|
s.require_paths = ['lib']
|
40
39
|
|
41
40
|
s.add_dependency 'activesupport', '>= 4.0.2'
|
@@ -44,7 +43,7 @@ Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
|
|
44
43
|
s.add_dependency 'erubi'
|
45
44
|
s.add_dependency 'highline', '>= 2.0.0'
|
46
45
|
s.add_dependency 'i18n'
|
47
|
-
s.add_dependency 'parser', '>= 2.2.
|
46
|
+
s.add_dependency 'parser', '>= 3.2.2.1'
|
48
47
|
s.add_dependency 'rails-i18n'
|
49
48
|
s.add_dependency 'rainbow', '>= 2.2.2', '< 4.0'
|
50
49
|
s.add_dependency 'terminal-table', '>= 1.5.1'
|
@@ -52,7 +51,9 @@ Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
|
|
52
51
|
s.add_development_dependency 'overcommit', '~> 0.58.0'
|
53
52
|
s.add_development_dependency 'rake'
|
54
53
|
s.add_development_dependency 'rspec', '~> 3.3'
|
55
|
-
s.add_development_dependency 'rubocop', '~> 1.
|
54
|
+
s.add_development_dependency 'rubocop', '~> 1.50.1'
|
55
|
+
s.add_development_dependency 'rubocop-rake', '~> 0.6.0'
|
56
|
+
s.add_development_dependency 'rubocop-rspec', '~> 2.19.0'
|
56
57
|
s.add_development_dependency 'simplecov'
|
57
58
|
s.add_development_dependency 'yard'
|
58
59
|
|
data/lib/i18n/tasks/cli.rb
CHANGED
@@ -35,14 +35,14 @@ class I18n::Tasks::CLI
|
|
35
35
|
|
36
36
|
def run(argv)
|
37
37
|
argv.each_with_index do |arg, i|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
38
|
+
next unless ['--config', '-c'].include?(arg)
|
39
|
+
|
40
|
+
_, config_file = argv.slice!(i, 2)
|
41
|
+
if File.exist?(config_file)
|
42
|
+
@config_file = config_file
|
43
|
+
break
|
44
|
+
else
|
45
|
+
error "Config file doesn't exist: #{config_file}", 128
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
@@ -43,7 +43,7 @@ module I18n::Tasks
|
|
43
43
|
args: [:locales, :locale_to_translate_from, arg(:out_format).from(1), :translation_backend, :pattern]
|
44
44
|
|
45
45
|
def translate_missing(opt = {})
|
46
|
-
missing
|
46
|
+
missing = i18n.missing_diff_forest opt[:locales], opt[:from]
|
47
47
|
if opt[:pattern]
|
48
48
|
pattern_re = i18n.compile_key_pattern(opt[:pattern])
|
49
49
|
missing.select_keys! { |full_key, _node| full_key =~ pattern_re }
|
@@ -61,7 +61,7 @@ module I18n::Tasks
|
|
61
61
|
['--nil-value', 'Set value to nil. Takes precedence over the value argument.']]
|
62
62
|
|
63
63
|
# Merge base locale first, as this may affect the value for the other locales
|
64
|
-
def add_missing(opt = {})
|
64
|
+
def add_missing(opt = {}) # rubocop:disable Metrics/AbcSize
|
65
65
|
[
|
66
66
|
[i18n.base_locale] & opt[:locales],
|
67
67
|
opt[:locales] - [i18n.base_locale]
|
@@ -66,6 +66,7 @@ module I18n::Tasks::Configuration # rubocop:disable Metrics/ModuleLength
|
|
66
66
|
conf[:deepl_api_key] = ENV['DEEPL_AUTH_KEY'] if ENV.key?('DEEPL_AUTH_KEY')
|
67
67
|
conf[:deepl_host] = ENV['DEEPL_HOST'] if ENV.key?('DEEPL_HOST')
|
68
68
|
conf[:deepl_version] = ENV['DEEPL_VERSION'] if ENV.key?('DEEPL_VERSION')
|
69
|
+
conf[:openai_api_key] = ENV['OPENAI_API_KEY'] if ENV.key?('OPENAI_API_KEY')
|
69
70
|
conf[:yandex_api_key] = ENV['YANDEX_API_KEY'] if ENV.key?('YANDEX_API_KEY')
|
70
71
|
conf
|
71
72
|
end
|
@@ -87,7 +88,7 @@ module I18n::Tasks::Configuration # rubocop:disable Metrics/ModuleLength
|
|
87
88
|
valid_locales = Dir[File.join(I18n::Tasks.gem_path, 'config', 'locales', '*.yml')]
|
88
89
|
.map { |f| File.basename(f, '.yml') }
|
89
90
|
unless valid_locales.include?(internal_locale)
|
90
|
-
log_warn "invalid internal_locale #{internal_locale.inspect}. "\
|
91
|
+
log_warn "invalid internal_locale #{internal_locale.inspect}. " \
|
91
92
|
"Available internal locales: #{valid_locales * ', '}."
|
92
93
|
internal_locale = DEFAULTS[:internal_locale].to_s
|
93
94
|
end
|
@@ -6,6 +6,7 @@ module I18n::Tasks
|
|
6
6
|
module Adapter
|
7
7
|
module YamlAdapter
|
8
8
|
EMOJI_REGEX = /\\u[\da-f]{8}/i.freeze
|
9
|
+
TRAILING_SPACE_REGEX = / $/.freeze
|
9
10
|
|
10
11
|
class << self
|
11
12
|
# @return [Hash] locale tree
|
@@ -20,13 +21,18 @@ module I18n::Tasks
|
|
20
21
|
|
21
22
|
# @return [String]
|
22
23
|
def dump(tree, options)
|
23
|
-
restore_emojis(tree.to_yaml(options || {}))
|
24
|
+
strip_trailing_spaces(restore_emojis(tree.to_yaml(options || {})))
|
24
25
|
end
|
25
26
|
|
26
27
|
# @return [String]
|
27
28
|
def restore_emojis(yaml)
|
28
29
|
yaml.gsub(EMOJI_REGEX) { |m| [m[-8..].to_i(16)].pack('U') }
|
29
30
|
end
|
31
|
+
|
32
|
+
# @return [String]
|
33
|
+
def strip_trailing_spaces(yaml)
|
34
|
+
yaml.gsub(TRAILING_SPACE_REGEX, '')
|
35
|
+
end
|
30
36
|
end
|
31
37
|
end
|
32
38
|
end
|
@@ -161,12 +161,12 @@ module I18n::Tasks
|
|
161
161
|
end
|
162
162
|
|
163
163
|
def grep_keys(match, opts = {})
|
164
|
-
select_keys(opts) do |full_key, _node|
|
164
|
+
select_keys(**opts) do |full_key, _node|
|
165
165
|
match === full_key # rubocop:disable Style/CaseEquality
|
166
166
|
end
|
167
167
|
end
|
168
168
|
|
169
|
-
def set_each_value!(val_pattern, key_pattern = nil, &value_proc)
|
169
|
+
def set_each_value!(val_pattern, key_pattern = nil, &value_proc) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
170
170
|
value_proc ||= proc do |node|
|
171
171
|
node_value = node.value
|
172
172
|
next node_value if node.reference?
|
@@ -21,6 +21,7 @@ module I18n::Tasks::KeyPatternMatching
|
|
21
21
|
# In patterns:
|
22
22
|
# * is like .* in regexs
|
23
23
|
# : matches a single key
|
24
|
+
# *: matches part of a single key, equivalent to `[^.]+?` regex
|
24
25
|
# { a, b.c } match any in set, can use : and *, match is captured
|
25
26
|
def compile_key_pattern(key_pattern)
|
26
27
|
return key_pattern if key_pattern.is_a?(Regexp)
|
@@ -31,6 +32,7 @@ module I18n::Tasks::KeyPatternMatching
|
|
31
32
|
def key_pattern_re_body(key_pattern)
|
32
33
|
key_pattern
|
33
34
|
.gsub(/\./, '\.')
|
35
|
+
.gsub(/\*:/, '[^.]+?')
|
34
36
|
.gsub(/\*/, '.*')
|
35
37
|
.gsub(/:/, '(?<=^|\.)[^.]+?(?=\.|$)')
|
36
38
|
.gsub(/\{(.*?)}/) { "(#{Regexp.last_match(1).strip.gsub(/\s*,\s*/, '|')})" }
|
@@ -90,9 +90,9 @@ module I18n::Tasks
|
|
90
90
|
on_leaves_merge: lambda do |node, other|
|
91
91
|
if node.value != other.value
|
92
92
|
log_warn(
|
93
|
-
'Conflicting references: '\
|
94
|
-
"#{node.full_key(root: false)} ⮕ #{node.value} in #{node.data[:locale]},"\
|
95
|
-
"
|
93
|
+
'Conflicting references: ' \
|
94
|
+
"#{node.full_key(root: false)} ⮕ #{node.value} in #{node.data[:locale]}, " \
|
95
|
+
"but ⮕ #{other.value} in #{other.data[:locale]}"
|
96
96
|
)
|
97
97
|
end
|
98
98
|
end
|
@@ -36,7 +36,7 @@ module I18n::Tasks::Reports
|
|
36
36
|
|
37
37
|
def used_title(keys_nodes, filter)
|
38
38
|
used_n = keys_nodes.map { |_k, node| node.data[:occurrences].size }.reduce(:+).to_i
|
39
|
-
"#{keys_nodes.size} key#{'s' if keys_nodes.size != 1}#{" matching '#{filter}'" if filter}"\
|
39
|
+
"#{keys_nodes.size} key#{'s' if keys_nodes.size != 1}#{" matching '#{filter}'" if filter}" \
|
40
40
|
"#{" (#{used_n} usage#{'s' if used_n != 1})" if used_n.positive?}"
|
41
41
|
end
|
42
42
|
|
@@ -112,7 +112,7 @@ module I18n
|
|
112
112
|
when :missing_plural
|
113
113
|
leaf[:data][:missing_keys].join(', ')
|
114
114
|
else
|
115
|
-
"#{Rainbow(leaf[:data][:missing_diff_locale]).cyan} "\
|
115
|
+
"#{Rainbow(leaf[:data][:missing_diff_locale]).cyan} " \
|
116
116
|
"#{format_value(leaf[:value].is_a?(String) ? leaf[:value].strip : leaf[:value])}"
|
117
117
|
end
|
118
118
|
end
|
@@ -79,10 +79,10 @@ module I18n::Tasks::Scanners::AstMatchers
|
|
79
79
|
end
|
80
80
|
if default_arg_node = extract_hash_pair(node, 'default')
|
81
81
|
default_arg = if default_arg_node.children[1]&.type == :hash
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
82
|
+
extract_hash(default_arg_node.children[1])
|
83
|
+
else
|
84
|
+
extract_string(default_arg_node.children[1])
|
85
|
+
end
|
86
86
|
end
|
87
87
|
|
88
88
|
[key, default_arg]
|
@@ -12,7 +12,7 @@ module I18n::Tasks::Scanners
|
|
12
12
|
include OccurrenceFromPosition
|
13
13
|
include RubyKeyLiterals
|
14
14
|
|
15
|
-
TRANSLATE_CALL_RE = /(?<=^|[^\w'\-.]|[^\w'
|
15
|
+
TRANSLATE_CALL_RE = /(?<=^|[^\w'\-.]|[^\w'-]I18n\.|I18n\.)t(?:!|ranslate!?)?/.freeze
|
16
16
|
IGNORE_LINES = {
|
17
17
|
'coffee' => /^\s*#(?!\si18n-tasks-use)/,
|
18
18
|
'erb' => /^\s*<%\s*#(?!\si18n-tasks-use)/,
|
@@ -4,6 +4,7 @@ require 'i18n/tasks/scanners/file_scanner'
|
|
4
4
|
require 'i18n/tasks/scanners/relative_keys'
|
5
5
|
require 'i18n/tasks/scanners/ruby_ast_call_finder'
|
6
6
|
require 'i18n/tasks/scanners/ast_matchers/message_receivers_matcher'
|
7
|
+
require 'i18n/tasks/scanners/ast_matchers/rails_model_matcher'
|
7
8
|
require 'parser/current'
|
8
9
|
|
9
10
|
module I18n::Tasks::Scanners
|
@@ -2,13 +2,14 @@
|
|
2
2
|
|
3
3
|
require 'i18n/tasks/translators/deepl_translator'
|
4
4
|
require 'i18n/tasks/translators/google_translator'
|
5
|
+
require 'i18n/tasks/translators/openai_translator'
|
5
6
|
require 'i18n/tasks/translators/yandex_translator'
|
6
7
|
|
7
8
|
module I18n::Tasks
|
8
9
|
module Translation
|
9
10
|
# @param [I18n::Tasks::Tree::Siblings] forest to translate to the locales of its root nodes
|
10
11
|
# @param [String] from locale
|
11
|
-
# @param [:deepl, :google, :yandex] backend
|
12
|
+
# @param [:deepl, :openai, :google, :yandex] backend
|
12
13
|
# @return [I18n::Tasks::Tree::Siblings] translated forest
|
13
14
|
def translate_forest(forest, from:, backend: :google)
|
14
15
|
case backend
|
@@ -16,6 +17,8 @@ module I18n::Tasks
|
|
16
17
|
Translators::DeeplTranslator.new(self).translate_forest(forest, from)
|
17
18
|
when :google
|
18
19
|
Translators::GoogleTranslator.new(self).translate_forest(forest, from)
|
20
|
+
when :openai
|
21
|
+
Translators::OpenAiTranslator.new(self).translate_forest(forest, from)
|
19
22
|
when :yandex
|
20
23
|
Translators::YandexTranslator.new(self).translate_forest(forest, from)
|
21
24
|
else
|
@@ -3,6 +3,7 @@
|
|
3
3
|
module I18n::Tasks
|
4
4
|
module Translators
|
5
5
|
class BaseTranslator
|
6
|
+
include ::I18n::Tasks::Logging
|
6
7
|
# @param [I18n::Tasks::BaseTask] i18n_tasks
|
7
8
|
def initialize(i18n_tasks)
|
8
9
|
@i18n_tasks = i18n_tasks
|
@@ -31,7 +32,7 @@ module I18n::Tasks
|
|
31
32
|
reference_key_vals = list.select { |_k, v| v.is_a? Symbol } || []
|
32
33
|
list -= reference_key_vals
|
33
34
|
result = list.group_by { |k_v| @i18n_tasks.html_key? k_v[0], opts[:from] }.map do |is_html, list_slice|
|
34
|
-
fetch_translations
|
35
|
+
fetch_translations(list_slice, opts.merge(is_html ? options_for_html : options_for_plain))
|
35
36
|
end.reduce(:+) || []
|
36
37
|
result.concat(reference_key_vals)
|
37
38
|
result.sort! { |a, b| key_pos[a[0]] <=> key_pos[b[0]] }
|
@@ -41,34 +42,36 @@ module I18n::Tasks
|
|
41
42
|
# @param [Array<[String, Object]>] list of key-value pairs
|
42
43
|
# @return [Array<[String, Object]>] translated list
|
43
44
|
def fetch_translations(list, opts)
|
44
|
-
|
45
|
+
options = options_for_translate_values(**opts)
|
46
|
+
from_values(list, translate_values(to_values(list, options), **options), options).tap do |result|
|
45
47
|
fail CommandError, no_results_error_message if result.blank?
|
46
48
|
end
|
47
49
|
end
|
48
50
|
|
49
51
|
# @param [Array<[String, Object]>] list of key-value pairs
|
50
52
|
# @return [Array<String>] values for translation extracted from list
|
51
|
-
def to_values(list)
|
52
|
-
list.map { |l| dump_value
|
53
|
+
def to_values(list, opts)
|
54
|
+
list.map { |l| dump_value(l[1], opts) }.flatten.compact
|
53
55
|
end
|
54
56
|
|
55
57
|
# @param [Array<[String, Object]>] list
|
56
58
|
# @param [Array<String>] translated_values
|
57
59
|
# @return [Array<[String, Object]>] translated key-value pairs
|
58
|
-
def from_values(list, translated_values)
|
60
|
+
def from_values(list, translated_values, opts)
|
59
61
|
keys = list.map(&:first)
|
60
62
|
untranslated_values = list.map(&:last)
|
61
|
-
keys.zip parse_value(untranslated_values, translated_values.to_enum)
|
63
|
+
keys.zip parse_value(untranslated_values, translated_values.to_enum, opts)
|
62
64
|
end
|
63
65
|
|
64
66
|
# Prepare value for translation.
|
65
67
|
# @return [String, Array<String, nil>, nil] value for Google Translate or nil for non-string values
|
66
|
-
def dump_value(value)
|
68
|
+
def dump_value(value, opts)
|
67
69
|
case value
|
68
70
|
when Array
|
69
71
|
# dump recursively
|
70
|
-
value.map { |v| dump_value
|
72
|
+
value.map { |v| dump_value(v, opts) }
|
71
73
|
when String
|
74
|
+
value = CGI.escapeHTML(value) if opts[:html_escape]
|
72
75
|
replace_interpolations value unless value.empty?
|
73
76
|
end
|
74
77
|
end
|
@@ -77,16 +80,18 @@ module I18n::Tasks
|
|
77
80
|
# @param [Object] untranslated
|
78
81
|
# @param [Enumerator] each_translated
|
79
82
|
# @return [Object] final translated value
|
80
|
-
def parse_value(untranslated, each_translated)
|
83
|
+
def parse_value(untranslated, each_translated, opts)
|
81
84
|
case untranslated
|
82
85
|
when Array
|
83
86
|
# implode array
|
84
|
-
untranslated.map { |from| parse_value(from, each_translated) }
|
87
|
+
untranslated.map { |from| parse_value(from, each_translated, opts) }
|
85
88
|
when String
|
86
89
|
if untranslated.empty?
|
87
90
|
untranslated
|
88
91
|
else
|
89
|
-
|
92
|
+
value = each_translated.next
|
93
|
+
value = CGI.unescapeHTML(value) if opts[:html_escape]
|
94
|
+
restore_interpolations(untranslated, value)
|
90
95
|
end
|
91
96
|
else
|
92
97
|
untranslated
|
@@ -4,6 +4,11 @@ require 'i18n/tasks/translators/base_translator'
|
|
4
4
|
|
5
5
|
module I18n::Tasks::Translators
|
6
6
|
class DeeplTranslator < BaseTranslator
|
7
|
+
# max allowed texts per request
|
8
|
+
BATCH_SIZE = 50
|
9
|
+
# those languages must be specified with their sub-kind e.g en-us
|
10
|
+
SPECIFIC_TARGETS = %w[en pt].freeze
|
11
|
+
|
7
12
|
def initialize(*)
|
8
13
|
begin
|
9
14
|
require 'deepl'
|
@@ -17,16 +22,22 @@ module I18n::Tasks::Translators
|
|
17
22
|
protected
|
18
23
|
|
19
24
|
def translate_values(list, from:, to:, **options)
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
+
results = []
|
26
|
+
list.each_slice(BATCH_SIZE) do |parts|
|
27
|
+
res = DeepL.translate(parts, to_deepl_source_locale(from), to_deepl_target_locale(to), options)
|
28
|
+
if res.is_a?(DeepL::Resources::Text)
|
29
|
+
results << res.text
|
30
|
+
else
|
31
|
+
results += res.map(&:text)
|
32
|
+
end
|
25
33
|
end
|
34
|
+
results
|
26
35
|
end
|
27
36
|
|
28
37
|
def options_for_translate_values(**options)
|
29
|
-
|
38
|
+
extra_options = @i18n_tasks.translation_config[:deepl_options]&.symbolize_keys || {}
|
39
|
+
|
40
|
+
extra_options.merge({ ignore_tags: %w[i18n] }).merge(options)
|
30
41
|
end
|
31
42
|
|
32
43
|
def options_for_html
|
@@ -34,7 +45,7 @@ module I18n::Tasks::Translators
|
|
34
45
|
end
|
35
46
|
|
36
47
|
def options_for_plain
|
37
|
-
{ preserve_formatting: true }
|
48
|
+
{ preserve_formatting: true, tag_handling: 'xml', html_escape: true }
|
38
49
|
end
|
39
50
|
|
40
51
|
# @param [String] value
|
@@ -60,11 +71,23 @@ module I18n::Tasks::Translators
|
|
60
71
|
|
61
72
|
private
|
62
73
|
|
63
|
-
# Convert 'es-ES' to 'ES'
|
64
|
-
def
|
74
|
+
# Convert 'es-ES' to 'ES', en-us to EN
|
75
|
+
def to_deepl_source_locale(locale)
|
65
76
|
locale.to_s.split('-', 2).first.upcase
|
66
77
|
end
|
67
78
|
|
79
|
+
# Convert 'es-ES' to 'ES' but warn about locales requiring a specific variant
|
80
|
+
def to_deepl_target_locale(locale)
|
81
|
+
loc, sub = locale.to_s.split('-')
|
82
|
+
if SPECIFIC_TARGETS.include?(loc)
|
83
|
+
# Must see how the deepl api evolves, so this could be an error in the future
|
84
|
+
warn_deprecated I18n.t('i18n_tasks.deepl_translate.errors.specific_target_missing') unless sub
|
85
|
+
locale.to_s.upcase
|
86
|
+
else
|
87
|
+
loc.upcase
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
68
91
|
def configure_api_key!
|
69
92
|
api_key = @i18n_tasks.translation_config[:deepl_api_key]
|
70
93
|
host = @i18n_tasks.translation_config[:deepl_host]
|
@@ -55,7 +55,7 @@ module I18n::Tasks::Translators
|
|
55
55
|
key = @i18n_tasks.translation_config[:google_translate_api_key]
|
56
56
|
# fallback with deprecation warning
|
57
57
|
if @i18n_tasks.translation_config[:api_key]
|
58
|
-
|
58
|
+
warn_deprecated(
|
59
59
|
'Please rename Google Translate API Key from `api_key` to `google_translate_api_key`.'
|
60
60
|
)
|
61
61
|
key ||= translation_config[:api_key]
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/translators/base_translator'
|
4
|
+
|
5
|
+
module I18n::Tasks::Translators
|
6
|
+
class OpenAiTranslator < BaseTranslator
|
7
|
+
# max allowed texts per request
|
8
|
+
BATCH_SIZE = 50
|
9
|
+
|
10
|
+
def initialize(*)
|
11
|
+
begin
|
12
|
+
require 'openai'
|
13
|
+
rescue LoadError
|
14
|
+
raise ::I18n::Tasks::CommandError, "Add gem 'ruby-openai' to your Gemfile to use this command"
|
15
|
+
end
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
def options_for_translate_values(from:, to:, **options)
|
20
|
+
options.merge(
|
21
|
+
from: from,
|
22
|
+
to: to
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def options_for_html
|
27
|
+
{}
|
28
|
+
end
|
29
|
+
|
30
|
+
def options_for_plain
|
31
|
+
{}
|
32
|
+
end
|
33
|
+
|
34
|
+
def no_results_error_message
|
35
|
+
I18n.t('i18n_tasks.openai_translate.errors.no_results')
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def translator
|
41
|
+
@translator ||= OpenAI::Client.new(access_token: api_key)
|
42
|
+
end
|
43
|
+
|
44
|
+
def api_key
|
45
|
+
@api_key ||= begin
|
46
|
+
key = @i18n_tasks.translation_config[:openai_api_key]
|
47
|
+
fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.openai_translate.errors.no_api_key') if key.blank?
|
48
|
+
|
49
|
+
key
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def translate_values(list, from:, to:)
|
54
|
+
results = []
|
55
|
+
|
56
|
+
list.each_slice(BATCH_SIZE) do |batch|
|
57
|
+
translations = translate(batch, from, to)
|
58
|
+
|
59
|
+
results << JSON.parse(translations)
|
60
|
+
end
|
61
|
+
|
62
|
+
results.flatten
|
63
|
+
end
|
64
|
+
|
65
|
+
def translate(values, from, to) # rubocop:disable Metrics/MethodLength
|
66
|
+
messages = [
|
67
|
+
{
|
68
|
+
role: 'system',
|
69
|
+
content: "You are a helpful assistant that translates content from the #{from} to #{to} locale in an i18n
|
70
|
+
locale array. The array has a structured format and contains multiple strings. Your task is to translate
|
71
|
+
each of these strings and create a new array with the translated strings. Keep in mind the context of all
|
72
|
+
the strings for a more accurate translation.\n"
|
73
|
+
},
|
74
|
+
{
|
75
|
+
role: 'user',
|
76
|
+
content: "Translate this array: \n\n\n"
|
77
|
+
},
|
78
|
+
{
|
79
|
+
role: 'user',
|
80
|
+
content: values.to_json
|
81
|
+
}
|
82
|
+
]
|
83
|
+
|
84
|
+
response = translator.chat(
|
85
|
+
parameters: {
|
86
|
+
model: 'gpt-3.5-turbo',
|
87
|
+
messages: messages,
|
88
|
+
temperature: 0.7
|
89
|
+
}
|
90
|
+
)
|
91
|
+
|
92
|
+
translations = response.dig('choices', 0, 'message', 'content')
|
93
|
+
error = response['error']
|
94
|
+
|
95
|
+
fail "AI error: #{error}" if error.present?
|
96
|
+
|
97
|
+
translations
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
data/lib/i18n/tasks/used_keys.rb
CHANGED
@@ -28,8 +28,8 @@ module I18n::Tasks
|
|
28
28
|
strict: true
|
29
29
|
}.freeze
|
30
30
|
|
31
|
-
ALWAYS_EXCLUDE = %w[*.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss
|
32
|
-
*.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus
|
31
|
+
ALWAYS_EXCLUDE = %w[*.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss
|
32
|
+
*.less *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus
|
33
33
|
*.webp *.map *.xlsx].freeze
|
34
34
|
|
35
35
|
# Find all keys in the source and return a forest with the keys in absolute form and their occurrences.
|
@@ -142,7 +142,7 @@ module I18n::Tasks
|
|
142
142
|
|
143
143
|
# keys in the source that end with a ., e.g. t("category.#{ cat.i18n_key }") or t("category." + category.key)
|
144
144
|
# @param [String] replacement for interpolated values.
|
145
|
-
def expr_key_re(replacement: '
|
145
|
+
def expr_key_re(replacement: '*:')
|
146
146
|
@expr_key_re ||= begin
|
147
147
|
# disallow patterns with no keys
|
148
148
|
ignore_pattern_re = /\A[.#{replacement}]*\z/
|
data/lib/i18n/tasks/version.rb
CHANGED
@@ -13,7 +13,7 @@ data:
|
|
13
13
|
## Provide a custom adapter:
|
14
14
|
# adapter: I18n::Tasks::Data::FileSystem
|
15
15
|
|
16
|
-
# Locale files or `
|
16
|
+
# Locale files or `Find.find` patterns where translations are read from:
|
17
17
|
read:
|
18
18
|
## Default:
|
19
19
|
# - config/locales/%{locale}.yml
|
@@ -52,7 +52,7 @@ data:
|
|
52
52
|
|
53
53
|
# Find translate calls
|
54
54
|
search:
|
55
|
-
## Paths or `
|
55
|
+
## Paths or `Find.find` patterns to search in:
|
56
56
|
# paths:
|
57
57
|
# - app/
|
58
58
|
|
@@ -94,7 +94,7 @@ search:
|
|
94
94
|
## User.human_attribute_name(:email) and User.model_name.human
|
95
95
|
##
|
96
96
|
## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`.
|
97
|
-
<%# I18n::Tasks.add_ast_matcher('I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher') %>
|
97
|
+
# <%# I18n::Tasks.add_ast_matcher('I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher') %>
|
98
98
|
|
99
99
|
## Multiple scanners can be used. Their results are merged.
|
100
100
|
## The options specified above are passed down to each scanner. Per-scanner options can be specified as well.
|
@@ -110,7 +110,9 @@ search:
|
|
110
110
|
# deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A"
|
111
111
|
# # deepl_host: "https://api.deepl.com"
|
112
112
|
# # deepl_version: "v2"
|
113
|
-
|
113
|
+
# # add additional options to the DeepL.translate call: https://www.deepl.com/docs-api/translate-text/translate-text/
|
114
|
+
# deepl_options:
|
115
|
+
# formality: prefer_less
|
114
116
|
## Do not consider these keys missing:
|
115
117
|
# ignore_missing:
|
116
118
|
# - 'errors.messages.{accepted,blank,invalid,too_short,too_long}'
|
@@ -5,18 +5,18 @@ require 'i18n/tasks'
|
|
5
5
|
class I18nTest < ActiveSupport::TestCase
|
6
6
|
def setup
|
7
7
|
@i18n = I18n::Tasks::BaseTask.new
|
8
|
-
@missing_keys = @i18n.missing_keys
|
9
|
-
@unused_keys = @i18n.unused_keys
|
10
8
|
end
|
11
9
|
|
12
10
|
def test_no_missing_keys
|
13
|
-
|
14
|
-
|
11
|
+
missing_keys = @i18n.missing_keys
|
12
|
+
assert_empty missing_keys,
|
13
|
+
"Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them"
|
15
14
|
end
|
16
15
|
|
17
16
|
def test_no_unused_keys
|
18
|
-
|
19
|
-
|
17
|
+
unused_keys = @i18n.unused_keys
|
18
|
+
assert_empty unused_keys,
|
19
|
+
"#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them"
|
20
20
|
end
|
21
21
|
|
22
22
|
def test_files_are_normalized
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: i18n-tasks
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.13
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- glebm
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-10-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -106,14 +106,14 @@ dependencies:
|
|
106
106
|
requirements:
|
107
107
|
- - ">="
|
108
108
|
- !ruby/object:Gem::Version
|
109
|
-
version: 2.2.
|
109
|
+
version: 3.2.2.1
|
110
110
|
type: :runtime
|
111
111
|
prerelease: false
|
112
112
|
version_requirements: !ruby/object:Gem::Requirement
|
113
113
|
requirements:
|
114
114
|
- - ">="
|
115
115
|
- !ruby/object:Gem::Version
|
116
|
-
version: 2.2.
|
116
|
+
version: 3.2.2.1
|
117
117
|
- !ruby/object:Gem::Dependency
|
118
118
|
name: rails-i18n
|
119
119
|
requirement: !ruby/object:Gem::Requirement
|
@@ -230,14 +230,42 @@ dependencies:
|
|
230
230
|
requirements:
|
231
231
|
- - "~>"
|
232
232
|
- !ruby/object:Gem::Version
|
233
|
-
version: 1.
|
233
|
+
version: 1.50.1
|
234
234
|
type: :development
|
235
235
|
prerelease: false
|
236
236
|
version_requirements: !ruby/object:Gem::Requirement
|
237
237
|
requirements:
|
238
238
|
- - "~>"
|
239
239
|
- !ruby/object:Gem::Version
|
240
|
-
version: 1.
|
240
|
+
version: 1.50.1
|
241
|
+
- !ruby/object:Gem::Dependency
|
242
|
+
name: rubocop-rake
|
243
|
+
requirement: !ruby/object:Gem::Requirement
|
244
|
+
requirements:
|
245
|
+
- - "~>"
|
246
|
+
- !ruby/object:Gem::Version
|
247
|
+
version: 0.6.0
|
248
|
+
type: :development
|
249
|
+
prerelease: false
|
250
|
+
version_requirements: !ruby/object:Gem::Requirement
|
251
|
+
requirements:
|
252
|
+
- - "~>"
|
253
|
+
- !ruby/object:Gem::Version
|
254
|
+
version: 0.6.0
|
255
|
+
- !ruby/object:Gem::Dependency
|
256
|
+
name: rubocop-rspec
|
257
|
+
requirement: !ruby/object:Gem::Requirement
|
258
|
+
requirements:
|
259
|
+
- - "~>"
|
260
|
+
- !ruby/object:Gem::Version
|
261
|
+
version: 2.19.0
|
262
|
+
type: :development
|
263
|
+
prerelease: false
|
264
|
+
version_requirements: !ruby/object:Gem::Requirement
|
265
|
+
requirements:
|
266
|
+
- - "~>"
|
267
|
+
- !ruby/object:Gem::Version
|
268
|
+
version: 2.19.0
|
241
269
|
- !ruby/object:Gem::Dependency
|
242
270
|
name: simplecov
|
243
271
|
requirement: !ruby/object:Gem::Requirement
|
@@ -408,6 +436,7 @@ files:
|
|
408
436
|
- lib/i18n/tasks/translators/base_translator.rb
|
409
437
|
- lib/i18n/tasks/translators/deepl_translator.rb
|
410
438
|
- lib/i18n/tasks/translators/google_translator.rb
|
439
|
+
- lib/i18n/tasks/translators/openai_translator.rb
|
411
440
|
- lib/i18n/tasks/translators/yandex_translator.rb
|
412
441
|
- lib/i18n/tasks/unused_keys.rb
|
413
442
|
- lib/i18n/tasks/used_keys.rb
|
@@ -445,8 +474,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
445
474
|
- !ruby/object:Gem::Version
|
446
475
|
version: '0'
|
447
476
|
requirements: []
|
448
|
-
rubygems_version: 3.
|
449
|
-
signing_key:
|
477
|
+
rubygems_version: 3.2.3
|
478
|
+
signing_key:
|
450
479
|
specification_version: 4
|
451
480
|
summary: Manage localization and translation with the awesome power of static analysis
|
452
481
|
test_files: []
|