i18n-context-generator 0.4.0 → 0.5.1
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 +163 -26
- data/lib/i18n_context_generator/android_resource.rb +209 -0
- data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
- data/lib/i18n_context_generator/cache.rb +40 -8
- data/lib/i18n_context_generator/changed_location.rb +55 -0
- data/lib/i18n_context_generator/cli.rb +163 -76
- data/lib/i18n_context_generator/config/cli_values.rb +33 -0
- data/lib/i18n_context_generator/config/defaults.rb +35 -0
- data/lib/i18n_context_generator/config/schema.rb +225 -0
- data/lib/i18n_context_generator/config/serialization.rb +64 -0
- data/lib/i18n_context_generator/config/validation.rb +249 -0
- data/lib/i18n_context_generator/config.rb +293 -102
- data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
- data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
- data/lib/i18n_context_generator/context_extractor/run_logging.rb +45 -6
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
- data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
- data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
- data/lib/i18n_context_generator/context_extractor.rb +207 -216
- data/lib/i18n_context_generator/file_classifier.rb +72 -0
- data/lib/i18n_context_generator/generated_comment.rb +32 -0
- data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
- data/lib/i18n_context_generator/git_diff.rb +224 -95
- data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
- data/lib/i18n_context_generator/llm/client.rb +304 -92
- data/lib/i18n_context_generator/llm/openai.rb +92 -51
- data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
- data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
- data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
- data/lib/i18n_context_generator/localization_syntax.rb +246 -0
- data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
- data/lib/i18n_context_generator/parsers/base.rb +7 -4
- data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
- data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
- data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
- data/lib/i18n_context_generator/path_policy.rb +107 -0
- data/lib/i18n_context_generator/platform_validator.rb +24 -50
- data/lib/i18n_context_generator/run_metrics.rb +47 -0
- data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
- data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
- data/lib/i18n_context_generator/searcher/source_discovery.rb +109 -64
- data/lib/i18n_context_generator/searcher.rb +61 -205
- data/lib/i18n_context_generator/supplemental_context.rb +77 -0
- data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
- data/lib/i18n_context_generator/version.rb +1 -1
- data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
- data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
- data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
- data/lib/i18n_context_generator/writers/helpers.rb +7 -29
- data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
- data/lib/i18n_context_generator/writers/preview.rb +80 -0
- data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
- data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
- data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
- data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
- data/lib/i18n_context_generator/xcstrings_document.rb +248 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- metadata +59 -12
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'context_extractor/source_filters'
|
|
4
4
|
require_relative 'context_extractor/run_logging'
|
|
5
|
+
require_relative 'context_extractor/source_entries'
|
|
6
|
+
require_relative 'context_extractor/translation_filters'
|
|
7
|
+
require_relative 'context_extractor/cache_identity'
|
|
8
|
+
require_relative 'context_extractor/extraction_result'
|
|
9
|
+
require_relative 'context_extractor/workflow'
|
|
5
10
|
|
|
6
11
|
module I18nContextGenerator
|
|
7
12
|
# Main orchestrator that parses translation files, searches source code for usages,
|
|
@@ -10,45 +15,36 @@ module I18nContextGenerator
|
|
|
10
15
|
include Writers::Helpers
|
|
11
16
|
include SourceFilters
|
|
12
17
|
include RunLogging
|
|
18
|
+
include SourceEntries
|
|
19
|
+
include TranslationFilters
|
|
20
|
+
include CacheIdentity
|
|
21
|
+
include Workflow
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
ExtractionResult = Data.define(:key, :text, :description, :source_file, :ui_element, :tone,
|
|
16
|
-
:max_length, :locations, :error) do
|
|
17
|
-
def initialize(key:, text:, description:, source_file: nil, ui_element: nil, tone: nil,
|
|
18
|
-
max_length: nil, locations: [], error: nil)
|
|
19
|
-
super
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def to_h
|
|
23
|
-
{
|
|
24
|
-
key: key,
|
|
25
|
-
text: text,
|
|
26
|
-
description: description,
|
|
27
|
-
source_file: source_file,
|
|
28
|
-
ui_element: ui_element,
|
|
29
|
-
tone: tone,
|
|
30
|
-
max_length: max_length,
|
|
31
|
-
locations: locations,
|
|
32
|
-
error: error
|
|
33
|
-
}
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
attr_reader :results, :errors
|
|
23
|
+
attr_reader :results, :errors, :metrics
|
|
38
24
|
|
|
39
|
-
def initialize(config
|
|
25
|
+
def initialize(config, log_output: nil, structured_output: nil, patch_output: nil,
|
|
26
|
+
quiet: false, progress: true)
|
|
40
27
|
@config = config
|
|
28
|
+
@configured_log_output = log_output
|
|
29
|
+
@structured_output = structured_output
|
|
30
|
+
@patch_output = patch_output
|
|
31
|
+
@quiet = quiet
|
|
32
|
+
@progress_enabled = progress && !quiet
|
|
41
33
|
@results = Concurrent::Array.new
|
|
42
34
|
@errors = Concurrent::Array.new
|
|
35
|
+
@metrics = RunMetrics.from([], provider: @config.provider, model: resolved_model)
|
|
43
36
|
|
|
44
37
|
# Defer initialization of expensive resources
|
|
45
38
|
@searcher = nil
|
|
46
39
|
@llm = nil
|
|
47
40
|
@cache = nil
|
|
41
|
+
@supplemental_context = nil
|
|
42
|
+
@cache_context_sources = nil
|
|
48
43
|
end
|
|
49
44
|
|
|
50
45
|
def run
|
|
51
|
-
|
|
46
|
+
@config.validate!
|
|
47
|
+
@platform = PlatformValidator.new(@config).validate!
|
|
52
48
|
|
|
53
49
|
entries = load_entries
|
|
54
50
|
entries = filter_entries(entries) if @config.key_filter
|
|
@@ -62,25 +58,27 @@ module I18nContextGenerator
|
|
|
62
58
|
|
|
63
59
|
log_loaded_entries(entries.size)
|
|
64
60
|
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
return if pre_extraction_stage_handled?(entries)
|
|
62
|
+
|
|
63
|
+
# Resolve shared prompt inputs and provider state on the caller thread so
|
|
64
|
+
# file reads, digest work, and configuration errors are not duplicated by workers.
|
|
65
|
+
context_sources = supplemental_context
|
|
66
|
+
cache_context_sources
|
|
67
|
+
llm_client = llm
|
|
68
|
+
unless context_sources.empty?
|
|
69
|
+
llm_client.validate_supplemental_context!(
|
|
70
|
+
supplemental_context: context_sources,
|
|
71
|
+
redact_prompts: @config.redact_prompts,
|
|
72
|
+
max_prompt_chars: @config.max_prompt_chars
|
|
73
|
+
)
|
|
70
74
|
end
|
|
71
|
-
|
|
72
75
|
process_entries(entries)
|
|
76
|
+
@metrics = RunMetrics.from(@results, provider: @config.provider, model: resolved_model)
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
write_output
|
|
76
|
-
puts "\nWrote #{@results.size} results to #{@config.output_path}"
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
write_back_to_source if @config.write_back
|
|
80
|
-
|
|
81
|
-
write_back_to_code if @config.write_back_to_code
|
|
78
|
+
deliver_results
|
|
82
79
|
|
|
83
|
-
|
|
80
|
+
log "Errors: #{@errors.size}" if @errors.any?
|
|
81
|
+
log_metrics
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
private
|
|
@@ -89,95 +87,49 @@ module I18nContextGenerator
|
|
|
89
87
|
@searcher ||= Searcher.new(
|
|
90
88
|
source_paths: @config.source_paths,
|
|
91
89
|
ignore_patterns: @config.ignore_patterns,
|
|
92
|
-
context_lines: @config.context_lines
|
|
90
|
+
context_lines: @config.context_lines,
|
|
91
|
+
platform: @platform,
|
|
92
|
+
swift_functions: @config.swift_functions
|
|
93
93
|
)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def llm
|
|
97
|
-
@llm ||= LLM::Client.for(@config.provider)
|
|
97
|
+
@llm ||= LLM::Client.for(@config.provider, endpoint: @config.endpoint)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def cache
|
|
101
|
-
@cache ||= Cache.new(enabled: !@config.no_cache)
|
|
101
|
+
@cache ||= Cache.new(enabled: !@config.no_cache, directory: @config.cache_dir)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def supplemental_context
|
|
105
|
+
@supplemental_context ||= SupplementalContext.load(
|
|
106
|
+
files: @config.context_files,
|
|
107
|
+
runtime: @config.supplemental_context
|
|
108
|
+
)
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
def load_translations
|
|
105
112
|
return @load_translations if defined?(@load_translations)
|
|
106
113
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
warn "Translation file not found: #{path}"
|
|
110
|
-
next []
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
parser = Parsers::Base.for(path)
|
|
114
|
+
entries = @config.translations.flat_map do |path|
|
|
115
|
+
parser = Parsers::Base.for(path, locale: @config.translation_locales[path])
|
|
114
116
|
parser.parse(path)
|
|
115
117
|
end
|
|
116
|
-
end
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
load_translations
|
|
124
|
-
else
|
|
125
|
-
auto_discovery_entries
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def auto_discovery_entries
|
|
130
|
-
return load_source_entries if @config.translations.empty?
|
|
131
|
-
|
|
132
|
-
load_translations
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def load_source_entries
|
|
136
|
-
translation_lookup = load_translation_lookup
|
|
137
|
-
discovered_entries = filter_source_entries(searcher.discover_localization_entries)
|
|
138
|
-
|
|
139
|
-
discovered_entries.map do |entry|
|
|
140
|
-
hydrated_entry = translation_lookup[entry.key]
|
|
141
|
-
translation_comment = hydrated_entry&.metadata&.dig(:comment)
|
|
142
|
-
source_comment = entry.comment
|
|
143
|
-
metadata = {}
|
|
144
|
-
metadata[:comment] = translation_comment || source_comment if translation_comment || source_comment
|
|
145
|
-
metadata[:source_location] = "#{entry.file}:#{entry.line}"
|
|
146
|
-
|
|
147
|
-
Parsers::TranslationEntry.new(
|
|
148
|
-
key: entry.key,
|
|
149
|
-
text: hydrated_entry&.text || entry.text || entry.key,
|
|
150
|
-
source_file: hydrated_entry&.source_file,
|
|
151
|
-
metadata: metadata
|
|
152
|
-
)
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def load_translation_lookup
|
|
157
|
-
return @load_translation_lookup if defined?(@load_translation_lookup)
|
|
158
|
-
|
|
159
|
-
@load_translation_lookup = load_translations.each_with_object({}) do |entry, lookup|
|
|
160
|
-
lookup[entry.key] ||= entry
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def normalized_discovery_mode
|
|
165
|
-
@config.discovery_mode.to_s.downcase
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def translation_backed_discovery?
|
|
169
|
-
return true if normalized_discovery_mode == 'translations'
|
|
170
|
-
return false if normalized_discovery_mode == 'source'
|
|
171
|
-
|
|
172
|
-
@config.translations.any?
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def entry_label_for_logging
|
|
176
|
-
translation_backed_discovery? ? 'translation keys' : 'source localization entries'
|
|
119
|
+
@load_translations = validate_translation_entries(entries)
|
|
120
|
+
rescue I18nContextGenerator::Error
|
|
121
|
+
raise
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
raise Error, "Failed to load translations: #{e.message}"
|
|
177
124
|
end
|
|
178
125
|
|
|
179
126
|
def filter_entries(entries)
|
|
180
|
-
|
|
127
|
+
configured_patterns = if @config.key_filter.is_a?(Array)
|
|
128
|
+
@config.key_filter
|
|
129
|
+
else
|
|
130
|
+
@config.key_filter.split(',')
|
|
131
|
+
end
|
|
132
|
+
patterns = configured_patterns.map do |pattern|
|
|
181
133
|
escaped = Regexp.escape(pattern.strip).gsub('\*', '.*')
|
|
182
134
|
Regexp.new("^#{escaped}$")
|
|
183
135
|
end
|
|
@@ -187,109 +139,93 @@ module I18nContextGenerator
|
|
|
187
139
|
end
|
|
188
140
|
end
|
|
189
141
|
|
|
190
|
-
def filter_by_diff(entries)
|
|
191
|
-
git_diff = GitDiff.new(base_ref: @config.diff_base)
|
|
192
|
-
changed_keys = git_diff.changed_keys(@config.translations)
|
|
193
|
-
|
|
194
|
-
if changed_keys.empty?
|
|
195
|
-
puts "No changes detected in translation files since #{@config.diff_base}"
|
|
196
|
-
return []
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
puts "Found #{changed_keys.size} changed keys in git diff"
|
|
200
|
-
|
|
201
|
-
entries.select do |entry|
|
|
202
|
-
changed_keys.include?(entry.key) || changed_keys.include?(android_base_key(entry.key))
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# Extract the base resource name from composite Android keys
|
|
207
|
-
# e.g., "post_likes_count:one" -> "post_likes_count"
|
|
208
|
-
# "days_of_week[0]" -> "days_of_week"
|
|
209
|
-
def android_base_key(key)
|
|
210
|
-
key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
|
|
211
|
-
end
|
|
212
|
-
|
|
213
142
|
def filter_by_range(entries)
|
|
214
143
|
start_idx = 0
|
|
215
144
|
end_idx = entries.size - 1
|
|
216
145
|
|
|
217
146
|
if @config.start_key
|
|
218
147
|
found_idx = entries.find_index { |e| e.key == @config.start_key }
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
puts "Warning: start_key '#{@config.start_key}' not found, starting from beginning"
|
|
223
|
-
end
|
|
148
|
+
raise Error, "start_key not found: #{@config.start_key}" unless found_idx
|
|
149
|
+
|
|
150
|
+
start_idx = found_idx
|
|
224
151
|
end
|
|
225
152
|
|
|
226
153
|
if @config.end_key
|
|
227
|
-
found_idx = entries.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
puts "Warning: end_key '#{@config.end_key}' not found, processing to end"
|
|
232
|
-
end
|
|
154
|
+
found_idx = entries.rindex { |e| e.key == @config.end_key }
|
|
155
|
+
raise Error, "end_key not found: #{@config.end_key}" unless found_idx
|
|
156
|
+
|
|
157
|
+
end_idx = found_idx
|
|
233
158
|
end
|
|
234
159
|
|
|
160
|
+
raise Error, 'start_key must not come after end_key' if start_idx > end_idx
|
|
161
|
+
|
|
235
162
|
range_info = []
|
|
236
163
|
range_info << "from '#{@config.start_key}'" if @config.start_key
|
|
237
164
|
range_info << "to '#{@config.end_key}'" if @config.end_key
|
|
238
|
-
|
|
165
|
+
log "Filtering #{range_info.join(' ')}: keys #{start_idx + 1} to #{end_idx + 1}"
|
|
239
166
|
|
|
240
167
|
entries[start_idx..end_idx]
|
|
241
168
|
end
|
|
242
169
|
|
|
243
170
|
def process_entries(entries)
|
|
244
|
-
#
|
|
245
|
-
|
|
171
|
+
# Results expose changed source locations even during translation-backed
|
|
172
|
+
# discovery. Resolve the diff once on the caller thread before workers can
|
|
173
|
+
# race to initialize the lazy filter.
|
|
174
|
+
source_line_filter if @config.diff_base
|
|
246
175
|
|
|
247
|
-
progress =
|
|
248
|
-
'[:bar] :current/:total :percent :eta :key',
|
|
249
|
-
total: entries.size,
|
|
250
|
-
width: 30,
|
|
251
|
-
output: $stdout
|
|
252
|
-
)
|
|
176
|
+
progress = build_progress(entries.size)
|
|
253
177
|
|
|
254
178
|
# Use a thread pool for concurrent processing
|
|
255
179
|
pool = Concurrent::FixedThreadPool.new(@config.concurrency)
|
|
256
|
-
semaphore = Concurrent::Semaphore.new(@config.concurrency)
|
|
257
|
-
current_key = Concurrent::AtomicReference.new('')
|
|
258
180
|
|
|
259
181
|
entries.each do |entry|
|
|
260
182
|
pool.post do
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
progress.advance(key: current_key.get)
|
|
281
|
-
end
|
|
183
|
+
result = process_entry(entry)
|
|
184
|
+
@results << result
|
|
185
|
+
@errors << result if result.error
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
# Capture errors as results so they're visible in output
|
|
188
|
+
result = ExtractionResult.new(
|
|
189
|
+
key: entry.key,
|
|
190
|
+
text: entry.text,
|
|
191
|
+
description: 'Processing failed',
|
|
192
|
+
source_file: entry.source_file,
|
|
193
|
+
translation_key: translation_key_for(entry),
|
|
194
|
+
changed_translation_locations: changed_translation_locations_for(entry),
|
|
195
|
+
status: :error,
|
|
196
|
+
error: e.message
|
|
197
|
+
)
|
|
198
|
+
@results << result
|
|
199
|
+
@errors << result
|
|
200
|
+
ensure
|
|
201
|
+
progress&.advance(key: truncate(entry.key, 40))
|
|
282
202
|
end
|
|
283
203
|
end
|
|
284
204
|
|
|
285
205
|
pool.shutdown
|
|
286
206
|
pool.wait_for_termination
|
|
287
|
-
|
|
207
|
+
log if progress # New line after progress bar
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_progress(total)
|
|
211
|
+
return unless @progress_enabled
|
|
212
|
+
|
|
213
|
+
TTY::ProgressBar.new(
|
|
214
|
+
'[:bar] :current/:total :percent :eta :key',
|
|
215
|
+
total: total,
|
|
216
|
+
width: 30,
|
|
217
|
+
output: log_output
|
|
218
|
+
)
|
|
288
219
|
end
|
|
289
220
|
|
|
290
221
|
def process_entry(entry)
|
|
291
222
|
# Search for key usage in code first — needed for both cache key and LLM prompt
|
|
292
|
-
|
|
223
|
+
resource_type = entry.metadata&.dig(:resource_type)
|
|
224
|
+
matches = if resource_type
|
|
225
|
+
searcher.search(entry.key, resource_type: resource_type)
|
|
226
|
+
else
|
|
227
|
+
searcher.search(entry.key)
|
|
228
|
+
end
|
|
293
229
|
comment = @config.include_translation_comments ? entry.metadata&.dig(:comment) : nil
|
|
294
230
|
|
|
295
231
|
if matches.empty?
|
|
@@ -298,28 +234,22 @@ module I18nContextGenerator
|
|
|
298
234
|
text: entry.text,
|
|
299
235
|
description: 'No usage found in source code',
|
|
300
236
|
source_file: entry.source_file,
|
|
237
|
+
translation_key: translation_key_for(entry),
|
|
238
|
+
changed_translation_locations: changed_translation_locations_for(entry),
|
|
239
|
+
status: :no_usage,
|
|
301
240
|
locations: []
|
|
302
241
|
)
|
|
303
242
|
end
|
|
304
243
|
|
|
305
244
|
# Limit matches to avoid huge prompts
|
|
306
245
|
matches = matches.first(@config.max_matches_per_key)
|
|
246
|
+
context_sources = supplemental_context
|
|
307
247
|
|
|
308
|
-
|
|
309
|
-
# invalidates when source code, comments, or model change
|
|
310
|
-
cache_ctx = [
|
|
311
|
-
matches.map { |m| "#{m.file}:#{m.line}:#{m.match_line}:#{m.enclosing_scope}:#{m.context}" }.sort.join("\0"),
|
|
312
|
-
"comment:#{comment}",
|
|
313
|
-
"provider:#{@config.provider}",
|
|
314
|
-
"model:#{@config.model}",
|
|
315
|
-
"include_file_paths:#{@config.include_file_paths}",
|
|
316
|
-
"redact_prompts:#{@config.redact_prompts}"
|
|
317
|
-
].join("\n")
|
|
248
|
+
cache_ctx = cache_context(entry, matches, comment)
|
|
318
249
|
|
|
319
250
|
# Check cache with match context included
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
end
|
|
251
|
+
cached = cache.get(entry.key, entry.text, context: cache_ctx)
|
|
252
|
+
return cached_extraction_result(entry, cached, matches) if cached && !(cached[:error] || cached['error'])
|
|
323
253
|
|
|
324
254
|
# Get context from LLM
|
|
325
255
|
llm_result = llm.generate_context(
|
|
@@ -329,9 +259,12 @@ module I18nContextGenerator
|
|
|
329
259
|
model: @config.model,
|
|
330
260
|
comment: comment,
|
|
331
261
|
include_file_paths: @config.include_file_paths,
|
|
332
|
-
redact_prompts: @config.redact_prompts
|
|
262
|
+
redact_prompts: @config.redact_prompts,
|
|
263
|
+
max_prompt_chars: @config.max_prompt_chars,
|
|
264
|
+
supplemental_context: context_sources
|
|
333
265
|
)
|
|
334
266
|
|
|
267
|
+
result_locations = result_locations_for(entry, matches)
|
|
335
268
|
result = ExtractionResult.new(
|
|
336
269
|
key: entry.key,
|
|
337
270
|
text: entry.text,
|
|
@@ -340,14 +273,52 @@ module I18nContextGenerator
|
|
|
340
273
|
ui_element: llm_result.ui_element,
|
|
341
274
|
tone: llm_result.tone,
|
|
342
275
|
max_length: llm_result.max_length,
|
|
343
|
-
|
|
276
|
+
confidence: llm_result.confidence,
|
|
277
|
+
ambiguity_reason: llm_result.ambiguity_reason,
|
|
278
|
+
request_count: llm_result.request_count,
|
|
279
|
+
input_tokens: llm_result.input_tokens,
|
|
280
|
+
output_tokens: llm_result.output_tokens,
|
|
281
|
+
retries: llm_result.retries,
|
|
282
|
+
locations: result_locations,
|
|
283
|
+
**changed_location_attributes_for(entry, result_locations),
|
|
284
|
+
translation_key: translation_key_for(entry),
|
|
285
|
+
changed_translation_locations: changed_translation_locations_for(entry),
|
|
286
|
+
status: llm_result.error ? :error : :success,
|
|
344
287
|
error: llm_result.error
|
|
345
288
|
)
|
|
346
289
|
|
|
347
|
-
|
|
290
|
+
unless result.error
|
|
291
|
+
cache.set(
|
|
292
|
+
entry.key,
|
|
293
|
+
entry.text,
|
|
294
|
+
result.to_h.except(
|
|
295
|
+
:source_file, :changed_locations, :changed_location_groups, :changed_translation_locations,
|
|
296
|
+
:cache_hit, :request_count, :input_tokens, :output_tokens, :retries
|
|
297
|
+
),
|
|
298
|
+
context: cache_ctx
|
|
299
|
+
)
|
|
300
|
+
end
|
|
348
301
|
result
|
|
349
302
|
end
|
|
350
303
|
|
|
304
|
+
def cached_extraction_result(entry, cached, matches)
|
|
305
|
+
attributes = cached.transform_keys(&:to_sym).except(
|
|
306
|
+
:source_file, :locations, :changed_locations, :changed_location_groups,
|
|
307
|
+
:translation_key, :changed_translation_locations,
|
|
308
|
+
:cache_hit, :request_count, :input_tokens, :output_tokens, :retries
|
|
309
|
+
)
|
|
310
|
+
locations = result_locations_for(entry, matches)
|
|
311
|
+
ExtractionResult.new(
|
|
312
|
+
source_file: entry.source_file,
|
|
313
|
+
locations: locations,
|
|
314
|
+
**changed_location_attributes_for(entry, locations),
|
|
315
|
+
translation_key: translation_key_for(entry),
|
|
316
|
+
changed_translation_locations: changed_translation_locations_for(entry),
|
|
317
|
+
cache_hit: true,
|
|
318
|
+
**attributes
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
351
322
|
def write_output
|
|
352
323
|
writer = case @config.output_format.to_s.downcase
|
|
353
324
|
when 'json'
|
|
@@ -356,7 +327,12 @@ module I18nContextGenerator
|
|
|
356
327
|
Writers::CsvWriter.new
|
|
357
328
|
end
|
|
358
329
|
|
|
359
|
-
writer.write(
|
|
330
|
+
writer.write(
|
|
331
|
+
@results,
|
|
332
|
+
@config.output_path,
|
|
333
|
+
metrics: @metrics,
|
|
334
|
+
output: @structured_output || $stdout
|
|
335
|
+
)
|
|
360
336
|
end
|
|
361
337
|
|
|
362
338
|
def write_back_to_source
|
|
@@ -369,33 +345,44 @@ module I18nContextGenerator
|
|
|
369
345
|
relevant_results = @results.select { |result| result_matches_source_path?(result, path) }
|
|
370
346
|
next if relevant_results.empty?
|
|
371
347
|
|
|
372
|
-
writer.write(relevant_results, path)
|
|
373
|
-
|
|
348
|
+
updated = writer.write(relevant_results, path)
|
|
349
|
+
log "Updated #{path} with context comments" if updated
|
|
374
350
|
end
|
|
375
351
|
end
|
|
376
352
|
|
|
377
353
|
def write_back_to_code
|
|
378
|
-
swift_writer = Writers::SwiftWriter.new(
|
|
379
|
-
functions: @config.swift_functions,
|
|
380
|
-
context_prefix: @config.context_prefix,
|
|
381
|
-
context_mode: @config.context_mode
|
|
382
|
-
)
|
|
383
|
-
|
|
384
354
|
updated_count = 0
|
|
385
355
|
results_by_key = build_results_by_key_for_code_write_back
|
|
356
|
+
return if results_by_key.empty?
|
|
386
357
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if swift_writer.update_file(swift_file, results_by_key)
|
|
392
|
-
updated_count += 1
|
|
393
|
-
puts "Updated #{swift_file} with context comments"
|
|
394
|
-
end
|
|
358
|
+
swift_files_for_write_back.each do |swift_file|
|
|
359
|
+
if swift_writer.update_file(swift_file, results_by_key)
|
|
360
|
+
updated_count += 1
|
|
361
|
+
log "Updated #{swift_file} with context comments"
|
|
395
362
|
end
|
|
396
363
|
end
|
|
397
364
|
|
|
398
|
-
|
|
365
|
+
log "Updated #{updated_count} Swift files with context comments" if updated_count.positive?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def validate_translation_entries(entries)
|
|
369
|
+
invalid_entry = entries.find do |entry|
|
|
370
|
+
!entry.key.is_a?(String) || entry.key.empty? ||
|
|
371
|
+
!entry.text.is_a?(String) ||
|
|
372
|
+
!entry.source_file.is_a?(String) || entry.source_file.empty?
|
|
373
|
+
end
|
|
374
|
+
if invalid_entry
|
|
375
|
+
source = invalid_entry.source_file || 'unknown source'
|
|
376
|
+
raise Error, "Invalid translation entry in #{source}: key, text, and source file must be strings"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
grouped = entries.group_by { |entry| [File.expand_path(entry.source_file), entry.key] }
|
|
380
|
+
conflicts = grouped.filter_map do |(source_file, key), duplicates|
|
|
381
|
+
"#{source_file}:#{key}" if duplicates.map(&:text).uniq.size > 1
|
|
382
|
+
end
|
|
383
|
+
raise Error, "Conflicting duplicate translation keys: #{conflicts.join(', ')}" if conflicts.any?
|
|
384
|
+
|
|
385
|
+
entries.uniq { |entry| [File.expand_path(entry.source_file), entry.key, entry.text] }
|
|
399
386
|
end
|
|
400
387
|
|
|
401
388
|
def build_results_by_key_for_code_write_back
|
|
@@ -416,7 +403,6 @@ module I18nContextGenerator
|
|
|
416
403
|
end
|
|
417
404
|
|
|
418
405
|
def source_writer_for(path)
|
|
419
|
-
basename = File.basename(path).downcase
|
|
420
406
|
ext = File.extname(path).downcase
|
|
421
407
|
|
|
422
408
|
case ext
|
|
@@ -425,8 +411,13 @@ module I18nContextGenerator
|
|
|
425
411
|
context_prefix: @config.context_prefix,
|
|
426
412
|
context_mode: @config.context_mode
|
|
427
413
|
)
|
|
414
|
+
when '.xcstrings'
|
|
415
|
+
Writers::XcstringsWriter.new(
|
|
416
|
+
context_prefix: @config.context_prefix,
|
|
417
|
+
context_mode: @config.context_mode
|
|
418
|
+
)
|
|
428
419
|
when '.xml'
|
|
429
|
-
if
|
|
420
|
+
if FileClassifier.android_translation_file?(path)
|
|
430
421
|
Writers::AndroidXmlWriter.new(
|
|
431
422
|
context_prefix: @config.context_prefix,
|
|
432
423
|
context_mode: @config.context_mode
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
# Owns source and translation file-type classification. Header files remain
|
|
5
|
+
# searchable for iOS runs but are deliberately not platform evidence because
|
|
6
|
+
# generic C headers also occur in Android projects.
|
|
7
|
+
module FileClassifier
|
|
8
|
+
IOS_SOURCE_EXTENSIONS = %w[.swift .m .mm].freeze
|
|
9
|
+
IOS_SEARCH_EXTENSIONS = (IOS_SOURCE_EXTENSIONS + ['.h']).freeze
|
|
10
|
+
ANDROID_SOURCE_EXTENSIONS = %w[.kt .java].freeze
|
|
11
|
+
SEARCH_EXTENSIONS = {
|
|
12
|
+
ios: IOS_SEARCH_EXTENSIONS,
|
|
13
|
+
android: (ANDROID_SOURCE_EXTENSIONS + ['.xml']).freeze,
|
|
14
|
+
unknown: (IOS_SEARCH_EXTENSIONS + ANDROID_SOURCE_EXTENSIONS + ['.xml']).freeze
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def source_platform(path)
|
|
20
|
+
extension = File.extname(path).downcase
|
|
21
|
+
return :ios if IOS_SOURCE_EXTENSIONS.include?(extension)
|
|
22
|
+
return :android if ANDROID_SOURCE_EXTENSIONS.include?(extension)
|
|
23
|
+
|
|
24
|
+
:android if android_source_xml?(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def searchable_platform(path, platform_hint: nil)
|
|
28
|
+
return :ios if File.extname(path).downcase == '.h'
|
|
29
|
+
return :android if File.extname(path).downcase == '.xml' && platform_hint&.to_sym == :android
|
|
30
|
+
|
|
31
|
+
source_platform(path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def searchable_source?(path, platform: :unknown)
|
|
35
|
+
extensions = SEARCH_EXTENSIONS.fetch(platform.to_sym, SEARCH_EXTENSIONS[:unknown])
|
|
36
|
+
return false unless extensions.include?(File.extname(path).downcase)
|
|
37
|
+
return platform.to_sym == :android || android_source_xml?(path) if File.extname(path).downcase == '.xml'
|
|
38
|
+
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def translation_platform(path)
|
|
43
|
+
case File.extname(path).downcase
|
|
44
|
+
when '.strings', '.xcstrings' then :ios
|
|
45
|
+
when '.xml' then :android if android_translation_file?(path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def android_resource_xml?(path)
|
|
50
|
+
File.extname(path).downcase == '.xml' && path_components(path).include?('res')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def android_source_xml?(path)
|
|
54
|
+
android_resource_xml?(path) || File.basename(path).casecmp('AndroidManifest.xml').zero?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def android_translation_file?(path)
|
|
58
|
+
return false unless File.extname(path).downcase == '.xml'
|
|
59
|
+
return true if File.basename(path).casecmp('strings.xml').zero?
|
|
60
|
+
|
|
61
|
+
path_components(path).each_cons(2).any? { |first, second| first == 'res' && second.start_with?('values') }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def swift_source?(path)
|
|
65
|
+
File.extname(path).downcase == '.swift'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def path_components(path)
|
|
69
|
+
path.to_s.tr('\\', '/').split('/').reject(&:empty?)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|