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: 162ac503b51db5c9395059702dfeedca75cbcd3bb26f68702059f45eb4540ffc
4
- data.tar.gz: d73722901b8e2d68c3287187687761930962e4392e7016847bfc9946d57c3748
3
+ metadata.gz: c370098458b2bc0d453de5101fa0a1f3273947cd41bc953bb94cf74f5ad58d6f
4
+ data.tar.gz: 96cccf7814b8796119d08340cca72089dea6c48a177df111b52bb2b1196ee756
5
5
  SHA512:
6
- metadata.gz: b260653373ca72b56e753aad327338559eeb330e64e602f905720b12d8cb6e6c35d0c5750e8be8199169d41cc7549dc19cd4f30a19caf652465ce3a6d10f54b1
7
- data.tar.gz: 21df8623e7bce11bf1c3658917dd498bd95929e77236dc32509ca8f3245c8491450cecee1996f2b40147e834aa2a76582facea074dcaa17b8e916a652e964193
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
- client = OpenAI::Client.new(
11
- access_token: params[:api_token],
12
- request_timeout: params[:request_timeout]
13
- )
14
-
15
- input_hash = Helper::TranslateGptHelper.get_strings(params[:source_file])
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
- to_translate = input_hash
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 self.get_strings(localization_file)
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 self.get_context(localization_file, localization_key)
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 self.timeout(total)
248
+ def wait(seconds = @timeout)
30
249
  sleep_time = 0
31
- while sleep_time < total
32
- percent_complete = (sleep_time.to_f / total.to_f) * 100.0
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 "] %.2f%%" % percent_complete
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
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module TranslateGpt
3
- VERSION = "0.1.3"
3
+ VERSION = "0.1.5"
4
4
  end
5
5
  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.3
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-05-20 00:00:00.000000000 Z
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: