fastlane-plugin-translate 0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +318 -0
- data/lib/fastlane/plugin/translate/actions/translate_with_deepl.rb +547 -0
- data/lib/fastlane/plugin/translate/helper/deepl_language_mapper_helper.rb +99 -0
- data/lib/fastlane/plugin/translate/helper/language_registry_helper.rb +81 -0
- data/lib/fastlane/plugin/translate/helper/translation_progress_helper.rb +78 -0
- data/lib/fastlane/plugin/translate/version.rb +7 -0
- data/lib/fastlane/plugin/translate.rb +20 -0
- metadata +204 -0
@@ -0,0 +1,547 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'deepl'
|
6
|
+
|
7
|
+
module Fastlane
|
8
|
+
module Actions
|
9
|
+
module SharedValues
|
10
|
+
TRANSLATE_WITH_DEEPL_TRANSLATED_COUNT = :TRANSLATE_WITH_DEEPL_TRANSLATED_COUNT
|
11
|
+
TRANSLATE_WITH_DEEPL_TARGET_LANGUAGE = :TRANSLATE_WITH_DEEPL_TARGET_LANGUAGE
|
12
|
+
TRANSLATE_WITH_DEEPL_BACKUP_FILE = :TRANSLATE_WITH_DEEPL_BACKUP_FILE
|
13
|
+
end
|
14
|
+
|
15
|
+
class TranslateWithDeeplAction < Action
|
16
|
+
def self.run(params)
|
17
|
+
# Setup and validation
|
18
|
+
setup_deepl_client(params)
|
19
|
+
xcstrings_path = find_xcstrings_file(params[:xcstrings_path])
|
20
|
+
backup_file = create_backup(xcstrings_path)
|
21
|
+
|
22
|
+
# Parse xcstrings file
|
23
|
+
xcstrings_data = JSON.parse(File.read(xcstrings_path))
|
24
|
+
source_language = xcstrings_data['sourceLanguage']
|
25
|
+
available_languages = extract_available_languages(xcstrings_data)
|
26
|
+
|
27
|
+
# Filter languages supported by DeepL
|
28
|
+
supported_languages = Helper::DeeplLanguageMapperHelper.supported_languages_from_list(available_languages)
|
29
|
+
unsupported_languages = Helper::DeeplLanguageMapperHelper.unsupported_languages(available_languages)
|
30
|
+
|
31
|
+
UI.important("⚠️ Languages not supported by DeepL: #{unsupported_languages.map { |l| "#{Helper::LanguageRegistryHelper.language_name(l)} (#{l})" }.join(', ')}") if unsupported_languages.any?
|
32
|
+
|
33
|
+
UI.user_error!('❌ No DeepL-supported languages found in xcstrings file') if supported_languages.empty?
|
34
|
+
|
35
|
+
# Language selection
|
36
|
+
target_language = select_target_language(params[:target_language], supported_languages, xcstrings_data)
|
37
|
+
|
38
|
+
# Formality detection
|
39
|
+
formality = detect_and_ask_formality(target_language, params[:formality])
|
40
|
+
|
41
|
+
# Translate the selected language
|
42
|
+
translated_count = translate_language(xcstrings_data, xcstrings_path, source_language, target_language, formality, params)
|
43
|
+
|
44
|
+
# Set shared values for other actions
|
45
|
+
Actions.lane_context[SharedValues::TRANSLATE_WITH_DEEPL_TRANSLATED_COUNT] = translated_count
|
46
|
+
Actions.lane_context[SharedValues::TRANSLATE_WITH_DEEPL_TARGET_LANGUAGE] = target_language
|
47
|
+
Actions.lane_context[SharedValues::TRANSLATE_WITH_DEEPL_BACKUP_FILE] = backup_file
|
48
|
+
|
49
|
+
UI.success('🎉 Translation completed!')
|
50
|
+
UI.message("📊 Translated #{translated_count} strings for #{Helper::LanguageRegistryHelper.language_name(target_language)} (#{target_language})")
|
51
|
+
UI.message("📄 Backup saved: #{backup_file}")
|
52
|
+
UI.message('🗑️ You can delete the backup after verifying results')
|
53
|
+
|
54
|
+
translated_count
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.setup_deepl_client(params)
|
58
|
+
DeepL.configure do |config|
|
59
|
+
config.auth_key = params[:api_token]
|
60
|
+
config.host = params[:free_api] ? 'https://api-free.deepl.com' : 'https://api.deepl.com'
|
61
|
+
end
|
62
|
+
|
63
|
+
# Test API key
|
64
|
+
begin
|
65
|
+
DeepL.usage
|
66
|
+
UI.success('✅ DeepL API key validated')
|
67
|
+
rescue DeepL::Exceptions::AuthorizationFailed
|
68
|
+
UI.user_error!('❌ Invalid DeepL API key. Get one at: https://www.deepl.com/pro#developer')
|
69
|
+
rescue StandardError => e
|
70
|
+
UI.user_error!("❌ DeepL API connection failed: #{e.message}")
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.find_xcstrings_file(provided_path)
|
75
|
+
if provided_path
|
76
|
+
UI.user_error!("❌ Localizable.xcstrings file not found at: #{provided_path}") unless File.exist?(provided_path)
|
77
|
+
return provided_path
|
78
|
+
end
|
79
|
+
|
80
|
+
# Search for xcstrings files
|
81
|
+
xcstrings_files = Dir.glob('**/Localizable.xcstrings')
|
82
|
+
|
83
|
+
UI.user_error!('❌ No Localizable.xcstrings files found. Please specify the path with xcstrings_path parameter.') if xcstrings_files.empty?
|
84
|
+
|
85
|
+
if xcstrings_files.count == 1
|
86
|
+
UI.message("📁 Found xcstrings file: #{xcstrings_files.first}")
|
87
|
+
return xcstrings_files.first
|
88
|
+
end
|
89
|
+
|
90
|
+
# Multiple files found, let user choose
|
91
|
+
UI.message('📁 Multiple xcstrings files found:')
|
92
|
+
UI.select('Choose file:', xcstrings_files)
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.create_backup(xcstrings_path)
|
96
|
+
timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
|
97
|
+
backup_path = "#{xcstrings_path}.backup_#{timestamp}"
|
98
|
+
FileUtils.cp(xcstrings_path, backup_path)
|
99
|
+
UI.message("💾 Backup created: #{backup_path}")
|
100
|
+
backup_path
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.extract_available_languages(xcstrings_data)
|
104
|
+
languages = Set.new
|
105
|
+
|
106
|
+
xcstrings_data['strings'].each do |_, string_data|
|
107
|
+
next unless string_data['localizations']
|
108
|
+
|
109
|
+
string_data['localizations'].each_key { |lang| languages.add(lang) }
|
110
|
+
end
|
111
|
+
|
112
|
+
# Remove source language from target options
|
113
|
+
source_lang = xcstrings_data['sourceLanguage']
|
114
|
+
languages.delete(source_lang)
|
115
|
+
|
116
|
+
languages.to_a.sort
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.select_target_language(param_language, available_languages, xcstrings_data)
|
120
|
+
if param_language
|
121
|
+
UI.user_error!("❌ Language '#{param_language}' not found in xcstrings file. Available: #{available_languages.join(', ')}") unless available_languages.include?(param_language)
|
122
|
+
return param_language
|
123
|
+
end
|
124
|
+
|
125
|
+
# Calculate translation percentages for each language
|
126
|
+
language_stats = calculate_translation_stats(xcstrings_data, available_languages)
|
127
|
+
|
128
|
+
# Create display list with language names and translation status
|
129
|
+
language_options = available_languages.map do |lang_code|
|
130
|
+
lang_name = Helper::LanguageRegistryHelper.language_name(lang_code)
|
131
|
+
stats = language_stats[lang_code]
|
132
|
+
percentage = ((stats[:translated].to_f / stats[:total]) * 100).round(1)
|
133
|
+
|
134
|
+
display_name = "#{lang_name} (#{lang_code}): #{percentage}% translated (#{stats[:untranslated]} remaining"
|
135
|
+
display_name += ", #{stats[:skipped_dont_translate]} don't translate" if (stats[:skipped_dont_translate]).positive?
|
136
|
+
display_name += ')'
|
137
|
+
|
138
|
+
# Add formality indicator
|
139
|
+
display_name += ' [supports formality]' if Helper::DeeplLanguageMapperHelper.supports_formality?(lang_code)
|
140
|
+
|
141
|
+
{ display: display_name, code: lang_code, stats: }
|
142
|
+
end
|
143
|
+
|
144
|
+
# Sort by most untranslated first (prioritize languages that need work)
|
145
|
+
language_options.sort_by! { |opt| -opt[:stats][:untranslated] }
|
146
|
+
|
147
|
+
UI.message('📋 Available languages for translation:')
|
148
|
+
selected_display = UI.select('Choose target language:', language_options.map { |opt| opt[:display] })
|
149
|
+
|
150
|
+
# Find the corresponding language code
|
151
|
+
selected_option = language_options.find { |opt| opt[:display] == selected_display }
|
152
|
+
selected_option[:code]
|
153
|
+
end
|
154
|
+
|
155
|
+
def self.calculate_translation_stats(xcstrings_data, languages)
|
156
|
+
stats = {}
|
157
|
+
|
158
|
+
languages.each do |lang_code|
|
159
|
+
total = 0
|
160
|
+
translated = 0
|
161
|
+
skipped_dont_translate = 0
|
162
|
+
|
163
|
+
xcstrings_data['strings'].each do |string_key, string_data|
|
164
|
+
next if string_key.empty?
|
165
|
+
next unless string_data.dig('localizations', lang_code)
|
166
|
+
|
167
|
+
# Skip strings marked as "Don't translate" from statistics
|
168
|
+
if string_data['shouldTranslate'] == false
|
169
|
+
skipped_dont_translate += 1
|
170
|
+
next
|
171
|
+
end
|
172
|
+
|
173
|
+
total += 1
|
174
|
+
localization = string_data.dig('localizations', lang_code, 'stringUnit')
|
175
|
+
|
176
|
+
if localization && localization['state'] == 'translated' &&
|
177
|
+
localization['value'] && !localization['value'].empty?
|
178
|
+
translated += 1
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
stats[lang_code] = {
|
183
|
+
total:,
|
184
|
+
translated:,
|
185
|
+
untranslated: total - translated,
|
186
|
+
skipped_dont_translate:
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
190
|
+
stats
|
191
|
+
end
|
192
|
+
|
193
|
+
def self.detect_and_ask_formality(target_language, formality_param)
|
194
|
+
return formality_param if formality_param
|
195
|
+
return nil unless Helper::DeeplLanguageMapperHelper.supports_formality?(target_language)
|
196
|
+
|
197
|
+
lang_name = Helper::LanguageRegistryHelper.language_name(target_language)
|
198
|
+
choice = UI.select(
|
199
|
+
"🎭 #{lang_name} supports formality options. Choose style:",
|
200
|
+
['default', 'more (formal)', 'less (informal)', 'prefer_more (formal if possible)', 'prefer_less (informal if possible)']
|
201
|
+
)
|
202
|
+
|
203
|
+
case choice
|
204
|
+
when 'more (formal)' then 'more'
|
205
|
+
when 'less (informal)' then 'less'
|
206
|
+
when 'prefer_more (formal if possible)' then 'prefer_more'
|
207
|
+
when 'prefer_less (informal if possible)' then 'prefer_less'
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def self.translate_language(xcstrings_data, xcstrings_path, source_language, target_language, formality, params)
|
212
|
+
# Validate DeepL support
|
213
|
+
UI.user_error!("❌ Language '#{target_language}' is not supported by DeepL") unless Helper::DeeplLanguageMapperHelper.supported?(target_language)
|
214
|
+
|
215
|
+
# Get DeepL language codes
|
216
|
+
deepl_source = Helper::DeeplLanguageMapperHelper.get_source_language(source_language)
|
217
|
+
deepl_target = Helper::DeeplLanguageMapperHelper.get_target_language(target_language)
|
218
|
+
|
219
|
+
UI.message("🔄 Translating from #{deepl_source} to #{deepl_target}")
|
220
|
+
|
221
|
+
# Progress setup
|
222
|
+
progress = Helper::TranslationProgressHelper.create_progress_tracker(xcstrings_path, target_language)
|
223
|
+
|
224
|
+
if progress.has_progress?
|
225
|
+
summary = progress.progress_summary
|
226
|
+
UI.message("📈 Found existing progress: #{summary[:translated_count]} strings translated")
|
227
|
+
choice = UI.select('Continue from where you left off?', ['Yes, continue', 'No, start fresh'])
|
228
|
+
progress.cleanup if choice == 'No, start fresh'
|
229
|
+
end
|
230
|
+
|
231
|
+
# Extract untranslated strings
|
232
|
+
untranslated_strings = extract_untranslated_strings(
|
233
|
+
xcstrings_data, source_language, target_language, progress.get_translated_strings
|
234
|
+
)
|
235
|
+
|
236
|
+
if untranslated_strings.empty?
|
237
|
+
UI.success("✅ All strings already translated for #{target_language}")
|
238
|
+
progress.cleanup
|
239
|
+
return 0
|
240
|
+
end
|
241
|
+
|
242
|
+
UI.message("📝 Found #{untranslated_strings.count} untranslated strings")
|
243
|
+
|
244
|
+
# Batch translation
|
245
|
+
translated_count = translate_in_batches(
|
246
|
+
untranslated_strings, deepl_source, deepl_target,
|
247
|
+
formality, params[:batch_size], progress
|
248
|
+
)
|
249
|
+
|
250
|
+
# Update xcstrings file
|
251
|
+
update_xcstrings_file(xcstrings_path, xcstrings_data, target_language,
|
252
|
+
progress.get_translated_strings)
|
253
|
+
|
254
|
+
# Validation and cleanup
|
255
|
+
validate_json_file(xcstrings_path)
|
256
|
+
progress.cleanup
|
257
|
+
|
258
|
+
translated_count
|
259
|
+
end
|
260
|
+
|
261
|
+
def self.extract_untranslated_strings(xcstrings_data, source_language, target_language, already_translated)
|
262
|
+
untranslated = {}
|
263
|
+
|
264
|
+
xcstrings_data['strings'].each do |string_key, string_data|
|
265
|
+
next if string_key.empty? # Skip empty keys
|
266
|
+
|
267
|
+
# Skip strings marked as "Don't translate" in Xcode
|
268
|
+
if string_data['shouldTranslate'] == false
|
269
|
+
UI.message("⏭️ Skipping string marked as 'Don't translate': \"#{string_key}\"")
|
270
|
+
next
|
271
|
+
end
|
272
|
+
|
273
|
+
localization = string_data.dig('localizations', target_language, 'stringUnit')
|
274
|
+
next unless localization
|
275
|
+
|
276
|
+
# Check if NOT fully translated (inverse of the translation stats logic)
|
277
|
+
# A string is considered translated only if: state == 'translated' AND has non-empty value
|
278
|
+
is_fully_translated = localization['state'] == 'translated' &&
|
279
|
+
localization['value'] && !localization['value'].empty?
|
280
|
+
next if is_fully_translated
|
281
|
+
|
282
|
+
# Skip if already translated in progress
|
283
|
+
next if already_translated[string_key]
|
284
|
+
|
285
|
+
# Get source text from source language
|
286
|
+
source_text = string_data.dig('localizations', source_language, 'stringUnit', 'value')
|
287
|
+
next if source_text.nil? || source_text.empty?
|
288
|
+
|
289
|
+
context = extract_string_context(string_key, string_data)
|
290
|
+
untranslated[string_key] = {
|
291
|
+
'source_text' => source_text,
|
292
|
+
'context' => context
|
293
|
+
}
|
294
|
+
end
|
295
|
+
|
296
|
+
untranslated
|
297
|
+
end
|
298
|
+
|
299
|
+
def self.extract_string_context(string_key, string_data)
|
300
|
+
# Check for comment field in the string data
|
301
|
+
comment = string_data['comment']
|
302
|
+
return comment if comment && !comment.empty?
|
303
|
+
|
304
|
+
# Fallback: use string key as minimal context if it's descriptive
|
305
|
+
string_key.length > 50 ? nil : string_key
|
306
|
+
end
|
307
|
+
|
308
|
+
def self.translate_in_batches(untranslated_strings, source_lang, target_lang, formality, batch_size, progress)
|
309
|
+
batches = untranslated_strings.each_slice(batch_size).to_a
|
310
|
+
total_translated = 0
|
311
|
+
|
312
|
+
batches.each_with_index do |batch, index|
|
313
|
+
UI.message("🔄 Translating batch #{index + 1}/#{batches.count} (#{batch.count} strings)...")
|
314
|
+
|
315
|
+
# Prepare batch for DeepL API
|
316
|
+
texts_to_translate = batch.map { |_, data| data['source_text'] }
|
317
|
+
|
318
|
+
retry_count = 0
|
319
|
+
max_retries = 3
|
320
|
+
|
321
|
+
begin
|
322
|
+
# Build translation options (exclude source_lang and target_lang)
|
323
|
+
translation_options = {}
|
324
|
+
translation_options[:formality] = formality if formality
|
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
|
348
|
+
|
349
|
+
progress.save_translated_strings(translated_batch)
|
350
|
+
total_translated += translated_batch.size
|
351
|
+
|
352
|
+
success_msg = "✅ Batch #{index + 1} completed (#{translated_batch.size} strings translated"
|
353
|
+
success_msg += ", #{skipped_empty} empty translations skipped" if skipped_empty.positive?
|
354
|
+
success_msg += ')'
|
355
|
+
UI.success(success_msg)
|
356
|
+
rescue DeepL::Exceptions::AuthorizationFailed
|
357
|
+
UI.user_error!('❌ Invalid DeepL API key')
|
358
|
+
rescue DeepL::Exceptions::QuotaExceeded
|
359
|
+
UI.user_error!('❌ DeepL quota exceeded. Upgrade your plan or wait for reset.')
|
360
|
+
rescue DeepL::Exceptions::LimitExceeded
|
361
|
+
action = handle_rate_limit_error(batch, index, batches.count)
|
362
|
+
if action == :retry && retry_count < max_retries
|
363
|
+
retry_count += 1
|
364
|
+
retry
|
365
|
+
elsif action == :skip
|
366
|
+
next
|
367
|
+
end
|
368
|
+
rescue StandardError => e
|
369
|
+
action = handle_translation_error(e, batch, index, batches.count)
|
370
|
+
if action == :retry && retry_count < max_retries
|
371
|
+
retry_count += 1
|
372
|
+
retry
|
373
|
+
elsif action == :skip
|
374
|
+
next
|
375
|
+
end
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
total_translated
|
380
|
+
end
|
381
|
+
|
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
|
+
def self.update_xcstrings_file(xcstrings_path, xcstrings_data, target_language, translated_strings)
|
415
|
+
UI.message("📝 Updating xcstrings file with #{translated_strings.size} translations...")
|
416
|
+
|
417
|
+
# Update the JSON structure
|
418
|
+
actually_updated = 0
|
419
|
+
translated_strings.each do |string_key, translated_text|
|
420
|
+
localization = xcstrings_data.dig('strings', string_key, 'localizations', target_language, 'stringUnit')
|
421
|
+
next unless localization
|
422
|
+
|
423
|
+
# Double-check: only mark as translated if we have actual content
|
424
|
+
if translated_text && !translated_text.strip.empty?
|
425
|
+
localization['value'] = translated_text
|
426
|
+
localization['state'] = 'translated'
|
427
|
+
actually_updated += 1
|
428
|
+
else
|
429
|
+
# Keep as 'new' if translation is empty
|
430
|
+
localization['value'] = translated_text || ''
|
431
|
+
localization['state'] = 'new'
|
432
|
+
UI.important("⚠️ Keeping empty translation as 'new' state: \"#{string_key}\"")
|
433
|
+
end
|
434
|
+
end
|
435
|
+
|
436
|
+
# Write updated JSON back to file
|
437
|
+
File.write(xcstrings_path, JSON.pretty_generate(xcstrings_data))
|
438
|
+
UI.success("💾 Updated xcstrings file (#{actually_updated} marked as translated)")
|
439
|
+
end
|
440
|
+
|
441
|
+
def self.validate_json_file(xcstrings_path)
|
442
|
+
JSON.parse(File.read(xcstrings_path))
|
443
|
+
UI.success('✅ Updated xcstrings file is valid JSON')
|
444
|
+
rescue JSON::ParserError => e
|
445
|
+
UI.user_error!("❌ Generated xcstrings file is invalid JSON: #{e.message}")
|
446
|
+
end
|
447
|
+
|
448
|
+
#####################################################
|
449
|
+
# @!group Documentation
|
450
|
+
#####################################################
|
451
|
+
|
452
|
+
def self.description
|
453
|
+
'Automatically translate untranslated strings in Localizable.xcstrings using DeepL API'
|
454
|
+
end
|
455
|
+
|
456
|
+
def self.details
|
457
|
+
'This action finds your Localizable.xcstrings file, analyzes translation status for each language, ' \
|
458
|
+
'and uses DeepL API to translate missing strings. It supports progress tracking, formality options, ' \
|
459
|
+
'and provides comprehensive error handling with user choices for recovery.'
|
460
|
+
end
|
461
|
+
|
462
|
+
def self.available_options
|
463
|
+
[
|
464
|
+
FastlaneCore::ConfigItem.new(
|
465
|
+
key: :api_token,
|
466
|
+
env_name: 'DEEPL_AUTH_KEY',
|
467
|
+
description: 'DeepL API authentication key',
|
468
|
+
sensitive: true,
|
469
|
+
verify_block: proc do |value|
|
470
|
+
UI.user_error!('DeepL API key required. Get one at: https://www.deepl.com/pro#developer') if value.to_s.empty?
|
471
|
+
end
|
472
|
+
),
|
473
|
+
FastlaneCore::ConfigItem.new(
|
474
|
+
key: :xcstrings_path,
|
475
|
+
description: 'Path to Localizable.xcstrings file (auto-detected if not provided)',
|
476
|
+
optional: true
|
477
|
+
),
|
478
|
+
FastlaneCore::ConfigItem.new(
|
479
|
+
key: :target_language,
|
480
|
+
description: 'Target language code (e.g., "de", "fr", "es") - will prompt if not provided',
|
481
|
+
optional: true
|
482
|
+
),
|
483
|
+
FastlaneCore::ConfigItem.new(
|
484
|
+
key: :batch_size,
|
485
|
+
description: 'Number of strings to translate per API call',
|
486
|
+
type: Integer,
|
487
|
+
default_value: 50,
|
488
|
+
verify_block: proc do |value|
|
489
|
+
UI.user_error!('Batch size must be between 1 and 50') unless (1..50).cover?(value)
|
490
|
+
end
|
491
|
+
),
|
492
|
+
FastlaneCore::ConfigItem.new(
|
493
|
+
key: :free_api,
|
494
|
+
description: 'Use DeepL Free API endpoint instead of Pro',
|
495
|
+
type: Boolean,
|
496
|
+
default_value: false
|
497
|
+
),
|
498
|
+
FastlaneCore::ConfigItem.new(
|
499
|
+
key: :formality,
|
500
|
+
description: 'Translation formality (auto-detected if language supports it): default, more, less, prefer_more, prefer_less',
|
501
|
+
optional: true,
|
502
|
+
verify_block: proc do |value|
|
503
|
+
if value
|
504
|
+
valid_options = %w[default more less prefer_more prefer_less]
|
505
|
+
UI.user_error!("Invalid formality. Use: #{valid_options.join(', ')}") unless valid_options.include?(value)
|
506
|
+
end
|
507
|
+
end
|
508
|
+
)
|
509
|
+
]
|
510
|
+
end
|
511
|
+
|
512
|
+
def self.output
|
513
|
+
[
|
514
|
+
['TRANSLATE_WITH_DEEPL_TRANSLATED_COUNT', 'Number of strings that were translated'],
|
515
|
+
['TRANSLATE_WITH_DEEPL_TARGET_LANGUAGE', 'The target language that was translated'],
|
516
|
+
['TRANSLATE_WITH_DEEPL_BACKUP_FILE', 'Path to the backup file created before translation']
|
517
|
+
]
|
518
|
+
end
|
519
|
+
|
520
|
+
def self.return_value
|
521
|
+
'Number of strings that were translated'
|
522
|
+
end
|
523
|
+
|
524
|
+
def self.authors
|
525
|
+
['Your GitHub/Twitter Name']
|
526
|
+
end
|
527
|
+
|
528
|
+
def self.is_supported?(platform)
|
529
|
+
platform == :ios
|
530
|
+
end
|
531
|
+
# rubocop:enable Naming/PredicateName
|
532
|
+
|
533
|
+
def self.example_code
|
534
|
+
[
|
535
|
+
'translate_with_deepl',
|
536
|
+
'translate_with_deepl(target_language: "de")',
|
537
|
+
'translate_with_deepl(target_language: "fr", formality: "more")',
|
538
|
+
'translate_with_deepl(xcstrings_path: "./MyApp/Localizable.xcstrings", free_api: true)'
|
539
|
+
]
|
540
|
+
end
|
541
|
+
|
542
|
+
def self.category
|
543
|
+
:misc
|
544
|
+
end
|
545
|
+
end
|
546
|
+
end
|
547
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fastlane_core/ui/ui'
|
4
|
+
|
5
|
+
module Fastlane
|
6
|
+
UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
|
7
|
+
|
8
|
+
module Helper
|
9
|
+
class DeeplLanguageMapperHelper
|
10
|
+
# DeepL API language mappings
|
11
|
+
# Based on: https://developers.deepl.com/docs/resources/supported-languages
|
12
|
+
DEEPL_MAPPINGS = {
|
13
|
+
# iOS language code => { source: DeepL_source_code, target: DeepL_target_code }
|
14
|
+
'ar' => { source: 'AR', target: 'AR' },
|
15
|
+
'bg' => { source: 'BG', target: 'BG' },
|
16
|
+
'cs' => { source: 'CS', target: 'CS' },
|
17
|
+
'da' => { source: 'DA', target: 'DA' },
|
18
|
+
'de' => { source: 'DE', target: 'DE' },
|
19
|
+
'el' => { source: 'EL', target: 'EL' },
|
20
|
+
'en' => { source: 'EN', target: 'EN' },
|
21
|
+
'en-US' => { source: 'EN', target: 'EN-US' },
|
22
|
+
'en-GB' => { source: 'EN', target: 'EN-GB' },
|
23
|
+
'en-AU' => { source: 'EN', target: 'EN-GB' }, # DeepL doesn't have AU variant
|
24
|
+
'en-CA' => { source: 'EN', target: 'EN-US' }, # DeepL doesn't have CA variant
|
25
|
+
'es' => { source: 'ES', target: 'ES' },
|
26
|
+
'es-ES' => { source: 'ES', target: 'ES' },
|
27
|
+
'es-MX' => { source: 'ES', target: 'ES' }, # DeepL doesn't distinguish ES variants for target
|
28
|
+
'et' => { source: 'ET', target: 'ET' },
|
29
|
+
'fi' => { source: 'FI', target: 'FI' },
|
30
|
+
'fr' => { source: 'FR', target: 'FR' },
|
31
|
+
'fr-CA' => { source: 'FR', target: 'FR' },
|
32
|
+
'hu' => { source: 'HU', target: 'HU' },
|
33
|
+
'id' => { source: 'ID', target: 'ID' },
|
34
|
+
'it' => { source: 'IT', target: 'IT' },
|
35
|
+
'ja' => { source: 'JA', target: 'JA' },
|
36
|
+
'ko' => { source: 'KO', target: 'KO' },
|
37
|
+
'lt' => { source: 'LT', target: 'LT' },
|
38
|
+
'lv' => { source: 'LV', target: 'LV' },
|
39
|
+
'nb' => { source: 'NB', target: 'NB' },
|
40
|
+
'nl' => { source: 'NL', target: 'NL' },
|
41
|
+
'pl' => { source: 'PL', target: 'PL' },
|
42
|
+
'pt' => { source: 'PT', target: 'PT-PT' },
|
43
|
+
'pt-BR' => { source: 'PT', target: 'PT-BR' },
|
44
|
+
'pt-PT' => { source: 'PT', target: 'PT-PT' },
|
45
|
+
'ro' => { source: 'RO', target: 'RO' },
|
46
|
+
'ru' => { source: 'RU', target: 'RU' },
|
47
|
+
'sk' => { source: 'SK', target: 'SK' },
|
48
|
+
'sl' => { source: 'SL', target: 'SL' },
|
49
|
+
'sv' => { source: 'SV', target: 'SV' },
|
50
|
+
'tr' => { source: 'TR', target: 'TR' },
|
51
|
+
'uk' => { source: 'UK', target: 'UK' },
|
52
|
+
'zh' => { source: 'ZH', target: 'ZH' },
|
53
|
+
'zh-Hans' => { source: 'ZH', target: 'ZH' },
|
54
|
+
'zh-Hant' => { source: 'ZH', target: 'ZH-HANT' },
|
55
|
+
'zh-HK' => { source: 'ZH', target: 'ZH-HANT' }
|
56
|
+
}.freeze
|
57
|
+
|
58
|
+
# Languages that support formality in DeepL
|
59
|
+
FORMALITY_SUPPORTED = %w[DE FR IT ES NL PL PT-BR PT-PT JA RU].freeze
|
60
|
+
|
61
|
+
def self.supported?(ios_language_code)
|
62
|
+
DEEPL_MAPPINGS.key?(ios_language_code)
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.get_source_language(ios_language_code)
|
66
|
+
mapping = DEEPL_MAPPINGS[ios_language_code]
|
67
|
+
return nil unless mapping
|
68
|
+
|
69
|
+
mapping[:source]
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.get_target_language(ios_language_code)
|
73
|
+
mapping = DEEPL_MAPPINGS[ios_language_code]
|
74
|
+
return nil unless mapping
|
75
|
+
|
76
|
+
mapping[:target]
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.supports_formality?(ios_language_code)
|
80
|
+
target_lang = get_target_language(ios_language_code)
|
81
|
+
return false unless target_lang
|
82
|
+
|
83
|
+
FORMALITY_SUPPORTED.include?(target_lang)
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.unsupported_languages(ios_language_codes)
|
87
|
+
ios_language_codes.reject { |code| supported?(code) }
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.supported_languages_from_list(ios_language_codes)
|
91
|
+
ios_language_codes.select { |code| supported?(code) }
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.all_supported_languages
|
95
|
+
DEEPL_MAPPINGS.keys
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|