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.
- checksums.yaml +4 -4
- data/README.md +85 -13
- data/Rakefile +4 -4
- data/bin/i18n-tasks +3 -3
- data/config/locales/en.yml +6 -0
- data/config/locales/ru.yml +7 -0
- data/i18n-tasks.gemspec +28 -41
- data/lib/i18n/tasks/base_task.rb +19 -19
- data/lib/i18n/tasks/cli.rb +37 -30
- data/lib/i18n/tasks/command/collection.rb +4 -4
- data/lib/i18n/tasks/command/commander.rb +5 -5
- data/lib/i18n/tasks/command/commands/check_prism.rb +126 -0
- data/lib/i18n/tasks/command/commands/data.rb +33 -33
- data/lib/i18n/tasks/command/commands/eq_base.rb +3 -3
- data/lib/i18n/tasks/command/commands/health.rb +6 -5
- data/lib/i18n/tasks/command/commands/interpolations.rb +14 -3
- data/lib/i18n/tasks/command/commands/meta.rb +6 -6
- data/lib/i18n/tasks/command/commands/missing.rb +25 -25
- data/lib/i18n/tasks/command/commands/tree.rb +33 -33
- data/lib/i18n/tasks/command/commands/usages.rb +24 -24
- data/lib/i18n/tasks/command/dsl.rb +1 -1
- data/lib/i18n/tasks/command/option_parsers/enum.rb +5 -5
- data/lib/i18n/tasks/command/option_parsers/locale.rb +4 -4
- data/lib/i18n/tasks/command/options/common.rb +16 -16
- data/lib/i18n/tasks/command/options/data.rb +18 -18
- data/lib/i18n/tasks/command/options/locales.rb +32 -32
- data/lib/i18n/tasks/commands.rb +14 -12
- data/lib/i18n/tasks/concurrent/cache.rb +1 -1
- data/lib/i18n/tasks/concurrent/cached_value.rb +1 -1
- data/lib/i18n/tasks/configuration.rb +22 -21
- data/lib/i18n/tasks/console_context.rb +11 -11
- data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
- data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +5 -5
- data/lib/i18n/tasks/data/file_formats.rb +3 -3
- data/lib/i18n/tasks/data/file_system.rb +5 -5
- data/lib/i18n/tasks/data/file_system_base.rb +26 -26
- data/lib/i18n/tasks/data/language_names.rb +202 -0
- data/lib/i18n/tasks/data/router/conservative_router.rb +3 -3
- data/lib/i18n/tasks/data/router/isolating_router.rb +19 -19
- data/lib/i18n/tasks/data/router/pattern_router.rb +5 -5
- data/lib/i18n/tasks/data/tree/node.rb +27 -27
- data/lib/i18n/tasks/data/tree/nodes.rb +10 -10
- data/lib/i18n/tasks/data/tree/siblings.rb +20 -20
- data/lib/i18n/tasks/data/tree/traversal.rb +5 -5
- data/lib/i18n/tasks/data.rb +4 -4
- data/lib/i18n/tasks/html_keys.rb +2 -2
- data/lib/i18n/tasks/ignore_keys.rb +9 -9
- data/lib/i18n/tasks/interpolations.rb +21 -1
- data/lib/i18n/tasks/key_pattern_matching.rb +8 -8
- data/lib/i18n/tasks/logging.rb +2 -1
- data/lib/i18n/tasks/missing_keys.rb +24 -8
- data/lib/i18n/tasks/plural_keys.rb +6 -4
- data/lib/i18n/tasks/references.rb +4 -4
- data/lib/i18n/tasks/reports/base.rb +18 -14
- data/lib/i18n/tasks/reports/terminal.rb +64 -47
- data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +10 -10
- data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +1 -1
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +69 -10
- data/lib/i18n/tasks/scanners/file_scanner.rb +5 -5
- data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +3 -3
- data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +3 -3
- data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +2 -2
- data/lib/i18n/tasks/scanners/files/file_finder.rb +8 -8
- data/lib/i18n/tasks/scanners/files/file_reader.rb +1 -1
- data/lib/i18n/tasks/scanners/local_ruby_parser.rb +8 -8
- data/lib/i18n/tasks/scanners/occurrence_from_position.rb +1 -1
- data/lib/i18n/tasks/scanners/pattern_mapper.rb +7 -7
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +20 -20
- data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +8 -8
- data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +8 -1
- data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +101 -61
- data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +169 -105
- data/lib/i18n/tasks/scanners/relative_keys.rb +8 -8
- data/lib/i18n/tasks/scanners/results/key_occurrences.rb +3 -3
- data/lib/i18n/tasks/scanners/results/occurrence.rb +14 -10
- data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_key_literals.rb +6 -6
- data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_scanner.rb +225 -0
- data/lib/i18n/tasks/scanners/scanner.rb +2 -2
- data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +1 -1
- data/lib/i18n/tasks/split_key.rb +4 -4
- data/lib/i18n/tasks/stats.rb +3 -3
- data/lib/i18n/tasks/translation.rb +5 -5
- data/lib/i18n/tasks/translators/base_translator.rb +40 -14
- data/lib/i18n/tasks/translators/deepl_translator.rb +17 -14
- data/lib/i18n/tasks/translators/google_translator.rb +169 -25
- data/lib/i18n/tasks/translators/openai_translator.rb +34 -23
- data/lib/i18n/tasks/translators/watsonx_translator.rb +16 -16
- data/lib/i18n/tasks/translators/yandex_translator.rb +8 -8
- data/lib/i18n/tasks/unused_keys.rb +1 -1
- data/lib/i18n/tasks/used_keys.rb +32 -33
- data/lib/i18n/tasks/version.rb +1 -1
- data/lib/i18n/tasks.rb +17 -17
- data/templates/config/i18n-tasks.yml +12 -0
- data/templates/minitest/i18n_test.rb +3 -3
- data/templates/rspec/i18n_spec.rb +7 -7
- metadata +25 -185
- data/lib/i18n/tasks/scanners/prism_scanner.rb +0 -83
- 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
|
|
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
|
|
14
|
+
fail "Unimplemented"
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
data/lib/i18n/tasks/split_key.rb
CHANGED
|
@@ -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(
|
|
47
|
+
current_parenthesis_end_char ? parts.concat(part.split(".")) : parts << part
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def last_key_part(key)
|
data/lib/i18n/tasks/stats.rb
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require
|
|
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:
|
|
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 = /%\{[^}]+}
|
|
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
|
-
"#{
|
|
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
|
|
149
|
+
return translated if !INTERPOLATION_KEY_RE.match?(untranslated)
|
|
129
150
|
|
|
130
151
|
values = untranslated.scan(INTERPOLATION_KEY_RE)
|
|
131
|
-
translated.gsub(
|
|
132
|
-
values[
|
|
152
|
+
translated.gsub(/X__(\d+)/) do |m|
|
|
153
|
+
values[$1.to_i]
|
|
133
154
|
end
|
|
134
|
-
rescue
|
|
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)
|
|
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)
|
|
178
|
+
def options_for_translate_values(options)
|
|
179
|
+
end
|
|
157
180
|
|
|
158
181
|
# @return [Hash]
|
|
159
182
|
# @abstract
|
|
160
|
-
def options_for_html
|
|
183
|
+
def options_for_html
|
|
184
|
+
end
|
|
161
185
|
|
|
162
186
|
# @return [Hash]
|
|
163
187
|
# @abstract
|
|
164
|
-
def options_for_plain
|
|
188
|
+
def options_for_plain
|
|
189
|
+
end
|
|
165
190
|
|
|
166
191
|
# @return [String]
|
|
167
192
|
# @abstract
|
|
168
|
-
def no_results_error_message
|
|
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
|
|
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
|
|
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({
|
|
47
|
+
extra_options.merge({ignore_tags: %w[i18n]}).merge(options)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def options_for_html
|
|
51
|
-
{
|
|
51
|
+
{tag_handling: "xml"}
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def options_for_plain
|
|
55
|
-
{
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
116
|
-
|
|
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
|