fastlane-plugin-translate_gpt 0.1.3 → 0.1.5
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
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c370098458b2bc0d453de5101fa0a1f3273947cd41bc953bb94cf74f5ad58d6f
|
4
|
+
data.tar.gz: 96cccf7814b8796119d08340cca72089dea6c48a177df111b52bb2b1196ee756
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5d07f6a7c6a9351b633781d53ddd40e1a432a62bd270d9df8223d40d33b8ae5034123b430db52498a97c462baf5841f7d828df582a9e3cfd4767d83e064ef7ff
|
7
|
+
data.tar.gz: e5e90093831fa83665f98b302ca433f5e04e5765f74c6a0a0fd5972fcad3dbaf690657e0136acf7ab0f49f253117a5b4deb6ab23b5e209507871b1376d59de2f
|
data/README.md
CHANGED
@@ -53,6 +53,9 @@ The following options are available for `translate-gpt`:
|
|
53
53
|
| `source_file` | The path to the `Localizable.strings` or `strings.xml` file to be translated. | `GPT_SOURCE_FILE` |
|
54
54
|
| `target_file` | The path to the output file for the translated strings. | `GPT_TARGET_FILE` |
|
55
55
|
| `context` | Common context for the translation | `GPT_COMMON_CONTEXT` |
|
56
|
+
| `bunch_size` | Number of strings to translate in a single request.| `GPT_BUNCH_SIZE` |
|
57
|
+
|
58
|
+
**Note:** __I advise using `bunch_size`. It will reduce the number of API requests and translations will be more accurate.__
|
56
59
|
|
57
60
|
## Providing context
|
58
61
|
|
@@ -7,73 +7,16 @@ module Fastlane
|
|
7
7
|
module Actions
|
8
8
|
class TranslateGptAction < Action
|
9
9
|
def self.run(params)
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
)
|
14
|
-
|
15
|
-
|
16
|
-
output_hash = Helper::TranslateGptHelper.get_strings(params[:target_file])
|
17
|
-
|
18
|
-
if params[:skip_translated]
|
19
|
-
to_translate = input_hash.reject { |k, v| output_hash[k] }
|
10
|
+
helper = Helper::TranslateGptHelper.new(params)
|
11
|
+
helper.prepare_hashes()
|
12
|
+
bunch_size = params[:bunch_size]
|
13
|
+
helper.log_input(bunch_size)
|
14
|
+
if bunch_size.nil? || bunch_size < 1
|
15
|
+
helper.translate_strings()
|
20
16
|
else
|
21
|
-
|
22
|
-
end
|
23
|
-
|
24
|
-
UI.message "Translating #{to_translate.size} strings..."
|
25
|
-
|
26
|
-
to_translate.each_with_index do |(key, string), index|
|
27
|
-
prompt = "I want you to act as a translator for a mobile application strings. " + \
|
28
|
-
"You need to answer only with translation and nothing else. No commentaries. " + \
|
29
|
-
"Try to keep length of the translated text. " + \
|
30
|
-
"I will send you a text and you translate it from #{params[:source_language]} to #{params[:target_language]}. "
|
31
|
-
if params[:context] && !params[:context].empty?
|
32
|
-
prompt += "This app is #{params[:context]}. "
|
33
|
-
end
|
34
|
-
context = string.comment
|
35
|
-
if context && !context.empty?
|
36
|
-
prompt += "Additional context is #{context}. "
|
37
|
-
end
|
38
|
-
prompt += "Source text:\n#{string.value}"
|
39
|
-
|
40
|
-
# translate the source string to the target language
|
41
|
-
response = client.chat(
|
42
|
-
parameters: {
|
43
|
-
model: params[:model_name],
|
44
|
-
messages: [
|
45
|
-
{ role: "user", content: prompt }
|
46
|
-
],
|
47
|
-
temperature: params[:temperature],
|
48
|
-
}
|
49
|
-
)
|
50
|
-
# extract the translated string from the response
|
51
|
-
error = response.dig("error", "message")
|
52
|
-
if error
|
53
|
-
UI.error "Error translating #{key}: #{error}"
|
54
|
-
else
|
55
|
-
target_string = response.dig("choices", 0, "message", "content")
|
56
|
-
if target_string && !target_string.empty?
|
57
|
-
UI.message "Translating #{key} - #{string.value} -> #{target_string}"
|
58
|
-
string.value = target_string
|
59
|
-
output_hash[key] = string
|
60
|
-
else
|
61
|
-
UI.warning "Unable to translate #{key} - #{string.value}"
|
62
|
-
end
|
63
|
-
end
|
64
|
-
if index < to_translate.size - 1
|
65
|
-
Helper::TranslateGptHelper.timeout params[:request_timeout]
|
66
|
-
end
|
17
|
+
helper.translate_bunch_of_strings(bunch_size)
|
67
18
|
end
|
68
|
-
|
69
|
-
UI.message "Writing #{output_hash.size} strings to #{params[:target_file]}..."
|
70
|
-
|
71
|
-
file = LocoStrings.load(params[:target_file])
|
72
|
-
file.read
|
73
|
-
output_hash.each do |key, value|
|
74
|
-
file.update(key, value.value, value.comment)
|
75
|
-
end
|
76
|
-
file.write
|
19
|
+
helper.write_output()
|
77
20
|
end
|
78
21
|
|
79
22
|
#####################################################
|
@@ -159,7 +102,14 @@ module Fastlane
|
|
159
102
|
description: "Common context for the translation",
|
160
103
|
optional: true,
|
161
104
|
type: String
|
162
|
-
)
|
105
|
+
),
|
106
|
+
FastlaneCore::ConfigItem.new(
|
107
|
+
key: :bunch_size,
|
108
|
+
env_name: "GPT_BUNCH_SIZE",
|
109
|
+
description: "Number of strings to translate in a single request",
|
110
|
+
optional: true,
|
111
|
+
type: Integer
|
112
|
+
),
|
163
113
|
]
|
164
114
|
end
|
165
115
|
|
@@ -1,15 +1,226 @@
|
|
1
1
|
require 'fastlane_core/ui/ui'
|
2
2
|
require 'loco_strings'
|
3
|
+
require 'json'
|
3
4
|
|
4
5
|
module Fastlane
|
5
6
|
UI = FastlaneCore::UI unless Fastlane.const_defined?("UI")
|
6
7
|
|
7
8
|
module Helper
|
8
9
|
class TranslateGptHelper
|
10
|
+
def initialize(params)
|
11
|
+
@params = params
|
12
|
+
@client = OpenAI::Client.new(
|
13
|
+
access_token: params[:api_token],
|
14
|
+
request_timeout: params[:request_timeout]
|
15
|
+
)
|
16
|
+
@timeout = params[:request_timeout]
|
17
|
+
end
|
18
|
+
|
19
|
+
# Get the strings from a file
|
20
|
+
def prepare_hashes()
|
21
|
+
@input_hash = get_strings(@params[:source_file])
|
22
|
+
@output_hash = get_strings(@params[:target_file])
|
23
|
+
@to_translate = filter_translated(@params[:skip_translated], @input_hash, @output_hash)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Log information about the input strings
|
27
|
+
def log_input(bunch_size)
|
28
|
+
@translation_count = @to_translate.size
|
29
|
+
number_of_strings = Colorizer::colorize("#{@translation_count}", :blue)
|
30
|
+
UI.message "Translating #{number_of_strings} strings..."
|
31
|
+
if bunch_size.nil? || bunch_size < 1
|
32
|
+
estimated_string = Colorizer::colorize("#{@translation_count * @params[:request_timeout]}", :white)
|
33
|
+
UI.message "Estimated time: #{estimated_string} seconds"
|
34
|
+
else
|
35
|
+
number_of_bunches = (@translation_count / bunch_size.to_f).ceil
|
36
|
+
estimated_string = Colorizer::colorize("#{number_of_bunches * @params[:request_timeout]}", :white)
|
37
|
+
UI.message "Estimated time: #{estimated_string} seconds"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Cycle through the input strings and translate them
|
42
|
+
def translate_strings()
|
43
|
+
@to_translate.each_with_index do |(key, string), index|
|
44
|
+
prompt = prepare_prompt string
|
45
|
+
|
46
|
+
max_retries = 10
|
47
|
+
times_retried = 0
|
48
|
+
|
49
|
+
# translate the source string to the target language
|
50
|
+
begin
|
51
|
+
request_translate(key, string, prompt, index)
|
52
|
+
rescue Net::ReadTimeout => error
|
53
|
+
if times_retried < max_retries
|
54
|
+
times_retried += 1
|
55
|
+
UI.important "Failed to request translation, retry #{times_retried}/#{max_retries}"
|
56
|
+
wait 1
|
57
|
+
retry
|
58
|
+
else
|
59
|
+
UI.error "Can't translate #{key}: #{error}"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
if index < @translation_count - 1 then wait end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def translate_bunch_of_strings(bunch_size)
|
67
|
+
bunch_index = 0
|
68
|
+
number_of_bunches = (@translation_count / bunch_size.to_f).ceil
|
69
|
+
@to_translate.each_slice(bunch_size) do |bunch|
|
70
|
+
prompt = prepare_bunch_prompt bunch
|
71
|
+
max_retries = 10
|
72
|
+
times_retried = 0
|
73
|
+
|
74
|
+
# translate the source string to the target language
|
75
|
+
begin
|
76
|
+
request_bunch_translate(bunch, prompt, bunch_index, number_of_bunches)
|
77
|
+
bunch_index += 1
|
78
|
+
rescue Net::ReadTimeout => error
|
79
|
+
if times_retried < max_retries
|
80
|
+
times_retried += 1
|
81
|
+
UI.important "Failed to request translation, retry #{times_retried}/#{max_retries}"
|
82
|
+
wait 1
|
83
|
+
retry
|
84
|
+
else
|
85
|
+
UI.error "Can't translate #{key}: #{error}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
if bunch_index < number_of_bunches - 1 then wait end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Prepare the prompt for the GPT API
|
93
|
+
def prepare_prompt(string)
|
94
|
+
prompt = "I want you to act as a translator for a mobile application strings. " + \
|
95
|
+
"Try to keep length of the translated text. " + \
|
96
|
+
"You need to answer only with the translation and nothing else until I say to stop it. No commentaries."
|
97
|
+
if @params[:context] && !@params[:context].empty?
|
98
|
+
prompt += "This app is #{@params[:context]}. "
|
99
|
+
end
|
100
|
+
context = string.comment
|
101
|
+
if context && !context.empty?
|
102
|
+
prompt += "Additional context is #{context}. "
|
103
|
+
end
|
104
|
+
prompt += "Translate next text from #{@params[:source_language]} to #{@params[:target_language]}:\n" +
|
105
|
+
"#{string.value}"
|
106
|
+
return prompt
|
107
|
+
end
|
108
|
+
|
109
|
+
def prepare_bunch_prompt(strings)
|
110
|
+
prompt = "I want you to act as a translator for a mobile application strings. " + \
|
111
|
+
"Try to keep length of the translated text. " + \
|
112
|
+
"You need to answer only with the translation and nothing else until I say to stop it."
|
113
|
+
if @params[:context] && !@params[:context].empty?
|
114
|
+
prompt += "This app is #{@params[:context]}. "
|
115
|
+
end
|
116
|
+
prompt += "Translate next text from #{@params[:source_language]} to #{@params[:target_language]}:\n"
|
117
|
+
|
118
|
+
json_hash = []
|
119
|
+
strings.each do |key, string|
|
120
|
+
string_hash = {}
|
121
|
+
context = string.comment
|
122
|
+
if context && !context.empty?
|
123
|
+
string_hash["context"] = context
|
124
|
+
end
|
125
|
+
string_hash["key"] = string.key
|
126
|
+
string_hash["string_to_translate"] = string.value
|
127
|
+
json_hash << string_hash
|
128
|
+
end
|
129
|
+
prompt += "'''\n"
|
130
|
+
prompt += json_hash.to_json
|
131
|
+
prompt += "\n'''"
|
132
|
+
return prompt
|
133
|
+
end
|
134
|
+
|
135
|
+
# Request a translation from the GPT API
|
136
|
+
def request_translate(key, string, prompt, index)
|
137
|
+
response = @client.chat(
|
138
|
+
parameters: {
|
139
|
+
model: @params[:model_name],
|
140
|
+
messages: [
|
141
|
+
{ role: "user", content: prompt }
|
142
|
+
],
|
143
|
+
temperature: @params[:temperature],
|
144
|
+
}
|
145
|
+
)
|
146
|
+
# extract the translated string from the response
|
147
|
+
error = response.dig("error", "message")
|
148
|
+
key_log = Colorizer::colorize(key, :blue)
|
149
|
+
index_log = Colorizer::colorize("[#{index + 1}/#{@translation_count}]", :white)
|
150
|
+
if error
|
151
|
+
UI.error "#{index_log} Error translating #{key_log}: #{error}"
|
152
|
+
else
|
153
|
+
target_string = response.dig("choices", 0, "message", "content")
|
154
|
+
if target_string && !target_string.empty?
|
155
|
+
UI.message "#{index_log} Translating #{key_log} - #{string.value} -> #{target_string}"
|
156
|
+
string.value = target_string
|
157
|
+
@output_hash[key] = string
|
158
|
+
else
|
159
|
+
UI.important "#{index_log} Unable to translate #{key_log} - #{string.value}"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def request_bunch_translate(strings, prompt, index, number_of_bunches)
|
165
|
+
response = @client.chat(
|
166
|
+
parameters: {
|
167
|
+
model: @params[:model_name],
|
168
|
+
messages: [
|
169
|
+
{ role: "user", content: prompt }
|
170
|
+
],
|
171
|
+
temperature: @params[:temperature],
|
172
|
+
}
|
173
|
+
)
|
174
|
+
# extract the translated string from the response
|
175
|
+
error = response.dig("error", "message")
|
176
|
+
|
177
|
+
#key_log = Colorizer::colorize(key, :blue)
|
178
|
+
index_log = Colorizer::colorize("[#{index + 1}/#{number_of_bunches}]", :white)
|
179
|
+
if error
|
180
|
+
UI.error "#{index_log} Error translating: #{error}"
|
181
|
+
else
|
182
|
+
target_string = response.dig("choices", 0, "message", "content")
|
183
|
+
json_string = target_string[/\[[^\[\]]*\]/m]
|
184
|
+
json_hash = JSON.parse(json_string)
|
185
|
+
keys_to_translate = json_hash.map { |string_hash| string_hash["key"] }
|
186
|
+
json_hash.each do |string_hash|
|
187
|
+
key = string_hash["key"]
|
188
|
+
context = string_hash["context"]
|
189
|
+
string_hash.delete("key")
|
190
|
+
string_hash.delete("context")
|
191
|
+
translated_string = string_hash.values.first
|
192
|
+
if key && !key.empty? && translated_string && !translated_string.empty?
|
193
|
+
UI.message "#{index_log} Translating #{key} - #{translated_string}"
|
194
|
+
string = LocoStrings::LocoString.new(key, translated_string, context)
|
195
|
+
@output_hash[key] = string
|
196
|
+
keys_to_translate.delete(key)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
if keys_to_translate.length > 0
|
201
|
+
UI.important "#{index_log} Unable to translate #{keys_to_translate.join(", ")}"
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
# Write the translated strings to the target file
|
207
|
+
def write_output()
|
208
|
+
number_of_strings = Colorizer::colorize("#{@output_hash.size}", :blue)
|
209
|
+
target_string = Colorizer::colorize(@params[:target_file], :white)
|
210
|
+
UI.message "Writing #{number_of_strings} strings to #{target_string}..."
|
211
|
+
|
212
|
+
file = LocoStrings.load(@params[:target_file])
|
213
|
+
file.read
|
214
|
+
@output_hash.each do |key, value|
|
215
|
+
file.update(key, value.value, value.comment)
|
216
|
+
end
|
217
|
+
file.write
|
218
|
+
end
|
219
|
+
|
9
220
|
# Read the strings file into a hash
|
10
221
|
# @param localization_file [String] The path to the strings file
|
11
222
|
# @return [Hash] The strings file as a hash
|
12
|
-
def
|
223
|
+
def get_strings(localization_file)
|
13
224
|
file = LocoStrings.load(localization_file)
|
14
225
|
return file.read
|
15
226
|
end
|
@@ -18,25 +229,36 @@ module Fastlane
|
|
18
229
|
# @param localization_file [String] The path to the strings file
|
19
230
|
# @param localization_key [String] The localization key
|
20
231
|
# @return [String] The context associated with the localization key
|
21
|
-
def
|
232
|
+
def get_context(localization_file, localization_key)
|
22
233
|
file = LocoStrings.load(localization_file)
|
23
234
|
string = file.read[localization_key]
|
24
235
|
return string.comment
|
25
236
|
end
|
26
237
|
|
238
|
+
def filter_translated(need_to_skip, base, target)
|
239
|
+
if need_to_skip
|
240
|
+
return base.reject { |k, v| target[k] }
|
241
|
+
else
|
242
|
+
return base
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
27
246
|
# Sleep for a specified number of seconds, displaying a progress bar
|
28
247
|
# @param seconds [Integer] The number of seconds to sleep
|
29
|
-
def
|
248
|
+
def wait(seconds = @timeout)
|
30
249
|
sleep_time = 0
|
31
|
-
while sleep_time <
|
32
|
-
percent_complete = (sleep_time.to_f /
|
250
|
+
while sleep_time < seconds
|
251
|
+
percent_complete = (sleep_time.to_f / seconds.to_f) * 100.0
|
33
252
|
progress_bar_width = 20
|
34
253
|
completed_width = (progress_bar_width * percent_complete / 100.0).round
|
35
254
|
remaining_width = progress_bar_width - completed_width
|
36
|
-
print "\rTimeout ["
|
255
|
+
print "\rTimeout ["
|
256
|
+
print Colorizer::code(:green)
|
37
257
|
print "=" * completed_width
|
38
258
|
print " " * remaining_width
|
39
|
-
print
|
259
|
+
print Colorizer::code(:reset)
|
260
|
+
print "]"
|
261
|
+
print " %.2f%%" % percent_complete
|
40
262
|
$stdout.flush
|
41
263
|
sleep(1)
|
42
264
|
sleep_time += 1
|
@@ -45,5 +267,28 @@ module Fastlane
|
|
45
267
|
$stdout.flush
|
46
268
|
end
|
47
269
|
end
|
270
|
+
|
271
|
+
# Helper class for bash colors
|
272
|
+
class Colorizer
|
273
|
+
COLORS = {
|
274
|
+
black: 30,
|
275
|
+
red: 31,
|
276
|
+
green: 32,
|
277
|
+
yellow: 33,
|
278
|
+
blue: 34,
|
279
|
+
magenta: 35,
|
280
|
+
cyan: 36,
|
281
|
+
white: 37,
|
282
|
+
reset: 0,
|
283
|
+
}
|
284
|
+
|
285
|
+
def self.colorize(text, color)
|
286
|
+
color_code = COLORS[color.to_sym]
|
287
|
+
"\e[#{color_code}m#{text}\e[0m"
|
288
|
+
end
|
289
|
+
def self.code(color)
|
290
|
+
"\e[#{COLORS[color.to_sym]}m"
|
291
|
+
end
|
292
|
+
end
|
48
293
|
end
|
49
294
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fastlane-plugin-translate_gpt
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Aleksei Cherepanov
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-07-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: ruby-openai
|
@@ -193,7 +193,10 @@ files:
|
|
193
193
|
homepage: https://github.com/ftp27/fastlane-plugin-translate_gpt
|
194
194
|
licenses:
|
195
195
|
- MIT
|
196
|
-
metadata:
|
196
|
+
metadata:
|
197
|
+
homepage_uri: https://github.com/ftp27/fastlane-plugin-translate_gpt
|
198
|
+
source_code_uri: https://github.com/ftp27/fastlane-plugin-translate_gpt
|
199
|
+
github_repo: https://github.com/ftp27/fastlane-plugin-translate_gpt
|
197
200
|
post_install_message:
|
198
201
|
rdoc_options: []
|
199
202
|
require_paths:
|