i18n-tasks 1.0.15 → 1.1.0

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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +85 -13
  3. data/Rakefile +4 -4
  4. data/bin/i18n-tasks +3 -3
  5. data/config/locales/en.yml +6 -0
  6. data/config/locales/ru.yml +7 -0
  7. data/i18n-tasks.gemspec +28 -41
  8. data/lib/i18n/tasks/base_task.rb +19 -19
  9. data/lib/i18n/tasks/cli.rb +37 -30
  10. data/lib/i18n/tasks/command/collection.rb +4 -4
  11. data/lib/i18n/tasks/command/commander.rb +5 -5
  12. data/lib/i18n/tasks/command/commands/check_prism.rb +126 -0
  13. data/lib/i18n/tasks/command/commands/data.rb +33 -33
  14. data/lib/i18n/tasks/command/commands/eq_base.rb +3 -3
  15. data/lib/i18n/tasks/command/commands/health.rb +6 -5
  16. data/lib/i18n/tasks/command/commands/interpolations.rb +14 -3
  17. data/lib/i18n/tasks/command/commands/meta.rb +6 -6
  18. data/lib/i18n/tasks/command/commands/missing.rb +25 -25
  19. data/lib/i18n/tasks/command/commands/tree.rb +33 -33
  20. data/lib/i18n/tasks/command/commands/usages.rb +24 -24
  21. data/lib/i18n/tasks/command/dsl.rb +1 -1
  22. data/lib/i18n/tasks/command/option_parsers/enum.rb +5 -5
  23. data/lib/i18n/tasks/command/option_parsers/locale.rb +4 -4
  24. data/lib/i18n/tasks/command/options/common.rb +16 -16
  25. data/lib/i18n/tasks/command/options/data.rb +18 -18
  26. data/lib/i18n/tasks/command/options/locales.rb +32 -32
  27. data/lib/i18n/tasks/commands.rb +14 -12
  28. data/lib/i18n/tasks/concurrent/cache.rb +1 -1
  29. data/lib/i18n/tasks/concurrent/cached_value.rb +1 -1
  30. data/lib/i18n/tasks/configuration.rb +22 -21
  31. data/lib/i18n/tasks/console_context.rb +11 -11
  32. data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
  33. data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +5 -5
  34. data/lib/i18n/tasks/data/file_formats.rb +3 -3
  35. data/lib/i18n/tasks/data/file_system.rb +5 -5
  36. data/lib/i18n/tasks/data/file_system_base.rb +26 -26
  37. data/lib/i18n/tasks/data/language_names.rb +202 -0
  38. data/lib/i18n/tasks/data/router/conservative_router.rb +3 -3
  39. data/lib/i18n/tasks/data/router/isolating_router.rb +19 -19
  40. data/lib/i18n/tasks/data/router/pattern_router.rb +5 -5
  41. data/lib/i18n/tasks/data/tree/node.rb +27 -27
  42. data/lib/i18n/tasks/data/tree/nodes.rb +10 -10
  43. data/lib/i18n/tasks/data/tree/siblings.rb +20 -20
  44. data/lib/i18n/tasks/data/tree/traversal.rb +5 -5
  45. data/lib/i18n/tasks/data.rb +4 -4
  46. data/lib/i18n/tasks/html_keys.rb +2 -2
  47. data/lib/i18n/tasks/ignore_keys.rb +9 -9
  48. data/lib/i18n/tasks/interpolations.rb +21 -1
  49. data/lib/i18n/tasks/key_pattern_matching.rb +8 -8
  50. data/lib/i18n/tasks/logging.rb +2 -1
  51. data/lib/i18n/tasks/missing_keys.rb +24 -8
  52. data/lib/i18n/tasks/plural_keys.rb +6 -4
  53. data/lib/i18n/tasks/references.rb +4 -4
  54. data/lib/i18n/tasks/reports/base.rb +18 -14
  55. data/lib/i18n/tasks/reports/terminal.rb +64 -47
  56. data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +3 -3
  57. data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +3 -3
  58. data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +10 -10
  59. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +1 -1
  60. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +69 -10
  61. data/lib/i18n/tasks/scanners/file_scanner.rb +5 -5
  62. data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +3 -3
  63. data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +3 -3
  64. data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +2 -2
  65. data/lib/i18n/tasks/scanners/files/file_finder.rb +8 -8
  66. data/lib/i18n/tasks/scanners/files/file_reader.rb +1 -1
  67. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +8 -8
  68. data/lib/i18n/tasks/scanners/occurrence_from_position.rb +1 -1
  69. data/lib/i18n/tasks/scanners/pattern_mapper.rb +7 -7
  70. data/lib/i18n/tasks/scanners/pattern_scanner.rb +20 -20
  71. data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +8 -8
  72. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +8 -1
  73. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +101 -61
  74. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +169 -105
  75. data/lib/i18n/tasks/scanners/relative_keys.rb +8 -8
  76. data/lib/i18n/tasks/scanners/results/key_occurrences.rb +3 -3
  77. data/lib/i18n/tasks/scanners/results/occurrence.rb +14 -10
  78. data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +1 -1
  79. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +6 -6
  80. data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +1 -1
  81. data/lib/i18n/tasks/scanners/ruby_scanner.rb +225 -0
  82. data/lib/i18n/tasks/scanners/scanner.rb +2 -2
  83. data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +1 -1
  84. data/lib/i18n/tasks/split_key.rb +4 -4
  85. data/lib/i18n/tasks/stats.rb +3 -3
  86. data/lib/i18n/tasks/translation.rb +5 -5
  87. data/lib/i18n/tasks/translators/base_translator.rb +40 -14
  88. data/lib/i18n/tasks/translators/deepl_translator.rb +17 -14
  89. data/lib/i18n/tasks/translators/google_translator.rb +169 -25
  90. data/lib/i18n/tasks/translators/openai_translator.rb +34 -23
  91. data/lib/i18n/tasks/translators/watsonx_translator.rb +16 -16
  92. data/lib/i18n/tasks/translators/yandex_translator.rb +8 -8
  93. data/lib/i18n/tasks/unused_keys.rb +1 -1
  94. data/lib/i18n/tasks/used_keys.rb +32 -33
  95. data/lib/i18n/tasks/version.rb +1 -1
  96. data/lib/i18n/tasks.rb +17 -17
  97. data/templates/config/i18n-tasks.yml +12 -0
  98. data/templates/minitest/i18n_test.rb +3 -3
  99. data/templates/rspec/i18n_spec.rb +7 -7
  100. metadata +25 -185
  101. data/lib/i18n/tasks/scanners/prism_scanner.rb +0 -83
  102. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +0 -145
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n/tasks/logging"
4
+ require "i18n/tasks/scanners/file_scanner"
5
+ require "i18n/tasks/scanners/relative_keys"
6
+ require "i18n/tasks/scanners/ruby_ast_call_finder"
7
+ require "i18n/tasks/scanners/ruby_parser_factory"
8
+ require "i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher"
9
+ require "i18n/tasks/scanners/ast_matchers/message_receivers_matcher"
10
+ require "i18n/tasks/scanners/ast_matchers/rails_model_matcher"
11
+ require "i18n/tasks/scanners/prism_scanners/visitor"
12
+
13
+ module I18n::Tasks::Scanners
14
+ # Scan for I18n.translate calls using whitequark/parser primarily and Prism if configured.
15
+ class RubyScanner < FileScanner
16
+ MAGIC_COMMENT_SKIP_PRISM = "i18n-tasks-skip-prism"
17
+ include RelativeKeys
18
+ include AST::Sexp
19
+ include ::I18n::Tasks::Logging
20
+
21
+ MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/
22
+
23
+ protected
24
+
25
+ # Extract all occurrences of translate calls from the file at the given path.
26
+ #
27
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
28
+ def scan_file(path)
29
+ if config[:prism]
30
+ prism_parse_file(path)
31
+ else
32
+ ast_parser_parse_file(path)
33
+ end
34
+ rescue => e
35
+ raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
36
+ end
37
+
38
+ def ast_parser_parse_file(path)
39
+ setup_ast_parser
40
+ ast, comments = path_to_ast_and_comments(path)
41
+
42
+ ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
43
+ end
44
+
45
+ # Parse file on path and returns AST and comments.
46
+ #
47
+ # @param path Path to file to parse
48
+ # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
49
+ def path_to_ast_and_comments(path)
50
+ @parser.reset
51
+ @parser.parse_with_comments(make_buffer(path))
52
+ end
53
+
54
+ # Create an {Parser::Source::Buffer} with the given contents.
55
+ # The contents are assigned a {Parser::Source::Buffer#raw_source}.
56
+ #
57
+ # @param path [String] Path to assign as the buffer name.
58
+ # @param contents [String]
59
+ # @return [Parser::Source::Buffer] file contents
60
+ def make_buffer(path, contents = read_file(path))
61
+ Parser::Source::Buffer.new(path).tap do |buffer|
62
+ buffer.raw_source = contents
63
+ end
64
+ end
65
+
66
+ # Convert an array of {Parser::Source::Comment} to occurrences.
67
+ #
68
+ # @param path Path to file
69
+ # @param ast Parser::AST::Node
70
+ # @param comments [Parser::Source::Comment]
71
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
72
+ def comments_to_occurences(path, ast, comments)
73
+ magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
74
+ comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
75
+ h.transform_values!(&:first)
76
+ end.invert
77
+
78
+ magic_comments.flat_map do |comment|
79
+ @parser.reset
80
+ associated_node = comment_to_node[comment]
81
+ ast = @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, "").split(/\s+(?=t)/).join("; ")))
82
+ calls = RubyAstCallFinder.new.collect_calls(ast)
83
+ results = []
84
+
85
+ # method_name is not available at this stage
86
+ calls.each do |(send_node, _method_name)|
87
+ @matchers.each do |matcher|
88
+ result = matcher.convert_to_key_occurrences(
89
+ send_node,
90
+ nil,
91
+ location: associated_node || comment.location
92
+ )
93
+ next unless result
94
+
95
+ if result.is_a?(Array) && result.first.is_a?(Array)
96
+ results.concat(result)
97
+ else
98
+ results << result
99
+ end
100
+ end
101
+ end
102
+
103
+ results
104
+ end
105
+ end
106
+
107
+ # Convert {Parser::AST::Node} to occurrences.
108
+ #
109
+ # @param ast {Parser::Source::Comment}
110
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
111
+ def ast_to_occurences(ast)
112
+ calls = RubyAstCallFinder.new.collect_calls(ast)
113
+ results = []
114
+ calls.each do |send_node, method_name|
115
+ @matchers.each do |matcher|
116
+ result = matcher.convert_to_key_occurrences(send_node, method_name)
117
+ next unless result
118
+
119
+ if result.is_a?(Array) && result.first.is_a?(Array)
120
+ results.concat(result)
121
+ else
122
+ results << result
123
+ end
124
+ end
125
+ end
126
+
127
+ results
128
+ end
129
+
130
+ def setup_ast_parser
131
+ @parser ||= RubyParserFactory.create_parser
132
+ @magic_comment_parser ||= RubyParserFactory.create_parser
133
+ setup_ast_matchers
134
+ end
135
+
136
+ def setup_ast_matchers
137
+ return if defined?(@matchers)
138
+
139
+ if config[:receiver_messages]
140
+ @matchers = config[:receiver_messages].map do |receiver, message|
141
+ AstMatchers::MessageReceiversMatcher.new(
142
+ receivers: [receiver],
143
+ message: message,
144
+ scanner: self
145
+ )
146
+ end
147
+ else
148
+ @matchers = %i[t t! translate translate!].map do |message|
149
+ AstMatchers::MessageReceiversMatcher.new(
150
+ receivers: [
151
+ AST::Node.new(:const, [nil, :I18n]),
152
+ nil
153
+ ],
154
+ message: message,
155
+ scanner: self
156
+ )
157
+ end
158
+
159
+ Array(config[:ast_matchers]).each do |class_name|
160
+ @matchers << ActiveSupport::Inflector.constantize(class_name).new(scanner: self)
161
+ end
162
+ end
163
+ end
164
+
165
+ # ---------- Prism parser below ----------
166
+
167
+ # Extract all occurrences of translate calls from the file at the given path.
168
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
169
+ def prism_parse_file(path)
170
+ process_prism_results(path, Prism.parse_file(path))
171
+ end
172
+
173
+ # This method handles only parsing to be able to test it properly.
174
+ # Therefore it cannot handle the parsing itself.
175
+ def process_prism_results(path, parse_results)
176
+ comments = parse_results.attach_comments!
177
+ parsed = parse_results.value
178
+
179
+ # Check for magic comment to skip prism parsing, fallback to Parser AST
180
+ return ast_parser_parse_file(path) if skip_prism_comment?(comments)
181
+
182
+ visitor = I18n::Tasks::Scanners::PrismScanners::Visitor.new(
183
+ rails: config[:prism] != "ruby",
184
+ file_path: path
185
+ )
186
+ parsed.accept(visitor)
187
+
188
+ occurrences = []
189
+ visitor.process.each do |translation_call|
190
+ result = translation_call.occurrences(path)
191
+ next unless result
192
+
193
+ if result.is_a?(Array) && result.first.is_a?(Array)
194
+ occurrences.concat(result)
195
+ else
196
+ occurrences << result
197
+ end
198
+ end
199
+
200
+ occurrences
201
+ end
202
+
203
+ def skip_prism_comment?(comments)
204
+ comments.any? do |comment|
205
+ content =
206
+ comment.respond_to?(:slice) ? comment.slice : comment.location.slice
207
+ content.include?(MAGIC_COMMENT_SKIP_PRISM)
208
+ end
209
+ end
210
+ end
211
+
212
+ class RubyAstScanner < RubyScanner
213
+ def initialize(**args)
214
+ warn_deprecated("RubyAstScanner is deprecated, use RubyScanner instead")
215
+ super
216
+ end
217
+ end
218
+
219
+ class PrismScanner < RubyScanner
220
+ def initialize(**args)
221
+ warn_deprecated('PrismScanner is deprecated, use RubyScanner with prism: "rails" or prism: "ruby" instead')
222
+ super
223
+ end
224
+ end
225
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/results/key_occurrences'
3
+ require "i18n/tasks/scanners/results/key_occurrences"
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  # Describes the API of a scanner.
@@ -11,7 +11,7 @@ module I18n::Tasks::Scanners
11
11
  # @abstract
12
12
  # @return [Array<Results::KeyOccurrences>] the keys found by this scanner and their occurrences.
13
13
  def keys
14
- fail 'Unimplemented'
14
+ fail "Unimplemented"
15
15
  end
16
16
  end
17
17
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/scanner'
3
+ require "i18n/tasks/scanners/scanner"
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  # Run multiple {Scanner Scanners} and merge their results.
@@ -21,7 +21,7 @@ module I18n
21
21
 
22
22
  parts = []
23
23
  current_parenthesis_end_char = nil
24
- part = ''
24
+ part = ""
25
25
  key.each_char.with_index do |char, index|
26
26
  if current_parenthesis_end_char
27
27
  part += char
@@ -29,14 +29,14 @@ module I18n
29
29
  elsif START_KEYS.include?(char)
30
30
  part += char
31
31
  current_parenthesis_end_char = END_KEYS[char]
32
- elsif char == '.'
32
+ elsif char == "."
33
33
  parts << part
34
34
  if parts.size + 1 == max
35
35
  remaining = key[(index + 1)..]
36
36
  parts << remaining unless remaining.empty?
37
37
  return parts
38
38
  end
39
- part = ''
39
+ part = ""
40
40
  else
41
41
  part += char
42
42
  end
@@ -44,7 +44,7 @@ module I18n
44
44
 
45
45
  return parts if part.empty?
46
46
 
47
- current_parenthesis_end_char ? parts.concat(part.split('.')) : parts << part
47
+ current_parenthesis_end_char ? parts.concat(part.split(".")) : parts << part
48
48
  end
49
49
 
50
50
  def last_key_part(key)
@@ -6,15 +6,15 @@ module I18n::Tasks
6
6
  key_count = forest.leaves.count
7
7
  locale_count = forest.count
8
8
  if key_count.zero?
9
- { key_count: 0 }
9
+ {key_count: 0}
10
10
  else
11
11
  {
12
- locales: forest.map(&:key).join(', '),
12
+ locales: forest.map(&:key).join(", "),
13
13
  key_count: key_count,
14
14
  locale_count: locale_count,
15
15
  per_locale_avg: forest.inject(0) { |sum, f| sum + f.leaves.count } / locale_count,
16
16
  key_segments_avg: format(
17
- '%.1f', forest.leaves.inject(0) { |sum, node| sum + node.walk_to_root.count - 1 } / key_count.to_f
17
+ "%.1f", forest.leaves.inject(0) { |sum, node| sum + node.walk_to_root.count - 1 } / key_count.to_f
18
18
  ),
19
19
  value_chars_avg: forest.leaves.inject(0) { |sum, node| sum + node.value.to_s.length } / key_count
20
20
  }
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/translators/deepl_translator'
4
- require 'i18n/tasks/translators/google_translator'
5
- require 'i18n/tasks/translators/openai_translator'
6
- require 'i18n/tasks/translators/watsonx_translator'
7
- require 'i18n/tasks/translators/yandex_translator'
3
+ require "i18n/tasks/translators/deepl_translator"
4
+ require "i18n/tasks/translators/google_translator"
5
+ require "i18n/tasks/translators/openai_translator"
6
+ require "i18n/tasks/translators/watsonx_translator"
7
+ require "i18n/tasks/translators/yandex_translator"
8
8
 
9
9
  module I18n::Tasks
10
10
  module Translation
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "i18n/tasks/data/language_names"
4
+
3
5
  module I18n::Tasks
4
6
  module Translators
5
7
  class BaseTranslator
6
8
  include ::I18n::Tasks::Logging
9
+
7
10
  # @param [I18n::Tasks::BaseTask] i18n_tasks
8
11
  def initialize(i18n_tasks)
9
12
  @i18n_tasks = i18n_tasks
@@ -16,9 +19,24 @@ module I18n::Tasks
16
19
  forest.inject @i18n_tasks.empty_forest do |result, root|
17
20
  pairs = root.key_values(root: true)
18
21
 
19
- @progress_bar = ProgressBar.create(total: pairs.flatten.size, format: '%a <%B> %e %c/%C (%p%%)')
22
+ @progress_bar = ProgressBar.create(total: pairs.flatten.size, format: "%a <%B> %e %c/%C (%p%%)")
23
+
24
+ begin
25
+ translated = translate_pairs(pairs, to: root.key, from: from)
26
+ rescue => e
27
+ warn "Translation for locale #{root.key} failed: #{e.message}"
28
+ # If translate_pairs raised, try to salvage any partial translations
29
+ # by attempting to translate each slice individually and collecting successes.
30
+ translated = []
31
+ pairs.group_by { |k_v| @i18n_tasks.html_key? k_v[0], from }.each do |_is_html, list_slice|
32
+ translated.concat(fetch_translations(list_slice, to: root.key, from: from))
33
+ rescue => e2
34
+ warn "Partial translation failed for locale #{root.key}: #{e2.message} - leaving keys untranslated"
35
+ # leave the original list_slice untranslated
36
+ translated.concat(list_slice)
37
+ end
38
+ end
20
39
 
21
- translated = translate_pairs(pairs, to: root.key, from: from)
22
40
  result.merge! Data::Tree::Siblings.from_flat_pairs(translated)
23
41
  end
24
42
  end
@@ -37,6 +55,10 @@ module I18n::Tasks
37
55
  list -= reference_key_vals
38
56
  result = list.group_by { |k_v| @i18n_tasks.html_key? k_v[0], opts[:from] }.map do |is_html, list_slice|
39
57
  fetch_translations(list_slice, opts.merge(is_html ? options_for_html : options_for_plain))
58
+ rescue => e
59
+ warn "Translation slice failed: #{e.message} - leaving slice untranslated"
60
+ # Return the original untranslated slice so already completed translations are preserved
61
+ list_slice
40
62
  end.reduce(:+) || []
41
63
  result.concat(reference_key_vals)
42
64
  result.sort! { |a, b| key_pos[a[0]] <=> key_pos[b[0]] }
@@ -108,8 +130,7 @@ module I18n::Tasks
108
130
  end
109
131
  end
110
132
 
111
- INTERPOLATION_KEY_RE = /%\{[^}]+}/.freeze
112
- UNTRANSLATABLE_STRING = 'X__'
133
+ INTERPOLATION_KEY_RE = /%\{[^}]+}/
113
134
 
114
135
  # @param [String] value
115
136
  # @return [String] 'hello, %{name}' => 'hello, <round-trippable string>'
@@ -117,7 +138,7 @@ module I18n::Tasks
117
138
  i = -1
118
139
  value.gsub INTERPOLATION_KEY_RE do
119
140
  i += 1
120
- "#{UNTRANSLATABLE_STRING}#{i}"
141
+ "X__#{i}"
121
142
  end
122
143
  end
123
144
 
@@ -125,13 +146,13 @@ module I18n::Tasks
125
146
  # @param [String] translated
126
147
  # @return [String] 'hello, <round-trippable string>' => 'hello, %{name}'
127
148
  def restore_interpolations(untranslated, translated)
128
- return translated if untranslated !~ INTERPOLATION_KEY_RE
149
+ return translated if !INTERPOLATION_KEY_RE.match?(untranslated)
129
150
 
130
151
  values = untranslated.scan(INTERPOLATION_KEY_RE)
131
- translated.gsub(/#{Regexp.escape(UNTRANSLATABLE_STRING)}\d+/i) do |m|
132
- values[m[UNTRANSLATABLE_STRING.length..].to_i]
152
+ translated.gsub(/X__(\d+)/) do |m|
153
+ values[$1.to_i]
133
154
  end
134
- rescue StandardError => e
155
+ rescue => e
135
156
  raise_interpolation_error(untranslated, translated, e)
136
157
  end
137
158
 
@@ -148,24 +169,29 @@ module I18n::Tasks
148
169
  # @param [Hash] options
149
170
  # @return [Array<String>]
150
171
  # @abstract
151
- def translate_values(list, **options); end
172
+ def translate_values(list, **options)
173
+ end
152
174
 
153
175
  # @param [Hash] options
154
176
  # @return [Hash]
155
177
  # @abstract
156
- def options_for_translate_values(options); end
178
+ def options_for_translate_values(options)
179
+ end
157
180
 
158
181
  # @return [Hash]
159
182
  # @abstract
160
- def options_for_html; end
183
+ def options_for_html
184
+ end
161
185
 
162
186
  # @return [Hash]
163
187
  # @abstract
164
- def options_for_plain; end
188
+ def options_for_plain
189
+ end
165
190
 
166
191
  # @return [String]
167
192
  # @abstract
168
- def no_results_error_message; end
193
+ def no_results_error_message
194
+ end
169
195
  end
170
196
  end
171
197
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/translators/base_translator'
3
+ require "i18n/tasks/translators/base_translator"
4
4
 
5
5
  module I18n::Tasks::Translators
6
6
  class DeeplTranslator < BaseTranslator
@@ -11,7 +11,7 @@ module I18n::Tasks::Translators
11
11
 
12
12
  def initialize(*)
13
13
  begin
14
- require 'deepl'
14
+ require "deepl"
15
15
  rescue LoadError
16
16
  raise ::I18n::Tasks::CommandError, "Add gem 'deepl-rb' to your Gemfile to use this command"
17
17
  end
@@ -44,15 +44,15 @@ module I18n::Tasks::Translators
44
44
  def options_for_translate_values(**options)
45
45
  extra_options = @i18n_tasks.translation_config[:deepl_options]&.symbolize_keys || {}
46
46
 
47
- extra_options.merge({ ignore_tags: %w[i18n] }).merge(options)
47
+ extra_options.merge({ignore_tags: %w[i18n]}).merge(options)
48
48
  end
49
49
 
50
50
  def options_for_html
51
- { tag_handling: 'xml' }
51
+ {tag_handling: "xml"}
52
52
  end
53
53
 
54
54
  def options_for_plain
55
- { preserve_formatting: true, tag_handling: 'xml', html_escape: true }
55
+ {preserve_formatting: true, tag_handling: "xml", html_escape: true}
56
56
  end
57
57
 
58
58
  # @param [String] value
@@ -67,20 +67,20 @@ module I18n::Tasks::Translators
67
67
  def restore_interpolations(untranslated, translated)
68
68
  return translated if untranslated !~ INTERPOLATION_KEY_RE
69
69
 
70
- translated.gsub(%r{</?i18n>}, '')
71
- rescue StandardError => e
70
+ translated.gsub(%r{</?i18n>}, "")
71
+ rescue => e
72
72
  raise_interpolation_error(untranslated, translated, e)
73
73
  end
74
74
 
75
75
  def no_results_error_message
76
- I18n.t('i18n_tasks.deepl_translate.errors.no_results')
76
+ I18n.t("i18n_tasks.deepl_translate.errors.no_results")
77
77
  end
78
78
 
79
79
  private
80
80
 
81
81
  # Convert 'es-ES' to 'ES', en-us to EN
82
82
  def to_deepl_source_locale(locale)
83
- locale.to_s.split('-', 2).first.upcase
83
+ locale.to_s.split("-", 2).first.upcase
84
84
  end
85
85
 
86
86
  # Convert 'es-ES' to 'ES' but warn about locales requiring a specific variant
@@ -88,10 +88,10 @@ module I18n::Tasks::Translators
88
88
  locale_aliases = @i18n_tasks.translation_config[:deepl_locale_aliases]
89
89
  locale = locale_aliases[locale.to_s.downcase] || locale if locale_aliases.is_a?(Hash)
90
90
 
91
- loc, sub = locale.to_s.split('-')
91
+ loc, sub = locale.to_s.split("-")
92
92
  if SPECIFIC_TARGETS.include?(loc)
93
93
  # Must see how the deepl api evolves, so this could be an error in the future
94
- warn_deprecated I18n.t('i18n_tasks.deepl_translate.errors.specific_target_missing') unless sub
94
+ warn_deprecated I18n.t("i18n_tasks.deepl_translate.errors.specific_target_missing") unless sub
95
95
  locale.to_s.upcase
96
96
  else
97
97
  loc.upcase
@@ -102,7 +102,7 @@ module I18n::Tasks::Translators
102
102
  api_key = @i18n_tasks.translation_config[:deepl_api_key]
103
103
  host = @i18n_tasks.translation_config[:deepl_host]
104
104
  version = @i18n_tasks.translation_config[:deepl_version]
105
- fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.deepl_translate.errors.no_api_key') if api_key.blank?
105
+ fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.deepl_translate.errors.no_api_key") if api_key.blank?
106
106
 
107
107
  DeepL.configure do |config|
108
108
  config.auth_key = api_key
@@ -111,9 +111,12 @@ module I18n::Tasks::Translators
111
111
  end
112
112
  end
113
113
 
114
+ # The Free API endpoint doesn’t expose glossaries via DeepL.glossaries.list,
115
+ # so if no API-backed glossary is found, fall back to the first ID from i18n-config.yml.
114
116
  def options_with_glossary(options, from, to)
115
- glossary = find_glossary(from, to)
116
- glossary ? { glossary_id: glossary.id }.merge(options) : options
117
+ configured = @i18n_tasks.translation_config[:deepl_glossary_ids]
118
+ gid = find_glossary(from, to)&.id || configured&.first
119
+ gid ? {glossary_id: gid}.merge(options) : options
117
120
  end
118
121
 
119
122
  def all_ready_glossaries