fastlane-plugin-translate 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4b55852f8515ef6942b0bf6819379eb2c3cb81e20ef872a924470a2e9b6d886
4
- data.tar.gz: 7d1d3b603837c68bd8fd548dca30b171ff074e42218956b910f9446109a3fea2
3
+ metadata.gz: cb50e549d9a01fa0537ad44d52a80ddb8fb372417336eb31e2455b684f84ecba
4
+ data.tar.gz: c9a6afa40d2c1d40fc07cc1bf4a143f71db9603519c4a9c20cbdd356adf56ce9
5
5
  SHA512:
6
- metadata.gz: 83b931afb04769b0d6bd57fe7b686a772e707d350239dbcef6d191633078d2cd205f5ebf2f0229a88aab838ef824dd078f3326b7489ed633bd6462af633d3e91
7
- data.tar.gz: 1be7fd87de5af70a4ea87a8c05fc8900606e3fa07327c2f0694997a0f614163be7fdcec98c170ad7e4d2bee798d53b4d396838ec57fd398c603fcf7fec9f67a1
6
+ metadata.gz: ce8b51d5e1c16e0249d56b90c2b1500dcbcb4d82560e6d64da3c1a88753e0f13a96d21110c9f743613ec168a029f0f2dc9dd7ad48dc988d258e3d4d7ef8942e2
7
+ data.tar.gz: 682624a1f320c81a3304d3a6dab231909e3c6dce4a0e7c7b7c624db178afc312a57d7dd760df983a8f126352149b5610db5c077a07f9a92bf0250be036d44936
data/README.md CHANGED
@@ -5,24 +5,32 @@
5
5
  </div>
6
6
 
7
7
  [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-translate)
8
- [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow.svg?style=flat)](https://buymeacoffee.com/xocrgwybbs)
8
+ [![Gem Version](https://badge.fury.io/rb/fastlane-plugin-translate.svg)](https://badge.fury.io/rb/fastlane-plugin-translate)
9
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/D1D6P4LAR)
9
10
 
10
11
  ## About translate
11
12
 
12
- Automatically translate iOS `Localizable.xcstrings` files using DeepL API. This plugin helps you efficiently manage app localization by translating untranslated strings while preserving your existing translations.
13
+ Automatically translate iOS `Localizable.xcstrings` files and App Store metadata using DeepL API. This plugin helps you efficiently manage app localization by translating untranslated strings while preserving your existing translations, and seamlessly translate App Store metadata files like release notes and descriptions to all your supported languages.
13
14
 
14
15
  ## Features
15
16
 
16
- - 🔍 **Auto-discovery**: Automatically finds your `Localizable.xcstrings` file
17
- - 📊 **Translation analysis**: Shows translation progress for each language
18
- - 🎯 **Smart targeting**: Only translates untranslated strings (state: "new" with empty values)
19
- - 💾 **Progress tracking**: Saves progress between runs to avoid re-translating
20
- - 🎭 **Formality support**: Automatically detects and offers formality options for supported languages
21
- - 📝 **Context extraction**: Uses comments from xcstrings as translation context
22
- - 🔄 **Batch processing**: Efficiently handles large numbers of strings
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
+ ### App Strings Translation
18
+
19
+ - **Language selection with progress**: Shows translation completeness for each language
20
+ - **Smart targeting**: Only translates missing strings, preserves existing translations
21
+ - **Formality options**: Formal/informal translation styles for supported languages
22
+ - **Context-aware translation**: Uses xcstrings comments to improve translation quality
23
+ - **Progress tracking**: Resume interrupted translations without starting over
24
+ - **Automatic backups**: Safe translation with rollback capability
25
+ - **Error recovery**: Handle API failures gracefully with retry options
26
+
27
+ ### App Store Metadata Translation
28
+
29
+ - **Auto-detection**: Automatically detects target languages from existing metadata directories
30
+ - **Smart mapping**: Handles App Store locale directory names to DeepL language codes
31
+ - **Multiple files**: Translate release notes, descriptions, keywords, or any metadata file
32
+ - **Batch translation**: Translate to all supported languages in one command
33
+ - **Error resilience**: Continues translating other languages if one fails
26
34
 
27
35
  ## Getting Started
28
36
 
@@ -59,9 +67,29 @@ bundle install
59
67
 
60
68
  ### Basic Usage
61
69
 
70
+ After installation, create a lane in your Fastfile and run it:
71
+
72
+ ```bash
73
+ fastlane ios translate
74
+ ```
75
+
76
+ **Simple dedicated lane:**
77
+
78
+ ```ruby
79
+ lane :translate do
80
+ translate_with_deepl
81
+ end
82
+ ```
83
+
84
+ **Within complex workflows:**
85
+
62
86
  ```ruby
63
- # Automatically detect xcstrings file and show language selection
64
- translate_with_deepl
87
+ lane :prepare_release do
88
+ build_app
89
+ translate_with_deepl(target_language: "de")
90
+ upload_to_testflight
91
+ slack(message: "New build with German translations available!")
92
+ end
65
93
  ```
66
94
 
67
95
  ### Specify Target Language
@@ -90,6 +118,173 @@ translate_with_deepl(
90
118
  )
91
119
  ```
92
120
 
121
+ ## App Store Metadata Translation
122
+
123
+ In addition to translating app strings, the plugin can translate App Store metadata files like release notes, descriptions, and keywords using the `translate_metadata_with_deepl` action.
124
+
125
+ ### Features
126
+
127
+ - **Auto-detection**: Automatically detects target languages from existing metadata directories
128
+ - **Smart mapping**: Handles App Store locale directory names (e.g., `de-DE`, `fr-FR`) to DeepL language codes
129
+ - **Multiple files**: Translate `release_notes.txt`, `description.txt`, `keywords.txt`, or any metadata file
130
+ - **Backup creation**: Creates backups before translation for safety
131
+ - **Progress tracking**: Shows translation progress for each language
132
+ - **Error resilience**: Continues translating other languages if one fails
133
+
134
+ ### Basic Metadata Translation
135
+
136
+ ```ruby
137
+ # Translate release notes to all detected languages
138
+ translate_metadata_with_deepl(
139
+ file_name: "release_notes.txt"
140
+ )
141
+ ```
142
+
143
+ ### Fastfile Examples
144
+
145
+ ```ruby
146
+ # Translate release notes to all supported languages
147
+ lane :release_notes_translate do
148
+ translate_metadata_with_deepl(
149
+ metadata_path: "./fastlane/metadata",
150
+ source_locale: "en-US",
151
+ file_name: "release_notes.txt",
152
+ formality: "prefer_less"
153
+ )
154
+ end
155
+
156
+ # Translate app description
157
+ lane :description_translate do
158
+ translate_metadata_with_deepl(
159
+ file_name: "description.txt",
160
+ formality: "prefer_more"
161
+ )
162
+ end
163
+
164
+ # Translate any metadata file
165
+ lane :translate_metadata do |options|
166
+ file_name = options[:file] || UI.input("Enter metadata file name: ")
167
+
168
+ translate_metadata_with_deepl(
169
+ file_name: file_name,
170
+ formality: "prefer_less"
171
+ )
172
+ end
173
+ ```
174
+
175
+ ### Advanced Configuration
176
+
177
+ ```ruby
178
+ # Translate only to specific languages
179
+ translate_metadata_with_deepl(
180
+ file_name: "release_notes.txt",
181
+ target_languages: ["de", "fr", "es", "ja"],
182
+ formality: "more"
183
+ )
184
+
185
+ # Use custom metadata path and source locale
186
+ translate_metadata_with_deepl(
187
+ metadata_path: "./custom/metadata",
188
+ source_locale: "en-GB",
189
+ file_name: "keywords.txt",
190
+ formality: "prefer_less"
191
+ )
192
+ ```
193
+
194
+ ### Metadata Translation Parameters
195
+
196
+ | Parameter | Description | Default | Required |
197
+ |-----------|-------------|---------|----------|
198
+ | `metadata_path` | Path to fastlane metadata directory | `./fastlane/metadata` | No |
199
+ | `source_locale` | Source language locale (e.g., en-US) | `en-US` | No |
200
+ | `file_name` | Metadata file to translate | `release_notes.txt` | No |
201
+ | `target_languages` | Specific languages to translate to | Auto-detected | No |
202
+ | `formality` | Translation formality setting | Language default | No |
203
+ | `api_token` | DeepL API authentication key | `ENV['DEEPL_AUTH_KEY']` | Yes |
204
+ | `free_api` | Use DeepL Free API endpoint | `false` | No |
205
+
206
+ ### Example Output
207
+
208
+ ```
209
+ 🔍 Auto-detecting target languages from metadata directories...
210
+ 📁 Found metadata directories for: de, fr, es, ja, ko, zh-Hans
211
+ ✅ DeepL API key validated
212
+ 💾 Backup created: ./fastlane/metadata/en-US/release_notes.txt.backup_20241201_163535
213
+
214
+ 📋 Translating release_notes.txt from en-US to 6 languages:
215
+ • German (de)
216
+ • French (fr)
217
+ • Spanish (es)
218
+ • Japanese (ja)
219
+ • Korean (ko)
220
+ • Chinese (Simplified) (zh-Hans)
221
+
222
+ 🔄 Translating to German (de)...
223
+ ✅ de: Translation completed
224
+ 🔄 Translating to French (fr)...
225
+ ✅ fr: Translation completed
226
+ 🔄 Translating to Spanish (es)...
227
+ ✅ es: Translation completed
228
+
229
+ 🎉 Metadata translation completed!
230
+ 📊 Successfully translated release_notes.txt for 6 languages
231
+ 📄 Backup saved: ./fastlane/metadata/en-US/release_notes.txt.backup_20241201_163535
232
+ ```
233
+
234
+ ### Directory Structure
235
+
236
+ The action expects and maintains the standard fastlane metadata structure:
237
+
238
+ ```
239
+ fastlane/
240
+ └── metadata/
241
+ ├── en-US/ # Source locale
242
+ │ ├── release_notes.txt
243
+ │ ├── description.txt
244
+ │ └── keywords.txt
245
+ ├── de-DE/ # German
246
+ │ ├── release_notes.txt
247
+ │ └── description.txt
248
+ ├── fr-FR/ # French
249
+ │ └── release_notes.txt
250
+ └── ja/ # Japanese
251
+ └── release_notes.txt
252
+ ```
253
+
254
+ ### Language Mapping
255
+
256
+ The plugin automatically handles App Store locale directory naming:
257
+
258
+ | App Store Directory | DeepL Language Code | Language |
259
+ |---------------------|---------------------|----------|
260
+ | `de-DE` | `de` | German |
261
+ | `fr-FR` | `fr` | French |
262
+ | `es-ES` | `es` | Spanish |
263
+ | `nl-NL` | `nl` | Dutch |
264
+ | `no` | `nb` | Norwegian Bokmål |
265
+ | `pt-BR` | `pt-BR` | Portuguese (Brazil) |
266
+ | `pt-PT` | `pt-PT` | Portuguese (Portugal) |
267
+ | `zh-Hans` | `zh` | Chinese (Simplified) |
268
+
269
+ ### Shared Values
270
+
271
+ The metadata translation action sets these shared values:
272
+
273
+ - `TRANSLATE_METADATA_WITH_DEEPL_TRANSLATED_COUNT` - Number of languages successfully translated
274
+ - `TRANSLATE_METADATA_WITH_DEEPL_TARGET_LANGUAGES` - Array of target language codes that were translated
275
+ - `TRANSLATE_METADATA_WITH_DEEPL_BACKUP_FILE` - Path to the backup file created
276
+
277
+ ```ruby
278
+ lane :translate_and_upload do
279
+ count = translate_metadata_with_deepl(file_name: "release_notes.txt")
280
+
281
+ if count > 0
282
+ upload_to_app_store(skip_binary_upload: true)
283
+ slack(message: "✅ Translated release notes for #{count} languages and uploaded!")
284
+ end
285
+ end
286
+ ```
287
+
93
288
  ## Supported Languages
94
289
 
95
290
  The plugin supports all languages available in both Apple's App Store Connect and DeepL API:
@@ -0,0 +1,315 @@
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_METADATA_WITH_DEEPL_TRANSLATED_COUNT = :TRANSLATE_METADATA_WITH_DEEPL_TRANSLATED_COUNT
11
+ TRANSLATE_METADATA_WITH_DEEPL_TARGET_LANGUAGES = :TRANSLATE_METADATA_WITH_DEEPL_TARGET_LANGUAGES
12
+ TRANSLATE_METADATA_WITH_DEEPL_BACKUP_FILE = :TRANSLATE_METADATA_WITH_DEEPL_BACKUP_FILE
13
+ end
14
+
15
+ class TranslateMetadataWithDeeplAction < Action
16
+ def self.run(params)
17
+ # Setup and validation
18
+ setup_deepl_client(params)
19
+
20
+ metadata_path = params[:metadata_path]
21
+ source_locale = params[:source_locale]
22
+ file_name = params[:file_name]
23
+
24
+ # Validate source file exists
25
+ source_file_path = File.join(metadata_path, source_locale, file_name)
26
+ UI.user_error!("❌ Source file not found: #{source_file_path}") unless File.exist?(source_file_path)
27
+
28
+ # Read source content
29
+ source_content = File.read(source_file_path).strip
30
+ UI.user_error!("❌ Source file is empty: #{source_file_path}") if source_content.empty?
31
+
32
+ # Create backup
33
+ backup_file = create_backup(source_file_path)
34
+
35
+ # Detect available target languages
36
+ target_languages = detect_target_languages(metadata_path, source_locale, params[:target_languages])
37
+
38
+ # Filter by DeepL support
39
+ supported_languages = Helper::DeeplLanguageMapperHelper.supported_languages_from_list(target_languages)
40
+ unsupported_languages = Helper::DeeplLanguageMapperHelper.unsupported_languages(target_languages)
41
+
42
+ UI.important("⚠️ Languages not supported by DeepL: #{unsupported_languages.map { |l| "#{Helper::LanguageRegistryHelper.language_name(l)} (#{l})" }.join(', ')}") if unsupported_languages.any?
43
+
44
+ UI.user_error!('❌ No DeepL-supported languages found') if supported_languages.empty?
45
+
46
+ UI.message("📋 Translating #{file_name} from #{source_locale} to #{supported_languages.size} languages:")
47
+ supported_languages.each { |lang| UI.message(" • #{Helper::LanguageRegistryHelper.language_name(lang)} (#{lang})") }
48
+
49
+ # Translate to each target language
50
+ total_translated = 0
51
+ successful_languages = []
52
+
53
+ supported_languages.each do |target_language|
54
+ UI.message("🔄 Translating to #{Helper::LanguageRegistryHelper.language_name(target_language)} (#{target_language})...")
55
+
56
+ begin
57
+ # Determine formality for this language
58
+ formality = determine_formality(target_language, params[:formality])
59
+
60
+ # Translate content
61
+ translated_content = translate_content(source_content, source_locale, target_language, formality)
62
+
63
+ # Write to target file
64
+ target_file_path = File.join(metadata_path, map_to_metadata_directory(target_language), file_name)
65
+ ensure_directory_exists(File.dirname(target_file_path))
66
+ File.write(target_file_path, translated_content)
67
+
68
+ UI.success("✅ #{target_language}: Translation completed")
69
+ total_translated += 1
70
+ successful_languages << target_language
71
+ rescue StandardError => e
72
+ UI.error("❌ #{target_language}: Translation failed - #{e.message}")
73
+ end
74
+ end
75
+
76
+ # Set shared values
77
+ Actions.lane_context[SharedValues::TRANSLATE_METADATA_WITH_DEEPL_TRANSLATED_COUNT] = total_translated
78
+ Actions.lane_context[SharedValues::TRANSLATE_METADATA_WITH_DEEPL_TARGET_LANGUAGES] = successful_languages
79
+ Actions.lane_context[SharedValues::TRANSLATE_METADATA_WITH_DEEPL_BACKUP_FILE] = backup_file
80
+
81
+ UI.success('🎉 Metadata translation completed!')
82
+ UI.message("📊 Successfully translated #{file_name} for #{total_translated} languages")
83
+ UI.message("📄 Backup saved: #{backup_file}")
84
+ UI.message('🗑️ You can delete the backup after verifying results')
85
+
86
+ total_translated
87
+ end
88
+
89
+ def self.setup_deepl_client(params)
90
+ DeepL.configure do |config|
91
+ config.auth_key = params[:api_token]
92
+ config.host = params[:free_api] ? 'https://api-free.deepl.com' : 'https://api.deepl.com'
93
+ end
94
+
95
+ # Test API key
96
+ begin
97
+ DeepL.usage
98
+ UI.success('✅ DeepL API key validated')
99
+ rescue DeepL::Exceptions::AuthorizationFailed
100
+ UI.user_error!('❌ Invalid DeepL API key. Get one at: https://www.deepl.com/pro#developer')
101
+ rescue StandardError => e
102
+ UI.user_error!("❌ DeepL API connection failed: #{e.message}")
103
+ end
104
+ end
105
+
106
+ def self.create_backup(source_file_path)
107
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
108
+ backup_path = "#{source_file_path}.backup_#{timestamp}"
109
+ FileUtils.cp(source_file_path, backup_path)
110
+ UI.message("💾 Backup created: #{backup_path}")
111
+ backup_path
112
+ end
113
+
114
+ def self.detect_target_languages(metadata_path, source_locale, specified_languages)
115
+ if specified_languages && !specified_languages.empty?
116
+ UI.message("🎯 Using specified target languages: #{specified_languages.join(', ')}")
117
+ return specified_languages
118
+ end
119
+
120
+ # Auto-detect from existing metadata directories
121
+ UI.message('🔍 Auto-detecting target languages from metadata directories...')
122
+
123
+ detected_languages = []
124
+ Dir.glob(File.join(metadata_path, '*')).each do |dir|
125
+ next unless File.directory?(dir)
126
+
127
+ locale = File.basename(dir)
128
+ next if locale == source_locale # Skip source locale
129
+ next if locale == 'default' # Skip special directories
130
+ next if locale == 'review_information'
131
+
132
+ # Map metadata directory names to DeepL-compatible language codes
133
+ deepl_language = map_metadata_directory_to_language(locale)
134
+ detected_languages << deepl_language
135
+ end
136
+
137
+ UI.message("📁 Found metadata directories for: #{detected_languages.join(', ')}")
138
+ detected_languages
139
+ end
140
+
141
+ def self.map_metadata_directory_to_language(metadata_dir)
142
+ # Map App Store metadata directory names to DeepL-compatible language codes
143
+ case metadata_dir
144
+ when 'de-DE'
145
+ 'de' # German metadata directory -> de for DeepL
146
+ when 'es-ES'
147
+ 'es' # Spanish (Spain) metadata directory -> es for DeepL
148
+ when 'fr-FR'
149
+ 'fr' # French metadata directory -> fr for DeepL
150
+ when 'nl-NL'
151
+ 'nl' # Dutch metadata directory -> nl for DeepL
152
+ when 'no'
153
+ 'nb' # Norwegian metadata directory -> nb (Bokmål) for DeepL
154
+ else
155
+ metadata_dir # Direct mapping for most languages
156
+ end
157
+ end
158
+
159
+ def self.determine_formality(target_language, formality_param)
160
+ return formality_param if formality_param
161
+
162
+ if Helper::DeeplLanguageMapperHelper.supports_formality?(target_language)
163
+ UI.message("🎭 #{Helper::LanguageRegistryHelper.language_name(target_language)} supports formality options")
164
+ return 'prefer_more' # Default to more formal for App Store metadata
165
+ end
166
+
167
+ nil
168
+ end
169
+
170
+ def self.translate_content(source_content, source_language, target_language, formality)
171
+ source_lang = Helper::DeeplLanguageMapperHelper.get_source_language(source_language)
172
+ target_lang = Helper::DeeplLanguageMapperHelper.get_target_language(target_language)
173
+
174
+ translation_options = {}
175
+ translation_options[:formality] = formality if formality
176
+
177
+ # DeepL.translate expects: texts, source_lang, target_lang, options
178
+ result = DeepL.translate([source_content], source_lang, target_lang, translation_options)
179
+
180
+ # Handle both single and array responses
181
+ result.is_a?(Array) ? result.first.text : result.text
182
+ rescue StandardError => e
183
+ raise "DeepL translation failed: #{e.message}"
184
+ end
185
+
186
+ def self.map_to_metadata_directory(language_code)
187
+ # Map DeepL language codes back to App Store metadata directory names
188
+ case language_code
189
+ when 'nb'
190
+ 'no' # Norwegian Bokmål -> no
191
+ when 'de'
192
+ 'de-DE' # German -> de-DE
193
+ when 'es'
194
+ 'es-ES' # Spanish -> es-ES
195
+ when 'fr'
196
+ 'fr-FR' # French -> fr-FR
197
+ when 'nl'
198
+ 'nl-NL' # Dutch -> nl-NL
199
+ else
200
+ language_code # Direct mapping for most languages
201
+ end
202
+ end
203
+
204
+ def self.ensure_directory_exists(directory_path)
205
+ FileUtils.mkdir_p(directory_path) unless File.directory?(directory_path)
206
+ end
207
+
208
+ def self.description
209
+ 'Translate App Store metadata files using DeepL API'
210
+ end
211
+
212
+ def self.details
213
+ 'This action translates App Store metadata files (like release_notes.txt, description.txt) ' \
214
+ 'from a source language to all supported target languages using DeepL API. ' \
215
+ 'It automatically detects target languages from existing metadata directories ' \
216
+ 'and only translates to languages supported by both App Store and DeepL.'
217
+ end
218
+
219
+ def self.available_options
220
+ [
221
+ FastlaneCore::ConfigItem.new(
222
+ key: :api_token,
223
+ env_name: 'DEEPL_AUTH_KEY',
224
+ description: 'DeepL API authentication key',
225
+ sensitive: true,
226
+ default_value: ENV.fetch('DEEPL_AUTH_KEY', nil)
227
+ ),
228
+ FastlaneCore::ConfigItem.new(
229
+ key: :metadata_path,
230
+ description: 'Path to fastlane metadata directory',
231
+ default_value: './fastlane/metadata',
232
+ verify_block: proc do |value|
233
+ UI.user_error!("Metadata directory not found: #{value}") unless File.directory?(value)
234
+ end
235
+ ),
236
+ FastlaneCore::ConfigItem.new(
237
+ key: :source_locale,
238
+ description: 'Source language locale (e.g., en-US)',
239
+ default_value: 'en-US'
240
+ ),
241
+ FastlaneCore::ConfigItem.new(
242
+ key: :file_name,
243
+ description: 'Metadata file to translate (e.g., release_notes.txt, description.txt)',
244
+ default_value: 'release_notes.txt'
245
+ ),
246
+ FastlaneCore::ConfigItem.new(
247
+ key: :target_languages,
248
+ description: 'Specific target languages to translate to (optional, auto-detects if not specified)',
249
+ type: Array,
250
+ optional: true
251
+ ),
252
+ FastlaneCore::ConfigItem.new(
253
+ key: :formality,
254
+ description: 'Formality setting for translation (default, more, less, prefer_more, prefer_less)',
255
+ optional: true,
256
+ verify_block: proc do |value|
257
+ valid_options = %w[default more less prefer_more prefer_less]
258
+ UI.user_error!("Invalid formality option. Must be one of: #{valid_options.join(', ')}") unless valid_options.include?(value)
259
+ end
260
+ ),
261
+ FastlaneCore::ConfigItem.new(
262
+ key: :free_api,
263
+ description: 'Use DeepL Free API endpoint',
264
+ type: Boolean,
265
+ default_value: false
266
+ )
267
+ ]
268
+ end
269
+
270
+ def self.output
271
+ [
272
+ ['TRANSLATE_METADATA_WITH_DEEPL_TRANSLATED_COUNT', 'Number of languages successfully translated'],
273
+ ['TRANSLATE_METADATA_WITH_DEEPL_TARGET_LANGUAGES', 'Array of target language codes that were translated'],
274
+ ['TRANSLATE_METADATA_WITH_DEEPL_BACKUP_FILE', 'Path to the backup file created before translation']
275
+ ]
276
+ end
277
+
278
+ def self.return_value
279
+ 'Number of languages that were successfully translated'
280
+ end
281
+
282
+ def self.authors
283
+ ['tijs']
284
+ end
285
+
286
+ def self.is_supported?(platform)
287
+ %i[ios android].include?(platform)
288
+ end
289
+
290
+ def self.example_code
291
+ [
292
+ '# Translate release notes to all detected languages',
293
+ 'translate_metadata_with_deepl',
294
+ '',
295
+ '# Translate description file with specific settings',
296
+ 'translate_metadata_with_deepl(',
297
+ ' file_name: "description.txt",',
298
+ ' source_locale: "en-US",',
299
+ ' formality: "prefer_more"',
300
+ ')',
301
+ '',
302
+ '# Translate only to specific languages',
303
+ 'translate_metadata_with_deepl(',
304
+ ' file_name: "release_notes.txt",',
305
+ ' target_languages: ["de", "fr-FR", "es-ES", "ja"]',
306
+ ')'
307
+ ]
308
+ end
309
+
310
+ def self.category
311
+ :misc
312
+ end
313
+ end
314
+ end
315
+ end
@@ -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
- # Find the corresponding language code
151
- selected_option = language_options.find { |opt| opt[:display] == selected_display }
152
- selected_option[:code]
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
- total += 1
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
- if localization && localization['state'] == 'translated' &&
177
- localization['value'] && !localization['value'].empty?
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
- 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
- )
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
- 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'
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
- choice = UI.select('Continue from where you left off?', ['Yes, continue', 'No, start fresh'])
228
- progress.cleanup if choice == 'No, start fresh'
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
- next unless localization
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
- next if source_text.nil? || source_text.empty?
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
- # 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
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
- progress.save_translated_strings(translated_batch)
350
- total_translated += translated_batch.size
440
+ total_translated += result[:translated_count]
351
441
 
352
- success_msg = "✅ Batch #{index + 1} completed (#{translated_batch.size} strings translated"
353
- success_msg += ", #{skipped_empty} empty translations skipped" if skipped_empty.positive?
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
- if action == :retry && retry_count < max_retries
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
- elsif action == :skip
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
- if action == :retry && retry_count < max_retries
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
- elsif action == :skip
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
- localization = xcstrings_data.dig('strings', string_key, 'localizations', target_language, 'stringUnit')
421
- next unless localization
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
- UI.important("⚠️ Keeping empty translation as 'new' state: \"#{string_key}\"")
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
- type: Boolean,
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fastlane
4
4
  module Translate
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tijs Teulings
@@ -164,8 +164,8 @@ dependencies:
164
164
  - !ruby/object:Gem::Version
165
165
  version: '0'
166
166
  description: A fastlane plugin to automatically translate iOS Localizable.xcstrings
167
- files using DeepL API. Supports progress tracking, formality options, error recovery,
168
- and more.
167
+ files and App Store metadata files using DeepL API. Supports progress tracking,
168
+ formality options, error recovery, auto-detection of target languages, and more.
169
169
  email: hello@tijs.org
170
170
  executables: []
171
171
  extensions: []
@@ -174,9 +174,12 @@ files:
174
174
  - LICENSE
175
175
  - README.md
176
176
  - lib/fastlane/plugin/translate.rb
177
+ - lib/fastlane/plugin/translate/actions/translate_metadata_with_deepl.rb
177
178
  - lib/fastlane/plugin/translate/actions/translate_with_deepl.rb
179
+ - lib/fastlane/plugin/translate/helper/batch_translation_processor.rb
178
180
  - lib/fastlane/plugin/translate/helper/deepl_language_mapper_helper.rb
179
181
  - lib/fastlane/plugin/translate/helper/language_registry_helper.rb
182
+ - lib/fastlane/plugin/translate/helper/translation_error_handler.rb
180
183
  - lib/fastlane/plugin/translate/helper/translation_progress_helper.rb
181
184
  - lib/fastlane/plugin/translate/version.rb
182
185
  homepage: https://github.com/tijs/fastlane-plugin-translate
@@ -200,5 +203,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
200
203
  requirements: []
201
204
  rubygems_version: 3.6.9
202
205
  specification_version: 4
203
- summary: Automatically translate iOS Localizable.xcstrings files using DeepL API
206
+ summary: Automatically translate iOS Localizable.xcstrings files and App Store metadata
207
+ using DeepL API
204
208
  test_files: []