i18n-tasks 0.9.37 → 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 +4 -4
- data/README.md +43 -5
- data/Rakefile +1 -1
- data/config/locales/en.yml +12 -3
- data/config/locales/ru.yml +8 -0
- data/i18n-tasks.gemspec +14 -7
- data/lib/i18n/tasks/cli.rb +8 -7
- data/lib/i18n/tasks/command/commander.rb +1 -0
- data/lib/i18n/tasks/command/commands/missing.rb +17 -5
- data/lib/i18n/tasks/command/options/common.rb +0 -1
- data/lib/i18n/tasks/command/options/data.rb +1 -1
- data/lib/i18n/tasks/concurrent/cached_value.rb +0 -2
- data/lib/i18n/tasks/configuration.rb +13 -7
- data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +15 -2
- data/lib/i18n/tasks/data/file_formats.rb +1 -1
- data/lib/i18n/tasks/data/router/pattern_router.rb +1 -1
- data/lib/i18n/tasks/data/tree/node.rb +1 -1
- data/lib/i18n/tasks/data/tree/nodes.rb +5 -7
- data/lib/i18n/tasks/data/tree/siblings.rb +1 -2
- data/lib/i18n/tasks/data/tree/traversal.rb +25 -11
- data/lib/i18n/tasks/html_keys.rb +2 -2
- data/lib/i18n/tasks/interpolations.rb +7 -3
- data/lib/i18n/tasks/key_pattern_matching.rb +2 -0
- data/lib/i18n/tasks/locale_pathname.rb +1 -1
- data/lib/i18n/tasks/plural_keys.rb +0 -6
- data/lib/i18n/tasks/references.rb +3 -3
- data/lib/i18n/tasks/reports/base.rb +2 -2
- data/lib/i18n/tasks/reports/terminal.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +118 -0
- data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +91 -0
- data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +69 -0
- data/lib/i18n/tasks/scanners/erb_ast_processor.rb +74 -0
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +48 -0
- data/lib/i18n/tasks/scanners/local_ruby_parser.rb +85 -0
- data/lib/i18n/tasks/scanners/pattern_mapper.rb +1 -1
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +2 -2
- data/lib/i18n/tasks/scanners/relative_keys.rb +2 -2
- data/lib/i18n/tasks/scanners/results/occurrence.rb +17 -1
- data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +9 -34
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +91 -156
- data/lib/i18n/tasks/scanners/ruby_key_literals.rb +2 -2
- data/lib/i18n/tasks/split_key.rb +1 -1
- data/lib/i18n/tasks/translation.rb +4 -1
- data/lib/i18n/tasks/translators/base_translator.rb +17 -12
- data/lib/i18n/tasks/translators/deepl_translator.rb +34 -11
- data/lib/i18n/tasks/translators/google_translator.rb +1 -1
- data/lib/i18n/tasks/translators/openai_translator.rb +100 -0
- data/lib/i18n/tasks/used_keys.rb +9 -6
- data/lib/i18n/tasks/version.rb +1 -1
- data/lib/i18n/tasks.rb +11 -0
- data/templates/config/i18n-tasks.yml +17 -4
- data/templates/minitest/i18n_test.rb +6 -6
- metadata +74 -16
@@ -1,56 +1,31 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'ast'
|
4
|
-
|
4
|
+
|
5
5
|
module I18n::Tasks::Scanners
|
6
6
|
class RubyAstCallFinder
|
7
7
|
include AST::Processor::Mixin
|
8
8
|
|
9
|
-
# @param receiver_messages [Set<Pair<[nil, AST::Node>, Symbol>>] The receiver-message pairs to look for.
|
10
|
-
def initialize(receiver_messages:)
|
11
|
-
super()
|
12
|
-
@message_receivers = receiver_messages.each_with_object({}) do |(receiver, message), t|
|
13
|
-
(t[message] ||= []) << receiver
|
14
|
-
end
|
15
|
-
end
|
16
|
-
|
17
9
|
# @param root_node [Parser::AST:Node]
|
18
|
-
# @
|
19
|
-
# @yieldparam method_name [nil, String] the surrounding method's name.
|
20
|
-
def find_calls(root_node, &block)
|
21
|
-
@callback = block
|
22
|
-
process root_node
|
23
|
-
ensure
|
24
|
-
@callback = nil
|
25
|
-
end
|
26
|
-
|
27
|
-
# @param root_node (see #find_calls)
|
28
|
-
# @yieldparam (see #find_calls)
|
29
|
-
# @return [Array<block return values excluding nils>]
|
10
|
+
# @return [Pair<Parser::AST::Node, method_name as string>] for all nodes with :send type
|
30
11
|
def collect_calls(root_node)
|
31
|
-
results = []
|
32
|
-
|
33
|
-
|
34
|
-
results << result if result
|
35
|
-
end
|
36
|
-
results
|
12
|
+
@results = []
|
13
|
+
process(root_node)
|
14
|
+
@results
|
37
15
|
end
|
38
16
|
|
39
17
|
def on_def(node)
|
40
18
|
@method_name = node.children[0]
|
41
|
-
handler_missing
|
19
|
+
handler_missing(node)
|
42
20
|
ensure
|
43
21
|
@method_name = nil
|
44
22
|
end
|
45
23
|
|
46
24
|
def on_send(send_node)
|
47
|
-
|
48
|
-
|
49
|
-
valid_receivers = @message_receivers[message]
|
50
|
-
# use `any?` because `include?` checks type equality, but the receiver is a Parser::AST::Node != AST::Node.
|
51
|
-
@callback.call(send_node, @method_name) if valid_receivers&.any? { |r| r == receiver }
|
25
|
+
@results << [send_node, @method_name]
|
26
|
+
|
52
27
|
# always invoke handler_missing to get nested translations in children
|
53
|
-
handler_missing
|
28
|
+
handler_missing(send_node)
|
54
29
|
nil
|
55
30
|
end
|
56
31
|
|
@@ -3,27 +3,23 @@
|
|
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/ast_matchers/message_receivers_matcher'
|
7
|
+
require 'i18n/tasks/scanners/ast_matchers/rails_model_matcher'
|
6
8
|
require 'parser/current'
|
7
9
|
|
8
|
-
# rubocop:disable Metrics/AbcSize,Metrics/BlockNesting,Metrics/PerceivedComplexity
|
9
|
-
# TODO: make this class more readable.
|
10
|
-
|
11
10
|
module I18n::Tasks::Scanners
|
12
11
|
# Scan for I18n.translate calls using whitequark/parser
|
13
|
-
class RubyAstScanner < FileScanner
|
12
|
+
class RubyAstScanner < FileScanner
|
14
13
|
include RelativeKeys
|
15
14
|
include AST::Sexp
|
16
15
|
|
17
16
|
MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/.freeze
|
18
|
-
RECEIVER_MESSAGES = [nil, AST::Node.new(:const, [nil, :I18n])].product(%i[t t! translate translate!])
|
19
17
|
|
20
18
|
def initialize(**args)
|
21
19
|
super(**args)
|
22
20
|
@parser = ::Parser::CurrentRuby.new
|
23
21
|
@magic_comment_parser = ::Parser::CurrentRuby.new
|
24
|
-
@
|
25
|
-
receiver_messages: config[:receiver_messages] || RECEIVER_MESSAGES
|
26
|
-
)
|
22
|
+
@matchers = setup_matchers
|
27
23
|
end
|
28
24
|
|
29
25
|
protected
|
@@ -32,178 +28,117 @@ module I18n::Tasks::Scanners
|
|
32
28
|
#
|
33
29
|
# @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
|
34
30
|
def scan_file(path)
|
35
|
-
|
36
|
-
ast, comments = @parser.parse_with_comments(make_buffer(path))
|
37
|
-
|
38
|
-
results = @call_finder.collect_calls ast do |send_node, method_name|
|
39
|
-
send_node_to_key_occurrence(send_node, method_name)
|
40
|
-
end
|
31
|
+
ast, comments = path_to_ast_and_comments(path)
|
41
32
|
|
42
|
-
|
43
|
-
comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
|
44
|
-
# transform_values is only available in ActiveSupport 4.2+
|
45
|
-
h.each { |k, v| h[k] = v.first }
|
46
|
-
end.invert
|
47
|
-
results + (magic_comments.flat_map do |comment|
|
48
|
-
@parser.reset
|
49
|
-
associated_node = comment_to_node[comment]
|
50
|
-
@call_finder.collect_calls(
|
51
|
-
@parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, '').split(/\s+(?=t)/).join('; ')))
|
52
|
-
) do |send_node, _method_name|
|
53
|
-
# method_name is not available at this stage
|
54
|
-
send_node_to_key_occurrence(send_node, nil, location: associated_node || comment.location)
|
55
|
-
end
|
56
|
-
end)
|
33
|
+
ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
|
57
34
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
58
35
|
raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
|
59
36
|
end
|
60
37
|
|
61
|
-
#
|
62
|
-
#
|
63
|
-
# @param
|
64
|
-
# @return [
|
65
|
-
def
|
66
|
-
|
67
|
-
|
68
|
-
if (second_arg_node = send_node.children[3]) &&
|
69
|
-
second_arg_node.type == :hash
|
70
|
-
if (scope_node = extract_hash_pair(second_arg_node, 'scope'))
|
71
|
-
scope = extract_string(scope_node.children[1],
|
72
|
-
array_join_with: '.', array_flatten: true, array_reject_blank: true)
|
73
|
-
return nil if scope.nil? && scope_node.type != :nil
|
74
|
-
|
75
|
-
key = [scope, key].join('.') unless scope == ''
|
76
|
-
end
|
77
|
-
default_arg = if (default_arg_node = extract_hash_pair(second_arg_node, 'default'))
|
78
|
-
extract_string(default_arg_node.children[1])
|
79
|
-
end
|
80
|
-
end
|
81
|
-
full_key = if send_node.children[0].nil?
|
82
|
-
# Relative keys only work if called via `t()` but not `I18n.t()`:
|
83
|
-
absolute_key(key, location.expression.source_buffer.name, calling_method: method_name)
|
84
|
-
else
|
85
|
-
key
|
86
|
-
end
|
87
|
-
[full_key, range_to_occurrence(key, location.expression, default_arg: default_arg)]
|
88
|
-
end
|
38
|
+
# Parse file on path and returns AST and comments.
|
39
|
+
#
|
40
|
+
# @param path Path to file to parse
|
41
|
+
# @return [{Parser::AST::Node}, [Parser::Source::Comment]]
|
42
|
+
def path_to_ast_and_comments(path)
|
43
|
+
@parser.reset
|
44
|
+
@parser.parse_with_comments(make_buffer(path))
|
89
45
|
end
|
90
46
|
|
91
|
-
|
92
|
-
|
93
|
-
# @param node [AST::Node] a node of type `:hash`.
|
94
|
-
# @param key [String] node key as a string (indifferent symbol-string matching).
|
95
|
-
# @return [AST::Node, nil] a node of type `:pair` or nil.
|
96
|
-
def extract_hash_pair(node, key)
|
97
|
-
node.children.detect do |child|
|
98
|
-
next unless child.type == :pair
|
99
|
-
|
100
|
-
key_node = child.children[0]
|
101
|
-
%i[sym str].include?(key_node.type) && key_node.children[0].to_s == key
|
102
|
-
end
|
47
|
+
def keys_relative_to_calling_method?(path)
|
48
|
+
/controllers|mailers/.match(path)
|
103
49
|
end
|
104
50
|
|
105
|
-
#
|
106
|
-
#
|
107
|
-
# return the source as if it were a string.
|
51
|
+
# Create an {Parser::Source::Buffer} with the given contents.
|
52
|
+
# The contents are assigned a {Parser::Source::Buffer#raw_source}.
|
108
53
|
#
|
109
|
-
# @param
|
110
|
-
# @param
|
111
|
-
# @
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
# @return [String, nil] `nil` is returned only when a dynamic value is encountered in strict mode
|
116
|
-
# or the node type is not supported.
|
117
|
-
def extract_string(node, array_join_with: nil, array_flatten: false, array_reject_blank: false)
|
118
|
-
if %i[sym str int].include?(node.type)
|
119
|
-
node.children[0].to_s
|
120
|
-
elsif %i[true false].include?(node.type)
|
121
|
-
node.type.to_s
|
122
|
-
elsif node.type == :nil
|
123
|
-
''
|
124
|
-
elsif node.type == :array && array_join_with
|
125
|
-
extract_array_as_string(
|
126
|
-
node,
|
127
|
-
array_join_with: array_join_with,
|
128
|
-
array_flatten: array_flatten,
|
129
|
-
array_reject_blank: array_reject_blank
|
130
|
-
).tap do |str|
|
131
|
-
# `nil` is returned when a dynamic value is encountered in strict mode. Propagate:
|
132
|
-
return nil if str.nil?
|
133
|
-
end
|
134
|
-
elsif !config[:strict] && %i[dsym dstr].include?(node.type)
|
135
|
-
node.children.map do |child|
|
136
|
-
if %i[sym str].include?(child.type)
|
137
|
-
child.children[0].to_s
|
138
|
-
else
|
139
|
-
child.loc.expression.source
|
140
|
-
end
|
141
|
-
end.join
|
54
|
+
# @param path [String] Path to assign as the buffer name.
|
55
|
+
# @param contents [String]
|
56
|
+
# @return [Parser::Source::Buffer] file contents
|
57
|
+
def make_buffer(path, contents = read_file(path))
|
58
|
+
Parser::Source::Buffer.new(path).tap do |buffer|
|
59
|
+
buffer.raw_source = contents
|
142
60
|
end
|
143
61
|
end
|
144
62
|
|
145
|
-
#
|
63
|
+
# Convert an array of {Parser::Source::Comment} to occurrences.
|
146
64
|
#
|
147
|
-
# @param
|
148
|
-
# @param
|
149
|
-
#
|
150
|
-
# @
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
65
|
+
# @param path Path to file
|
66
|
+
# @param ast Parser::AST::Node
|
67
|
+
# @param comments [Parser::Source::Comment]
|
68
|
+
# @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
|
69
|
+
def comments_to_occurences(path, ast, comments)
|
70
|
+
magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
|
71
|
+
comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
|
72
|
+
h.transform_values!(&:first)
|
73
|
+
end.invert
|
74
|
+
|
75
|
+
magic_comments.flat_map do |comment|
|
76
|
+
@parser.reset
|
77
|
+
associated_node = comment_to_node[comment]
|
78
|
+
ast = @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, '').split(/\s+(?=t)/).join('; ')))
|
79
|
+
calls = RubyAstCallFinder.new.collect_calls(ast)
|
80
|
+
results = []
|
81
|
+
|
82
|
+
# method_name is not available at this stage
|
83
|
+
calls.each do |send_node, _method_name|
|
84
|
+
@matchers.each do |matcher|
|
85
|
+
result = matcher.convert_to_key_occurrences(
|
86
|
+
send_node,
|
87
|
+
nil,
|
88
|
+
location: associated_node || comment.location
|
89
|
+
)
|
90
|
+
results << result if result
|
164
91
|
end
|
165
92
|
end
|
93
|
+
|
94
|
+
results
|
166
95
|
end
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
96
|
+
end
|
97
|
+
|
98
|
+
# Convert {Parser::AST::Node} to occurrences.
|
99
|
+
#
|
100
|
+
# @param ast {Parser::Source::Comment}
|
101
|
+
# @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
|
102
|
+
def ast_to_occurences(ast)
|
103
|
+
calls = RubyAstCallFinder.new.collect_calls(ast)
|
104
|
+
results = []
|
105
|
+
calls.each do |send_node, method_name|
|
106
|
+
@matchers.each do |matcher|
|
107
|
+
result = matcher.convert_to_key_occurrences(send_node, method_name)
|
108
|
+
results << result if result
|
171
109
|
end
|
172
110
|
end
|
173
|
-
children_strings.join(array_join_with)
|
174
|
-
end
|
175
111
|
|
176
|
-
|
177
|
-
/controllers|mailers/.match(path)
|
112
|
+
results
|
178
113
|
end
|
179
114
|
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
115
|
+
def setup_matchers
|
116
|
+
if config[:receiver_messages]
|
117
|
+
config[:receiver_messages].map do |receiver, message|
|
118
|
+
AstMatchers::MessageReceiversMatcher.new(
|
119
|
+
receivers: [receiver],
|
120
|
+
message: message,
|
121
|
+
scanner: self
|
122
|
+
)
|
123
|
+
end
|
124
|
+
else
|
125
|
+
matchers = %i[t t! translate translate!].map do |message|
|
126
|
+
AstMatchers::MessageReceiversMatcher.new(
|
127
|
+
receivers: [
|
128
|
+
AST::Node.new(:const, [nil, :I18n]),
|
129
|
+
nil
|
130
|
+
],
|
131
|
+
message: message,
|
132
|
+
scanner: self
|
133
|
+
)
|
134
|
+
end
|
195
135
|
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
# @return [Parser::Source::Buffer] file contents
|
202
|
-
def make_buffer(path, contents = read_file(path))
|
203
|
-
Parser::Source::Buffer.new(path).tap do |buffer|
|
204
|
-
buffer.raw_source = contents
|
136
|
+
Array(config[:ast_matchers]).each do |class_name|
|
137
|
+
matchers << ActiveSupport::Inflector.constantize(class_name).new(scanner: self)
|
138
|
+
end
|
139
|
+
|
140
|
+
matchers
|
205
141
|
end
|
206
142
|
end
|
207
143
|
end
|
208
144
|
end
|
209
|
-
# rubocop:enable Metrics/AbcSize,Metrics/BlockNesting,Metrics/PerceivedComplexity
|
@@ -15,12 +15,12 @@ module I18n::Tasks::Scanners
|
|
15
15
|
# @param literal [String] e.g: "key", 'key', or :key.
|
16
16
|
# @return [String] key
|
17
17
|
def strip_literal(literal)
|
18
|
-
literal = literal[1
|
18
|
+
literal = literal[1..] if literal[0] == ':'
|
19
19
|
literal = literal[1..-2] if literal[0] == "'" || literal[0] == '"'
|
20
20
|
literal
|
21
21
|
end
|
22
22
|
|
23
|
-
VALID_KEY_CHARS =
|
23
|
+
VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž/])}.freeze
|
24
24
|
VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/.freeze
|
25
25
|
|
26
26
|
def valid_key?(key)
|
data/lib/i18n/tasks/split_key.rb
CHANGED
@@ -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
|
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
|
-
|
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
|
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
|
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
|
-
|
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
|
@@ -114,7 +119,7 @@ module I18n::Tasks
|
|
114
119
|
|
115
120
|
values = untranslated.scan(INTERPOLATION_KEY_RE)
|
116
121
|
translated.gsub(/#{Regexp.escape(UNTRANSLATABLE_STRING)}\d+/i) do |m|
|
117
|
-
values[m[UNTRANSLATABLE_STRING.length
|
122
|
+
values[m[UNTRANSLATABLE_STRING.length..].to_i]
|
118
123
|
end
|
119
124
|
rescue StandardError => e
|
120
125
|
raise_interpolation_error(untranslated, translated, e)
|
@@ -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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
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,22 +71,34 @@ module I18n::Tasks::Translators
|
|
60
71
|
|
61
72
|
private
|
62
73
|
|
63
|
-
# Convert 'es-ES' to 'ES'
|
64
|
-
def
|
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]
|
71
94
|
version = @i18n_tasks.translation_config[:deepl_version]
|
72
95
|
fail ::I18n::Tasks::CommandError, I18n.t('i18n_tasks.deepl_translate.errors.no_api_key') if api_key.blank?
|
73
96
|
|
74
|
-
DeepL.configure
|
97
|
+
DeepL.configure do |config|
|
75
98
|
config.auth_key = api_key
|
76
99
|
config.host = host unless host.blank?
|
77
100
|
config.version = version unless version.blank?
|
78
|
-
|
101
|
+
end
|
79
102
|
end
|
80
103
|
end
|
81
104
|
end
|
@@ -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
|
-
|
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]
|