fastlane-plugin-translate 0.1.0 → 0.2.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 +30 -12
- data/lib/fastlane/plugin/translate/actions/translate_with_deepl.rb +180 -101
- data/lib/fastlane/plugin/translate/helper/batch_translation_processor.rb +64 -0
- data/lib/fastlane/plugin/translate/helper/translation_error_handler.rb +74 -0
- data/lib/fastlane/plugin/translate/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '094cd9a767279fbff36838d5bea6a4adb7e6f64381df5cf2ae934e1a37c7038e'
|
4
|
+
data.tar.gz: f0de0b5a62196fc84570c1d8cd44651b16a75193f9659ac4eca2baea203ade18
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 75d924a65adfdb29515f747fb4513eed4bdc85eec3d378c299656f7aaecb0934e1ec33494714a2e4296141a0d6b2478083b02b5b1dc01fa2a2bfb9df9abfacfa
|
7
|
+
data.tar.gz: 0cca73dcf50da91a43bb3ff19e838ff0da44331e8a44b6e32551009d824516c29e87873750bc3a46d7b0ef4dc700d54567b943f81551854fd4866977a1b9fcec
|
data/README.md
CHANGED
@@ -5,6 +5,7 @@
|
|
5
5
|
</div>
|
6
6
|
|
7
7
|
[](https://rubygems.org/gems/fastlane-plugin-translate)
|
8
|
+
[](https://badge.fury.io/rb/fastlane-plugin-translate)
|
8
9
|
[](https://buymeacoffee.com/xocrgwybbs)
|
9
10
|
|
10
11
|
## About translate
|
@@ -13,16 +14,13 @@ Automatically translate iOS `Localizable.xcstrings` files using DeepL API. This
|
|
13
14
|
|
14
15
|
## Features
|
15
16
|
|
16
|
-
-
|
17
|
-
-
|
18
|
-
-
|
19
|
-
-
|
20
|
-
-
|
21
|
-
-
|
22
|
-
-
|
23
|
-
- 🛡️ **Error recovery**: Comprehensive error handling with user choices
|
24
|
-
- 📄 **Automatic backups**: Creates timestamped backups before translation
|
25
|
-
- ✅ **Validation**: Ensures output file is valid JSON
|
17
|
+
- **Language selection with progress**: Shows translation completeness for each language
|
18
|
+
- **Smart targeting**: Only translates missing strings, preserves existing translations
|
19
|
+
- **Formality options**: Formal/informal translation styles for supported languages
|
20
|
+
- **Context-aware translation**: Uses xcstrings comments to improve translation quality
|
21
|
+
- **Progress tracking**: Resume interrupted translations without starting over
|
22
|
+
- **Automatic backups**: Safe translation with rollback capability
|
23
|
+
- **Error recovery**: Handle API failures gracefully with retry options
|
26
24
|
|
27
25
|
## Getting Started
|
28
26
|
|
@@ -59,9 +57,29 @@ bundle install
|
|
59
57
|
|
60
58
|
### Basic Usage
|
61
59
|
|
60
|
+
After installation, create a lane in your Fastfile and run it:
|
61
|
+
|
62
|
+
```bash
|
63
|
+
fastlane ios translate
|
64
|
+
```
|
65
|
+
|
66
|
+
**Simple dedicated lane:**
|
67
|
+
|
62
68
|
```ruby
|
63
|
-
|
64
|
-
translate_with_deepl
|
69
|
+
lane :translate do
|
70
|
+
translate_with_deepl
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
**Within complex workflows:**
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
lane :prepare_release do
|
78
|
+
build_app
|
79
|
+
translate_with_deepl(target_language: "de")
|
80
|
+
upload_to_testflight
|
81
|
+
slack(message: "New build with German translations available!")
|
82
|
+
end
|
65
83
|
```
|
66
84
|
|
67
85
|
### Specify Target Language
|
@@ -17,6 +17,7 @@ module Fastlane
|
|
17
17
|
# Setup and validation
|
18
18
|
setup_deepl_client(params)
|
19
19
|
xcstrings_path = find_xcstrings_file(params[:xcstrings_path])
|
20
|
+
|
20
21
|
backup_file = create_backup(xcstrings_path)
|
21
22
|
|
22
23
|
# Parse xcstrings file
|
@@ -144,12 +145,36 @@ module Fastlane
|
|
144
145
|
# Sort by most untranslated first (prioritize languages that need work)
|
145
146
|
language_options.sort_by! { |opt| -opt[:stats][:untranslated] }
|
146
147
|
|
148
|
+
# Show all languages with their translation status
|
147
149
|
UI.message('📋 Available languages for translation:')
|
148
|
-
selected_display = UI.select('Choose target language:', language_options.map { |opt| opt[:display] })
|
149
150
|
|
150
|
-
#
|
151
|
-
|
152
|
-
|
151
|
+
# Display numbered list
|
152
|
+
language_options.each_with_index do |option, index|
|
153
|
+
UI.message(" #{index + 1}. #{option[:display]}")
|
154
|
+
end
|
155
|
+
|
156
|
+
# Force interactive mode and ensure we wait for user input
|
157
|
+
$stdout.flush
|
158
|
+
$stderr.flush
|
159
|
+
|
160
|
+
# Use a loop to ensure we get valid input
|
161
|
+
loop do
|
162
|
+
choice = UI.input("Choose target language (1-#{language_options.count}): ").strip
|
163
|
+
|
164
|
+
# Validate numeric input
|
165
|
+
if choice.match?(/^\d+$/)
|
166
|
+
choice_num = choice.to_i
|
167
|
+
if choice_num >= 1 && choice_num <= language_options.count
|
168
|
+
selected_option = language_options[choice_num - 1]
|
169
|
+
UI.message("✅ Selected: #{selected_option[:display]}")
|
170
|
+
return selected_option[:code]
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
UI.error("❌ Invalid selection '#{choice}'. Please enter a number between 1 and #{language_options.count}.")
|
175
|
+
rescue Interrupt
|
176
|
+
UI.user_error!('👋 Translation cancelled by user')
|
177
|
+
end
|
153
178
|
end
|
154
179
|
|
155
180
|
def self.calculate_translation_stats(xcstrings_data, languages)
|
@@ -161,8 +186,8 @@ module Fastlane
|
|
161
186
|
skipped_dont_translate = 0
|
162
187
|
|
163
188
|
xcstrings_data['strings'].each do |string_key, string_data|
|
189
|
+
# Skip empty string keys as they're usually not real translatable content
|
164
190
|
next if string_key.empty?
|
165
|
-
next unless string_data.dig('localizations', lang_code)
|
166
191
|
|
167
192
|
# Skip strings marked as "Don't translate" from statistics
|
168
193
|
if string_data['shouldTranslate'] == false
|
@@ -170,11 +195,27 @@ module Fastlane
|
|
170
195
|
next
|
171
196
|
end
|
172
197
|
|
173
|
-
|
198
|
+
# Check if this string has any localizations at all
|
199
|
+
if !string_data['localizations'] || string_data['localizations'].empty?
|
200
|
+
# String has no localizations - count as untranslated for this language
|
201
|
+
total += 1
|
202
|
+
next
|
203
|
+
end
|
204
|
+
|
205
|
+
# Check if this string has a localization for the target language
|
174
206
|
localization = string_data.dig('localizations', lang_code, 'stringUnit')
|
207
|
+
unless localization
|
208
|
+
# String exists but has no localization for this specific language - count as untranslated
|
209
|
+
total += 1
|
210
|
+
next
|
211
|
+
end
|
175
212
|
|
176
|
-
|
177
|
-
|
213
|
+
total += 1
|
214
|
+
|
215
|
+
# Count as translated ONLY if state is 'translated' AND has non-empty value
|
216
|
+
# Strings with state 'new', 'needs_review', or empty values are untranslated
|
217
|
+
if localization['state'] == 'translated' &&
|
218
|
+
localization['value'] && !localization['value'].strip.empty?
|
178
219
|
translated += 1
|
179
220
|
end
|
180
221
|
end
|
@@ -195,16 +236,41 @@ module Fastlane
|
|
195
236
|
return nil unless Helper::DeeplLanguageMapperHelper.supports_formality?(target_language)
|
196
237
|
|
197
238
|
lang_name = Helper::LanguageRegistryHelper.language_name(target_language)
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
239
|
+
options = [
|
240
|
+
{ display: 'default (no formality preference)', value: nil },
|
241
|
+
{ display: 'more (formal)', value: 'more' },
|
242
|
+
{ display: 'less (informal)', value: 'less' },
|
243
|
+
{ display: 'prefer_more (formal if possible)', value: 'prefer_more' },
|
244
|
+
{ display: 'prefer_less (informal if possible)', value: 'prefer_less' }
|
245
|
+
]
|
202
246
|
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
247
|
+
# Display numbered list
|
248
|
+
UI.message("🎭 #{lang_name} supports formality options. Choose style:")
|
249
|
+
options.each_with_index do |option, index|
|
250
|
+
UI.message(" #{index + 1}. #{option[:display]}")
|
251
|
+
end
|
252
|
+
|
253
|
+
# Force interactive mode and ensure we wait for user input
|
254
|
+
$stdout.flush
|
255
|
+
$stderr.flush
|
256
|
+
|
257
|
+
# Use a loop to ensure we get valid input
|
258
|
+
loop do
|
259
|
+
choice = UI.input("Choose formality style (1-#{options.count}): ").strip
|
260
|
+
|
261
|
+
# Validate numeric input
|
262
|
+
if choice.match?(/^\d+$/)
|
263
|
+
choice_num = choice.to_i
|
264
|
+
if choice_num >= 1 && choice_num <= options.count
|
265
|
+
selected_option = options[choice_num - 1]
|
266
|
+
UI.message("✅ Selected: #{selected_option[:display]}")
|
267
|
+
return selected_option[:value]
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
UI.error("❌ Invalid selection '#{choice}'. Please enter a number between 1 and #{options.count}.")
|
272
|
+
rescue Interrupt
|
273
|
+
UI.user_error!('👋 Translation cancelled by user')
|
208
274
|
end
|
209
275
|
end
|
210
276
|
|
@@ -224,8 +290,34 @@ module Fastlane
|
|
224
290
|
if progress.has_progress?
|
225
291
|
summary = progress.progress_summary
|
226
292
|
UI.message("📈 Found existing progress: #{summary[:translated_count]} strings translated")
|
227
|
-
|
228
|
-
|
293
|
+
|
294
|
+
# Display numbered options
|
295
|
+
UI.message('Continue from where you left off?')
|
296
|
+
UI.message(' 1. Yes, continue')
|
297
|
+
UI.message(' 2. No, start fresh')
|
298
|
+
|
299
|
+
# Force interactive mode and ensure we wait for user input
|
300
|
+
$stdout.flush
|
301
|
+
$stderr.flush
|
302
|
+
|
303
|
+
# Use a loop to ensure we get valid input
|
304
|
+
loop do
|
305
|
+
choice = UI.input('Choose option (1-2): ').strip
|
306
|
+
|
307
|
+
case choice
|
308
|
+
when '1'
|
309
|
+
UI.message('✅ Continuing from existing progress')
|
310
|
+
break
|
311
|
+
when '2'
|
312
|
+
UI.message('✅ Starting fresh')
|
313
|
+
progress.cleanup
|
314
|
+
break
|
315
|
+
else
|
316
|
+
UI.error("❌ Invalid selection '#{choice}'. Please enter 1 or 2.")
|
317
|
+
end
|
318
|
+
rescue Interrupt
|
319
|
+
UI.user_error!('👋 Translation cancelled by user')
|
320
|
+
end
|
229
321
|
end
|
230
322
|
|
231
323
|
# Extract untranslated strings
|
@@ -270,21 +362,45 @@ module Fastlane
|
|
270
362
|
next
|
271
363
|
end
|
272
364
|
|
365
|
+
# Skip if already translated in progress
|
366
|
+
next if already_translated[string_key]
|
367
|
+
|
368
|
+
# Check if this string has any localizations at all
|
369
|
+
if !string_data['localizations'] || string_data['localizations'].empty?
|
370
|
+
# String has no localizations - it's completely new and needs translation
|
371
|
+
# Use the string key itself as the source text since there's no source localization
|
372
|
+
untranslated[string_key] = {
|
373
|
+
'source_text' => string_key,
|
374
|
+
'context' => extract_string_context(string_key, string_data)
|
375
|
+
}
|
376
|
+
next
|
377
|
+
end
|
378
|
+
|
379
|
+
# Check if target language has a localization
|
273
380
|
localization = string_data.dig('localizations', target_language, 'stringUnit')
|
274
|
-
|
381
|
+
unless localization
|
382
|
+
# String exists but has no localization for target language
|
383
|
+
# Get source text from source language or use string key as fallback
|
384
|
+
source_text = string_data.dig('localizations', source_language, 'stringUnit', 'value')
|
385
|
+
source_text = string_key if source_text.nil? || source_text.strip.empty?
|
386
|
+
|
387
|
+
untranslated[string_key] = {
|
388
|
+
'source_text' => source_text,
|
389
|
+
'context' => extract_string_context(string_key, string_data)
|
390
|
+
}
|
391
|
+
next
|
392
|
+
end
|
275
393
|
|
276
394
|
# Check if NOT fully translated (inverse of the translation stats logic)
|
277
395
|
# A string is considered translated only if: state == 'translated' AND has non-empty value
|
278
396
|
is_fully_translated = localization['state'] == 'translated' &&
|
279
|
-
localization['value'] && !localization['value'].empty?
|
397
|
+
localization['value'] && !localization['value'].strip.empty?
|
280
398
|
next if is_fully_translated
|
281
399
|
|
282
|
-
# Skip if already translated in progress
|
283
|
-
next if already_translated[string_key]
|
284
|
-
|
285
400
|
# Get source text from source language
|
286
401
|
source_text = string_data.dig('localizations', source_language, 'stringUnit', 'value')
|
287
|
-
|
402
|
+
# Use string key as fallback if no source text available
|
403
|
+
source_text = string_key if source_text.nil? || source_text.strip.empty?
|
288
404
|
|
289
405
|
context = extract_string_context(string_key, string_data)
|
290
406
|
untranslated[string_key] = {
|
@@ -312,45 +428,19 @@ module Fastlane
|
|
312
428
|
batches.each_with_index do |batch, index|
|
313
429
|
UI.message("🔄 Translating batch #{index + 1}/#{batches.count} (#{batch.count} strings)...")
|
314
430
|
|
315
|
-
# Prepare batch for DeepL API
|
316
|
-
texts_to_translate = batch.map { |_, data| data['source_text'] }
|
317
|
-
|
318
431
|
retry_count = 0
|
319
432
|
max_retries = 3
|
320
433
|
|
321
434
|
begin
|
322
|
-
#
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
# Get context from first item if available (DeepL applies to all)
|
327
|
-
first_context = batch.first&.last&.dig('context')
|
328
|
-
translation_options[:context] = first_context if first_context
|
329
|
-
|
330
|
-
# Call DeepL with positional arguments for source_lang and target_lang
|
331
|
-
translations = DeepL.translate(texts_to_translate, source_lang, target_lang, translation_options)
|
332
|
-
|
333
|
-
# Save translations to progress (only save non-empty translations)
|
334
|
-
translated_batch = {}
|
335
|
-
skipped_empty = 0
|
336
|
-
|
337
|
-
batch.each_with_index do |(string_key, _), text_index|
|
338
|
-
translated_text = translations.is_a?(Array) ? translations[text_index].text : translations.text
|
339
|
-
|
340
|
-
# Only save non-empty translations
|
341
|
-
if translated_text && !translated_text.strip.empty?
|
342
|
-
translated_batch[string_key] = translated_text
|
343
|
-
else
|
344
|
-
skipped_empty += 1
|
345
|
-
UI.important("⚠️ Skipping empty translation for: \"#{string_key}\"")
|
346
|
-
end
|
347
|
-
end
|
435
|
+
# Process the batch using the helper
|
436
|
+
result = Helper::BatchTranslationProcessor.process_batch(
|
437
|
+
batch, source_lang, target_lang, formality, progress
|
438
|
+
)
|
348
439
|
|
349
|
-
|
350
|
-
total_translated += translated_batch.size
|
440
|
+
total_translated += result[:translated_count]
|
351
441
|
|
352
|
-
success_msg = "✅ Batch #{index + 1} completed (#{
|
353
|
-
success_msg += ", #{
|
442
|
+
success_msg = "✅ Batch #{index + 1} completed (#{result[:translated_count]} strings translated"
|
443
|
+
success_msg += ", #{result[:skipped_count]} empty translations skipped" if result[:skipped_count].positive?
|
354
444
|
success_msg += ')'
|
355
445
|
UI.success(success_msg)
|
356
446
|
rescue DeepL::Exceptions::AuthorizationFailed
|
@@ -358,19 +448,25 @@ module Fastlane
|
|
358
448
|
rescue DeepL::Exceptions::QuotaExceeded
|
359
449
|
UI.user_error!('❌ DeepL quota exceeded. Upgrade your plan or wait for reset.')
|
360
450
|
rescue DeepL::Exceptions::LimitExceeded
|
361
|
-
action = handle_rate_limit_error(batch, index, batches.count)
|
362
|
-
|
451
|
+
action = Helper::TranslationErrorHandler.handle_rate_limit_error(batch, index, batches.count)
|
452
|
+
result = Helper::TranslationErrorHandler.handle_batch_result(action, retry_count, max_retries)
|
453
|
+
|
454
|
+
case result
|
455
|
+
when :retry
|
363
456
|
retry_count += 1
|
364
457
|
retry
|
365
|
-
|
458
|
+
when :skip
|
366
459
|
next
|
367
460
|
end
|
368
461
|
rescue StandardError => e
|
369
|
-
action = handle_translation_error(e, batch, index, batches.count)
|
370
|
-
|
462
|
+
action = Helper::TranslationErrorHandler.handle_translation_error(e, batch, index, batches.count)
|
463
|
+
result = Helper::TranslationErrorHandler.handle_batch_result(action, retry_count, max_retries)
|
464
|
+
|
465
|
+
case result
|
466
|
+
when :retry
|
371
467
|
retry_count += 1
|
372
468
|
retry
|
373
|
-
|
469
|
+
when :skip
|
374
470
|
next
|
375
471
|
end
|
376
472
|
end
|
@@ -379,63 +475,46 @@ module Fastlane
|
|
379
475
|
total_translated
|
380
476
|
end
|
381
477
|
|
382
|
-
def self.handle_rate_limit_error(_batch, index, total_batches)
|
383
|
-
choice = UI.select("⚠️ Rate limit exceeded for batch #{index + 1}/#{total_batches}",
|
384
|
-
['Wait 60s and retry', 'Skip this batch', 'Abort translation'])
|
385
|
-
case choice
|
386
|
-
when 'Wait 60s and retry'
|
387
|
-
UI.message('⏳ Waiting 60 seconds...')
|
388
|
-
sleep(60)
|
389
|
-
:retry
|
390
|
-
when 'Skip this batch'
|
391
|
-
UI.important("⏭️ Skipping batch #{index + 1}")
|
392
|
-
:skip
|
393
|
-
when 'Abort translation'
|
394
|
-
UI.user_error!('❌ Translation aborted by user')
|
395
|
-
end
|
396
|
-
end
|
397
|
-
|
398
|
-
def self.handle_translation_error(error, _batch, index, total_batches)
|
399
|
-
UI.error("❌ Translation error for batch #{index + 1}/#{total_batches}: #{error.message}")
|
400
|
-
choice = UI.select('Choose action:', ['Skip this batch', 'Retry batch', 'Abort translation'])
|
401
|
-
|
402
|
-
case choice
|
403
|
-
when 'Skip this batch'
|
404
|
-
UI.important("⏭️ Skipping batch #{index + 1}")
|
405
|
-
:skip
|
406
|
-
when 'Retry batch'
|
407
|
-
UI.message("🔄 Retrying batch #{index + 1}")
|
408
|
-
:retry
|
409
|
-
when 'Abort translation'
|
410
|
-
UI.user_error!('❌ Translation aborted by user')
|
411
|
-
end
|
412
|
-
end
|
413
|
-
|
414
478
|
def self.update_xcstrings_file(xcstrings_path, xcstrings_data, target_language, translated_strings)
|
415
479
|
UI.message("📝 Updating xcstrings file with #{translated_strings.size} translations...")
|
416
480
|
|
417
481
|
# Update the JSON structure
|
418
482
|
actually_updated = 0
|
483
|
+
empty_translations = 0
|
419
484
|
translated_strings.each do |string_key, translated_text|
|
420
|
-
|
421
|
-
|
485
|
+
# Ensure the string exists in the xcstrings structure
|
486
|
+
xcstrings_data['strings'][string_key] ||= {}
|
487
|
+
string_data = xcstrings_data['strings'][string_key]
|
488
|
+
|
489
|
+
# Ensure localizations structure exists
|
490
|
+
string_data['localizations'] ||= {}
|
491
|
+
|
492
|
+
# Ensure target language localization exists
|
493
|
+
string_data['localizations'][target_language] ||= {}
|
494
|
+
|
495
|
+
# Ensure stringUnit exists
|
496
|
+
string_data['localizations'][target_language]['stringUnit'] ||= {}
|
497
|
+
|
498
|
+
localization = string_data['localizations'][target_language]['stringUnit']
|
422
499
|
|
423
500
|
# Double-check: only mark as translated if we have actual content
|
424
501
|
if translated_text && !translated_text.strip.empty?
|
425
502
|
localization['value'] = translated_text
|
426
503
|
localization['state'] = 'translated'
|
427
504
|
actually_updated += 1
|
505
|
+
UI.message("✅ Updated \"#{string_key}\" -> \"#{translated_text}\"")
|
428
506
|
else
|
429
507
|
# Keep as 'new' if translation is empty
|
430
508
|
localization['value'] = translated_text || ''
|
431
509
|
localization['state'] = 'new'
|
432
|
-
|
510
|
+
empty_translations += 1
|
511
|
+
UI.important("⚠️ Empty translation for \"#{string_key}\" (received: \"#{translated_text || 'nil'}\")")
|
433
512
|
end
|
434
513
|
end
|
435
514
|
|
436
515
|
# Write updated JSON back to file
|
437
516
|
File.write(xcstrings_path, JSON.pretty_generate(xcstrings_data))
|
438
|
-
UI.success("💾 Updated xcstrings file (#{actually_updated} marked as translated)")
|
517
|
+
UI.success("💾 Updated xcstrings file (#{actually_updated} marked as translated, #{empty_translations} empty)")
|
439
518
|
end
|
440
519
|
|
441
520
|
def self.validate_json_file(xcstrings_path)
|
@@ -492,7 +571,7 @@ module Fastlane
|
|
492
571
|
FastlaneCore::ConfigItem.new(
|
493
572
|
key: :free_api,
|
494
573
|
description: 'Use DeepL Free API endpoint instead of Pro',
|
495
|
-
|
574
|
+
is_string: false,
|
496
575
|
default_value: false
|
497
576
|
),
|
498
577
|
FastlaneCore::ConfigItem.new(
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'deepl'
|
4
|
+
require 'fastlane_core/ui/ui'
|
5
|
+
|
6
|
+
module Fastlane
|
7
|
+
UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
|
8
|
+
|
9
|
+
module Helper
|
10
|
+
class BatchTranslationProcessor
|
11
|
+
def self.process_batch(batch, source_lang, target_lang, formality, progress)
|
12
|
+
# Prepare batch for DeepL API
|
13
|
+
texts_to_translate = batch.map { |_, data| data['source_text'] }
|
14
|
+
|
15
|
+
# Build translation options
|
16
|
+
translation_options = build_translation_options(formality, batch)
|
17
|
+
|
18
|
+
# Call DeepL API
|
19
|
+
translations = DeepL.translate(texts_to_translate, source_lang, target_lang, translation_options)
|
20
|
+
|
21
|
+
# Process and save translations
|
22
|
+
save_batch_translations(batch, translations, progress)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.build_translation_options(formality, batch)
|
26
|
+
translation_options = {}
|
27
|
+
translation_options[:formality] = formality if formality
|
28
|
+
|
29
|
+
# Get context from first item if available (DeepL applies to all)
|
30
|
+
first_context = batch.first&.last&.dig('context')
|
31
|
+
translation_options[:context] = first_context if first_context
|
32
|
+
|
33
|
+
translation_options
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.save_batch_translations(batch, translations, progress)
|
37
|
+
translated_batch = {}
|
38
|
+
skipped_empty = 0
|
39
|
+
|
40
|
+
batch.each_with_index do |(string_key, _), text_index|
|
41
|
+
translated_text = translations.is_a?(Array) ? translations[text_index].text : translations.text
|
42
|
+
|
43
|
+
# Save all translations, including empty ones - let the update logic handle them
|
44
|
+
translated_batch[string_key] = translated_text || ''
|
45
|
+
|
46
|
+
# Count empty translations for reporting
|
47
|
+
if !translated_text || translated_text.strip.empty?
|
48
|
+
skipped_empty += 1
|
49
|
+
UI.important("⚠️ DeepL returned empty translation for: \"#{string_key}\"")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
progress.save_translated_strings(translated_batch)
|
54
|
+
|
55
|
+
{
|
56
|
+
translated_count: translated_batch.size - skipped_empty, # Only count non-empty as "translated"
|
57
|
+
skipped_count: skipped_empty
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
private_class_method :build_translation_options, :save_batch_translations
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FastlaneCore
|
4
|
+
class UI
|
5
|
+
unless respond_to?(:select)
|
6
|
+
def self.select(message, options)
|
7
|
+
# Fallback implementation for testing
|
8
|
+
puts "#{message} (#{options.join(', ')})"
|
9
|
+
options.first
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module Fastlane
|
16
|
+
module Helper
|
17
|
+
class TranslationErrorHandler
|
18
|
+
def self.handle_rate_limit_error(_batch, batch_index, total_batches)
|
19
|
+
options = ['Wait 60s and retry', 'Skip this batch', 'Abort translation', '❌ Quit']
|
20
|
+
choice = UI.select("⚠️ Rate limit exceeded for batch #{batch_index + 1}/#{total_batches}",
|
21
|
+
options)
|
22
|
+
|
23
|
+
case choice
|
24
|
+
when 'Wait 60s and retry'
|
25
|
+
UI.message('⏳ Waiting 60 seconds...')
|
26
|
+
sleep(60)
|
27
|
+
:retry
|
28
|
+
when 'Skip this batch'
|
29
|
+
UI.important("⏭️ Skipping batch #{batch_index + 1}")
|
30
|
+
:skip
|
31
|
+
when 'Abort translation'
|
32
|
+
UI.user_error!('❌ Translation aborted by user')
|
33
|
+
when '❌ Quit'
|
34
|
+
:quit
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.handle_translation_error(error, _batch, batch_index, total_batches)
|
39
|
+
UI.error("❌ Translation error for batch #{batch_index + 1}/#{total_batches}: #{error.message}")
|
40
|
+
options = ['Skip this batch', 'Retry batch', 'Abort translation', '❌ Quit']
|
41
|
+
choice = UI.select('Choose action:', options)
|
42
|
+
|
43
|
+
case choice
|
44
|
+
when 'Skip this batch'
|
45
|
+
UI.important("⏭️ Skipping batch #{batch_index + 1}")
|
46
|
+
:skip
|
47
|
+
when 'Retry batch'
|
48
|
+
UI.message("🔄 Retrying batch #{batch_index + 1}")
|
49
|
+
:retry
|
50
|
+
when 'Abort translation'
|
51
|
+
UI.user_error!('❌ Translation aborted by user')
|
52
|
+
when '❌ Quit'
|
53
|
+
:quit
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.handle_batch_result(action, retry_count, max_retries)
|
58
|
+
case action
|
59
|
+
when :retry
|
60
|
+
return :retry if retry_count < max_retries
|
61
|
+
|
62
|
+
UI.error("❌ Max retries (#{max_retries}) exceeded")
|
63
|
+
:skip
|
64
|
+
when :skip
|
65
|
+
:skip
|
66
|
+
when :quit
|
67
|
+
:quit
|
68
|
+
else
|
69
|
+
:continue
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fastlane-plugin-translate
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tijs Teulings
|
@@ -175,8 +175,10 @@ files:
|
|
175
175
|
- README.md
|
176
176
|
- lib/fastlane/plugin/translate.rb
|
177
177
|
- lib/fastlane/plugin/translate/actions/translate_with_deepl.rb
|
178
|
+
- lib/fastlane/plugin/translate/helper/batch_translation_processor.rb
|
178
179
|
- lib/fastlane/plugin/translate/helper/deepl_language_mapper_helper.rb
|
179
180
|
- lib/fastlane/plugin/translate/helper/language_registry_helper.rb
|
181
|
+
- lib/fastlane/plugin/translate/helper/translation_error_handler.rb
|
180
182
|
- lib/fastlane/plugin/translate/helper/translation_progress_helper.rb
|
181
183
|
- lib/fastlane/plugin/translate/version.rb
|
182
184
|
homepage: https://github.com/tijs/fastlane-plugin-translate
|