i18n-tasks 1.0.12 → 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: e77c4c19ca040adf1879259e8ad1d963e181eb1682e97ffa99f5b09d1b59e803
4
- data.tar.gz: cc5ed0a16159c8f14a9006ab3908cfdc93b5dbc338603d54846d0ae4f84abb80
3
+ metadata.gz: 12e19e4d7fe61bd20e000b0b6e4db5bdf233443e8e8cf965b62e634bdfa3e5a2
4
+ data.tar.gz: 3dab05338d079c4defad590466ab2985faf43245d616f86af4c212003831a6e4
5
5
  SHA512:
6
- metadata.gz: 978d87d9e73dc5f4e183a7fdebabeec9864c3b1ba530f722bc3a80428b1c422eeda55f2bbd2b7ec8c1a67649ee128db4ae9ab0fc24db4b8cf386f7740b0f34d4
7
- data.tar.gz: 46a0b0cbb262c17aaf1195293edd1fc04d665dd85d5f8d1b399f1ae565db2eb99e3e281fb1a34a5cb3629aadee2c2b279642d8beca623814fcd9b75c0c502b5e
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.12'
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
@@ -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}."
@@ -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,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.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
 
@@ -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 = i18n.missing_diff_forest opt[:locales], opt[:from]
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
- " 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
@@ -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
- extract_hash(default_arg_node.children[1])
83
- else
84
- extract_string(default_arg_node.children[1])
85
- end
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'\-]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.12'
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.12
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: 2022-09-18 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
@@ -106,14 +106,14 @@ dependencies:
106
106
  requirements:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
- version: 2.2.3.0
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.3.0
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.27.0
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.27.0
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.1.2
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: []