Class: Fastlane::Actions::TranslateWithDeeplAction

Inherits:
Action
  • Object
show all
Defined in:
lib/fastlane/plugin/translate/actions/translate_with_deepl.rb

Documentation collapse

Class Method Summary collapse

Class Method Details

.authorsObject



603
604
605
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 603

def self.authors
  ['Your GitHub/Twitter Name']
end

.available_optionsObject



541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 541

def self.available_options
  [
    FastlaneCore::ConfigItem.new(
      key: :api_token,
      env_name: 'DEEPL_AUTH_KEY',
      description: 'DeepL API authentication key',
      sensitive: true,
      verify_block: proc do |value|
        UI.user_error!('DeepL API key required. Get one at: https://www.deepl.com/pro#developer') if value.to_s.empty?
      end
    ),
    FastlaneCore::ConfigItem.new(
      key: :xcstrings_path,
      description: 'Path to Localizable.xcstrings file (auto-detected if not provided)',
      optional: true
    ),
    FastlaneCore::ConfigItem.new(
      key: :target_language,
      description: 'Target language code (e.g., "de", "fr", "es") - will prompt if not provided',
      optional: true
    ),
    FastlaneCore::ConfigItem.new(
      key: :batch_size,
      description: 'Number of strings to translate per API call',
      type: Integer,
      default_value: 50,
      verify_block: proc do |value|
        UI.user_error!('Batch size must be between 1 and 50') unless (1..50).cover?(value)
      end
    ),
    FastlaneCore::ConfigItem.new(
      key: :free_api,
      description: 'Use DeepL Free API endpoint instead of Pro',
      is_string: false,
      default_value: false
    ),
    FastlaneCore::ConfigItem.new(
      key: :formality,
      description: 'Translation formality (auto-detected if language supports it): default, more, less, prefer_more, prefer_less',
      optional: true,
      verify_block: proc do |value|
        if value
          valid_options = %w[default more less prefer_more prefer_less]
          UI.user_error!("Invalid formality. Use: #{valid_options.join(', ')}") unless valid_options.include?(value)
        end
      end
    )
  ]
end

.calculate_translation_stats(xcstrings_data, languages) ⇒ Object



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 180

def self.calculate_translation_stats(xcstrings_data, languages)
  stats = {}

  languages.each do |lang_code|
    total = 0
    translated = 0
    skipped_dont_translate = 0

    xcstrings_data['strings'].each do |string_key, string_data|
      # Skip empty string keys as they're usually not real translatable content
      next if string_key.empty?

      # Skip strings marked as "Don't translate" from statistics
      if string_data['shouldTranslate'] == false
        skipped_dont_translate += 1
        next
      end

      # Check if this string has any localizations at all
      if !string_data['localizations'] || string_data['localizations'].empty?
        # String has no localizations - count as untranslated for this language
        total += 1
        next
      end

      # Check if this string has a localization for the target language
      localization = string_data.dig('localizations', lang_code, 'stringUnit')
      unless localization
        # String exists but has no localization for this specific language - count as untranslated
        total += 1
        next
      end

      total += 1

      # Count as translated ONLY if state is 'translated' AND has non-empty value
      # Strings with state 'new', 'needs_review', or empty values are untranslated
      if localization['state'] == 'translated' &&
         localization['value'] && !localization['value'].strip.empty?
        translated += 1
      end
    end

    stats[lang_code] = {
      total:,
      translated:,
      untranslated: total - translated,
      skipped_dont_translate:
    }
  end

  stats
end

.categoryObject



621
622
623
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 621

def self.category
  :misc
end

.create_backup(xcstrings_path) ⇒ Object



96
97
98
99
100
101
102
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 96

def self.create_backup(xcstrings_path)
  timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
  backup_path = "#{xcstrings_path}.backup_#{timestamp}"
  FileUtils.cp(xcstrings_path, backup_path)
  UI.message("💾 Backup created: #{backup_path}")
  backup_path
end

.descriptionObject



531
532
533
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 531

def self.description
  'Automatically translate untranslated strings in Localizable.xcstrings using DeepL API'
end

.detailsObject



535
536
537
538
539
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 535

def self.details
  'This action finds your Localizable.xcstrings file, analyzes translation status for each language, ' \
    'and uses DeepL API to translate missing strings. It supports progress tracking, formality options, ' \
    'and provides comprehensive error handling with user choices for recovery.'
end

.detect_and_ask_formality(target_language, formality_param) ⇒ Object



234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 234

def self.detect_and_ask_formality(target_language, formality_param)
  return formality_param if formality_param
  return nil unless Helper::DeeplLanguageMapperHelper.supports_formality?(target_language)

  lang_name = Helper::LanguageRegistryHelper.language_name(target_language)
  options = [
    { display: 'default (no formality preference)', value: nil },
    { display: 'more (formal)', value: 'more' },
    { display: 'less (informal)', value: 'less' },
    { display: 'prefer_more (formal if possible)', value: 'prefer_more' },
    { display: 'prefer_less (informal if possible)', value: 'prefer_less' }
  ]

  # Display numbered list
  UI.message("🎭 #{lang_name} supports formality options. Choose style:")
  options.each_with_index do |option, index|
    UI.message("  #{index + 1}. #{option[:display]}")
  end

  # Force interactive mode and ensure we wait for user input
  $stdout.flush
  $stderr.flush

  # Use a loop to ensure we get valid input
  loop do
    choice = UI.input("Choose formality style (1-#{options.count}): ").strip

    # Validate numeric input
    if choice.match?(/^\d+$/)
      choice_num = choice.to_i
      if choice_num >= 1 && choice_num <= options.count
        selected_option = options[choice_num - 1]
        UI.message("✅ Selected: #{selected_option[:display]}")
        return selected_option[:value]
      end
    end

    UI.error("❌ Invalid selection '#{choice}'. Please enter a number between 1 and #{options.count}.")
  rescue Interrupt
    UI.user_error!('👋 Translation cancelled by user')
  end
end

.example_codeObject

rubocop:enable Naming/PredicateName



612
613
614
615
616
617
618
619
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 612

def self.example_code
  [
    'translate_with_deepl',
    'translate_with_deepl(target_language: "de")',
    'translate_with_deepl(target_language: "fr", formality: "more")',
    'translate_with_deepl(xcstrings_path: "./MyApp/Localizable.xcstrings", free_api: true)'
  ]
end

.extract_available_languages(xcstrings_data) ⇒ Object



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 104

def self.extract_available_languages(xcstrings_data)
  languages = Set.new

  xcstrings_data['strings'].each do |_, string_data|
    next unless string_data['localizations']

    string_data['localizations'].each_key { |lang| languages.add(lang) }
  end

  # Remove source language from target options
  source_lang = xcstrings_data['sourceLanguage']
  languages.delete(source_lang)

  languages.to_a.sort
end

.extract_string_context(string_key, string_data) ⇒ Object



415
416
417
418
419
420
421
422
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 415

def self.extract_string_context(string_key, string_data)
  # Check for comment field in the string data
  comment = string_data['comment']
  return comment if comment && !comment.empty?

  # Fallback: use string key as minimal context if it's descriptive
  string_key.length > 50 ? nil : string_key
end

.extract_untranslated_strings(xcstrings_data, source_language, target_language, already_translated) ⇒ Object



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 353

def self.extract_untranslated_strings(xcstrings_data, source_language, target_language, already_translated)
  untranslated = {}

  xcstrings_data['strings'].each do |string_key, string_data|
    next if string_key.empty? # Skip empty keys

    # Skip strings marked as "Don't translate" in Xcode
    if string_data['shouldTranslate'] == false
      UI.message("⏭️ Skipping string marked as 'Don't translate': \"#{string_key}\"")
      next
    end

    # Skip if already translated in progress
    next if already_translated[string_key]

    # Check if this string has any localizations at all
    if !string_data['localizations'] || string_data['localizations'].empty?
      # String has no localizations - it's completely new and needs translation
      # Use the string key itself as the source text since there's no source localization
      untranslated[string_key] = {
        'source_text' => string_key,
        'context' => extract_string_context(string_key, string_data)
      }
      next
    end

    # Check if target language has a localization
    localization = string_data.dig('localizations', target_language, 'stringUnit')
    unless localization
      # String exists but has no localization for target language
      # Get source text from source language or use string key as fallback
      source_text = string_data.dig('localizations', source_language, 'stringUnit', 'value')
      source_text = string_key if source_text.nil? || source_text.strip.empty?

      untranslated[string_key] = {
        'source_text' => source_text,
        'context' => extract_string_context(string_key, string_data)
      }
      next
    end

    # Check if NOT fully translated (inverse of the translation stats logic)
    # A string is considered translated only if: state == 'translated' AND has non-empty value
    is_fully_translated = localization['state'] == 'translated' &&
                          localization['value'] && !localization['value'].strip.empty?
    next if is_fully_translated

    # Get source text from source language
    source_text = string_data.dig('localizations', source_language, 'stringUnit', 'value')
    # Use string key as fallback if no source text available
    source_text = string_key if source_text.nil? || source_text.strip.empty?

    context = extract_string_context(string_key, string_data)
    untranslated[string_key] = {
      'source_text' => source_text,
      'context' => context
    }
  end

  untranslated
end

.find_xcstrings_file(provided_path) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 75

def self.find_xcstrings_file(provided_path)
  if provided_path
    UI.user_error!("❌ Localizable.xcstrings file not found at: #{provided_path}") unless File.exist?(provided_path)
    return provided_path
  end

  # Search for xcstrings files
  xcstrings_files = Dir.glob('**/Localizable.xcstrings')

  UI.user_error!('❌ No Localizable.xcstrings files found. Please specify the path with xcstrings_path parameter.') if xcstrings_files.empty?

  if xcstrings_files.count == 1
    UI.message("📁 Found xcstrings file: #{xcstrings_files.first}")
    return xcstrings_files.first
  end

  # Multiple files found, let user choose
  UI.message('📁 Multiple xcstrings files found:')
  UI.select('Choose file:', xcstrings_files)
end

.is_supported?(platform) ⇒ Boolean

Returns:

  • (Boolean)


607
608
609
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 607

def self.is_supported?(platform)
  platform == :ios
end

.outputObject



591
592
593
594
595
596
597
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 591

def self.output
  [
    ['TRANSLATE_WITH_DEEPL_TRANSLATED_COUNT', 'Number of strings that were translated'],
    ['TRANSLATE_WITH_DEEPL_TARGET_LANGUAGE', 'The target language that was translated'],
    ['TRANSLATE_WITH_DEEPL_BACKUP_FILE', 'Path to the backup file created before translation']
  ]
end

.return_valueObject



599
600
601
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 599

def self.return_value
  'Number of strings that were translated'
end

.run(params) ⇒ Object



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 16

def self.run(params)
  # Setup and validation
  setup_deepl_client(params)
  xcstrings_path = find_xcstrings_file(params[:xcstrings_path])

  backup_file = create_backup(xcstrings_path)

  # Parse xcstrings file
  xcstrings_data = JSON.parse(File.read(xcstrings_path))
  source_language = xcstrings_data['sourceLanguage']
  available_languages = extract_available_languages(xcstrings_data)

  # Filter languages supported by DeepL
  supported_languages = Helper::DeeplLanguageMapperHelper.supported_languages_from_list(available_languages)
  unsupported_languages = Helper::DeeplLanguageMapperHelper.unsupported_languages(available_languages)

  UI.important("⚠️  Languages not supported by DeepL: #{unsupported_languages.map { |l| "#{Helper::LanguageRegistryHelper.language_name(l)} (#{l})" }.join(', ')}") if unsupported_languages.any?

  UI.user_error!('❌ No DeepL-supported languages found in xcstrings file') if supported_languages.empty?

  # Language selection
  target_language = select_target_language(params[:target_language], supported_languages, xcstrings_data)

  # Formality detection
  formality = detect_and_ask_formality(target_language, params[:formality])

  # Translate the selected language
  translated_count = translate_language(xcstrings_data, xcstrings_path, source_language, target_language, formality, params)

  # Set shared values for other actions
  Actions.lane_context[SharedValues::TRANSLATE_WITH_DEEPL_TRANSLATED_COUNT] = translated_count
  Actions.lane_context[SharedValues::TRANSLATE_WITH_DEEPL_TARGET_LANGUAGE] = target_language
  Actions.lane_context[SharedValues::TRANSLATE_WITH_DEEPL_BACKUP_FILE] = backup_file

  UI.success('🎉 Translation completed!')
  UI.message("📊 Translated #{translated_count} strings for #{Helper::LanguageRegistryHelper.language_name(target_language)} (#{target_language})")
  UI.message("📄 Backup saved: #{backup_file}")
  UI.message('🗑️  You can delete the backup after verifying results')

  translated_count
end

.select_target_language(param_language, available_languages, xcstrings_data) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 120

def self.select_target_language(param_language, available_languages, xcstrings_data)
  if param_language
    UI.user_error!("❌ Language '#{param_language}' not found in xcstrings file. Available: #{available_languages.join(', ')}") unless available_languages.include?(param_language)
    return param_language
  end

  # Calculate translation percentages for each language
  language_stats = calculate_translation_stats(xcstrings_data, available_languages)

  # Create display list with language names and translation status
  language_options = available_languages.map do |lang_code|
    lang_name = Helper::LanguageRegistryHelper.language_name(lang_code)
    stats = language_stats[lang_code]
    percentage = ((stats[:translated].to_f / stats[:total]) * 100).round(1)

    display_name = "#{lang_name} (#{lang_code}): #{percentage}% translated (#{stats[:untranslated]} remaining"
    display_name += ", #{stats[:skipped_dont_translate]} don't translate" if (stats[:skipped_dont_translate]).positive?
    display_name += ')'

    # Add formality indicator
    display_name += ' [supports formality]' if Helper::DeeplLanguageMapperHelper.supports_formality?(lang_code)

    { display: display_name, code: lang_code, stats: }
  end

  # Sort by most untranslated first (prioritize languages that need work)
  language_options.sort_by! { |opt| -opt[:stats][:untranslated] }

  # Show all languages with their translation status
  UI.message('📋 Available languages for translation:')

  # Display numbered list
  language_options.each_with_index do |option, index|
    UI.message("  #{index + 1}. #{option[:display]}")
  end

  # Force interactive mode and ensure we wait for user input
  $stdout.flush
  $stderr.flush

  # Use a loop to ensure we get valid input
  loop do
    choice = UI.input("Choose target language (1-#{language_options.count}): ").strip

    # Validate numeric input
    if choice.match?(/^\d+$/)
      choice_num = choice.to_i
      if choice_num >= 1 && choice_num <= language_options.count
        selected_option = language_options[choice_num - 1]
        UI.message("✅ Selected: #{selected_option[:display]}")
        return selected_option[:code]
      end
    end

    UI.error("❌ Invalid selection '#{choice}'. Please enter a number between 1 and #{language_options.count}.")
  rescue Interrupt
    UI.user_error!('👋 Translation cancelled by user')
  end
end

.setup_deepl_client(params) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 58

def self.setup_deepl_client(params)
  DeepL.configure do |config|
    config.auth_key = params[:api_token]
    config.host = params[:free_api] ? 'https://api-free.deepl.com' : 'https://api.deepl.com'
  end

  # Test API key
  begin
    DeepL.usage
    UI.success('✅ DeepL API key validated')
  rescue DeepL::Exceptions::AuthorizationFailed
    UI.user_error!('❌ Invalid DeepL API key. Get one at: https://www.deepl.com/pro#developer')
  rescue StandardError => e
    UI.user_error!("❌ DeepL API connection failed: #{e.message}")
  end
end

.translate_in_batches(untranslated_strings, source_lang, target_lang, formality, batch_size, progress) ⇒ Object



424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 424

def self.translate_in_batches(untranslated_strings, source_lang, target_lang, formality, batch_size, progress)
  batches = untranslated_strings.each_slice(batch_size).to_a
  total_translated = 0

  batches.each_with_index do |batch, index|
    UI.message("🔄 Translating batch #{index + 1}/#{batches.count} (#{batch.count} strings)...")

    retry_count = 0
    max_retries = 3

    begin
      # Process the batch using the helper
      result = Helper::BatchTranslationProcessor.process_batch(
        batch, source_lang, target_lang, formality, progress
      )

      total_translated += result[:translated_count]

      success_msg = "✅ Batch #{index + 1} completed (#{result[:translated_count]} strings translated"
      success_msg += ", #{result[:skipped_count]} empty translations skipped" if result[:skipped_count].positive?
      success_msg += ')'
      UI.success(success_msg)
    rescue DeepL::Exceptions::AuthorizationFailed
      UI.user_error!('❌ Invalid DeepL API key')
    rescue DeepL::Exceptions::QuotaExceeded
      UI.user_error!('❌ DeepL quota exceeded. Upgrade your plan or wait for reset.')
    rescue DeepL::Exceptions::LimitExceeded
      action = Helper::TranslationErrorHandler.handle_rate_limit_error(batch, index, batches.count)
      result = Helper::TranslationErrorHandler.handle_batch_result(action, retry_count, max_retries)

      case result
      when :retry
        retry_count += 1
        retry
      when :skip
        next
      end
    rescue StandardError => e
      action = Helper::TranslationErrorHandler.handle_translation_error(e, batch, index, batches.count)
      result = Helper::TranslationErrorHandler.handle_batch_result(action, retry_count, max_retries)

      case result
      when :retry
        retry_count += 1
        retry
      when :skip
        next
      end
    end
  end

  total_translated
end

.translate_language(xcstrings_data, xcstrings_path, source_language, target_language, formality, params) ⇒ Object



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 277

def self.translate_language(xcstrings_data, xcstrings_path, source_language, target_language, formality, params)
  # Validate DeepL support
  UI.user_error!("❌ Language '#{target_language}' is not supported by DeepL") unless Helper::DeeplLanguageMapperHelper.supported?(target_language)

  # Get DeepL language codes
  deepl_source = Helper::DeeplLanguageMapperHelper.get_source_language(source_language)
  deepl_target = Helper::DeeplLanguageMapperHelper.get_target_language(target_language)

  UI.message("🔄 Translating from #{deepl_source} to #{deepl_target}")

  # Progress setup
  progress = Helper::TranslationProgressHelper.create_progress_tracker(xcstrings_path, target_language)

  if progress.has_progress?
    summary = progress.progress_summary
    UI.message("📈 Found existing progress: #{summary[:translated_count]} strings translated")

    # Display numbered options
    UI.message('Continue from where you left off?')
    UI.message('  1. Yes, continue')
    UI.message('  2. No, start fresh')

    # Force interactive mode and ensure we wait for user input
    $stdout.flush
    $stderr.flush

    # Use a loop to ensure we get valid input
    loop do
      choice = UI.input('Choose option (1-2): ').strip

      case choice
      when '1'
        UI.message('✅ Continuing from existing progress')
        break
      when '2'
        UI.message('✅ Starting fresh')
        progress.cleanup
        break
      else
        UI.error("❌ Invalid selection '#{choice}'. Please enter 1 or 2.")
      end
    rescue Interrupt
      UI.user_error!('👋 Translation cancelled by user')
    end
  end

  # Extract untranslated strings
  untranslated_strings = extract_untranslated_strings(
    xcstrings_data, source_language, target_language, progress.get_translated_strings
  )

  if untranslated_strings.empty?
    UI.success("✅ All strings already translated for #{target_language}")
    progress.cleanup
    return 0
  end

  UI.message("📝 Found #{untranslated_strings.count} untranslated strings")

  # Batch translation
  translated_count = translate_in_batches(
    untranslated_strings, deepl_source, deepl_target,
    formality, params[:batch_size], progress
  )

  # Update xcstrings file
  update_xcstrings_file(xcstrings_path, xcstrings_data, target_language,
                        progress.get_translated_strings)

  # Validation and cleanup
  validate_json_file(xcstrings_path)
  progress.cleanup

  translated_count
end

.update_xcstrings_file(xcstrings_path, xcstrings_data, target_language, translated_strings) ⇒ Object



478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 478

def self.update_xcstrings_file(xcstrings_path, xcstrings_data, target_language, translated_strings)
  UI.message("📝 Updating xcstrings file with #{translated_strings.size} translations...")

  # Update the JSON structure
  actually_updated = 0
  empty_translations = 0
  translated_strings.each do |string_key, translated_text|
    # Ensure the string exists in the xcstrings structure
    xcstrings_data['strings'][string_key] ||= {}
    string_data = xcstrings_data['strings'][string_key]

    # Ensure localizations structure exists
    string_data['localizations'] ||= {}

    # Ensure target language localization exists
    string_data['localizations'][target_language] ||= {}

    # Ensure stringUnit exists
    string_data['localizations'][target_language]['stringUnit'] ||= {}

    localization = string_data['localizations'][target_language]['stringUnit']

    # Double-check: only mark as translated if we have actual content
    if translated_text && !translated_text.strip.empty?
      localization['value'] = translated_text
      localization['state'] = 'translated'
      actually_updated += 1
      UI.message("✅ Updated \"#{string_key}\" -> \"#{translated_text}\"")
    else
      # Keep as 'new' if translation is empty
      localization['value'] = translated_text || ''
      localization['state'] = 'new'
      empty_translations += 1
      UI.important("⚠️ Empty translation for \"#{string_key}\" (received: \"#{translated_text || 'nil'}\")")
    end
  end

  # Write updated JSON back to file
  File.write(xcstrings_path, JSON.pretty_generate(xcstrings_data))
  UI.success("💾 Updated xcstrings file (#{actually_updated} marked as translated, #{empty_translations} empty)")
end

.validate_json_file(xcstrings_path) ⇒ Object



520
521
522
523
524
525
# File 'lib/fastlane/plugin/translate/actions/translate_with_deepl.rb', line 520

def self.validate_json_file(xcstrings_path)
  JSON.parse(File.read(xcstrings_path))
  UI.success('✅ Updated xcstrings file is valid JSON')
rescue JSON::ParserError => e
  UI.user_error!("❌ Generated xcstrings file is invalid JSON: #{e.message}")
end