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