i18n-context-generator 0.3.0 → 0.5.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 +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 +165 -73
- 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 +315 -105
- 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 +65 -0
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +104 -0
- 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 +218 -164
- 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 +280 -88
- data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
- data/lib/i18n_context_generator/llm/client.rb +294 -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 +232 -0
- data/lib/i18n_context_generator/searcher.rb +69 -201
- 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 +218 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- metadata +62 -12
|
@@ -1,86 +1,84 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'context_extractor/source_filters'
|
|
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'
|
|
10
|
+
|
|
3
11
|
module I18nContextGenerator
|
|
4
12
|
# Main orchestrator that parses translation files, searches source code for usages,
|
|
5
13
|
# sends context to the LLM, and writes results via the configured writer.
|
|
6
14
|
class ContextExtractor
|
|
7
15
|
include Writers::Helpers
|
|
16
|
+
include SourceFilters
|
|
17
|
+
include RunLogging
|
|
18
|
+
include SourceEntries
|
|
19
|
+
include TranslationFilters
|
|
20
|
+
include CacheIdentity
|
|
21
|
+
include Workflow
|
|
8
22
|
|
|
9
|
-
|
|
10
|
-
ExtractionResult = Data.define(:key, :text, :description, :source_file, :ui_element, :tone,
|
|
11
|
-
:max_length, :locations, :error) do
|
|
12
|
-
def initialize(key:, text:, description:, source_file: nil, ui_element: nil, tone: nil,
|
|
13
|
-
max_length: nil, locations: [], error: nil)
|
|
14
|
-
super
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def to_h
|
|
18
|
-
{
|
|
19
|
-
key: key,
|
|
20
|
-
text: text,
|
|
21
|
-
description: description,
|
|
22
|
-
source_file: source_file,
|
|
23
|
-
ui_element: ui_element,
|
|
24
|
-
tone: tone,
|
|
25
|
-
max_length: max_length,
|
|
26
|
-
locations: locations,
|
|
27
|
-
error: error
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
end
|
|
23
|
+
attr_reader :results, :errors, :metrics
|
|
31
24
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def initialize(config)
|
|
25
|
+
def initialize(config, log_output: nil, structured_output: nil, patch_output: nil,
|
|
26
|
+
quiet: false, progress: true)
|
|
35
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
|
|
36
33
|
@results = Concurrent::Array.new
|
|
37
34
|
@errors = Concurrent::Array.new
|
|
35
|
+
@metrics = RunMetrics.from([], provider: @config.provider, model: resolved_model)
|
|
38
36
|
|
|
39
37
|
# Defer initialization of expensive resources
|
|
40
38
|
@searcher = nil
|
|
41
39
|
@llm = nil
|
|
42
40
|
@cache = nil
|
|
41
|
+
@supplemental_context = nil
|
|
42
|
+
@cache_context_sources = nil
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def run
|
|
46
|
-
|
|
46
|
+
@config.validate!
|
|
47
|
+
@platform = PlatformValidator.new(@config).validate!
|
|
47
48
|
|
|
48
|
-
entries =
|
|
49
|
+
entries = load_entries
|
|
49
50
|
entries = filter_entries(entries) if @config.key_filter
|
|
50
|
-
entries = filter_by_diff(entries) if @config.diff_base
|
|
51
|
+
entries = filter_by_diff(entries) if @config.diff_base && translation_backed_discovery?
|
|
51
52
|
entries = filter_by_range(entries) if @config.start_key || @config.end_key
|
|
52
53
|
|
|
53
54
|
if entries.empty?
|
|
54
|
-
|
|
55
|
-
puts "No changed translation keys found since #{@config.diff_base}."
|
|
56
|
-
else
|
|
57
|
-
puts 'No translation entries found.'
|
|
58
|
-
end
|
|
55
|
+
log_empty_entries_message
|
|
59
56
|
return
|
|
60
57
|
end
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
puts "(filtered to changes since #{@config.diff_base})" if @config.diff_base
|
|
59
|
+
log_loaded_entries(entries.size)
|
|
64
60
|
|
|
65
|
-
if
|
|
66
|
-
puts "\nDry run - would process these keys:"
|
|
67
|
-
entries.first(20).each { |e| puts " - #{e.key}: #{truncate(e.text, 50)}" }
|
|
68
|
-
puts " ... and #{entries.size - 20} more" if entries.size > 20
|
|
69
|
-
return
|
|
70
|
-
end
|
|
61
|
+
return if pre_extraction_stage_handled?(entries)
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
)
|
|
77
74
|
end
|
|
75
|
+
process_entries(entries)
|
|
76
|
+
@metrics = RunMetrics.from(@results, provider: @config.provider, model: resolved_model)
|
|
78
77
|
|
|
79
|
-
|
|
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,32 +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
|
-
@
|
|
106
|
-
unless File.exist?(path)
|
|
107
|
-
warn "Translation file not found: #{path}"
|
|
108
|
-
next []
|
|
109
|
-
end
|
|
112
|
+
return @load_translations if defined?(@load_translations)
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
entries = @config.translations.flat_map do |path|
|
|
115
|
+
parser = Parsers::Base.for(path, locale: @config.translation_locales[path])
|
|
112
116
|
parser.parse(path)
|
|
113
117
|
end
|
|
118
|
+
|
|
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}"
|
|
114
124
|
end
|
|
115
125
|
|
|
116
126
|
def filter_entries(entries)
|
|
117
|
-
|
|
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|
|
|
118
133
|
escaped = Regexp.escape(pattern.strip).gsub('\*', '.*')
|
|
119
134
|
Regexp.new("^#{escaped}$")
|
|
120
135
|
end
|
|
@@ -124,109 +139,93 @@ module I18nContextGenerator
|
|
|
124
139
|
end
|
|
125
140
|
end
|
|
126
141
|
|
|
127
|
-
def filter_by_diff(entries)
|
|
128
|
-
git_diff = GitDiff.new(base_ref: @config.diff_base)
|
|
129
|
-
changed_keys = git_diff.changed_keys(@config.translations)
|
|
130
|
-
|
|
131
|
-
if changed_keys.empty?
|
|
132
|
-
puts "No changes detected in translation files since #{@config.diff_base}"
|
|
133
|
-
return []
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
puts "Found #{changed_keys.size} changed keys in git diff"
|
|
137
|
-
|
|
138
|
-
entries.select do |entry|
|
|
139
|
-
changed_keys.include?(entry.key) || changed_keys.include?(android_base_key(entry.key))
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# Extract the base resource name from composite Android keys
|
|
144
|
-
# e.g., "post_likes_count:one" -> "post_likes_count"
|
|
145
|
-
# "days_of_week[0]" -> "days_of_week"
|
|
146
|
-
def android_base_key(key)
|
|
147
|
-
key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
|
|
148
|
-
end
|
|
149
|
-
|
|
150
142
|
def filter_by_range(entries)
|
|
151
143
|
start_idx = 0
|
|
152
144
|
end_idx = entries.size - 1
|
|
153
145
|
|
|
154
146
|
if @config.start_key
|
|
155
147
|
found_idx = entries.find_index { |e| e.key == @config.start_key }
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
puts "Warning: start_key '#{@config.start_key}' not found, starting from beginning"
|
|
160
|
-
end
|
|
148
|
+
raise Error, "start_key not found: #{@config.start_key}" unless found_idx
|
|
149
|
+
|
|
150
|
+
start_idx = found_idx
|
|
161
151
|
end
|
|
162
152
|
|
|
163
153
|
if @config.end_key
|
|
164
|
-
found_idx = entries.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
puts "Warning: end_key '#{@config.end_key}' not found, processing to end"
|
|
169
|
-
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
|
|
170
158
|
end
|
|
171
159
|
|
|
160
|
+
raise Error, 'start_key must not come after end_key' if start_idx > end_idx
|
|
161
|
+
|
|
172
162
|
range_info = []
|
|
173
163
|
range_info << "from '#{@config.start_key}'" if @config.start_key
|
|
174
164
|
range_info << "to '#{@config.end_key}'" if @config.end_key
|
|
175
|
-
|
|
165
|
+
log "Filtering #{range_info.join(' ')}: keys #{start_idx + 1} to #{end_idx + 1}"
|
|
176
166
|
|
|
177
167
|
entries[start_idx..end_idx]
|
|
178
168
|
end
|
|
179
169
|
|
|
180
170
|
def process_entries(entries)
|
|
181
|
-
#
|
|
182
|
-
|
|
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
|
|
183
175
|
|
|
184
|
-
progress =
|
|
185
|
-
'[:bar] :current/:total :percent :eta :key',
|
|
186
|
-
total: entries.size,
|
|
187
|
-
width: 30,
|
|
188
|
-
output: $stdout
|
|
189
|
-
)
|
|
176
|
+
progress = build_progress(entries.size)
|
|
190
177
|
|
|
191
178
|
# Use a thread pool for concurrent processing
|
|
192
179
|
pool = Concurrent::FixedThreadPool.new(@config.concurrency)
|
|
193
|
-
semaphore = Concurrent::Semaphore.new(@config.concurrency)
|
|
194
|
-
current_key = Concurrent::AtomicReference.new('')
|
|
195
180
|
|
|
196
181
|
entries.each do |entry|
|
|
197
182
|
pool.post do
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
progress.advance(key: current_key.get)
|
|
218
|
-
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))
|
|
219
202
|
end
|
|
220
203
|
end
|
|
221
204
|
|
|
222
205
|
pool.shutdown
|
|
223
206
|
pool.wait_for_termination
|
|
224
|
-
|
|
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
|
+
)
|
|
225
219
|
end
|
|
226
220
|
|
|
227
221
|
def process_entry(entry)
|
|
228
222
|
# Search for key usage in code first — needed for both cache key and LLM prompt
|
|
229
|
-
|
|
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
|
|
230
229
|
comment = @config.include_translation_comments ? entry.metadata&.dig(:comment) : nil
|
|
231
230
|
|
|
232
231
|
if matches.empty?
|
|
@@ -235,28 +234,22 @@ module I18nContextGenerator
|
|
|
235
234
|
text: entry.text,
|
|
236
235
|
description: 'No usage found in source code',
|
|
237
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,
|
|
238
240
|
locations: []
|
|
239
241
|
)
|
|
240
242
|
end
|
|
241
243
|
|
|
242
244
|
# Limit matches to avoid huge prompts
|
|
243
245
|
matches = matches.first(@config.max_matches_per_key)
|
|
246
|
+
context_sources = supplemental_context
|
|
244
247
|
|
|
245
|
-
|
|
246
|
-
# invalidates when source code, comments, or model change
|
|
247
|
-
cache_ctx = [
|
|
248
|
-
matches.map { |m| "#{m.file}:#{m.line}:#{m.match_line}:#{m.enclosing_scope}:#{m.context}" }.sort.join("\0"),
|
|
249
|
-
"comment:#{comment}",
|
|
250
|
-
"provider:#{@config.provider}",
|
|
251
|
-
"model:#{@config.model}",
|
|
252
|
-
"include_file_paths:#{@config.include_file_paths}",
|
|
253
|
-
"redact_prompts:#{@config.redact_prompts}"
|
|
254
|
-
].join("\n")
|
|
248
|
+
cache_ctx = cache_context(entry, matches, comment)
|
|
255
249
|
|
|
256
250
|
# Check cache with match context included
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
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'])
|
|
260
253
|
|
|
261
254
|
# Get context from LLM
|
|
262
255
|
llm_result = llm.generate_context(
|
|
@@ -266,9 +259,12 @@ module I18nContextGenerator
|
|
|
266
259
|
model: @config.model,
|
|
267
260
|
comment: comment,
|
|
268
261
|
include_file_paths: @config.include_file_paths,
|
|
269
|
-
redact_prompts: @config.redact_prompts
|
|
262
|
+
redact_prompts: @config.redact_prompts,
|
|
263
|
+
max_prompt_chars: @config.max_prompt_chars,
|
|
264
|
+
supplemental_context: context_sources
|
|
270
265
|
)
|
|
271
266
|
|
|
267
|
+
result_locations = result_locations_for(entry, matches)
|
|
272
268
|
result = ExtractionResult.new(
|
|
273
269
|
key: entry.key,
|
|
274
270
|
text: entry.text,
|
|
@@ -277,14 +273,52 @@ module I18nContextGenerator
|
|
|
277
273
|
ui_element: llm_result.ui_element,
|
|
278
274
|
tone: llm_result.tone,
|
|
279
275
|
max_length: llm_result.max_length,
|
|
280
|
-
|
|
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,
|
|
281
287
|
error: llm_result.error
|
|
282
288
|
)
|
|
283
289
|
|
|
284
|
-
|
|
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
|
|
285
301
|
result
|
|
286
302
|
end
|
|
287
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
|
+
|
|
288
322
|
def write_output
|
|
289
323
|
writer = case @config.output_format.to_s.downcase
|
|
290
324
|
when 'json'
|
|
@@ -293,7 +327,12 @@ module I18nContextGenerator
|
|
|
293
327
|
Writers::CsvWriter.new
|
|
294
328
|
end
|
|
295
329
|
|
|
296
|
-
writer.write(
|
|
330
|
+
writer.write(
|
|
331
|
+
@results,
|
|
332
|
+
@config.output_path,
|
|
333
|
+
metrics: @metrics,
|
|
334
|
+
output: @structured_output || $stdout
|
|
335
|
+
)
|
|
297
336
|
end
|
|
298
337
|
|
|
299
338
|
def write_back_to_source
|
|
@@ -306,33 +345,44 @@ module I18nContextGenerator
|
|
|
306
345
|
relevant_results = @results.select { |result| result_matches_source_path?(result, path) }
|
|
307
346
|
next if relevant_results.empty?
|
|
308
347
|
|
|
309
|
-
writer.write(relevant_results, path)
|
|
310
|
-
|
|
348
|
+
updated = writer.write(relevant_results, path)
|
|
349
|
+
log "Updated #{path} with context comments" if updated
|
|
311
350
|
end
|
|
312
351
|
end
|
|
313
352
|
|
|
314
353
|
def write_back_to_code
|
|
315
|
-
swift_writer = Writers::SwiftWriter.new(
|
|
316
|
-
functions: @config.swift_functions,
|
|
317
|
-
context_prefix: @config.context_prefix,
|
|
318
|
-
context_mode: @config.context_mode
|
|
319
|
-
)
|
|
320
|
-
|
|
321
354
|
updated_count = 0
|
|
322
355
|
results_by_key = build_results_by_key_for_code_write_back
|
|
356
|
+
return if results_by_key.empty?
|
|
323
357
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if swift_writer.update_file(swift_file, results_by_key)
|
|
329
|
-
updated_count += 1
|
|
330
|
-
puts "Updated #{swift_file} with context comments"
|
|
331
|
-
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"
|
|
332
362
|
end
|
|
333
363
|
end
|
|
334
364
|
|
|
335
|
-
|
|
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] }
|
|
336
386
|
end
|
|
337
387
|
|
|
338
388
|
def build_results_by_key_for_code_write_back
|
|
@@ -353,7 +403,6 @@ module I18nContextGenerator
|
|
|
353
403
|
end
|
|
354
404
|
|
|
355
405
|
def source_writer_for(path)
|
|
356
|
-
basename = File.basename(path).downcase
|
|
357
406
|
ext = File.extname(path).downcase
|
|
358
407
|
|
|
359
408
|
case ext
|
|
@@ -362,8 +411,13 @@ module I18nContextGenerator
|
|
|
362
411
|
context_prefix: @config.context_prefix,
|
|
363
412
|
context_mode: @config.context_mode
|
|
364
413
|
)
|
|
414
|
+
when '.xcstrings'
|
|
415
|
+
Writers::XcstringsWriter.new(
|
|
416
|
+
context_prefix: @config.context_prefix,
|
|
417
|
+
context_mode: @config.context_mode
|
|
418
|
+
)
|
|
365
419
|
when '.xml'
|
|
366
|
-
if
|
|
420
|
+
if FileClassifier.android_translation_file?(path)
|
|
367
421
|
Writers::AndroidXmlWriter.new(
|
|
368
422
|
context_prefix: @config.context_prefix,
|
|
369
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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
# Shared ownership and merge rules for context generated into destination
|
|
5
|
+
# comments. Writers remain responsible for destination-specific escaping.
|
|
6
|
+
module GeneratedComment
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def merge(existing:, generated:, prefix:, mode:, separator:)
|
|
10
|
+
return generated if existing.nil? || existing.empty? || mode.to_s == 'replace'
|
|
11
|
+
|
|
12
|
+
if prefix.empty?
|
|
13
|
+
return existing if existing.include?(generated)
|
|
14
|
+
|
|
15
|
+
return "#{existing}#{separator}#{generated}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return replace_managed(existing, generated, prefix) if managed?(existing, prefix: prefix)
|
|
19
|
+
|
|
20
|
+
"#{existing}#{separator}#{generated}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def managed?(comment, prefix:)
|
|
24
|
+
prefix.empty? || comment.to_s.include?(prefix)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def replace_managed(existing, generated, prefix)
|
|
28
|
+
existing.gsub(/#{Regexp.escape(prefix)}[^\n]*/) { generated }
|
|
29
|
+
end
|
|
30
|
+
private_class_method :replace_managed
|
|
31
|
+
end
|
|
32
|
+
end
|