fastlane-plugin-translate_gpt_release_notes 0.2.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 +124 -0
- data/lib/fastlane/plugin/translate_gpt_release_notes/actions/translate_gpt_release_notes_action.rb +22 -0
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/glossary_loader.rb +450 -0
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/anthropic_provider.rb +6 -5
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/base_provider.rb +60 -12
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/deepl_provider.rb +11 -4
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/gemini_provider.rb +6 -5
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/openai_provider.rb +17 -8
- data/lib/fastlane/plugin/translate_gpt_release_notes/helper/translate_gpt_release_notes_helper.rb +14 -1
- data/lib/fastlane/plugin/translate_gpt_release_notes/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b385f63605a33af014b3a222ecb688104e4e8a3088b14082b5c007f622ad34af
|
|
4
|
+
data.tar.gz: 546441b716377cf1e97b87fdddea9edee109df5cf7669bb5ab2aca0a5acc8f67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a35df216fcdff9f348c7498c1d13a1673fee2d5ca2ccd44f658d2b492402daf39b6fdd94f6f2e2446e74d7e584d3eb7ffdf3e1d72503220dc6a661718f82726
|
|
7
|
+
data.tar.gz: 7737dd4325c1e65c2b474c34da0a3facff4e1b792936aaa211074b719ba8713fd77fc31a273540a484961efb7d75ad0346fe97d7a8650270b40463c3e994165b
|
data/README.md
CHANGED
|
@@ -171,6 +171,109 @@ translate_gpt_release_notes(
|
|
|
171
171
|
|
|
172
172
|
**Note**: DeepL automatically detects free vs paid API keys (free keys end with `:fx`) and uses the appropriate endpoint.
|
|
173
173
|
|
|
174
|
+
## Glossary Support (Experimental)
|
|
175
|
+
|
|
176
|
+
> **Note**: Glossary support is an experimental feature. If you encounter any issues, please [open an issue on GitHub](https://github.com/antonkarliner/fastlane-plugin-translate_gpt_release_notes/issues).
|
|
177
|
+
|
|
178
|
+
The glossary feature ensures consistent translation of app-specific terms (screen names, feature names, UI labels) across all locales. Instead of letting the AI provider guess how to translate "Brew Diary" or "Cupping Score", the glossary provides exact translations extracted from your existing localization files.
|
|
179
|
+
|
|
180
|
+
### How It Works
|
|
181
|
+
|
|
182
|
+
1. The plugin loads glossary terms from a JSON file and/or a directory of localization files
|
|
183
|
+
2. Before each translation, it fuzzy-matches terms from the glossary against the source text
|
|
184
|
+
3. Only relevant terms are included in the translation prompt (keeping it concise)
|
|
185
|
+
4. Each provider uses the glossary differently:
|
|
186
|
+
- **OpenAI**: Glossary terms are sent as a system message for strong instruction following
|
|
187
|
+
- **Anthropic / Gemini**: Glossary terms are included in the translation prompt
|
|
188
|
+
- **DeepL**: Glossary terms are passed via the `context` parameter
|
|
189
|
+
|
|
190
|
+
### Supported Localization Formats
|
|
191
|
+
|
|
192
|
+
| Format | Extension | Common Use |
|
|
193
|
+
|--------|-----------|------------|
|
|
194
|
+
| ARB (Application Resource Bundle) | `.arb` | Flutter |
|
|
195
|
+
| Apple Strings | `.strings` | iOS / macOS |
|
|
196
|
+
| Android XML | `.xml` | Android |
|
|
197
|
+
| JSON i18n | `.json` | Web / React / Vue |
|
|
198
|
+
| XLIFF | `.xliff`, `.xlf` | Cross-platform |
|
|
199
|
+
|
|
200
|
+
### Using Localization Directory (Recommended)
|
|
201
|
+
|
|
202
|
+
Point the plugin at your existing localization directory. It will auto-detect the format and extract terms:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# Flutter project — use the l10n directory directly
|
|
206
|
+
translate_gpt_release_notes(
|
|
207
|
+
master_locale: 'en-US',
|
|
208
|
+
platform: 'ios',
|
|
209
|
+
glossary_dir: '../lib/l10n'
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# iOS project — use .lproj directories
|
|
213
|
+
translate_gpt_release_notes(
|
|
214
|
+
master_locale: 'en-US',
|
|
215
|
+
platform: 'ios',
|
|
216
|
+
glossary_dir: '../MyApp/Resources'
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Android project — use res/values directories
|
|
220
|
+
translate_gpt_release_notes(
|
|
221
|
+
master_locale: 'en-US',
|
|
222
|
+
platform: 'android',
|
|
223
|
+
glossary_dir: '../app/src/main/res'
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Using a Curated JSON Glossary File
|
|
228
|
+
|
|
229
|
+
For maximum control, create a JSON glossary file with exact translations per locale:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"Home Screen": {
|
|
234
|
+
"de": "Startbildschirm",
|
|
235
|
+
"fr": "Ecran d'accueil",
|
|
236
|
+
"es": "Pantalla de inicio",
|
|
237
|
+
"ja": "ホーム画面"
|
|
238
|
+
},
|
|
239
|
+
"Brew Diary": {
|
|
240
|
+
"de": "Brautagebuch",
|
|
241
|
+
"fr": "Journal de brassage"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
translate_gpt_release_notes(
|
|
248
|
+
master_locale: 'en-US',
|
|
249
|
+
platform: 'ios',
|
|
250
|
+
glossary: 'path/to/glossary.json'
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Combining Both Sources
|
|
255
|
+
|
|
256
|
+
You can use both a curated glossary file and a localization directory. The curated file takes priority for overlapping terms:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
translate_gpt_release_notes(
|
|
260
|
+
master_locale: 'en-US',
|
|
261
|
+
platform: 'ios',
|
|
262
|
+
glossary: 'fastlane/glossary.json',
|
|
263
|
+
glossary_dir: '../lib/l10n'
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Fuzzy Matching
|
|
268
|
+
|
|
269
|
+
The plugin uses smart fuzzy matching to find relevant glossary terms in the source text:
|
|
270
|
+
|
|
271
|
+
- **Full substring match**: "Home Screen" matches if it appears exactly in the text
|
|
272
|
+
- **Multi-word match**: A term matches if 2 or more significant words (4+ characters, excluding common English stopwords) appear in the text
|
|
273
|
+
- **Filtering**: Terms shorter than 4 characters, longer than 80 characters, or consisting of common stopwords are excluded
|
|
274
|
+
|
|
275
|
+
This keeps the glossary concise and avoids flooding the AI provider with irrelevant terms.
|
|
276
|
+
|
|
174
277
|
## Options
|
|
175
278
|
|
|
176
279
|
### Core Options
|
|
@@ -181,6 +284,8 @@ translate_gpt_release_notes(
|
|
|
181
284
|
| `master_locale` | Master language/locale for the source texts | `MASTER_LOCALE` | `en-US` |
|
|
182
285
|
| `platform` | Platform (`ios` or `android`) | `PLATFORM` | `ios` |
|
|
183
286
|
| `context` | Context for translation to improve accuracy | `GPT_CONTEXT` | - |
|
|
287
|
+
| `glossary` | Path to a curated JSON glossary file | `GLOSSARY_PATH` | - |
|
|
288
|
+
| `glossary_dir` | Path to localization files directory for auto-extracting glossary | `GLOSSARY_DIR` | - |
|
|
184
289
|
|
|
185
290
|
### Provider-Specific API Keys
|
|
186
291
|
|
|
@@ -379,6 +484,25 @@ export OPENAI_API_KEY='your-key-here'
|
|
|
379
484
|
|
|
380
485
|
**Solution**: This is expected behavior. If speed is critical, use `service_tier: 'default'` or `service_tier: 'priority'`.
|
|
381
486
|
|
|
487
|
+
### Glossary Terms Not Being Applied
|
|
488
|
+
|
|
489
|
+
**Cause**: The fuzzy matching didn't find your terms in the source text.
|
|
490
|
+
|
|
491
|
+
**Solutions**:
|
|
492
|
+
1. Check that terms are at least 4 characters long
|
|
493
|
+
2. For multi-word terms, at least 2 significant words (4+ chars, non-stopword) must appear in the text
|
|
494
|
+
3. Use `glossary` with a curated JSON file for critical terms
|
|
495
|
+
4. If using `glossary_dir`, verify the localization files contain translations for the target locale
|
|
496
|
+
|
|
497
|
+
### Too Many Glossary Terms Matched
|
|
498
|
+
|
|
499
|
+
**Cause**: The localization directory contains many short or generic terms.
|
|
500
|
+
|
|
501
|
+
**Solutions**:
|
|
502
|
+
1. Use a curated JSON glossary file (`glossary`) with only the most important terms instead of pointing at the full localization directory
|
|
503
|
+
2. Terms longer than 80 characters (full sentences) are automatically excluded
|
|
504
|
+
3. Common English stopwords are excluded from matching
|
|
505
|
+
|
|
382
506
|
## Provider Comparison Details
|
|
383
507
|
|
|
384
508
|
### When to Use Each Provider
|
data/lib/fastlane/plugin/translate_gpt_release_notes/actions/translate_gpt_release_notes_action.rb
CHANGED
|
@@ -227,6 +227,28 @@ module Fastlane
|
|
|
227
227
|
description: "Context for translation to improve accuracy",
|
|
228
228
|
optional: true,
|
|
229
229
|
type: String
|
|
230
|
+
),
|
|
231
|
+
FastlaneCore::ConfigItem.new(
|
|
232
|
+
key: :glossary,
|
|
233
|
+
env_name: "GLOSSARY_PATH",
|
|
234
|
+
description: "Path to a JSON glossary file with term translations per locale",
|
|
235
|
+
optional: true,
|
|
236
|
+
type: String,
|
|
237
|
+
verify_block: proc do |value|
|
|
238
|
+
next if value.nil? || value.to_s.strip.empty?
|
|
239
|
+
UI.user_error!("Glossary file not found: #{value}") unless File.exist?(value)
|
|
240
|
+
end
|
|
241
|
+
),
|
|
242
|
+
FastlaneCore::ConfigItem.new(
|
|
243
|
+
key: :glossary_dir,
|
|
244
|
+
env_name: "GLOSSARY_DIR",
|
|
245
|
+
description: "Path to directory with localization files (ARB, .strings, .xml, .json, .xliff) for auto-extracting glossary",
|
|
246
|
+
optional: true,
|
|
247
|
+
type: String,
|
|
248
|
+
verify_block: proc do |value|
|
|
249
|
+
next if value.nil? || value.to_s.strip.empty?
|
|
250
|
+
UI.user_error!("Glossary directory not found: #{value}") unless Dir.exist?(value)
|
|
251
|
+
end
|
|
230
252
|
)
|
|
231
253
|
]
|
|
232
254
|
end
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'nokogiri'
|
|
3
|
+
require 'loco_strings'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Helper
|
|
8
|
+
# Loads glossary terms from curated JSON files or localization directories.
|
|
9
|
+
# Supports ARB, Apple .strings, Android strings.xml, JSON i18n, and XLIFF formats.
|
|
10
|
+
# Filters terms by fuzzy matching against source text to keep prompts concise.
|
|
11
|
+
class GlossaryLoader
|
|
12
|
+
# Supported localization file extensions and their format identifiers
|
|
13
|
+
FORMAT_EXTENSIONS = {
|
|
14
|
+
'.arb' => :arb,
|
|
15
|
+
'.strings' => :strings,
|
|
16
|
+
'.xml' => :android_xml,
|
|
17
|
+
'.json' => :json,
|
|
18
|
+
'.xliff' => :xliff,
|
|
19
|
+
'.xlf' => :xliff
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Minimum word length for individual word matching in fuzzy search
|
|
23
|
+
MIN_WORD_LENGTH = 4
|
|
24
|
+
|
|
25
|
+
# Maximum term length in characters. Longer terms are full sentences/paragraphs
|
|
26
|
+
# and not useful as glossary entries for translation prompts.
|
|
27
|
+
MAX_TERM_LENGTH = 80
|
|
28
|
+
|
|
29
|
+
# Common English stopwords excluded from individual word matching.
|
|
30
|
+
# These are too generic to be useful for glossary term matching and cause
|
|
31
|
+
# excessive false positives with real-world localization files.
|
|
32
|
+
STOPWORDS = Set.new(%w[
|
|
33
|
+
about also back been come could does done each even from
|
|
34
|
+
give goes gone good have here high into just keep know
|
|
35
|
+
like long look made make many more most much must need
|
|
36
|
+
only once open over part read real right same show side
|
|
37
|
+
some such sure take than that them then they this time
|
|
38
|
+
used very want well what when will with work your
|
|
39
|
+
]).freeze
|
|
40
|
+
|
|
41
|
+
# @param params [Hash] Plugin parameters containing glossary config
|
|
42
|
+
def initialize(params)
|
|
43
|
+
@glossary = {}
|
|
44
|
+
@source_locale = params[:master_locale] || 'en-US'
|
|
45
|
+
|
|
46
|
+
load_from_file(params[:glossary]) if params[:glossary]
|
|
47
|
+
load_from_directory(params[:glossary_dir]) if params[:glossary_dir]
|
|
48
|
+
|
|
49
|
+
if @glossary.empty?
|
|
50
|
+
UI.message("Glossary: No terms loaded")
|
|
51
|
+
else
|
|
52
|
+
UI.message("Glossary: Loaded #{@glossary.size} source terms")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns glossary terms relevant to the source text for a specific target locale.
|
|
57
|
+
# Applies fuzzy matching to include only terms that appear in the source text.
|
|
58
|
+
#
|
|
59
|
+
# @param source_text [String] The release notes text being translated
|
|
60
|
+
# @param target_locale [String] Target language code (e.g., 'fr', 'de-DE')
|
|
61
|
+
# @return [Hash] Filtered hash of { source_term => target_translation }
|
|
62
|
+
def terms_for(source_text, target_locale)
|
|
63
|
+
canonical = canonicalize_locale(target_locale)
|
|
64
|
+
language_only = canonical.split('-').first
|
|
65
|
+
result = {}
|
|
66
|
+
|
|
67
|
+
@glossary.each do |source_term, locale_translations|
|
|
68
|
+
next unless fuzzy_match?(source_term, source_text)
|
|
69
|
+
|
|
70
|
+
# Try canonical locale first, then language-only, then any matching language
|
|
71
|
+
translation = locale_translations[canonical] ||
|
|
72
|
+
locale_translations[language_only] ||
|
|
73
|
+
find_language_match(locale_translations, language_only)
|
|
74
|
+
|
|
75
|
+
result[source_term] = translation if translation
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Loads a curated JSON glossary file.
|
|
84
|
+
# Format: { "source term": { "locale": "translation", ... }, ... }
|
|
85
|
+
#
|
|
86
|
+
# @param path [String] Path to the JSON glossary file
|
|
87
|
+
def load_from_file(path)
|
|
88
|
+
unless File.exist?(path)
|
|
89
|
+
UI.warning("Glossary file not found: #{path}")
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
UI.message("Glossary: Loading curated glossary from #{path}")
|
|
94
|
+
data = JSON.parse(File.read(path))
|
|
95
|
+
|
|
96
|
+
data.each do |source_term, translations|
|
|
97
|
+
next unless translations.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
@glossary[source_term] ||= {}
|
|
100
|
+
# Canonicalize locale keys so 'fr-FR', 'fr_FR', 'fr' all resolve consistently
|
|
101
|
+
translations.each do |locale, translation|
|
|
102
|
+
canonical = canonicalize_locale(locale)
|
|
103
|
+
@glossary[source_term][canonical] ||= translation
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
rescue JSON::ParserError => e
|
|
107
|
+
UI.error("Failed to parse glossary file #{path}: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Scans a directory for localization files and extracts glossary terms.
|
|
111
|
+
#
|
|
112
|
+
# @param dir [String] Path to the directory containing localization files
|
|
113
|
+
def load_from_directory(dir)
|
|
114
|
+
unless Dir.exist?(dir)
|
|
115
|
+
UI.warning("Glossary directory not found: #{dir}")
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
format = detect_format(dir)
|
|
120
|
+
unless format
|
|
121
|
+
UI.warning("No supported localization files found in #{dir}")
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
UI.message("Detected glossary format: #{format}")
|
|
126
|
+
locale_files = find_locale_files(dir, format)
|
|
127
|
+
|
|
128
|
+
source_key = find_source_locale_key(locale_files)
|
|
129
|
+
unless source_key
|
|
130
|
+
UI.warning("Source locale '#{@source_locale}' not found in #{dir}")
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
source_file = locale_files[source_key]
|
|
135
|
+
source_strings = parse_file(source_file, format)
|
|
136
|
+
|
|
137
|
+
locale_files.each do |locale, file_path|
|
|
138
|
+
next if locale == source_key
|
|
139
|
+
|
|
140
|
+
target_strings = parse_file(file_path, format)
|
|
141
|
+
merge_locale_strings(source_strings, target_strings, locale)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Detects the localization format from files in the directory.
|
|
146
|
+
#
|
|
147
|
+
# @param dir [String] Directory path
|
|
148
|
+
# @return [Symbol, nil] Format identifier or nil
|
|
149
|
+
def detect_format(dir)
|
|
150
|
+
# Check for .lproj subdirectories (iOS .strings)
|
|
151
|
+
return :strings if Dir.glob(File.join(dir, '**', '*.lproj')).any?
|
|
152
|
+
|
|
153
|
+
# Check for Android values-* directories
|
|
154
|
+
return :android_xml if Dir.glob(File.join(dir, 'values*', '*.xml')).any?
|
|
155
|
+
|
|
156
|
+
# Check files by extension
|
|
157
|
+
files = Dir.glob(File.join(dir, '**', '*')).select { |f| File.file?(f) }
|
|
158
|
+
files.each do |file|
|
|
159
|
+
ext = File.extname(file).downcase
|
|
160
|
+
return FORMAT_EXTENSIONS[ext] if FORMAT_EXTENSIONS.key?(ext)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Finds locale files in the directory based on format-specific patterns.
|
|
167
|
+
#
|
|
168
|
+
# @param dir [String] Directory path
|
|
169
|
+
# @param format [Symbol] Format identifier
|
|
170
|
+
# @return [Hash] { locale_code => file_path }
|
|
171
|
+
def find_locale_files(dir, format)
|
|
172
|
+
case format
|
|
173
|
+
when :arb then find_arb_files(dir)
|
|
174
|
+
when :strings then find_strings_files(dir)
|
|
175
|
+
when :android_xml then find_android_xml_files(dir)
|
|
176
|
+
when :json then find_json_files(dir)
|
|
177
|
+
when :xliff then find_xliff_files(dir)
|
|
178
|
+
else {}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def find_arb_files(dir)
|
|
183
|
+
files = {}
|
|
184
|
+
Dir.glob(File.join(dir, '**', '*.arb')).each do |path|
|
|
185
|
+
basename = File.basename(path, '.arb')
|
|
186
|
+
# Match patterns: app_en, app_en_US, intl_en, en
|
|
187
|
+
if basename =~ /(?:^|_)([a-z]{2}(?:[_-][A-Za-z]{2,})?)$/
|
|
188
|
+
locale = canonicalize_locale(Regexp.last_match(1))
|
|
189
|
+
files[locale] = path
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
files
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def find_strings_files(dir)
|
|
196
|
+
files = {}
|
|
197
|
+
Dir.glob(File.join(dir, '**', '*.lproj', '*.strings')).each do |path|
|
|
198
|
+
lproj = File.basename(File.dirname(path), '.lproj')
|
|
199
|
+
files[canonicalize_locale(lproj)] = path
|
|
200
|
+
end
|
|
201
|
+
files
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def find_android_xml_files(dir)
|
|
205
|
+
files = {}
|
|
206
|
+
Dir.glob(File.join(dir, 'values*', '*.xml')).each do |path|
|
|
207
|
+
values_dir = File.basename(File.dirname(path))
|
|
208
|
+
if values_dir == 'values'
|
|
209
|
+
files['default'] = path
|
|
210
|
+
elsif values_dir =~ /^values-(.+)$/
|
|
211
|
+
locale = Regexp.last_match(1)
|
|
212
|
+
files[canonicalize_locale(locale)] = path
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
files
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def find_json_files(dir)
|
|
219
|
+
files = {}
|
|
220
|
+
Dir.glob(File.join(dir, '**', '*.json')).each do |path|
|
|
221
|
+
basename = File.basename(path, '.json')
|
|
222
|
+
# Match: en.json, en-US.json, messages_en.json
|
|
223
|
+
if basename =~ /(?:^|_)([a-z]{2}(?:[_-][A-Za-z]{2,})?)$/
|
|
224
|
+
locale = canonicalize_locale(Regexp.last_match(1))
|
|
225
|
+
files[locale] = path
|
|
226
|
+
elsif basename =~ /^([a-z]{2}(?:[_-][A-Za-z]{2,})?)$/
|
|
227
|
+
locale = canonicalize_locale(Regexp.last_match(1))
|
|
228
|
+
files[locale] = path
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
files
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def find_xliff_files(dir)
|
|
235
|
+
files = {}
|
|
236
|
+
Dir.glob(File.join(dir, '**', '*.{xliff,xlf}')).each do |path|
|
|
237
|
+
doc = Nokogiri::XML(File.read(path))
|
|
238
|
+
doc.remove_namespaces!
|
|
239
|
+
doc.xpath('//file').each do |file_node|
|
|
240
|
+
target_lang = file_node['target-language']
|
|
241
|
+
source_lang = file_node['source-language']
|
|
242
|
+
files[source_lang] = path if source_lang && !files.key?(source_lang)
|
|
243
|
+
files[target_lang] = path if target_lang
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
files
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Finds the source locale key in the locale files hash.
|
|
250
|
+
# Tries canonical match, then language-only, then Android default.
|
|
251
|
+
#
|
|
252
|
+
# @param locale_files [Hash] Available locale files
|
|
253
|
+
# @return [String, nil] The matching key or nil
|
|
254
|
+
def find_source_locale_key(locale_files)
|
|
255
|
+
canonical = canonicalize_locale(@source_locale)
|
|
256
|
+
language_only = canonical.split('-').first
|
|
257
|
+
|
|
258
|
+
# Canonical match (handles en-US == en_US == en-us)
|
|
259
|
+
return canonical if locale_files.key?(canonical)
|
|
260
|
+
|
|
261
|
+
# Language-only match (en-US -> en)
|
|
262
|
+
return language_only if locale_files.key?(language_only)
|
|
263
|
+
|
|
264
|
+
# Android default (values/ directory = source locale)
|
|
265
|
+
return 'default' if locale_files.key?('default')
|
|
266
|
+
|
|
267
|
+
# Last resort: any key sharing the same language
|
|
268
|
+
locale_files.keys.find { |k| k.split('-').first == language_only }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Parses a localization file based on its format.
|
|
272
|
+
#
|
|
273
|
+
# @param path [String] File path
|
|
274
|
+
# @param format [Symbol] Format identifier
|
|
275
|
+
# @return [Hash] { key => value } pairs
|
|
276
|
+
def parse_file(path, format)
|
|
277
|
+
case format
|
|
278
|
+
when :arb then parse_arb(path)
|
|
279
|
+
when :strings, :android_xml then parse_loco_strings(path)
|
|
280
|
+
when :json then parse_json(path)
|
|
281
|
+
when :xliff then parse_xliff(path)
|
|
282
|
+
else {}
|
|
283
|
+
end
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
UI.warning("Failed to parse #{path}: #{e.message}")
|
|
286
|
+
{}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def parse_arb(path)
|
|
290
|
+
data = JSON.parse(File.read(path))
|
|
291
|
+
data.reject { |k, v| k.start_with?('@') || !v.is_a?(String) }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def parse_loco_strings(path)
|
|
295
|
+
file = LocoStrings.load(path)
|
|
296
|
+
strings = file.read
|
|
297
|
+
strings.each_with_object({}) do |(key, loco_string), hash|
|
|
298
|
+
hash[key] = loco_string.value if loco_string.respond_to?(:value) && loco_string.value
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def parse_json(path)
|
|
303
|
+
data = JSON.parse(File.read(path))
|
|
304
|
+
flatten_hash(data)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def parse_xliff(path)
|
|
308
|
+
doc = Nokogiri::XML(File.read(path))
|
|
309
|
+
doc.remove_namespaces!
|
|
310
|
+
result = {}
|
|
311
|
+
doc.xpath('//trans-unit').each do |unit|
|
|
312
|
+
key = unit['id']
|
|
313
|
+
source = unit.at_xpath('source')&.text
|
|
314
|
+
target = unit.at_xpath('target')&.text
|
|
315
|
+
result[key] = { source: source, target: target } if key && source
|
|
316
|
+
end
|
|
317
|
+
result
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Flattens a nested hash into dot-separated keys.
|
|
321
|
+
#
|
|
322
|
+
# @param hash [Hash] Nested hash
|
|
323
|
+
# @param prefix [String] Key prefix for recursion
|
|
324
|
+
# @return [Hash] Flat hash with dot-separated keys
|
|
325
|
+
def flatten_hash(hash, prefix = '')
|
|
326
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
327
|
+
key = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
|
|
328
|
+
if v.is_a?(Hash)
|
|
329
|
+
result.merge!(flatten_hash(v, key))
|
|
330
|
+
elsif v.is_a?(String)
|
|
331
|
+
result[key] = v
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Merges parsed locale strings into the glossary.
|
|
337
|
+
# For XLIFF, uses source/target pairs directly.
|
|
338
|
+
# For other formats, maps source values to target values by key.
|
|
339
|
+
# Locale keys are canonicalized for consistent lookup.
|
|
340
|
+
#
|
|
341
|
+
# @param source_strings [Hash] Source locale key-value pairs
|
|
342
|
+
# @param target_strings [Hash] Target locale key-value pairs
|
|
343
|
+
# @param target_locale [String] Target locale code (already canonical from find_locale_files)
|
|
344
|
+
def merge_locale_strings(source_strings, target_strings, target_locale)
|
|
345
|
+
canonical_locale = canonicalize_locale(target_locale)
|
|
346
|
+
|
|
347
|
+
if xliff_format?(source_strings)
|
|
348
|
+
merge_xliff_strings(target_strings, canonical_locale)
|
|
349
|
+
else
|
|
350
|
+
source_strings.each do |key, source_value|
|
|
351
|
+
target_value = target_strings[key]
|
|
352
|
+
next unless target_value && !target_value.to_s.empty?
|
|
353
|
+
next if source_value.to_s.empty?
|
|
354
|
+
|
|
355
|
+
@glossary[source_value] ||= {}
|
|
356
|
+
# Don't overwrite curated glossary entries
|
|
357
|
+
@glossary[source_value][canonical_locale] ||= target_value
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def xliff_format?(strings)
|
|
363
|
+
strings.values.first.is_a?(Hash) && strings.values.first.key?(:source)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def merge_xliff_strings(strings, target_locale)
|
|
367
|
+
strings.each do |_key, entry|
|
|
368
|
+
next unless entry.is_a?(Hash) && entry[:source] && entry[:target]
|
|
369
|
+
|
|
370
|
+
source_value = entry[:source]
|
|
371
|
+
target_value = entry[:target]
|
|
372
|
+
next if source_value.empty? || target_value.empty?
|
|
373
|
+
|
|
374
|
+
@glossary[source_value] ||= {}
|
|
375
|
+
@glossary[source_value][target_locale] ||= target_value
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Fuzzy matches a glossary term against source text.
|
|
380
|
+
# Case-insensitive substring matching + multi-word matching for significant words.
|
|
381
|
+
# For multi-word terms, at least 2 significant (non-stopword, >= 4 char) words
|
|
382
|
+
# must appear in the source text to qualify as a match.
|
|
383
|
+
#
|
|
384
|
+
# @param term [String] The glossary source term
|
|
385
|
+
# @param text [String] The source text to match against
|
|
386
|
+
# @return [Boolean] Whether the term matches
|
|
387
|
+
def fuzzy_match?(term, text)
|
|
388
|
+
return false if term.nil? || text.nil?
|
|
389
|
+
return false if term.length < MIN_WORD_LENGTH
|
|
390
|
+
return false if term.length > MAX_TERM_LENGTH
|
|
391
|
+
|
|
392
|
+
# Ensure consistent UTF-8 encoding for comparison
|
|
393
|
+
downcased_text = text.encode('UTF-8', invalid: :replace, undef: :replace).downcase
|
|
394
|
+
downcased_term = term.encode('UTF-8', invalid: :replace, undef: :replace).downcase
|
|
395
|
+
|
|
396
|
+
# Full term substring match (case-insensitive)
|
|
397
|
+
return true if downcased_text.include?(downcased_term)
|
|
398
|
+
|
|
399
|
+
# Multi-word matching: require at least 2 significant words to match
|
|
400
|
+
words = downcased_term.split(/\s+/)
|
|
401
|
+
return false if words.length <= 1
|
|
402
|
+
|
|
403
|
+
significant_matches = words.count do |word|
|
|
404
|
+
word.length >= MIN_WORD_LENGTH && !STOPWORDS.include?(word) && downcased_text.include?(word)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
significant_matches >= 2
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Canonicalizes a locale code to a consistent format.
|
|
411
|
+
# Handles various conventions: underscores, hyphens, Android 'r' prefix,
|
|
412
|
+
# and case differences. Output is lowercase with hyphens.
|
|
413
|
+
#
|
|
414
|
+
# Examples:
|
|
415
|
+
# 'en-US' -> 'en-us'
|
|
416
|
+
# 'en_US' -> 'en-us'
|
|
417
|
+
# 'en' -> 'en'
|
|
418
|
+
# 'en-rUS' -> 'en-us' (Android resource qualifier)
|
|
419
|
+
# 'zh-Hans' -> 'zh-hans'
|
|
420
|
+
# 'zh_Hant_TW' -> 'zh-hant-tw'
|
|
421
|
+
# 'default' -> 'default' (Android values/ special case)
|
|
422
|
+
#
|
|
423
|
+
# @param locale [String] Locale code in any common format
|
|
424
|
+
# @return [String] Canonical lowercase hyphen-separated locale
|
|
425
|
+
def canonicalize_locale(locale)
|
|
426
|
+
code = locale.to_s.strip
|
|
427
|
+
return code if code == 'default'
|
|
428
|
+
|
|
429
|
+
# Normalize separators: underscores to hyphens
|
|
430
|
+
code = code.tr('_', '-')
|
|
431
|
+
|
|
432
|
+
# Remove Android 'r' prefix from region codes (e.g., 'en-rUS' -> 'en-US')
|
|
433
|
+
code = code.gsub(/\b([a-zA-Z]{2})-r([A-Z]{2})\b/, '\1-\2')
|
|
434
|
+
|
|
435
|
+
code.downcase
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Finds a matching translation for a target locale by language code.
|
|
439
|
+
# Used as a fallback when exact and language-only matches fail.
|
|
440
|
+
# For example, glossary has 'fr-fr' but target is 'fr-ca'.
|
|
441
|
+
#
|
|
442
|
+
# @param translations [Hash] Available translations { locale => text }
|
|
443
|
+
# @param language [String] Language code (e.g., 'fr', 'de')
|
|
444
|
+
# @return [String, nil] Matching translation or nil
|
|
445
|
+
def find_language_match(translations, language)
|
|
446
|
+
translations.find { |locale, _| locale.split('-').first == language }&.last
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
@@ -79,13 +79,14 @@ module Fastlane
|
|
|
79
79
|
# @param text [String] The text to translate
|
|
80
80
|
# @param source_locale [String] Source language code (e.g., 'en', 'de')
|
|
81
81
|
# @param target_locale [String] Target language code (e.g., 'es', 'fr')
|
|
82
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
82
83
|
# @return [String, nil] Translated text or nil on error
|
|
83
|
-
def translate(text, source_locale, target_locale)
|
|
84
|
-
# Build prompt using inherited method
|
|
85
|
-
prompt = build_prompt(text, source_locale, target_locale)
|
|
84
|
+
def translate(text, source_locale, target_locale, glossary_terms: {})
|
|
85
|
+
# Build prompt using inherited method (includes instructions, glossary, and text)
|
|
86
|
+
prompt = build_prompt(text, source_locale, target_locale, glossary_terms: glossary_terms)
|
|
86
87
|
|
|
87
|
-
# Add Android limitations if needed
|
|
88
|
-
prompt
|
|
88
|
+
# Add Android limitations if needed (appended after the text)
|
|
89
|
+
prompt += "\n\n" + android_limitation_instruction if @params[:platform] == 'android'
|
|
89
90
|
|
|
90
91
|
# Make API call using ruby-anthropic gem API
|
|
91
92
|
response = @client.complete(
|
|
@@ -26,8 +26,9 @@ module Fastlane
|
|
|
26
26
|
# @param text [String] The text to translate
|
|
27
27
|
# @param source_locale [String] Source language code (e.g., 'en', 'de')
|
|
28
28
|
# @param target_locale [String] Target language code (e.g., 'es', 'fr')
|
|
29
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
29
30
|
# @return [String, nil] Translated text or nil on error
|
|
30
|
-
def translate(text, source_locale, target_locale)
|
|
31
|
+
def translate(text, source_locale, target_locale, glossary_terms: {})
|
|
31
32
|
raise NotImplementedError, "#{self.class.name} must implement #translate"
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -83,19 +84,20 @@ module Fastlane
|
|
|
83
84
|
protected
|
|
84
85
|
|
|
85
86
|
# Builds a translation prompt for the AI provider.
|
|
86
|
-
#
|
|
87
|
+
# Structure: instructions first, context/glossary in the middle, text to translate last.
|
|
88
|
+
# This ordering ensures the model reads all constraints before processing the text.
|
|
87
89
|
#
|
|
88
90
|
# @param text [String] The text to translate
|
|
89
91
|
# @param source_locale [String] Source language code
|
|
90
92
|
# @param target_locale [String] Target language code
|
|
93
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
91
94
|
# @return [String] The formatted prompt
|
|
92
|
-
def build_prompt(text, source_locale, target_locale)
|
|
95
|
+
def build_prompt(text, source_locale, target_locale, glossary_terms: {})
|
|
93
96
|
prompt_parts = []
|
|
94
97
|
|
|
95
|
-
#
|
|
96
|
-
prompt_parts << "Translate the following
|
|
97
|
-
prompt_parts << ""
|
|
98
|
-
prompt_parts << "\"#{text}\""
|
|
98
|
+
# Instructions first: role, task, and output format
|
|
99
|
+
prompt_parts << "Translate the following release notes from #{source_locale} to #{target_locale}."
|
|
100
|
+
prompt_parts << "Respond with ONLY the translated text. Preserve the original formatting, line breaks, and bullet points."
|
|
99
101
|
|
|
100
102
|
# Add context if provided
|
|
101
103
|
if @params[:context]
|
|
@@ -103,23 +105,69 @@ module Fastlane
|
|
|
103
105
|
prompt_parts << "Context: #{@params[:context]}"
|
|
104
106
|
end
|
|
105
107
|
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
+
# Add glossary terms before the text so the model reads them first
|
|
109
|
+
unless glossary_terms.nil? || glossary_terms.empty?
|
|
108
110
|
prompt_parts << ""
|
|
109
|
-
prompt_parts <<
|
|
111
|
+
prompt_parts << "Use the following glossary for consistent terminology. Apply these exact translations for the specified terms:"
|
|
112
|
+
glossary_terms.each do |source_term, target_term|
|
|
113
|
+
prompt_parts << "- \"#{source_term}\" -> \"#{target_term}\""
|
|
114
|
+
end
|
|
110
115
|
end
|
|
111
116
|
|
|
117
|
+
# Text to translate last, so the model processes it with full context
|
|
118
|
+
prompt_parts << ""
|
|
119
|
+
prompt_parts << "Text to translate:"
|
|
120
|
+
prompt_parts << text
|
|
121
|
+
|
|
112
122
|
prompt_parts.join("\n")
|
|
113
123
|
end
|
|
114
124
|
|
|
125
|
+
# Builds a system instruction for providers that support separate system/user messages.
|
|
126
|
+
# Contains all translation rules, context, and glossary — but NOT the text to translate.
|
|
127
|
+
# Used by OpenAI (system message). Other providers use build_prompt which combines everything.
|
|
128
|
+
#
|
|
129
|
+
# @param source_locale [String] Source language code
|
|
130
|
+
# @param target_locale [String] Target language code
|
|
131
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
132
|
+
# @return [String] The system instruction
|
|
133
|
+
def build_system_instruction(source_locale, target_locale, glossary_terms: {})
|
|
134
|
+
parts = []
|
|
135
|
+
|
|
136
|
+
parts << "Translate the following release notes from #{source_locale} to #{target_locale}."
|
|
137
|
+
parts << "Respond with ONLY the translated text. Preserve the original formatting, line breaks, and bullet points."
|
|
138
|
+
|
|
139
|
+
if @params[:context]
|
|
140
|
+
parts << ""
|
|
141
|
+
parts << "Context: #{@params[:context]}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
unless glossary_terms.nil? || glossary_terms.empty?
|
|
145
|
+
parts << ""
|
|
146
|
+
parts << "Use the following glossary for consistent terminology. Apply these exact translations for the specified terms:"
|
|
147
|
+
glossary_terms.each do |source_term, target_term|
|
|
148
|
+
parts << "- \"#{source_term}\" -> \"#{target_term}\""
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
parts.join("\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Returns the Android character limitation instruction as a standalone string.
|
|
156
|
+
# Used by providers that build messages separately (e.g., OpenAI with system/user split).
|
|
157
|
+
#
|
|
158
|
+
# @return [String] The Android limitation instruction
|
|
159
|
+
def android_limitation_instruction
|
|
160
|
+
"IMPORTANT: The translated text must not exceed #{ANDROID_CHAR_LIMIT} characters " \
|
|
161
|
+
"(Google Play Store release notes limit). Provide a concise translation."
|
|
162
|
+
end
|
|
163
|
+
|
|
115
164
|
# Adds Android character limit constraint to the prompt.
|
|
116
165
|
# Google Play has a 500 character limit for release notes.
|
|
117
166
|
#
|
|
118
167
|
# @param prompt [String] The existing prompt to append to
|
|
119
168
|
# @return [String] The prompt with limitation instruction appended
|
|
120
169
|
def apply_android_limitations(prompt)
|
|
121
|
-
prompt +
|
|
122
|
-
"(Google Play Store release notes limit). Please provide a concise translation."
|
|
170
|
+
prompt + android_limitation_instruction
|
|
123
171
|
end
|
|
124
172
|
|
|
125
173
|
# Adds a configuration error to the errors list.
|
|
@@ -89,8 +89,9 @@ module Fastlane
|
|
|
89
89
|
# @param text [String] The text to translate
|
|
90
90
|
# @param source_locale [String] Source language code (e.g., 'en-US', 'de-DE')
|
|
91
91
|
# @param target_locale [String] Target language code (e.g., 'es', 'fr')
|
|
92
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
92
93
|
# @return [String, nil] Translated text or nil on error
|
|
93
|
-
def translate(text, source_locale, target_locale)
|
|
94
|
+
def translate(text, source_locale, target_locale, glossary_terms: {})
|
|
94
95
|
# DeepL uses ISO 639-1 language codes (2-letter codes)
|
|
95
96
|
# Convert locales like 'en-US' to 'EN'
|
|
96
97
|
source_lang = normalize_locale(source_locale)
|
|
@@ -103,11 +104,17 @@ module Fastlane
|
|
|
103
104
|
formality = @params[:formality].to_s.strip
|
|
104
105
|
options[:formality] = formality unless formality.empty? || formality == 'default'
|
|
105
106
|
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
# Build context from user-provided context and glossary terms
|
|
108
|
+
context_parts = []
|
|
109
|
+
context_parts << @params[:context] if @params[:context] && !@params[:context].empty?
|
|
110
|
+
|
|
111
|
+
if glossary_terms && !glossary_terms.empty?
|
|
112
|
+
glossary_str = glossary_terms.map { |s, t| "#{s} = #{t}" }.join("; ")
|
|
113
|
+
context_parts << "Glossary: #{glossary_str}"
|
|
109
114
|
end
|
|
110
115
|
|
|
116
|
+
options[:context] = context_parts.join(". ") unless context_parts.empty?
|
|
117
|
+
|
|
111
118
|
# Make API call
|
|
112
119
|
result = DeepL.translate(text, source_lang, target_lang, options)
|
|
113
120
|
|
|
@@ -76,13 +76,14 @@ module Fastlane
|
|
|
76
76
|
# @param text [String] The text to translate
|
|
77
77
|
# @param source_locale [String] Source language code (e.g., 'en', 'de')
|
|
78
78
|
# @param target_locale [String] Target language code (e.g., 'es', 'fr')
|
|
79
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
79
80
|
# @return [String, nil] Translated text or nil on error
|
|
80
|
-
def translate(text, source_locale, target_locale)
|
|
81
|
-
# Build prompt using inherited method
|
|
82
|
-
prompt = build_prompt(text, source_locale, target_locale)
|
|
81
|
+
def translate(text, source_locale, target_locale, glossary_terms: {})
|
|
82
|
+
# Build prompt using inherited method (includes instructions, glossary, and text)
|
|
83
|
+
prompt = build_prompt(text, source_locale, target_locale, glossary_terms: glossary_terms)
|
|
83
84
|
|
|
84
|
-
# Add Android limitations if needed
|
|
85
|
-
prompt
|
|
85
|
+
# Add Android limitations if needed (appended after the text)
|
|
86
|
+
prompt += "\n\n" + android_limitation_instruction if @params[:platform] == 'android'
|
|
86
87
|
|
|
87
88
|
# Make API call
|
|
88
89
|
result = make_api_request(prompt)
|
|
@@ -72,22 +72,31 @@ module Fastlane
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# Translates text from source locale to target locale using OpenAI's API.
|
|
75
|
+
# Uses a system message for instructions and a user message for the text,
|
|
76
|
+
# which improves instruction following for glossary terms and output format.
|
|
75
77
|
#
|
|
76
78
|
# @param text [String] The text to translate
|
|
77
79
|
# @param source_locale [String] Source language code (e.g., 'en', 'de')
|
|
78
80
|
# @param target_locale [String] Target language code (e.g., 'es', 'fr')
|
|
81
|
+
# @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
|
|
79
82
|
# @return [String, nil] Translated text or nil on error
|
|
80
|
-
def translate(text, source_locale, target_locale)
|
|
81
|
-
# Build
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
def translate(text, source_locale, target_locale, glossary_terms: {})
|
|
84
|
+
# Build system instruction and user content separately for better results
|
|
85
|
+
system_instruction = build_system_instruction(source_locale, target_locale, glossary_terms: glossary_terms)
|
|
86
|
+
user_content = text
|
|
87
|
+
|
|
88
|
+
# Add Android limitations to system instruction if needed
|
|
89
|
+
if @params[:platform] == 'android'
|
|
90
|
+
system_instruction += "\n\n" + android_limitation_instruction
|
|
91
|
+
end
|
|
86
92
|
|
|
87
|
-
# Build parameters hash
|
|
93
|
+
# Build parameters hash with separate system and user messages
|
|
88
94
|
parameters = {
|
|
89
95
|
model: @params[:model_name] || DEFAULT_MODEL,
|
|
90
|
-
messages: [
|
|
96
|
+
messages: [
|
|
97
|
+
{ role: 'system', content: system_instruction },
|
|
98
|
+
{ role: 'user', content: user_content }
|
|
99
|
+
],
|
|
91
100
|
temperature: (@params[:temperature] || DEFAULT_TEMPERATURE).to_f
|
|
92
101
|
}
|
|
93
102
|
|
data/lib/fastlane/plugin/translate_gpt_release_notes/helper/translate_gpt_release_notes_helper.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'fastlane_core/ui/ui'
|
|
2
2
|
require_relative 'providers/provider_factory'
|
|
3
|
+
require_relative 'glossary_loader'
|
|
3
4
|
|
|
4
5
|
module Fastlane
|
|
5
6
|
UI = FastlaneCore::UI unless Fastlane.const_defined?("UI")
|
|
@@ -23,12 +24,24 @@ module Fastlane
|
|
|
23
24
|
unless @provider.valid?
|
|
24
25
|
UI.user_error!("Provider configuration errors: #{@provider.config_errors.join(', ')}")
|
|
25
26
|
end
|
|
27
|
+
|
|
28
|
+
# Initialize glossary loader if glossary parameters are provided
|
|
29
|
+
if params[:glossary] || params[:glossary_dir]
|
|
30
|
+
@glossary_loader = GlossaryLoader.new(params)
|
|
31
|
+
UI.message("Glossary sources: #{[params[:glossary] && 'file', params[:glossary_dir] && 'directory'].compact.join(' + ')}")
|
|
32
|
+
end
|
|
26
33
|
end
|
|
27
34
|
|
|
28
35
|
# Request a translation from the configured provider
|
|
29
36
|
def translate_text(text, target_locale, _platform)
|
|
30
37
|
source_locale = @params[:master_locale]
|
|
31
|
-
@
|
|
38
|
+
glossary_terms = @glossary_loader&.terms_for(text, target_locale) || {}
|
|
39
|
+
|
|
40
|
+
if glossary_terms.any?
|
|
41
|
+
UI.message("Glossary: #{glossary_terms.size} terms matched for #{target_locale}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@provider.translate(text, source_locale, target_locale, glossary_terms: glossary_terms)
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
# Sleep for a specified number of seconds, displaying a progress bar
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fastlane-plugin-translate_gpt_release_notes
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton Karliner
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-03-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ruby-openai
|
|
@@ -245,6 +245,7 @@ files:
|
|
|
245
245
|
- lib/fastlane/plugin/translate_gpt_release_notes.rb
|
|
246
246
|
- lib/fastlane/plugin/translate_gpt_release_notes/actions/translate_gpt_release_notes_action.rb
|
|
247
247
|
- lib/fastlane/plugin/translate_gpt_release_notes/helper/credential_resolver.rb
|
|
248
|
+
- lib/fastlane/plugin/translate_gpt_release_notes/helper/glossary_loader.rb
|
|
248
249
|
- lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/anthropic_provider.rb
|
|
249
250
|
- lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/base_provider.rb
|
|
250
251
|
- lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/deepl_provider.rb
|