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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 409b1bcd2c73ee2accc11399a91ffb090bbecd3ae523950e1e15583327702d9e
4
- data.tar.gz: 1ee83d987e74e9dbbb2e903940cdc74eff26476ab436f0637b2ff5681dddb455
3
+ metadata.gz: b385f63605a33af014b3a222ecb688104e4e8a3088b14082b5c007f622ad34af
4
+ data.tar.gz: 546441b716377cf1e97b87fdddea9edee109df5cf7669bb5ab2aca0a5acc8f67
5
5
  SHA512:
6
- metadata.gz: e64e5272e48df8a01e1a28b625d31a4c859ce7c1e3998a6116ddbb4eed11f9d157eb12e801090d3ee84e0a7175c632f86515f270a55c1e792777c0f8425236cb
7
- data.tar.gz: 8f5c8d58a9ffc296c035946d303c3439877d24d39262f17231d7fd1d771054a247202ecddd91b1e6a6fc9877db3613c325bc59eec0e673e1c1f10be33fef6c87
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
@@ -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 = apply_android_limitations(prompt) if @params[:platform] == 'android'
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
- # Includes context about platform limitations if applicable.
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
- # Base translation instruction
96
- prompt_parts << "Translate the following text from #{source_locale} to #{target_locale}:"
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
- # Apply Android limitations if specified
107
- if @params[:android_limitations]
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 << apply_android_limitations("")
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 + "IMPORTANT: The translated text must not exceed #{ANDROID_CHAR_LIMIT} characters " \
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
- # DeepL supports context parameter for better translations
107
- if @params[:context] && !@params[:context].empty?
108
- options[:context] = @params[:context]
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 = apply_android_limitations(prompt) if @params[:platform] == 'android'
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 prompt using inherited build_prompt method
82
- prompt = build_prompt(text, source_locale, target_locale)
83
-
84
- # Add Android limitations if needed
85
- prompt = apply_android_limitations(prompt) if @params[:platform] == 'android'
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: [{ role: 'user', content: prompt }],
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
 
@@ -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
- @provider.translate(text, source_locale, target_locale)
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
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module TranslateGptReleaseNotes
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
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.2.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-01-29 00:00:00.000000000 Z
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