i18n-tasks 1.0.14 → 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 +138 -39
- data/Rakefile +4 -4
- data/bin/i18n-tasks +3 -3
- data/config/locales/en.yml +17 -1
- data/config/locales/ru.yml +18 -1
- data/i18n-tasks.gemspec +28 -38
- 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 +28 -26
- 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 +8 -7
- 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 +33 -24
- 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 +26 -20
- 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 +2 -2
- 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 +9 -9
- 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 +48 -0
- data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +374 -0
- data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +337 -0
- 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 +27 -0
- 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 +8 -5
- data/lib/i18n/tasks/translators/base_translator.rb +43 -13
- data/lib/i18n/tasks/translators/deepl_translator.rb +22 -14
- data/lib/i18n/tasks/translators/google_translator.rb +178 -26
- data/lib/i18n/tasks/translators/openai_translator.rb +56 -31
- data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
- data/lib/i18n/tasks/translators/yandex_translator.rb +13 -9
- data/lib/i18n/tasks/unused_keys.rb +1 -1
- data/lib/i18n/tasks/used_keys.rb +32 -32
- data/lib/i18n/tasks/version.rb +1 -1
- data/lib/i18n/tasks.rb +17 -16
- data/templates/config/i18n-tasks.yml +14 -2
- data/templates/minitest/i18n_test.rb +3 -3
- data/templates/rspec/i18n_spec.rb +7 -7
- metadata +38 -172
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +0 -145
|
@@ -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
|
|
@@ -35,6 +35,8 @@ module I18n::Tasks::Translators
|
|
|
35
35
|
else
|
|
36
36
|
results += res.map(&:text)
|
|
37
37
|
end
|
|
38
|
+
|
|
39
|
+
@progress_bar.progress += parts.size
|
|
38
40
|
end
|
|
39
41
|
results
|
|
40
42
|
end
|
|
@@ -42,15 +44,15 @@ module I18n::Tasks::Translators
|
|
|
42
44
|
def options_for_translate_values(**options)
|
|
43
45
|
extra_options = @i18n_tasks.translation_config[:deepl_options]&.symbolize_keys || {}
|
|
44
46
|
|
|
45
|
-
extra_options.merge({
|
|
47
|
+
extra_options.merge({ignore_tags: %w[i18n]}).merge(options)
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def options_for_html
|
|
49
|
-
{
|
|
51
|
+
{tag_handling: "xml"}
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
def options_for_plain
|
|
53
|
-
{
|
|
55
|
+
{preserve_formatting: true, tag_handling: "xml", html_escape: true}
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
# @param [String] value
|
|
@@ -65,28 +67,31 @@ module I18n::Tasks::Translators
|
|
|
65
67
|
def restore_interpolations(untranslated, translated)
|
|
66
68
|
return translated if untranslated !~ INTERPOLATION_KEY_RE
|
|
67
69
|
|
|
68
|
-
translated.gsub(%r{</?i18n>},
|
|
69
|
-
rescue
|
|
70
|
+
translated.gsub(%r{</?i18n>}, "")
|
|
71
|
+
rescue => e
|
|
70
72
|
raise_interpolation_error(untranslated, translated, e)
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
def no_results_error_message
|
|
74
|
-
I18n.t(
|
|
76
|
+
I18n.t("i18n_tasks.deepl_translate.errors.no_results")
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
private
|
|
78
80
|
|
|
79
81
|
# Convert 'es-ES' to 'ES', en-us to EN
|
|
80
82
|
def to_deepl_source_locale(locale)
|
|
81
|
-
locale.to_s.split(
|
|
83
|
+
locale.to_s.split("-", 2).first.upcase
|
|
82
84
|
end
|
|
83
85
|
|
|
84
86
|
# Convert 'es-ES' to 'ES' but warn about locales requiring a specific variant
|
|
85
87
|
def to_deepl_target_locale(locale)
|
|
86
|
-
|
|
88
|
+
locale_aliases = @i18n_tasks.translation_config[:deepl_locale_aliases]
|
|
89
|
+
locale = locale_aliases[locale.to_s.downcase] || locale if locale_aliases.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
loc, sub = locale.to_s.split("-")
|
|
87
92
|
if SPECIFIC_TARGETS.include?(loc)
|
|
88
93
|
# Must see how the deepl api evolves, so this could be an error in the future
|
|
89
|
-
warn_deprecated I18n.t(
|
|
94
|
+
warn_deprecated I18n.t("i18n_tasks.deepl_translate.errors.specific_target_missing") unless sub
|
|
90
95
|
locale.to_s.upcase
|
|
91
96
|
else
|
|
92
97
|
loc.upcase
|
|
@@ -97,7 +102,7 @@ module I18n::Tasks::Translators
|
|
|
97
102
|
api_key = @i18n_tasks.translation_config[:deepl_api_key]
|
|
98
103
|
host = @i18n_tasks.translation_config[:deepl_host]
|
|
99
104
|
version = @i18n_tasks.translation_config[:deepl_version]
|
|
100
|
-
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?
|
|
101
106
|
|
|
102
107
|
DeepL.configure do |config|
|
|
103
108
|
config.auth_key = api_key
|
|
@@ -106,9 +111,12 @@ module I18n::Tasks::Translators
|
|
|
106
111
|
end
|
|
107
112
|
end
|
|
108
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.
|
|
109
116
|
def options_with_glossary(options, from, to)
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
112
120
|
end
|
|
113
121
|
|
|
114
122
|
def all_ready_glossaries
|
|
@@ -1,30 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
require "i18n/tasks/translators/base_translator"
|
|
8
|
+
|
|
9
|
+
# https://cloud.google.com/translate/docs/reference/rest/v2/translate
|
|
4
10
|
|
|
5
11
|
module I18n::Tasks::Translators
|
|
6
12
|
class GoogleTranslator < BaseTranslator
|
|
7
|
-
|
|
13
|
+
API_ENDPOINT = "https://translation.googleapis.com/language/translate/v2"
|
|
14
|
+
MAX_TEXTS_PER_REQUEST = 128
|
|
15
|
+
MAX_CHARS_PER_REQUEST = 30_000
|
|
16
|
+
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
class RateLimitError < Error; end
|
|
20
|
+
|
|
21
|
+
class QuotaExceededError < Error; end
|
|
22
|
+
|
|
8
23
|
def initialize(*)
|
|
9
|
-
begin
|
|
10
|
-
require 'easy_translate'
|
|
11
|
-
rescue LoadError
|
|
12
|
-
raise ::I18n::Tasks::CommandError, "Add gem 'easy_translate' to your Gemfile to use this command"
|
|
13
|
-
end
|
|
14
24
|
super
|
|
15
25
|
end
|
|
16
26
|
|
|
17
27
|
protected
|
|
18
28
|
|
|
19
29
|
def translate_values(list, **options)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
html = options[:html].present?
|
|
31
|
+
result = restore_newlines(
|
|
32
|
+
translate(
|
|
33
|
+
texts: replace_newlines_with_placeholder(list),
|
|
34
|
+
api_key: api_key,
|
|
35
|
+
to: options[:to],
|
|
36
|
+
from: options[:from],
|
|
37
|
+
html:
|
|
25
38
|
),
|
|
26
|
-
|
|
39
|
+
html:
|
|
27
40
|
)
|
|
41
|
+
|
|
42
|
+
@progress_bar.progress += result.size
|
|
43
|
+
|
|
44
|
+
result
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
def options_for_translate_values(from:, to:, **options)
|
|
@@ -36,15 +53,15 @@ module I18n::Tasks::Translators
|
|
|
36
53
|
end
|
|
37
54
|
|
|
38
55
|
def options_for_html
|
|
39
|
-
{
|
|
56
|
+
{html: true}
|
|
40
57
|
end
|
|
41
58
|
|
|
42
59
|
def options_for_plain
|
|
43
|
-
{
|
|
60
|
+
{format: "text"}
|
|
44
61
|
end
|
|
45
62
|
|
|
46
63
|
def no_results_error_message
|
|
47
|
-
I18n.t(
|
|
64
|
+
I18n.t("i18n_tasks.google_translate.errors.no_results")
|
|
48
65
|
end
|
|
49
66
|
|
|
50
67
|
private
|
|
@@ -55,29 +72,164 @@ module I18n::Tasks::Translators
|
|
|
55
72
|
# fallback with deprecation warning
|
|
56
73
|
if @i18n_tasks.translation_config[:api_key]
|
|
57
74
|
warn_deprecated(
|
|
58
|
-
|
|
75
|
+
"Please rename Google Translate API Key from `api_key` to `google_translate_api_key`."
|
|
59
76
|
)
|
|
60
77
|
key ||= translation_config[:api_key]
|
|
61
78
|
end
|
|
62
|
-
fail ::I18n::Tasks::CommandError, I18n.t(
|
|
79
|
+
fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.google_translate.errors.no_api_key") if key.blank?
|
|
63
80
|
|
|
64
81
|
key
|
|
65
82
|
end
|
|
66
83
|
end
|
|
67
84
|
|
|
68
|
-
def replace_newlines_with_placeholder(list
|
|
69
|
-
return list unless html
|
|
70
|
-
|
|
85
|
+
def replace_newlines_with_placeholder(list)
|
|
71
86
|
list.map do |value|
|
|
72
|
-
value.gsub(
|
|
87
|
+
value.gsub(/\n(\s*)/) do
|
|
88
|
+
"<Z__#{::Regexp.last_match(1)&.length || 0}>"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def restore_newlines(translations, html:)
|
|
94
|
+
restored = translations.map do |translation|
|
|
95
|
+
translation.gsub(/<Z__(\d+)>/) do
|
|
96
|
+
"\n#{" " * ::Regexp.last_match(1).to_i}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Need to unescape if translating HTML content
|
|
101
|
+
html ? restored.map { |t| CGI.unescapeHTML(t) } : restored
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param texts [Array<String>] texts to translate
|
|
105
|
+
# @param api_key [String] Google Translate API key (required)
|
|
106
|
+
# @param to [String] target language code (required)
|
|
107
|
+
# @param format [Symbol] :text or :html (required)
|
|
108
|
+
# @param from [String] source language code
|
|
109
|
+
# @return [Array<String>] translated texts
|
|
110
|
+
def translate(texts:, api_key:, to:, html:, from: nil)
|
|
111
|
+
texts = Array(texts)
|
|
112
|
+
|
|
113
|
+
return [] if texts.empty?
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
116
|
+
|
|
117
|
+
raise ArgumentError, "target language (:to) is required" if to.nil? || to.empty?
|
|
118
|
+
|
|
119
|
+
# Split into batches if needed
|
|
120
|
+
batches = batch_texts(texts)
|
|
121
|
+
|
|
122
|
+
batches.flat_map do |batch|
|
|
123
|
+
translate_batch(texts: batch, api_key:, to:, from:, html:)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def batch_texts(texts)
|
|
128
|
+
batches = []
|
|
129
|
+
current_batch = []
|
|
130
|
+
current_chars = 0
|
|
131
|
+
|
|
132
|
+
texts.each do |text|
|
|
133
|
+
text_length = text.to_s.length
|
|
134
|
+
|
|
135
|
+
# If adding this text would exceed limits, start new batch
|
|
136
|
+
if current_batch.size >= MAX_TEXTS_PER_REQUEST ||
|
|
137
|
+
(current_chars + text_length > MAX_CHARS_PER_REQUEST && current_batch.any?)
|
|
138
|
+
batches << current_batch
|
|
139
|
+
current_batch = []
|
|
140
|
+
current_chars = 0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
current_batch << text
|
|
144
|
+
current_chars += text_length
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
batches << current_batch if current_batch.any?
|
|
148
|
+
batches
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def translate_batch(texts:, api_key:, to:, from:, html:)
|
|
152
|
+
uri = URI(API_ENDPOINT)
|
|
153
|
+
# Provide API key via query string as expected by v2 API
|
|
154
|
+
uri.query = URI.encode_www_form(key: api_key)
|
|
155
|
+
|
|
156
|
+
body = {
|
|
157
|
+
q: texts,
|
|
158
|
+
target: to,
|
|
159
|
+
format: html ? "html" : "text"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Add source language if specified
|
|
163
|
+
if from
|
|
164
|
+
body[:source] = from
|
|
73
165
|
end
|
|
166
|
+
|
|
167
|
+
response = make_request(uri, body)
|
|
168
|
+
parse_response(response, texts.size)
|
|
74
169
|
end
|
|
75
170
|
|
|
76
|
-
def
|
|
77
|
-
|
|
171
|
+
def make_request(uri, body)
|
|
172
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
173
|
+
http.use_ssl = true
|
|
174
|
+
http.read_timeout = 60
|
|
175
|
+
http.open_timeout = 30
|
|
176
|
+
|
|
177
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
178
|
+
request["Content-Type"] = "application/json"
|
|
179
|
+
request["Accept"] = "application/json"
|
|
180
|
+
request["User-Agent"] = "i18n-tasks/GoogleTranslateApi"
|
|
181
|
+
request.body = JSON.generate(body)
|
|
182
|
+
|
|
183
|
+
response = http.request(request)
|
|
184
|
+
|
|
185
|
+
handle_error_response(response) unless response.is_a?(Net::HTTPSuccess)
|
|
186
|
+
response
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_response(response, expected_count)
|
|
190
|
+
data = JSON.parse(response.body)
|
|
191
|
+
|
|
192
|
+
unless data["data"] && data["data"]["translations"]
|
|
193
|
+
raise Error, "Unexpected API response format"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
translations = data["data"]["translations"].map do |translation|
|
|
197
|
+
translation["translatedText"]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if translations.size != expected_count
|
|
201
|
+
raise Error, "Expected #{expected_count} translations, got #{translations.size}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
translations
|
|
205
|
+
rescue JSON::ParserError => e
|
|
206
|
+
raise Error, "Failed to parse API response: #{e.message}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def handle_error_response(response)
|
|
210
|
+
begin
|
|
211
|
+
error_data = JSON.parse(response.body)
|
|
212
|
+
error_message = error_data.dig("error", "message") || "Unknown error"
|
|
213
|
+
error_code = error_data.dig("error", "code")
|
|
214
|
+
rescue JSON::ParserError
|
|
215
|
+
error_message = response.body
|
|
216
|
+
error_code = response.code
|
|
217
|
+
end
|
|
78
218
|
|
|
79
|
-
|
|
80
|
-
|
|
219
|
+
status = response.code.to_i
|
|
220
|
+
diagnostic = "(status=#{status}, code=#{error_code})"
|
|
221
|
+
case status
|
|
222
|
+
when 400
|
|
223
|
+
raise Error, "Bad request #{diagnostic}: #{error_message}"
|
|
224
|
+
when 401, 403
|
|
225
|
+
raise Error, "Authentication/authorization failed #{diagnostic}: #{error_message}. " \
|
|
226
|
+
"Ensure the Cloud Translation API v2 is enabled, billing is active, and the key is unrestricted for this API."
|
|
227
|
+
when 429
|
|
228
|
+
raise RateLimitError, "Rate limit exceeded #{diagnostic}: #{error_message}"
|
|
229
|
+
when 503
|
|
230
|
+
raise QuotaExceededError, "Quota exceeded #{diagnostic}: #{error_message}"
|
|
231
|
+
else
|
|
232
|
+
raise Error, "API error #{diagnostic}: #{error_message}"
|
|
81
233
|
end
|
|
82
234
|
end
|
|
83
235
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require "i18n/tasks/translators/base_translator"
|
|
4
|
+
require "active_support/core_ext/string/filters"
|
|
5
5
|
|
|
6
6
|
module I18n::Tasks::Translators
|
|
7
7
|
class OpenAiTranslator < BaseTranslator
|
|
8
|
+
include ::I18n::Tasks::Data::LanguageNames
|
|
9
|
+
|
|
8
10
|
# max allowed texts per request
|
|
9
11
|
BATCH_SIZE = 50
|
|
10
12
|
DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
|
|
@@ -18,11 +20,15 @@ module I18n::Tasks::Translators
|
|
|
18
20
|
Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
|
|
19
21
|
|
|
20
22
|
Keep in mind the context of all the strings for a more accurate translation.
|
|
23
|
+
It is CRITICAL you output only the result, without any additional information, code block syntax or comments.
|
|
24
|
+
PROMPT
|
|
25
|
+
JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT = <<~PROMPT.squish
|
|
26
|
+
Return the translations as a JSON object with a 'translations' array containing the translated strings.
|
|
21
27
|
PROMPT
|
|
22
28
|
|
|
23
29
|
def initialize(*)
|
|
24
30
|
begin
|
|
25
|
-
require
|
|
31
|
+
require "openai"
|
|
26
32
|
rescue LoadError
|
|
27
33
|
raise ::I18n::Tasks::CommandError, "Add gem 'ruby-openai' to your Gemfile to use this command"
|
|
28
34
|
end
|
|
@@ -45,74 +51,93 @@ module I18n::Tasks::Translators
|
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def no_results_error_message
|
|
48
|
-
I18n.t(
|
|
54
|
+
I18n.t("i18n_tasks.openai_translate.errors.no_results")
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
private
|
|
52
58
|
|
|
53
59
|
def translator
|
|
54
|
-
@translator ||= OpenAI::Client.new(access_token: api_key)
|
|
60
|
+
@translator ||= OpenAI::Client.new(access_token: api_key, log_errors: true)
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
def api_key
|
|
58
64
|
@api_key ||= begin
|
|
59
65
|
key = @i18n_tasks.translation_config[:openai_api_key]
|
|
60
|
-
fail ::I18n::Tasks::CommandError, I18n.t(
|
|
66
|
+
fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.openai_translate.errors.no_api_key") if key.blank?
|
|
61
67
|
|
|
62
68
|
key
|
|
63
69
|
end
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
def model
|
|
67
|
-
@model ||= @i18n_tasks.translation_config[:openai_model].presence ||
|
|
73
|
+
@model ||= @i18n_tasks.translation_config[:openai_model].presence || "gpt-4o-mini"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def temperature
|
|
77
|
+
@temperature ||= @i18n_tasks.translation_config[:openai_temperature].presence || 0.0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def system_prompt(to_locale)
|
|
81
|
+
prompt = if locale_prompts[to_locale].present?
|
|
82
|
+
locale_prompts[to_locale]
|
|
83
|
+
else
|
|
84
|
+
@i18n_tasks.translation_config[:openai_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
prompt.concat("\n#{JSON_FORMAT_INSTRUCTIONS_SYSTEM_PROMPT}")
|
|
68
88
|
end
|
|
69
89
|
|
|
70
|
-
def
|
|
71
|
-
@
|
|
90
|
+
def locale_prompts
|
|
91
|
+
@locale_prompts ||= @i18n_tasks.translation_config[:openai_locale_prompts] || {}
|
|
72
92
|
end
|
|
73
93
|
|
|
74
94
|
def translate_values(list, from:, to:)
|
|
75
95
|
results = []
|
|
76
96
|
|
|
77
97
|
list.each_slice(BATCH_SIZE) do |batch|
|
|
78
|
-
|
|
98
|
+
result = translate(batch, from, to)
|
|
99
|
+
results << result
|
|
79
100
|
|
|
80
|
-
|
|
101
|
+
@progress_bar.progress += result.size
|
|
81
102
|
end
|
|
82
103
|
|
|
83
104
|
results.flatten
|
|
84
105
|
end
|
|
85
106
|
|
|
86
107
|
def translate(values, from, to)
|
|
87
|
-
messages = [
|
|
88
|
-
{
|
|
89
|
-
role: 'system',
|
|
90
|
-
content: format(system_prompt, from: from, to: to)
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
role: 'user',
|
|
94
|
-
content: "Translate this array: \n\n\n"
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
role: 'user',
|
|
98
|
-
content: values.to_json
|
|
99
|
-
}
|
|
100
|
-
]
|
|
101
|
-
|
|
102
108
|
response = translator.chat(
|
|
103
109
|
parameters: {
|
|
104
110
|
model: model,
|
|
105
|
-
messages:
|
|
106
|
-
temperature:
|
|
111
|
+
messages: build_messages(values, from, to),
|
|
112
|
+
temperature: temperature,
|
|
113
|
+
response_format: {type: "json_object"}
|
|
107
114
|
}
|
|
108
115
|
)
|
|
109
116
|
|
|
110
|
-
translations = response.dig(
|
|
111
|
-
error = response[
|
|
117
|
+
translations = response.dig("choices", 0, "message", "content")
|
|
118
|
+
error = response["error"]
|
|
112
119
|
|
|
113
120
|
fail "AI error: #{error}" if error.present?
|
|
114
121
|
|
|
115
|
-
|
|
122
|
+
# Extract the array from the JSON object response
|
|
123
|
+
JSON.parse(translations)["translations"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_messages(values, from, to)
|
|
127
|
+
[
|
|
128
|
+
{
|
|
129
|
+
role: "system",
|
|
130
|
+
content: format(system_prompt(to), from: language_name(from), to: language_name(to))
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
role: "user",
|
|
134
|
+
content: "Translate this array: \n\n\n"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
role: "user",
|
|
138
|
+
content: values.to_json
|
|
139
|
+
}
|
|
140
|
+
]
|
|
116
141
|
end
|
|
117
142
|
end
|
|
118
143
|
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "i18n/tasks/translators/base_translator"
|
|
4
|
+
require "active_support/core_ext/string/filters"
|
|
5
|
+
|
|
6
|
+
module I18n::Tasks::Translators
|
|
7
|
+
class WatsonxTranslator < BaseTranslator
|
|
8
|
+
# max allowed texts per request
|
|
9
|
+
BATCH_SIZE = 50
|
|
10
|
+
DEFAULT_SYSTEM_PROMPT = <<~PROMPT.squish
|
|
11
|
+
You are a helpful assistant that translates content from the %{from} locale
|
|
12
|
+
to the %{to} locale in an i18n locale array.
|
|
13
|
+
You always preserve the structure and formatting exactly as it is.
|
|
14
|
+
|
|
15
|
+
The array has a structured format and contains multiple strings. Your task is to translate
|
|
16
|
+
each of these strings and create a new array with the translated strings.
|
|
17
|
+
|
|
18
|
+
Reminder:
|
|
19
|
+
- Translate only the text, preserving the structure and formatting.
|
|
20
|
+
- Do not translate any URLs.
|
|
21
|
+
- Do not translate HTML tags like `<details>` and `<summary>`.
|
|
22
|
+
- HTML markups (enclosed in < and > characters) must not be changed under any circumstance.
|
|
23
|
+
- Variables (starting with %%{ and ending with }) must not be changed under any circumstance.
|
|
24
|
+
- Output only the result, without any additional information or comments.
|
|
25
|
+
PROMPT
|
|
26
|
+
|
|
27
|
+
def options_for_translate_values(from:, to:, **options)
|
|
28
|
+
options.merge(
|
|
29
|
+
from: from,
|
|
30
|
+
to: to
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def options_for_html
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def options_for_plain
|
|
39
|
+
{}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def no_results_error_message
|
|
43
|
+
I18n.t("i18n_tasks.watsonx_translate.errors.no_results")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def translator
|
|
49
|
+
@translator ||= WatsonxClient.new(key: api_key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def api_key
|
|
53
|
+
@api_key ||= begin
|
|
54
|
+
key = @i18n_tasks.translation_config[:watsonx_api_key]
|
|
55
|
+
fail ::I18n::Tasks::CommandError, I18n.t("i18n_tasks.watsonx_translate.errors.no_api_key") if key.blank?
|
|
56
|
+
|
|
57
|
+
key
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def project_id
|
|
62
|
+
@project_id ||= begin
|
|
63
|
+
project_id = @i18n_tasks.translation_config[:watsonx_project_id]
|
|
64
|
+
if project_id.blank?
|
|
65
|
+
fail ::I18n::Tasks::CommandError,
|
|
66
|
+
I18n.t("i18n_tasks.watsonx_translate.errors.no_project_id")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
project_id
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def model
|
|
74
|
+
@model ||= @i18n_tasks.translation_config[:watsonx_model].presence || "meta-llama/llama-3-2-90b-vision-instruct"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def system_prompt
|
|
78
|
+
@system_prompt ||= @i18n_tasks.translation_config[:watsonx_system_prompt].presence || DEFAULT_SYSTEM_PROMPT
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def translate_values(list, from:, to:)
|
|
82
|
+
results = []
|
|
83
|
+
|
|
84
|
+
list.each_slice(BATCH_SIZE) do |batch|
|
|
85
|
+
translations = translate(batch, from, to)
|
|
86
|
+
result = JSON.parse(translations)
|
|
87
|
+
results << result
|
|
88
|
+
|
|
89
|
+
@progress_bar.progress += results.size
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
results.flatten
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def translate(values, from, to)
|
|
96
|
+
prompt = [
|
|
97
|
+
"<|eot_id|><|start_header_id|>system<|end_header_id|>",
|
|
98
|
+
format(system_prompt, from: from, to: to),
|
|
99
|
+
"<|eot_id|><|start_header_id|>user<|end_header_id|>Translate this array:",
|
|
100
|
+
"<|eot_id|><|start_header_id|>user<|end_header_id|>#{values.to_json}",
|
|
101
|
+
"<|eot_id|><|start_header_id|>assistant<|end_header_id|>"
|
|
102
|
+
].join
|
|
103
|
+
|
|
104
|
+
response = translator.generate_text(
|
|
105
|
+
model_id: model,
|
|
106
|
+
project_id: project_id,
|
|
107
|
+
input: prompt,
|
|
108
|
+
parameters: {
|
|
109
|
+
decoding_method: :greedy,
|
|
110
|
+
max_new_tokens: 2048,
|
|
111
|
+
repetition_penalty: 1
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
response.dig("results", 0, "generated_text")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class WatsonxClient
|
|
120
|
+
WATSONX_BASE_URL = "https://us-south.ml.cloud.ibm.com/ml/"
|
|
121
|
+
IBM_CLOUD_IAM_URL = "https://iam.cloud.ibm.com/identity/token"
|
|
122
|
+
|
|
123
|
+
def initialize(key:)
|
|
124
|
+
begin
|
|
125
|
+
require "faraday"
|
|
126
|
+
rescue LoadError
|
|
127
|
+
raise ::I18n::Tasks::CommandError, "Add gem 'faraday' to your Gemfile to use this command"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@http = Faraday.new(url: WATSONX_BASE_URL) do |conn|
|
|
131
|
+
conn.use Faraday::Response::RaiseError
|
|
132
|
+
conn.request :json
|
|
133
|
+
conn.response :json
|
|
134
|
+
conn.options.timeout = 600
|
|
135
|
+
conn.request :authorization, :Bearer, token(key)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def generate_text(**opts)
|
|
140
|
+
@http.post("v1/text/generation?version=2024-05-20", **opts).body
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def token(key)
|
|
146
|
+
Faraday.new(url: IBM_CLOUD_IAM_URL) do |conn|
|
|
147
|
+
conn.use Faraday::Response::RaiseError
|
|
148
|
+
conn.response :json
|
|
149
|
+
conn.params = {
|
|
150
|
+
grant_type: "urn:ibm:params:oauth:grant-type:apikey",
|
|
151
|
+
apikey: key
|
|
152
|
+
}
|
|
153
|
+
end.post.body["access_token"]
|
|
154
|
+
end
|
|
155
|
+
end
|