i18n-tasks 1.0.14 → 1.0.15

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -38
  3. data/config/locales/en.yml +11 -1
  4. data/config/locales/ru.yml +11 -1
  5. data/i18n-tasks.gemspec +4 -1
  6. data/lib/i18n/tasks/command/commands/missing.rb +3 -1
  7. data/lib/i18n/tasks/command/option_parsers/enum.rb +4 -3
  8. data/lib/i18n/tasks/command/options/locales.rb +12 -3
  9. data/lib/i18n/tasks/configuration.rb +7 -2
  10. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +1 -1
  11. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +2 -2
  12. data/lib/i18n/tasks/scanners/pattern_scanner.rb +1 -1
  13. data/lib/i18n/tasks/scanners/prism_scanner.rb +83 -0
  14. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +41 -0
  15. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +334 -0
  16. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +273 -0
  17. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +3 -3
  18. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +1 -1
  19. data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
  20. data/lib/i18n/tasks/translation.rb +4 -1
  21. data/lib/i18n/tasks/translators/base_translator.rb +5 -1
  22. data/lib/i18n/tasks/translators/deepl_translator.rb +5 -0
  23. data/lib/i18n/tasks/translators/google_translator.rb +12 -4
  24. data/lib/i18n/tasks/translators/openai_translator.rb +34 -20
  25. data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
  26. data/lib/i18n/tasks/translators/yandex_translator.rb +5 -1
  27. data/lib/i18n/tasks/used_keys.rb +3 -2
  28. data/lib/i18n/tasks/version.rb +1 -1
  29. data/lib/i18n/tasks.rb +1 -0
  30. data/templates/config/i18n-tasks.yml +2 -2
  31. metadata +31 -5
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ # These classes are used in the PrismScanners::Visitor class to store the translations found in the parsed code
4
+ # Used in the PrismScanners::Visitor class.
5
+ module I18n::Tasks::Scanners::PrismScanners
6
+ class Root
7
+ attr_reader(:calls, :translation_calls, :children, :node, :parent)
8
+
9
+ def initialize(node: nil, parent: nil)
10
+ @calls = []
11
+ @translation_calls = []
12
+ @children = []
13
+ @node = node
14
+ @parent = parent
15
+ end
16
+
17
+ def add_child(node)
18
+ @children << node
19
+ node
20
+ end
21
+
22
+ def add_call(node)
23
+ @calls << node
24
+ end
25
+
26
+ def add_translation_call(translation_call)
27
+ @translation_calls << translation_call
28
+ end
29
+
30
+ def support_relative_keys?
31
+ false
32
+ end
33
+
34
+ def private_method
35
+ false
36
+ end
37
+
38
+ def path
39
+ []
40
+ end
41
+
42
+ def process
43
+ (@translation_calls + @children.flat_map(&:process)).flatten
44
+ end
45
+ end
46
+
47
+ class TranslationCall
48
+ attr_reader(:node, :key, :receiver, :options, :parent)
49
+
50
+ def initialize(node:, key:, receiver:, options:, parent:)
51
+ @node = node
52
+ @key = key
53
+ @receiver = receiver
54
+ @options = options
55
+ @parent = parent
56
+ end
57
+
58
+ def relative_key?
59
+ @key&.start_with?('.') && @receiver.nil?
60
+ end
61
+
62
+ def with_parent(parent)
63
+ self.class.new(
64
+ node: @node,
65
+ key: @key,
66
+ receiver: @receiver,
67
+ options: @options,
68
+ parent: parent
69
+ )
70
+ end
71
+
72
+ def with_node(node)
73
+ self.class.new(
74
+ node: node,
75
+ key: @key,
76
+ receiver: @receiver,
77
+ options: @options,
78
+ parent: @parent
79
+ )
80
+ end
81
+
82
+ def occurrences(file_path)
83
+ occurrence(file_path)
84
+ end
85
+
86
+ def full_key
87
+ return nil if key.nil?
88
+ return nil unless key.is_a?(String)
89
+ return nil if relative_key? && !support_relative_keys?
90
+
91
+ parts = [scope]
92
+
93
+ if relative_key?
94
+ parts.concat(parent&.path || [])
95
+ parts << key
96
+
97
+ # TODO: Fallback to controller without action name
98
+ elsif key.start_with?('.')
99
+ parts << key[1..]
100
+ else
101
+ parts << key
102
+ end
103
+
104
+ parts.compact.join('.').gsub('..', '.')
105
+ end
106
+
107
+ private
108
+
109
+ def scope
110
+ return nil if @options.nil?
111
+ return nil unless @options['scope']
112
+
113
+ Array(@options['scope']).compact.map(&:to_s).join('.')
114
+ end
115
+
116
+ def occurrence(file_path)
117
+ local_node = @node
118
+
119
+ location = local_node.location
120
+
121
+ final_key = full_key
122
+ return nil if final_key.nil?
123
+
124
+ [
125
+ final_key,
126
+ ::I18n::Tasks::Scanners::Results::Occurrence.new(
127
+ path: file_path,
128
+ line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
129
+ pos: location.start_offset,
130
+ line_pos: location.start_column,
131
+ line_num: location.start_line,
132
+ raw_key: key
133
+ )
134
+ ]
135
+ end
136
+
137
+ def occurrences_from_comments(file_path)
138
+ Array(@comment_translations).flat_map do |child_node|
139
+ child_node.with_context(
140
+ path: @path,
141
+ options: {
142
+ **@context_options,
143
+ comment_for_node: @node
144
+ }
145
+ ).occurrences(file_path)
146
+ end
147
+ end
148
+
149
+ # Only public methods are added to the context path
150
+ # Only some classes supports relative keys
151
+ def support_relative_keys?
152
+ parent.is_a?(ParsedMethod) && parent.support_relative_keys?
153
+ end
154
+ end
155
+
156
+ class ParsedModule < Root
157
+ def support_relative_keys?
158
+ false
159
+ end
160
+
161
+ def private_method
162
+ false
163
+ end
164
+
165
+ def path
166
+ (@parent&.path || []) + [path_name]
167
+ end
168
+
169
+ def path_name
170
+ @node.name.to_s.underscore
171
+ end
172
+ end
173
+
174
+ class ParsedClass < Root
175
+ attr_reader(:private_method)
176
+
177
+ def initialize(node:, parent:, rails:)
178
+ @private_method = false
179
+ @methods = []
180
+ @private_methods = []
181
+ @before_actions = []
182
+ @rails = rails
183
+
184
+ super(node: node, parent: parent)
185
+ end
186
+
187
+ def add_child(node)
188
+ case node
189
+ when ParsedMethod
190
+ if @private_method
191
+ @private_methods << node
192
+ else
193
+ @methods << node
194
+ end
195
+ when ParsedBeforeAction
196
+ @before_actions << node
197
+ end
198
+
199
+ super
200
+ end
201
+
202
+ def process # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
203
+ return @children.flat_map(&:process) unless controller?
204
+
205
+ methods_by_name = @methods.group_by(&:name)
206
+ private_methods_by_name = @private_methods.group_by(&:name)
207
+
208
+ # For each before_action we need to
209
+ # - Find which method it calls
210
+ # - Find out which methods it applies to
211
+ # - Calculate translation calls (and see if they are relative)
212
+ # - Add the translation calls to the methods it applies to
213
+
214
+ @before_actions.each do |before_action|
215
+ before_action_name = before_action.name&.to_sym
216
+ method_call = methods_by_name[before_action_name]&.first || private_methods_by_name[before_action_name]&.first
217
+ translation_calls = (method_call&.translation_calls || []) + before_action.translation_calls
218
+
219
+ # We need to handle the parent here, should not be the before_action when it is called in the method.
220
+ @methods.each do |method|
221
+ next unless before_action.applies_to?(method.name)
222
+
223
+ method.add_translation_call(
224
+ translation_calls.map { |call| call.with_parent(method) }
225
+ )
226
+ end
227
+ end
228
+
229
+ nested_calls = {}
230
+ new_translation_calls = []
231
+
232
+ @methods.each do |method|
233
+ method.calls.each do |call|
234
+ next if call.receiver.present?
235
+
236
+ other_method = methods_by_name[call.name]&.first || private_methods_by_name[call.name]&.first
237
+ next unless other_method
238
+
239
+ nested_calls[method.name] ||= []
240
+ nested_calls[method.name] << other_method.name
241
+
242
+ if nested_calls[call.name]&.include?(method.name)
243
+ fail(ArgumentError, "Cyclic call detected: #{call.name} -> #{method.name}")
244
+ end
245
+
246
+ other_method.translation_calls.each do |translation_call|
247
+ new_translation_calls.push(translation_call.with_parent(method))
248
+ end
249
+ end
250
+ end
251
+
252
+ @children.flat_map(&:process) + new_translation_calls
253
+ end
254
+
255
+ def private_methods!
256
+ @private_method = true
257
+ end
258
+
259
+ def support_relative_keys?
260
+ @rails && controller?
261
+ end
262
+
263
+ def path
264
+ (@parent&.path || []) + [path_name]
265
+ end
266
+
267
+ def controller?
268
+ @node.name.to_s.end_with?('Controller')
269
+ end
270
+
271
+ def path_name
272
+ path = @node.constant_path.full_name_parts.map { |s| s.to_s.underscore }
273
+ path.last.gsub!(/_controller\z/, '') if controller?
274
+
275
+ path
276
+ end
277
+ end
278
+
279
+ class ParsedMethod < Root
280
+ def initialize(node:, parent:, private_method: false)
281
+ @private_method = private_method
282
+
283
+ super(node: node, parent: parent)
284
+ end
285
+
286
+ def support_relative_keys?
287
+ !@private_method && @parent&.support_relative_keys?
288
+ end
289
+
290
+ def path
291
+ (@parent&.path || []) + [@node.name]
292
+ end
293
+
294
+ def name
295
+ @node.name
296
+ end
297
+
298
+ def process
299
+ @translation_calls
300
+ end
301
+ end
302
+
303
+ class ParsedBeforeAction < Root
304
+ attr_reader(:name)
305
+
306
+ def initialize(node:, parent:, name: nil, only: nil, except: nil)
307
+ @name = name
308
+ @only = only.present? ? Array(only).map(&:to_s) : nil
309
+ @except = except.present? ? Array(except).map(&:to_s) : nil
310
+
311
+ super(node: node, parent: parent)
312
+ end
313
+
314
+ def support_relative_keys?
315
+ true
316
+ end
317
+
318
+ def applies_to?(method_name)
319
+ if @only.nil? && @except.nil?
320
+ true
321
+ elsif @only.nil?
322
+ !@except.include?(method_name.to_s)
323
+ elsif @except.nil?
324
+ @only.include?(method_name.to_s)
325
+ else
326
+ false
327
+ end
328
+ end
329
+
330
+ def path
331
+ @parent&.path || []
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism/visitor'
4
+ require_relative 'nodes'
5
+ require_relative 'arguments_visitor'
6
+
7
+ # Implementation of Prism::Visitor (https://ruby.github.io/prism/rb/Prism/Visitor.html)
8
+ # It processes the parsed AST from Prism and creates a new AST with the nodes defined in prism_scanners/nodes.rb
9
+ # The only argument it receives is comments, which can be used for magic comments.
10
+ # It defines processing of arguments in a way that is needed for the translation calls.
11
+ # Any Rails-specific processing is added in the RailsVisitor class.
12
+
13
+ module I18n::Tasks::Scanners::PrismScanners
14
+ class Visitor < Prism::Visitor # rubocop:disable Metrics/ClassLength
15
+ MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/.freeze
16
+
17
+ attr_reader(:calls, :current_module, :current_class, :current_method, :root)
18
+
19
+ def initialize(comments: nil, rails: false)
20
+ @calls = []
21
+ @comment_translations_by_row = prepare_comments_by_line(comments)
22
+
23
+ @current_module = nil
24
+ @current_class = nil
25
+ @current_method = nil
26
+ @root = Root.new
27
+
28
+ @rails = rails
29
+
30
+ # Needs to have () because the Prism::Visitor has no arguments
31
+ super()
32
+ end
33
+
34
+ def parent
35
+ @current_before_action || @current_method || @current_class || @current_module || @root
36
+ end
37
+
38
+ def prepare_comments_by_line(comments)
39
+ return {} if comments.nil?
40
+
41
+ comments.each_with_object({}) do |comment, by_row|
42
+ content =
43
+ comment.respond_to?(:slice) ? comment.slice : comment.location.slice
44
+ match = content.match(MAGIC_COMMENT_PREFIX)
45
+
46
+ next by_row if match.nil?
47
+
48
+ string =
49
+ content.gsub(MAGIC_COMMENT_PREFIX, '').gsub('#', '').strip
50
+ visitor = Visitor.new
51
+ Prism
52
+ .parse(string)
53
+ .value
54
+ .accept(visitor)
55
+
56
+ nodes = visitor.process
57
+
58
+ next by_row if nodes.empty?
59
+
60
+ # Remap the found translation calls to be for the found comment
61
+ nodes = nodes.map { |node| node.with_node(comment) }
62
+
63
+ by_row[comment.location.start_line] = nodes
64
+ by_row
65
+ end
66
+ end
67
+
68
+ def visit_module_node(node)
69
+ previous_module = @current_module
70
+ @current_module = parent.add_child(
71
+ ParsedModule.new(node: node, parent: parent)
72
+ )
73
+
74
+ super
75
+ ensure
76
+ @current_module = previous_module
77
+ end
78
+
79
+ def visit_class_node(node)
80
+ previous_class = @current_class
81
+
82
+ @current_class = parent.add_child(
83
+ ParsedClass.new(
84
+ node: node,
85
+ parent: parent,
86
+ rails: @rails
87
+ )
88
+ )
89
+
90
+ super
91
+ ensure
92
+ @current_class = previous_class
93
+ end
94
+
95
+ def visit_def_node(node)
96
+ previous_method = @current_method
97
+ parent = @current_class || @current_module || @root
98
+ @current_method = parent.add_child(
99
+ ParsedMethod.new(
100
+ node: node,
101
+ parent: parent,
102
+ private_method: parent.private_method
103
+ )
104
+ )
105
+
106
+ super
107
+ ensure
108
+ @current_method = previous_method
109
+ end
110
+
111
+ def visit_call_node(node)
112
+ handle_comments(node)
113
+
114
+ case node.name
115
+ when :private
116
+ @current_class&.private_methods!
117
+ when :t, :t!, :translate, :translate!
118
+
119
+ args, kwargs = process_arguments(node)
120
+ parent.add_translation_call(
121
+ TranslationCall.new(
122
+ node: node,
123
+ key: args[0],
124
+ receiver: node.receiver,
125
+ options: kwargs,
126
+ parent: parent
127
+ )
128
+ )
129
+ else
130
+ if @rails
131
+ return rails_call_node(node) do
132
+ super
133
+ end || parent.add_call(node)
134
+ else
135
+ parent.add_call(node)
136
+ end
137
+ end
138
+
139
+ super
140
+ end
141
+
142
+ def process
143
+ @comment_translations_by_row.each_value do |nodes|
144
+ nodes.each do |node|
145
+ @root.add_translation_call(node)
146
+ end
147
+ end
148
+ @root.process
149
+ end
150
+
151
+ private
152
+
153
+ def process_arguments(node)
154
+ return [], {} if node.nil?
155
+ return [], {} unless node.respond_to?(:arguments)
156
+ return [], {} if node.arguments.nil?
157
+
158
+ arguments_visitor = ArgumentsVisitor.new
159
+ arguments = node.arguments.accept(arguments_visitor)
160
+ keywords, args = arguments.partition { |arg| arg.is_a?(Hash) }
161
+
162
+ [args.compact, keywords.first || {}]
163
+ end
164
+
165
+ def handle_comments(node)
166
+ comments = @comment_translations_by_row[node.location.start_line - 1]
167
+ comments&.each do |comment|
168
+ parent.add_translation_call(comment.with_node(node))
169
+ @comment_translations_by_row.delete(node.location.start_line - 1)
170
+ end
171
+ end
172
+
173
+ # ---- Rails specific methods ----
174
+ # Returns true if the node was handled
175
+ def rails_call_node(node, &block)
176
+ case node.name
177
+ when :before_action
178
+ rails_handle_before_action(node, &block)
179
+ true
180
+ when :human_attribute_name
181
+ rails_handle_human_attribute_name(node)
182
+ true
183
+ when :human
184
+ return false if node.receiver.name != :model_name
185
+
186
+ rails_handle_model_name(node)
187
+ true
188
+ else
189
+ false
190
+ end
191
+ end
192
+
193
+ def rails_handle_before_action(node) # rubocop:disable Metrics/MethodLength
194
+ array_arguments, keywords = process_arguments(node)
195
+ first_argument = array_arguments.first
196
+
197
+ before_action = if array_arguments.empty? && node.block.present?
198
+ ParsedBeforeAction.new(
199
+ node: node,
200
+ parent: parent
201
+ )
202
+ elsif first_argument.is_a?(String)
203
+ ParsedBeforeAction.new(
204
+ node: node,
205
+ parent: parent,
206
+ name: first_argument,
207
+ only: keywords['only'],
208
+ except: keywords['except']
209
+ )
210
+ elsif first_argument.try(:type) == :lambda_node
211
+ ParsedBeforeAction.new(
212
+ node: node,
213
+ parent: parent,
214
+ only: keywords['only'],
215
+ except: keywords['except']
216
+ )
217
+ else
218
+ fail(
219
+ ArgumentError,
220
+ "Cannot handle before_action with this argument #{first_argument.class}"
221
+ )
222
+ end
223
+ @current_before_action = parent&.add_child(before_action)
224
+
225
+ yield
226
+ ensure
227
+ @current_before_action = nil
228
+ end
229
+
230
+ def rails_handle_model_name(node)
231
+ _args, kwargs = process_arguments(node)
232
+ model_name = node.receiver.receiver.name.to_s.underscore
233
+
234
+ count_key = (kwargs['count'] || 0) > 1 ? 'other' : 'one'
235
+ parent.add_translation_call(
236
+ TranslationCall.new(
237
+ node: node,
238
+ receiver: nil,
239
+ key: [:activerecord, :models, model_name, count_key].join('.'),
240
+ parent: parent,
241
+ options: kwargs
242
+ )
243
+ )
244
+ end
245
+
246
+ def rails_handle_human_attribute_name(node)
247
+ array_args, keywords = process_arguments(node)
248
+ unless array_args.size == 1 && keywords.empty?
249
+ fail(
250
+ ArgumentError,
251
+ 'human_attribute_name should have only one argument'
252
+ )
253
+ end
254
+
255
+ key = [
256
+ :activerecord,
257
+ :attributes,
258
+ node.receiver.name.to_s.underscore,
259
+ array_args.first
260
+ ].join('.')
261
+
262
+ parent.add_translation_call(
263
+ TranslationCall.new(
264
+ node: node,
265
+ key: key,
266
+ receiver: nil,
267
+ parent: parent,
268
+ options: {}
269
+ )
270
+ )
271
+ end
272
+ end
273
+ end
@@ -3,10 +3,10 @@
3
3
  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
+ require 'i18n/tasks/scanners/ruby_parser_factory'
6
7
  require 'i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher'
7
8
  require 'i18n/tasks/scanners/ast_matchers/message_receivers_matcher'
8
9
  require 'i18n/tasks/scanners/ast_matchers/rails_model_matcher'
9
- require 'parser/current'
10
10
 
11
11
  module I18n::Tasks::Scanners
12
12
  # Scan for I18n.translate calls using whitequark/parser
@@ -18,8 +18,8 @@ module I18n::Tasks::Scanners
18
18
 
19
19
  def initialize(**args)
20
20
  super(**args)
21
- @parser = ::Parser::CurrentRuby.new
22
- @magic_comment_parser = ::Parser::CurrentRuby.new
21
+ @parser = RubyParserFactory.create_parser
22
+ @magic_comment_parser = RubyParserFactory.create_parser
23
23
  @matchers = setup_matchers
24
24
  end
25
25
 
@@ -20,7 +20,7 @@ module I18n::Tasks::Scanners
20
20
  literal
21
21
  end
22
22
 
23
- VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž/])}.freeze
23
+ VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž\\/]|(?<=[\p{L}\d])\s(?=[\p{L}\d]))}.freeze
24
24
  VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/.freeze
25
25
 
26
26
  def valid_key?(key)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module provides a factory class for creating a Ruby parser instance.
4
+ # It temporarily disables verbose mode to suppress compatibility warnings
5
+ # when loading the "parser/current" library.
6
+ #
7
+ # Example warning for the release of Ruby 3.4.1:
8
+ # warning: parser/current is loading parser/ruby34, which recognizes
9
+ # 3.4.0-compliant syntax, but you are running 3.4.1.
10
+ # Please see https://github.com/whitequark/parser#compatibility-with-ruby-mri.
11
+ #
12
+ # By disabling verbose mode, these warnings are suppressed to provide a cleaner
13
+ # output and avoid confusion. The verbose mode is restored after the parser
14
+ # instance is created to maintain the original behavior.
15
+
16
+ module I18n::Tasks::Scanners
17
+ class RubyParserFactory
18
+ def self.create_parser
19
+ prev = $VERBOSE
20
+ $VERBOSE = nil
21
+ require 'parser/current'
22
+ ::Parser::CurrentRuby.new
23
+ ensure
24
+ $VERBOSE = prev
25
+ end
26
+ end
27
+ end
@@ -3,6 +3,7 @@
3
3
  require 'i18n/tasks/translators/deepl_translator'
4
4
  require 'i18n/tasks/translators/google_translator'
5
5
  require 'i18n/tasks/translators/openai_translator'
6
+ require 'i18n/tasks/translators/watsonx_translator'
6
7
  require 'i18n/tasks/translators/yandex_translator'
7
8
 
8
9
  module I18n::Tasks
@@ -11,7 +12,7 @@ module I18n::Tasks
11
12
  # @param [String] from locale
12
13
  # @param [:deepl, :openai, :google, :yandex] backend
13
14
  # @return [I18n::Tasks::Tree::Siblings] translated forest
14
- def translate_forest(forest, from:, backend: :google)
15
+ def translate_forest(forest, from:, backend:)
15
16
  case backend
16
17
  when :deepl
17
18
  Translators::DeeplTranslator.new(self).translate_forest(forest, from)
@@ -19,6 +20,8 @@ module I18n::Tasks
19
20
  Translators::GoogleTranslator.new(self).translate_forest(forest, from)
20
21
  when :openai
21
22
  Translators::OpenAiTranslator.new(self).translate_forest(forest, from)
23
+ when :watsonx
24
+ Translators::WatsonxTranslator.new(self).translate_forest(forest, from)
22
25
  when :yandex
23
26
  Translators::YandexTranslator.new(self).translate_forest(forest, from)
24
27
  else
@@ -14,7 +14,11 @@ module I18n::Tasks
14
14
  # @return [I18n::Tasks::Tree::Siblings] translated forest
15
15
  def translate_forest(forest, from)
16
16
  forest.inject @i18n_tasks.empty_forest do |result, root|
17
- translated = translate_pairs(root.key_values(root: true), to: root.key, from: from)
17
+ pairs = root.key_values(root: true)
18
+
19
+ @progress_bar = ProgressBar.create(total: pairs.flatten.size, format: '%a <%B> %e %c/%C (%p%%)')
20
+
21
+ translated = translate_pairs(pairs, to: root.key, from: from)
18
22
  result.merge! Data::Tree::Siblings.from_flat_pairs(translated)
19
23
  end
20
24
  end