i18n-tasks 1.0.11 → 1.0.13

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a7092606f1ea327f57a9a2bc0489c07d5349f61b6b742415d0dd367b243237
4
- data.tar.gz: dd87c3098d774df0fe685829cefe3716e9cd42156cb483762a491174d0eaf733
3
+ metadata.gz: 12e19e4d7fe61bd20e000b0b6e4db5bdf233443e8e8cf965b62e634bdfa3e5a2
4
+ data.tar.gz: 3dab05338d079c4defad590466ab2985faf43245d616f86af4c212003831a6e4
5
5
  SHA512:
6
- metadata.gz: 7341b68328712adcbbb7b28b9c83695a56fcaac29eb9d7303c8866cf0f6004bd7e85065a1684a3acb1597786c1993b876e8eab04f5db555d95563f43976e7934
7
- data.tar.gz: 8b8f93c545f0cc80b3ee36610751db469c1cf6c47df4ba9376189c926480dc0a96d678545c90163f2f24f1826beeaaff1e24ec9bc273a24541e715b0701ceb61
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.11'
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
@@ -9,5 +9,5 @@ task :irb do
9
9
  # $: << File.expand_path('lib', __FILE__)
10
10
  require 'i18n/tasks'
11
11
  require 'i18n/tasks/commands'
12
- ::I18n::Tasks::Commands.new(::I18n::Tasks::BaseTask.new).irb
12
+ I18n::Tasks::Commands.new(I18n::Tasks::BaseTask.new).irb
13
13
  end
@@ -33,7 +33,7 @@ en:
33
33
  Value. Interpolates: %{value}, %{human_key}, %{key}, %{default}, %{value_or_human_key},
34
34
  %{value_or_default_or_human_key}
35
35
  desc:
36
- add_missing: add missing keys to locale data
36
+ add_missing: add missing keys to locale data, optionally match a pattern
37
37
  check_consistent_interpolations: verify that all translations use correct interpolation variables
38
38
  check_normalized: verify that all translation data is normalized
39
39
  config: display i18n-tasks configuration
@@ -46,12 +46,13 @@ en:
46
46
  gem_path: show path to the gem
47
47
  health: is everything OK?
48
48
  irb: start REPL session within i18n-tasks context
49
- missing: show missing translations
49
+ missing: show missing translations, optionally match a pattern
50
50
  mv: rename/merge the keys in locale data that match the given pattern
51
51
  normalize: 'normalize translation data: sort and move to the right files'
52
52
  remove_unused: remove unused keys
53
53
  rm: remove the keys in locale data that match the given pattern
54
- translate_missing: translate missing keys with Google Translate or DeepL Pro
54
+ translate_missing: translate missing keys with Google Translate or DeepL Pro, optionally match
55
+ a pattern
55
56
  tree_convert: convert tree between formats
56
57
  tree_filter: filter tree by key pattern
57
58
  tree_merge: merge trees
@@ -95,6 +96,8 @@ en:
95
96
  Setup DeepL Pro API key via DEEPL_AUTH_KEY environment variable or translation.deepl_api_key
96
97
  in config/i18n-tasks.yml. Get the key at https://www.deepl.com/pro.
97
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.
98
101
  google_translate:
99
102
  errors:
100
103
  no_api_key: >-
@@ -109,6 +112,12 @@ en:
109
112
  missing:
110
113
  details_title: Value in other locales or source
111
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.
112
121
  remove_unused:
113
122
  confirm:
114
123
  one: "%{count} translation will be removed from %{locales}."
@@ -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| # rubocop:disable Metrics/BlockLength
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,16 +35,15 @@ 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'
42
41
  s.add_dependency 'ast', '>= 2.1.0'
43
- s.add_dependency 'better_html', '~> 1.0'
42
+ s.add_dependency 'better_html', '>= 1.0', '< 3.0'
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.3.0'
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.27.0'
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
 
@@ -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
- if ['--config', '-c'].include?(arg)
39
- _, config_file = argv.slice!(i, 2)
40
- if File.exist?(config_file)
41
- @config_file = config_file
42
- break
43
- else
44
- error "Config file doesn't exist: #{config_file}", 128
45
- end
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
 
@@ -25,10 +25,14 @@ module I18n::Tasks
25
25
  cmd :missing,
26
26
  pos: '[locale ...]',
27
27
  desc: t('i18n_tasks.cmd.desc.missing'),
28
- args: %i[locales out_format missing_types]
28
+ args: %i[locales out_format missing_types pattern]
29
29
 
30
30
  def missing(opt = {})
31
31
  forest = i18n.missing_keys(**opt.slice(:locales, :base_locale, :types))
32
+ if opt[:pattern]
33
+ pattern_re = i18n.compile_key_pattern(opt[:pattern])
34
+ forest.select_keys! { |full_key, _node| full_key =~ pattern_re }
35
+ end
32
36
  print_forest forest, opt, :missing_keys
33
37
  :exit1 unless forest.empty?
34
38
  end
@@ -36,10 +40,14 @@ module I18n::Tasks
36
40
  cmd :translate_missing,
37
41
  pos: '[locale ...]',
38
42
  desc: t('i18n_tasks.cmd.desc.translate_missing'),
39
- args: [:locales, :locale_to_translate_from, arg(:out_format).from(1), :translation_backend]
43
+ args: [:locales, :locale_to_translate_from, arg(:out_format).from(1), :translation_backend, :pattern]
40
44
 
41
45
  def translate_missing(opt = {})
42
- missing = i18n.missing_diff_forest opt[:locales], opt[:from]
46
+ missing = i18n.missing_diff_forest opt[:locales], opt[:from]
47
+ if opt[:pattern]
48
+ pattern_re = i18n.compile_key_pattern(opt[:pattern])
49
+ missing.select_keys! { |full_key, _node| full_key =~ pattern_re }
50
+ end
43
51
  translated = i18n.translate_forest missing, from: opt[:from], backend: opt[:backend].to_sym
44
52
  i18n.data.merge! translated
45
53
  log_stderr t('i18n_tasks.translate_missing.translated', count: translated.leaves.count)
@@ -49,17 +57,21 @@ module I18n::Tasks
49
57
  cmd :add_missing,
50
58
  pos: '[locale ...]',
51
59
  desc: t('i18n_tasks.cmd.desc.add_missing'),
52
- args: [:locales, :out_format, arg(:value) + [{ default: '%{value_or_default_or_human_key}' }],
60
+ args: [:locales, :out_format, :pattern, arg(:value) + [{ default: '%{value_or_default_or_human_key}' }],
53
61
  ['--nil-value', 'Set value to nil. Takes precedence over the value argument.']]
54
62
 
55
63
  # Merge base locale first, as this may affect the value for the other locales
56
- def add_missing(opt = {})
64
+ def add_missing(opt = {}) # rubocop:disable Metrics/AbcSize
57
65
  [
58
66
  [i18n.base_locale] & opt[:locales],
59
67
  opt[:locales] - [i18n.base_locale]
60
68
  ].reject(&:empty?).each_with_object(i18n.empty_forest) do |locales, added|
61
69
  forest = i18n.missing_keys(locales: locales, **opt.slice(:types, :base_locale))
62
70
  .set_each_value!(opt[:'nil-value'] ? nil : opt[:value])
71
+ if opt[:pattern]
72
+ pattern_re = i18n.compile_key_pattern(opt[:pattern])
73
+ forest.select_keys! { |full_key, _node| full_key =~ pattern_re }
74
+ end
63
75
  i18n.data.merge! forest
64
76
  added.merge! forest
65
77
  end.tap do |added|
@@ -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?
@@ -174,15 +174,29 @@ module I18n::Tasks
174
174
  human_key = ActiveSupport::Inflector.humanize(node.key.to_s)
175
175
  full_key = node.full_key
176
176
  default = (node.data[:occurrences] || []).detect { |o| o.default_arg.presence }.try(:default_arg)
177
- StringInterpolation.interpolate_soft(
178
- val_pattern,
179
- value: node_value,
180
- human_key: human_key,
181
- key: full_key,
182
- default: default,
183
- value_or_human_key: node_value.presence || human_key,
184
- value_or_default_or_human_key: node_value.presence || default || human_key
185
- )
177
+ if default.is_a?(Hash)
178
+ default.each_with_object({}) do |(k, v), h|
179
+ h[k] = StringInterpolation.interpolate_soft(
180
+ val_pattern,
181
+ value: node_value,
182
+ human_key: human_key,
183
+ key: full_key,
184
+ default: v,
185
+ value_or_human_key: node_value.presence || human_key,
186
+ value_or_default_or_human_key: node_value.presence || v || human_key
187
+ )
188
+ end
189
+ else
190
+ StringInterpolation.interpolate_soft(
191
+ val_pattern,
192
+ value: node_value,
193
+ human_key: human_key,
194
+ key: full_key,
195
+ default: default,
196
+ value_or_human_key: node_value.presence || human_key,
197
+ value_or_default_or_human_key: node_value.presence || default || human_key
198
+ )
199
+ end
186
200
  end
187
201
  pattern_re = I18n::Tasks::KeyPatternMatching.compile_key_pattern(key_pattern) if key_pattern.present?
188
202
  keys.each do |key, node|
@@ -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*/, '|')})" }
@@ -10,7 +10,7 @@ module I18n::Tasks
10
10
  private
11
11
 
12
12
  def path_locale_re(locale)
13
- (@path_locale_res ||= {})[locale] ||= %r{(?<=^|[/.])#{locale}(?=[/.])}
13
+ (@path_locale_res ||= {})[locale] ||= %r{(?<=^|[/.-])#{locale}(?=[/.])}
14
14
  end
15
15
  end
16
16
  end
@@ -52,15 +52,9 @@ module I18n::Tasks::PluralKeys
52
52
  end
53
53
 
54
54
  def plural_forms?(s)
55
- return false if non_plural_other?(s)
56
-
57
55
  s.present? && s.all? { |node| node.leaf? && plural_suffix?(node.key) }
58
56
  end
59
57
 
60
- def non_plural_other?(s)
61
- s.size == 1 && s.first.leaf? && (!s.first.value.is_a?(String) || !s.first.value.include?('%{count}'))
62
- end
63
-
64
58
  def plural_suffix?(key)
65
59
  PLURAL_KEY_SUFFIXES.include?(key)
66
60
  end
@@ -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
- " but ⮕ #{other.value} in #{other.data[:locale]}"
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
@@ -54,6 +54,22 @@ module I18n::Tasks::Scanners::AstMatchers
54
54
  end
55
55
  end
56
56
 
57
+ # Extract the whole hash from a node of type `:hash`
58
+ #
59
+ # @param node [AST::Node] a node of type `:hash`.
60
+ # @return [Hash] the whole hash from the node
61
+ def extract_hash(node)
62
+ return {} if node.nil?
63
+
64
+ if node.type == :hash
65
+ node.children.each_with_object({}) do |pair, h|
66
+ key = pair.children[0].children[0].to_s
67
+ value = pair.children[1].children[0]
68
+ h[key] = value
69
+ end
70
+ end
71
+ end
72
+
57
73
  # Extract a hash pair with a given literal key.
58
74
  #
59
75
  # @param node [AST::Node] a node of type `:hash`.
@@ -77,8 +77,13 @@ module I18n::Tasks::Scanners::AstMatchers
77
77
 
78
78
  key = [scope, key].join('.') unless scope == ''
79
79
  end
80
- default_arg_node = extract_hash_pair(node, 'default')
81
- default_arg = extract_string(default_arg_node.children[1]) if default_arg_node
80
+ if default_arg_node = extract_hash_pair(node, 'default')
81
+ default_arg = if default_arg_node.children[1]&.type == :hash
82
+ extract_hash(default_arg_node.children[1])
83
+ else
84
+ extract_string(default_arg_node.children[1])
85
+ end
86
+ end
82
87
 
83
88
  [key, default_arg]
84
89
  end
@@ -12,7 +12,7 @@ module I18n::Tasks::Scanners
12
12
  include OccurrenceFromPosition
13
13
  include RubyKeyLiterals
14
14
 
15
- TRANSLATE_CALL_RE = /(?<=^|[^\w'\-.]|[^\w'\-]I18n\.|I18n\.)t(?:!|ranslate!?)?/.freeze
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 list_slice, opts.merge(is_html ? options_for_html : options_for_plain)
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
- from_values(list, translate_values(to_values(list), **options_for_translate_values(**opts))).tap do |result|
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 l[1] }.flatten.compact
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 v }
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
- restore_interpolations untranslated, each_translated.next
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
- result = DeepL.translate(list, to_deepl_compatible_locale(from), to_deepl_compatible_locale(to), options)
21
- if result.is_a?(DeepL::Resources::Text)
22
- [result.text]
23
- else
24
- result.map(&:text)
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
- { ignore_tags: %w[i18n] }.merge(options)
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 to_deepl_compatible_locale(locale)
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
- @i18n_tasks.warn_deprecated(
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
@@ -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 *.less
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/
@@ -2,6 +2,6 @@
2
2
 
3
3
  module I18n
4
4
  module Tasks
5
- VERSION = '1.0.11'
5
+ VERSION = '1.0.13'
6
6
  end
7
7
  end
@@ -13,7 +13,7 @@ data:
13
13
  ## Provide a custom adapter:
14
14
  # adapter: I18n::Tasks::Data::FileSystem
15
15
 
16
- # Locale files or `File.find` patterns where translations are read from:
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 `File.find` patterns to search in:
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
- assert_empty @missing_keys,
14
- "Missing #{@missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them"
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
- assert_empty @unused_keys,
19
- "#{@unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them"
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.11
4
+ version: 1.0.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - glebm
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-06-15 00:00:00.000000000 Z
11
+ date: 2023-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -42,16 +42,22 @@ dependencies:
42
42
  name: better_html
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: '1.0'
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: '3.0'
48
51
  type: :runtime
49
52
  prerelease: false
50
53
  version_requirements: !ruby/object:Gem::Requirement
51
54
  requirements:
52
- - - "~>"
55
+ - - ">="
53
56
  - !ruby/object:Gem::Version
54
57
  version: '1.0'
58
+ - - "<"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
55
61
  - !ruby/object:Gem::Dependency
56
62
  name: erubi
57
63
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +106,14 @@ dependencies:
100
106
  requirements:
101
107
  - - ">="
102
108
  - !ruby/object:Gem::Version
103
- version: 2.2.3.0
109
+ version: 3.2.2.1
104
110
  type: :runtime
105
111
  prerelease: false
106
112
  version_requirements: !ruby/object:Gem::Requirement
107
113
  requirements:
108
114
  - - ">="
109
115
  - !ruby/object:Gem::Version
110
- version: 2.2.3.0
116
+ version: 3.2.2.1
111
117
  - !ruby/object:Gem::Dependency
112
118
  name: rails-i18n
113
119
  requirement: !ruby/object:Gem::Requirement
@@ -224,14 +230,42 @@ dependencies:
224
230
  requirements:
225
231
  - - "~>"
226
232
  - !ruby/object:Gem::Version
227
- version: 1.27.0
233
+ version: 1.50.1
234
+ type: :development
235
+ prerelease: false
236
+ version_requirements: !ruby/object:Gem::Requirement
237
+ requirements:
238
+ - - "~>"
239
+ - !ruby/object:Gem::Version
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
228
262
  type: :development
229
263
  prerelease: false
230
264
  version_requirements: !ruby/object:Gem::Requirement
231
265
  requirements:
232
266
  - - "~>"
233
267
  - !ruby/object:Gem::Version
234
- version: 1.27.0
268
+ version: 2.19.0
235
269
  - !ruby/object:Gem::Dependency
236
270
  name: simplecov
237
271
  requirement: !ruby/object:Gem::Requirement
@@ -402,6 +436,7 @@ files:
402
436
  - lib/i18n/tasks/translators/base_translator.rb
403
437
  - lib/i18n/tasks/translators/deepl_translator.rb
404
438
  - lib/i18n/tasks/translators/google_translator.rb
439
+ - lib/i18n/tasks/translators/openai_translator.rb
405
440
  - lib/i18n/tasks/translators/yandex_translator.rb
406
441
  - lib/i18n/tasks/unused_keys.rb
407
442
  - lib/i18n/tasks/used_keys.rb