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.
@@ -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