i18n-tasks 1.0.11 → 1.0.13

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 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