better_translate 1.0.0 → 1.1.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/.rubocop.yml +28 -0
- data/.rubocop_todo.yml +291 -0
- data/CHANGELOG.md +88 -0
- data/README.md +262 -2
- data/RELEASE_NOTES_v1.0.0.md +240 -0
- data/Steepfile +2 -2
- data/docs/implementation/00-overview.md +1 -1
- data/docs/implementation/04-provider_architecture.md +5 -5
- data/lib/better_translate/analyzer/code_scanner.rb +151 -0
- data/lib/better_translate/analyzer/key_scanner.rb +109 -0
- data/lib/better_translate/analyzer/orphan_detector.rb +88 -0
- data/lib/better_translate/analyzer/reporter.rb +155 -0
- data/lib/better_translate/cache.rb +2 -1
- data/lib/better_translate/cli.rb +81 -2
- data/lib/better_translate/configuration.rb +48 -2
- data/lib/better_translate/errors.rb +9 -0
- data/lib/better_translate/json_handler.rb +227 -0
- data/lib/better_translate/providers/anthropic_provider.rb +4 -3
- data/lib/better_translate/providers/chatgpt_provider.rb +2 -1
- data/lib/better_translate/providers/gemini_provider.rb +5 -4
- data/lib/better_translate/railtie.rb +2 -1
- data/lib/better_translate/rate_limiter.rb +4 -1
- data/lib/better_translate/strategies/batch_strategy.rb +1 -1
- data/lib/better_translate/strategies/deep_strategy.rb +1 -1
- data/lib/better_translate/translator.rb +204 -19
- data/lib/better_translate/utils/hash_flattener.rb +2 -2
- data/lib/better_translate/variable_extractor.rb +7 -7
- data/lib/better_translate/version.rb +1 -1
- data/lib/better_translate/yaml_handler.rb +59 -0
- data/lib/better_translate.rb +5 -0
- data/lib/generators/better_translate/analyze/analyze_generator.rb +2 -1
- data/lib/generators/better_translate/install/install_generator.rb +4 -3
- data/lib/generators/better_translate/install/templates/initializer.rb.tt +39 -7
- data/lib/generators/better_translate/translate/translate_generator.rb +2 -1
- data/regenerate_vcr.rb +47 -0
- data/sig/better_translate/configuration.rbs +13 -2
- data/sig/better_translate/errors.rbs +4 -0
- data/sig/better_translate/providers/base_http_provider.rbs +1 -1
- data/sig/better_translate/translator.rbs +12 -1
- data/sig/better_translate/variable_extractor.rbs +1 -1
- data/sig/better_translate/yaml_handler.rbs +6 -0
- data/sig/better_translate.rbs +2 -1
- metadata +9 -1
|
@@ -34,12 +34,19 @@ module BetterTranslate
|
|
|
34
34
|
@config.validate!
|
|
35
35
|
provider_name = config.provider || :chatgpt
|
|
36
36
|
@provider = ProviderFactory.create(provider_name, config)
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
# Resolve input files (supports input_file, input_files array, or glob pattern)
|
|
39
|
+
@input_files = resolve_input_files
|
|
40
|
+
|
|
41
|
+
# We'll determine handler per file during translation
|
|
38
42
|
@progress_tracker = ProgressTracker.new(enabled: config.verbose)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# Translate to all target languages
|
|
42
46
|
#
|
|
47
|
+
# Uses parallel execution when max_concurrent_requests > 1,
|
|
48
|
+
# sequential execution otherwise. Supports multiple input files.
|
|
49
|
+
#
|
|
43
50
|
# @return [Hash] Results hash with :success_count, :failure_count, :errors
|
|
44
51
|
#
|
|
45
52
|
# @example
|
|
@@ -47,8 +54,128 @@ module BetterTranslate
|
|
|
47
54
|
# #=> { success_count: 2, failure_count: 0, errors: [] }
|
|
48
55
|
#
|
|
49
56
|
def translate_all
|
|
50
|
-
|
|
57
|
+
# @type var combined_results: translation_results
|
|
58
|
+
combined_results = {
|
|
59
|
+
success_count: 0,
|
|
60
|
+
failure_count: 0,
|
|
61
|
+
errors: []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@input_files.each do |input_file|
|
|
65
|
+
# Create appropriate handler for this file
|
|
66
|
+
handler = if input_file.end_with?(".json")
|
|
67
|
+
JsonHandler.new(config)
|
|
68
|
+
else
|
|
69
|
+
YAMLHandler.new(config)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Temporarily set input_file for this iteration
|
|
73
|
+
original_input_file = config.input_file
|
|
74
|
+
config.input_file = input_file
|
|
75
|
+
|
|
76
|
+
source_strings = handler.get_source_strings
|
|
77
|
+
|
|
78
|
+
results = if config.max_concurrent_requests > 1
|
|
79
|
+
translate_parallel(source_strings, handler)
|
|
80
|
+
else
|
|
81
|
+
translate_sequential(source_strings, handler)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Restore original config
|
|
85
|
+
config.input_file = original_input_file
|
|
86
|
+
|
|
87
|
+
# Accumulate results
|
|
88
|
+
combined_results[:success_count] += results[:success_count]
|
|
89
|
+
combined_results[:failure_count] += results[:failure_count]
|
|
90
|
+
combined_results[:errors].concat(results[:errors])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
combined_results
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Resolve input files from config
|
|
99
|
+
#
|
|
100
|
+
# Handles input_file, input_files array, or glob patterns
|
|
101
|
+
#
|
|
102
|
+
# @return [Array<String>] Resolved file paths
|
|
103
|
+
# @raise [FileError] if no files found
|
|
104
|
+
# @api private
|
|
105
|
+
#
|
|
106
|
+
def resolve_input_files
|
|
107
|
+
files = if config.input_files
|
|
108
|
+
# Handle input_files (array or glob pattern)
|
|
109
|
+
if config.input_files.is_a?(Array)
|
|
110
|
+
config.input_files
|
|
111
|
+
else
|
|
112
|
+
# Glob pattern
|
|
113
|
+
Dir.glob(config.input_files)
|
|
114
|
+
end
|
|
115
|
+
elsif config.input_file
|
|
116
|
+
# Backward compatibility with single input_file
|
|
117
|
+
[config.input_file]
|
|
118
|
+
else
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Validate files exist (unless glob pattern that found nothing)
|
|
123
|
+
if files.empty?
|
|
124
|
+
if config.input_files && !config.input_files.is_a?(Array)
|
|
125
|
+
raise FileError, "No files found matching pattern: #{config.input_files}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
raise FileError, "No input files specified"
|
|
129
|
+
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
files.each do |file|
|
|
133
|
+
Validator.validate_file_exists!(file)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
files
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Translate languages in parallel
|
|
140
|
+
#
|
|
141
|
+
# @param source_strings [Hash] Source strings (flattened)
|
|
142
|
+
# @param handler [YAMLHandler, JsonHandler] File handler for this file
|
|
143
|
+
# @return [Hash] Results hash with counts and errors
|
|
144
|
+
# @api private
|
|
145
|
+
#
|
|
146
|
+
def translate_parallel(source_strings, handler)
|
|
147
|
+
# @type var results: translation_results
|
|
148
|
+
results = {
|
|
149
|
+
success_count: 0,
|
|
150
|
+
failure_count: 0,
|
|
151
|
+
errors: []
|
|
152
|
+
}
|
|
153
|
+
mutex = Mutex.new
|
|
154
|
+
|
|
155
|
+
# Process languages in batches of max_concurrent_requests
|
|
156
|
+
config.target_languages.each_slice(config.max_concurrent_requests) do |batch|
|
|
157
|
+
threads = batch.map do |lang|
|
|
158
|
+
Thread.new do
|
|
159
|
+
translate_language(source_strings, lang, handler)
|
|
160
|
+
mutex.synchronize { results[:success_count] += 1 }
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
mutex.synchronize { record_error(results, lang, e) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
threads.each(&:join)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
results
|
|
169
|
+
end
|
|
51
170
|
|
|
171
|
+
# Translate languages sequentially
|
|
172
|
+
#
|
|
173
|
+
# @param source_strings [Hash] Source strings (flattened)
|
|
174
|
+
# @param handler [YAMLHandler, JsonHandler] File handler for this file
|
|
175
|
+
# @return [Hash] Results hash with counts and errors
|
|
176
|
+
# @api private
|
|
177
|
+
#
|
|
178
|
+
def translate_sequential(source_strings, handler)
|
|
52
179
|
# @type var results: translation_results
|
|
53
180
|
results = {
|
|
54
181
|
success_count: 0,
|
|
@@ -57,38 +184,53 @@ module BetterTranslate
|
|
|
57
184
|
}
|
|
58
185
|
|
|
59
186
|
config.target_languages.each do |lang|
|
|
60
|
-
translate_language(source_strings, lang)
|
|
187
|
+
translate_language(source_strings, lang, handler)
|
|
61
188
|
results[:success_count] += 1
|
|
62
189
|
rescue StandardError => e
|
|
63
|
-
results
|
|
64
|
-
# @type var error_context: Hash[Symbol, untyped]
|
|
65
|
-
error_context = e.respond_to?(:context) ? e.context : {}
|
|
66
|
-
results[:errors] << {
|
|
67
|
-
language: lang[:name],
|
|
68
|
-
error: e.message,
|
|
69
|
-
context: error_context
|
|
70
|
-
}
|
|
71
|
-
@progress_tracker.error(lang[:name], e)
|
|
190
|
+
record_error(results, lang, e)
|
|
72
191
|
end
|
|
73
192
|
|
|
74
193
|
results
|
|
75
194
|
end
|
|
76
195
|
|
|
77
|
-
|
|
196
|
+
# Record translation error in results
|
|
197
|
+
#
|
|
198
|
+
# @param results [Hash] Results hash to update
|
|
199
|
+
# @param lang [Hash] Language config
|
|
200
|
+
# @param error [StandardError] The error that occurred
|
|
201
|
+
# @return [void]
|
|
202
|
+
# @api private
|
|
203
|
+
#
|
|
204
|
+
def record_error(results, lang, error)
|
|
205
|
+
results[:failure_count] += 1
|
|
206
|
+
# @type var error_context: Hash[Symbol, untyped]
|
|
207
|
+
error_context = if error.is_a?(BetterTranslate::Error)
|
|
208
|
+
error.context
|
|
209
|
+
else
|
|
210
|
+
{}
|
|
211
|
+
end
|
|
212
|
+
results[:errors] << {
|
|
213
|
+
language: lang[:name],
|
|
214
|
+
error: error.message,
|
|
215
|
+
context: error_context
|
|
216
|
+
}
|
|
217
|
+
@progress_tracker.error(lang[:name], error)
|
|
218
|
+
end
|
|
78
219
|
|
|
79
220
|
# Translate to a single language
|
|
80
221
|
#
|
|
81
222
|
# @param source_strings [Hash] Source strings (flattened)
|
|
82
223
|
# @param lang [Hash] Language config with :short_name and :name
|
|
224
|
+
# @param handler [YAMLHandler, JsonHandler] File handler for this file
|
|
83
225
|
# @return [void]
|
|
84
226
|
# @api private
|
|
85
227
|
#
|
|
86
|
-
def translate_language(source_strings, lang)
|
|
228
|
+
def translate_language(source_strings, lang, handler)
|
|
87
229
|
target_lang_code = lang[:short_name]
|
|
88
230
|
target_lang_name = lang[:name]
|
|
89
231
|
|
|
90
232
|
# Filter exclusions
|
|
91
|
-
strings_to_translate =
|
|
233
|
+
strings_to_translate = handler.filter_exclusions(source_strings, target_lang_code)
|
|
92
234
|
|
|
93
235
|
return if strings_to_translate.empty?
|
|
94
236
|
|
|
@@ -104,20 +246,63 @@ module BetterTranslate
|
|
|
104
246
|
@progress_tracker.reset
|
|
105
247
|
translated = strategy.translate(strings_to_translate, target_lang_code, target_lang_name)
|
|
106
248
|
|
|
107
|
-
# Save
|
|
108
|
-
output_path =
|
|
249
|
+
# Save - generate output path with proper filename
|
|
250
|
+
output_path = build_output_path_for_file(config.input_file, target_lang_code)
|
|
109
251
|
|
|
110
252
|
final_translations = if config.translation_mode == :incremental
|
|
111
|
-
|
|
253
|
+
handler.merge_translations(output_path, translated)
|
|
112
254
|
else
|
|
113
255
|
Utils::HashFlattener.unflatten(translated)
|
|
114
256
|
end
|
|
115
257
|
|
|
116
258
|
# Wrap in language key (e.g., "it:")
|
|
117
259
|
wrapped = { target_lang_code => final_translations }
|
|
118
|
-
|
|
260
|
+
|
|
261
|
+
# Write using appropriate handler method (write_yaml or write_json)
|
|
262
|
+
if handler.is_a?(JsonHandler)
|
|
263
|
+
handler.write_json(output_path, wrapped)
|
|
264
|
+
else
|
|
265
|
+
handler.write_yaml(output_path, wrapped)
|
|
266
|
+
end
|
|
119
267
|
|
|
120
268
|
@progress_tracker.complete(target_lang_name, translated.size)
|
|
121
269
|
end
|
|
270
|
+
|
|
271
|
+
# Build output path for a specific file and language
|
|
272
|
+
#
|
|
273
|
+
# Replaces source language code with target language code in filename
|
|
274
|
+
# and preserves directory structure
|
|
275
|
+
#
|
|
276
|
+
# @param input_file [String] Input file path
|
|
277
|
+
# @param target_lang_code [String] Target language code
|
|
278
|
+
# @return [String] Output file path
|
|
279
|
+
# @api private
|
|
280
|
+
#
|
|
281
|
+
# @example
|
|
282
|
+
# build_output_path_for_file("config/locales/common.en.yml", "it")
|
|
283
|
+
# #=> "config/locales/common.it.yml"
|
|
284
|
+
#
|
|
285
|
+
def build_output_path_for_file(input_file, target_lang_code)
|
|
286
|
+
# Get file basename and directory
|
|
287
|
+
dir = File.dirname(input_file)
|
|
288
|
+
basename = File.basename(input_file)
|
|
289
|
+
ext = File.extname(basename)
|
|
290
|
+
name_without_ext = File.basename(basename, ext)
|
|
291
|
+
|
|
292
|
+
# Replace source language code with target language code
|
|
293
|
+
# Handles patterns like: common.en.yml -> common.it.yml
|
|
294
|
+
new_basename = name_without_ext.gsub(/\.#{config.source_language}$/, ".#{target_lang_code}") + ext
|
|
295
|
+
|
|
296
|
+
# If no language code in filename, use simple pattern
|
|
297
|
+
new_basename = "#{target_lang_code}#{ext}" if new_basename == basename
|
|
298
|
+
|
|
299
|
+
# Build output path
|
|
300
|
+
if config.output_folder
|
|
301
|
+
# Use output_folder but preserve relative directory structure if input was nested
|
|
302
|
+
File.join(config.output_folder, new_basename)
|
|
303
|
+
else
|
|
304
|
+
File.join(dir, new_basename)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
122
307
|
end
|
|
123
308
|
end
|
|
@@ -47,7 +47,7 @@ module BetterTranslate
|
|
|
47
47
|
# #=> { "config/database/host" => "localhost" }
|
|
48
48
|
#
|
|
49
49
|
def self.flatten(hash, parent_key = "", separator = ".")
|
|
50
|
-
initial_hash = {}
|
|
50
|
+
initial_hash = {} # : Hash[String, untyped]
|
|
51
51
|
hash.each_with_object(initial_hash) do |(key, value), result|
|
|
52
52
|
new_key = parent_key.empty? ? key.to_s : "#{parent_key}#{separator}#{key}"
|
|
53
53
|
|
|
@@ -85,7 +85,7 @@ module BetterTranslate
|
|
|
85
85
|
# #=> { "config" => { "database" => { "host" => "localhost" } } }
|
|
86
86
|
#
|
|
87
87
|
def self.unflatten(hash, separator = ".")
|
|
88
|
-
initial_hash = {}
|
|
88
|
+
initial_hash = {} # : Hash[String, untyped]
|
|
89
89
|
hash.each_with_object(initial_hash) do |(key, value), result|
|
|
90
90
|
keys = key.split(separator)
|
|
91
91
|
last_key = keys.pop
|
|
@@ -15,9 +15,9 @@ module BetterTranslate
|
|
|
15
15
|
# @example Basic usage
|
|
16
16
|
# extractor = VariableExtractor.new("Hello %{name}, you have {{count}} messages")
|
|
17
17
|
# safe_text = extractor.extract
|
|
18
|
-
# #=> "Hello
|
|
18
|
+
# #=> "Hello VARIABLE_0, you have VARIABLE_1 messages"
|
|
19
19
|
#
|
|
20
|
-
# translated = translate(safe_text) # "Ciao
|
|
20
|
+
# translated = translate(safe_text) # "Ciao VARIABLE_0, hai VARIABLE_1 messaggi"
|
|
21
21
|
# final = extractor.restore(translated)
|
|
22
22
|
# #=> "Ciao %{name}, hai {{count}} messaggi"
|
|
23
23
|
#
|
|
@@ -41,10 +41,10 @@ module BetterTranslate
|
|
|
41
41
|
COMBINED_PATTERN = Regexp.union(*VARIABLE_PATTERNS.values).freeze
|
|
42
42
|
|
|
43
43
|
# Placeholder prefix
|
|
44
|
-
PLACEHOLDER_PREFIX = "
|
|
44
|
+
PLACEHOLDER_PREFIX = "VARIABLE_"
|
|
45
45
|
|
|
46
46
|
# Placeholder suffix
|
|
47
|
-
PLACEHOLDER_SUFFIX = "
|
|
47
|
+
PLACEHOLDER_SUFFIX = ""
|
|
48
48
|
|
|
49
49
|
# @return [String] Original text with variables
|
|
50
50
|
attr_reader :original_text
|
|
@@ -72,13 +72,13 @@ module BetterTranslate
|
|
|
72
72
|
# Extract variables and replace with placeholders
|
|
73
73
|
#
|
|
74
74
|
# Scans the text for all supported variable formats and replaces them
|
|
75
|
-
# with numbered placeholders (
|
|
75
|
+
# with numbered placeholders (VARIABLE_0, VARIABLE_1, etc.).
|
|
76
76
|
#
|
|
77
77
|
# @return [String] Text with variables replaced by placeholders
|
|
78
78
|
#
|
|
79
79
|
# @example
|
|
80
80
|
# extractor = VariableExtractor.new("Hello %{name}")
|
|
81
|
-
# extractor.extract #=> "Hello
|
|
81
|
+
# extractor.extract #=> "Hello VARIABLE_0"
|
|
82
82
|
#
|
|
83
83
|
def extract
|
|
84
84
|
return "" if original_text.nil? || original_text.empty?
|
|
@@ -112,7 +112,7 @@ module BetterTranslate
|
|
|
112
112
|
# @example Successful restore
|
|
113
113
|
# extractor = VariableExtractor.new("Hello %{name}")
|
|
114
114
|
# extractor.extract
|
|
115
|
-
# extractor.restore("Ciao
|
|
115
|
+
# extractor.restore("Ciao VARIABLE_0") #=> "Ciao %{name}"
|
|
116
116
|
#
|
|
117
117
|
# @example Strict mode with missing variable
|
|
118
118
|
# extractor = VariableExtractor.new("Hello %{name}")
|
|
@@ -81,6 +81,9 @@ module BetterTranslate
|
|
|
81
81
|
|
|
82
82
|
return summary if config.dry_run
|
|
83
83
|
|
|
84
|
+
# Create backup if enabled and file exists
|
|
85
|
+
create_backup_file(file_path) if config.create_backup && File.exist?(file_path)
|
|
86
|
+
|
|
84
87
|
# Ensure output directory exists
|
|
85
88
|
FileUtils.mkdir_p(File.dirname(file_path))
|
|
86
89
|
|
|
@@ -164,5 +167,61 @@ module BetterTranslate
|
|
|
164
167
|
|
|
165
168
|
File.join(config.output_folder, "#{target_lang_code}.yml")
|
|
166
169
|
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# Create backup file with rotation support
|
|
174
|
+
#
|
|
175
|
+
# @param file_path [String] Path to file to backup
|
|
176
|
+
# @return [void]
|
|
177
|
+
# @api private
|
|
178
|
+
#
|
|
179
|
+
def create_backup_file(file_path)
|
|
180
|
+
return unless File.exist?(file_path)
|
|
181
|
+
|
|
182
|
+
# Rotate existing backups if max_backups > 1
|
|
183
|
+
rotate_backups(file_path) if config.max_backups > 1
|
|
184
|
+
|
|
185
|
+
# Create primary backup
|
|
186
|
+
backup_path = "#{file_path}.bak"
|
|
187
|
+
FileUtils.cp(file_path, backup_path)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Rotate backup files, keeping only max_backups
|
|
191
|
+
#
|
|
192
|
+
# Rotation strategy:
|
|
193
|
+
# - .bak is always the most recent
|
|
194
|
+
# - .bak.1, .bak.2, etc. are progressively older
|
|
195
|
+
# - When we reach max_backups, oldest is deleted
|
|
196
|
+
#
|
|
197
|
+
# @param file_path [String] Base file path
|
|
198
|
+
# @return [void]
|
|
199
|
+
# @api private
|
|
200
|
+
#
|
|
201
|
+
def rotate_backups(file_path)
|
|
202
|
+
primary_backup = "#{file_path}.bak"
|
|
203
|
+
return unless File.exist?(primary_backup)
|
|
204
|
+
|
|
205
|
+
# Clean up ANY backups that would exceed max_backups after rotation
|
|
206
|
+
# max_backups includes .bak itself, so numbered backups go from 1 to max_backups-1
|
|
207
|
+
# After rotation, .bak -> .bak.1, so we can have at most .bak.1 through .bak.(max_backups-1)
|
|
208
|
+
10.downto(config.max_backups) do |i|
|
|
209
|
+
numbered_backup = "#{file_path}.bak.#{i}"
|
|
210
|
+
FileUtils.rm_f(numbered_backup) if File.exist?(numbered_backup)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Rotate numbered backups from high to low to avoid overwrites
|
|
214
|
+
# max_backups=2: nothing to rotate (only .bak -> .bak.1)
|
|
215
|
+
# max_backups=3: .bak.1 -> .bak.2 (if exists)
|
|
216
|
+
(config.max_backups - 2).downto(1) do |i|
|
|
217
|
+
old_path = "#{file_path}.bak.#{i}"
|
|
218
|
+
new_path = "#{file_path}.bak.#{i + 1}"
|
|
219
|
+
|
|
220
|
+
FileUtils.mv(old_path, new_path) if File.exist?(old_path)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Move primary backup to .bak.1
|
|
224
|
+
FileUtils.mv(primary_backup, "#{file_path}.bak.1")
|
|
225
|
+
end
|
|
167
226
|
end
|
|
168
227
|
end
|
data/lib/better_translate.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "better_translate/providers/gemini_provider"
|
|
|
14
14
|
require_relative "better_translate/providers/anthropic_provider"
|
|
15
15
|
require_relative "better_translate/provider_factory"
|
|
16
16
|
require_relative "better_translate/yaml_handler"
|
|
17
|
+
require_relative "better_translate/json_handler"
|
|
17
18
|
require_relative "better_translate/progress_tracker"
|
|
18
19
|
require_relative "better_translate/strategies/base_strategy"
|
|
19
20
|
require_relative "better_translate/strategies/deep_strategy"
|
|
@@ -21,6 +22,10 @@ require_relative "better_translate/strategies/batch_strategy"
|
|
|
21
22
|
require_relative "better_translate/strategies/strategy_selector"
|
|
22
23
|
require_relative "better_translate/translator"
|
|
23
24
|
require_relative "better_translate/direct_translator"
|
|
25
|
+
require_relative "better_translate/analyzer/key_scanner"
|
|
26
|
+
require_relative "better_translate/analyzer/code_scanner"
|
|
27
|
+
require_relative "better_translate/analyzer/orphan_detector"
|
|
28
|
+
require_relative "better_translate/analyzer/reporter"
|
|
24
29
|
require_relative "better_translate/cli"
|
|
25
30
|
|
|
26
31
|
# Load Rails integration if Rails is present
|
|
@@ -12,7 +12,8 @@ module BetterTranslate
|
|
|
12
12
|
# rails generate better_translate:analyze config/locales/en.yml
|
|
13
13
|
#
|
|
14
14
|
class AnalyzeGenerator < Rails::Generators::Base
|
|
15
|
-
|
|
15
|
+
dir = __dir__
|
|
16
|
+
source_root File.expand_path("templates", dir) if dir
|
|
16
17
|
|
|
17
18
|
desc "Analyze YAML locale file structure and statistics"
|
|
18
19
|
|
|
@@ -12,7 +12,8 @@ module BetterTranslate
|
|
|
12
12
|
# rails generate better_translate:install
|
|
13
13
|
#
|
|
14
14
|
class InstallGenerator < Rails::Generators::Base
|
|
15
|
-
|
|
15
|
+
dir = __dir__
|
|
16
|
+
source_root File.expand_path("templates", dir) if dir
|
|
16
17
|
|
|
17
18
|
desc "Creates BetterTranslate initializer and config files"
|
|
18
19
|
|
|
@@ -46,8 +47,8 @@ module BetterTranslate
|
|
|
46
47
|
"dry_run" => false,
|
|
47
48
|
"translation_mode" => "override",
|
|
48
49
|
"preserve_variables" => true,
|
|
49
|
-
"global_exclusions" =>
|
|
50
|
-
"exclusions_per_language" =>
|
|
50
|
+
"global_exclusions" => [],
|
|
51
|
+
"exclusions_per_language" => {},
|
|
51
52
|
"model" => nil,
|
|
52
53
|
"temperature" => 0.3,
|
|
53
54
|
"max_tokens" => 2000,
|
|
@@ -14,15 +14,47 @@ BetterTranslate.configure do |config|
|
|
|
14
14
|
config.anthropic_key = ENV["ANTHROPIC_API_KEY"]
|
|
15
15
|
|
|
16
16
|
# Source and target languages
|
|
17
|
-
|
|
18
|
-
config.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
# Automatically uses Rails I18n configuration
|
|
18
|
+
# To configure I18n in your Rails app, set in config/application.rb:
|
|
19
|
+
# config.i18n.default_locale = :it
|
|
20
|
+
# config.i18n.available_locales = [:it, :en, :es, :fr]
|
|
21
|
+
config.source_language = I18n.default_locale.to_s
|
|
22
|
+
|
|
23
|
+
# Target languages: automatically derived from I18n.available_locales
|
|
24
|
+
# Excludes the source language from targets
|
|
25
|
+
available_targets = (I18n.available_locales - [I18n.default_locale]).map(&:to_s)
|
|
26
|
+
|
|
27
|
+
# Language name mapping for common languages
|
|
28
|
+
language_names = {
|
|
29
|
+
"en" => "English", "it" => "Italian", "es" => "Spanish", "fr" => "French",
|
|
30
|
+
"de" => "German", "pt" => "Portuguese", "ru" => "Russian", "zh" => "Chinese",
|
|
31
|
+
"ja" => "Japanese", "ko" => "Korean", "ar" => "Arabic", "nl" => "Dutch",
|
|
32
|
+
"pl" => "Polish", "tr" => "Turkish", "sv" => "Swedish", "da" => "Danish",
|
|
33
|
+
"fi" => "Finnish", "no" => "Norwegian", "cs" => "Czech", "el" => "Greek",
|
|
34
|
+
"he" => "Hebrew", "hi" => "Hindi", "th" => "Thai", "vi" => "Vietnamese"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if available_targets.any?
|
|
38
|
+
# Use I18n available locales
|
|
39
|
+
config.target_languages = available_targets.map do |locale|
|
|
40
|
+
{
|
|
41
|
+
short_name: locale,
|
|
42
|
+
name: language_names[locale] || locale.capitalize
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
# Fallback: suggest common languages
|
|
47
|
+
# Uncomment and modify the languages you want to translate to
|
|
48
|
+
config.target_languages = [
|
|
49
|
+
{ short_name: "it", name: "Italian" },
|
|
50
|
+
{ short_name: "es", name: "Spanish" },
|
|
51
|
+
{ short_name: "fr", name: "French" }
|
|
52
|
+
]
|
|
53
|
+
end
|
|
23
54
|
|
|
24
55
|
# File paths
|
|
25
|
-
|
|
56
|
+
# Uses source_language for input file
|
|
57
|
+
config.input_file = Rails.root.join("config", "locales", "#{config.source_language}.yml").to_s
|
|
26
58
|
config.output_folder = Rails.root.join("config", "locales").to_s
|
|
27
59
|
|
|
28
60
|
# Options
|
|
@@ -15,7 +15,8 @@ module BetterTranslate
|
|
|
15
15
|
# rails generate better_translate:translate --dry-run
|
|
16
16
|
#
|
|
17
17
|
class TranslateGenerator < Rails::Generators::Base
|
|
18
|
-
|
|
18
|
+
dir = __dir__
|
|
19
|
+
source_root File.expand_path("templates", dir) if dir
|
|
19
20
|
|
|
20
21
|
desc "Run BetterTranslate translation task"
|
|
21
22
|
|
data/regenerate_vcr.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "dotenv/load"
|
|
6
|
+
require "vcr"
|
|
7
|
+
require "webmock/rspec"
|
|
8
|
+
require_relative "lib/better_translate"
|
|
9
|
+
require "tmpdir"
|
|
10
|
+
|
|
11
|
+
# Setup VCR
|
|
12
|
+
VCR.configure do |config|
|
|
13
|
+
config.cassette_library_dir = "spec/vcr_cassettes"
|
|
14
|
+
config.hook_into :webmock
|
|
15
|
+
config.filter_sensitive_data("<GEMINI_API_KEY>") { ENV["GEMINI_API_KEY"] }
|
|
16
|
+
config.default_cassette_options = { record: :all }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
test_dir = Dir.mktmpdir("gemini_test")
|
|
20
|
+
puts "Test output dir: #{test_dir}"
|
|
21
|
+
|
|
22
|
+
VCR.use_cassette("rails/dummy_app_gemini_translation", record: :all) do
|
|
23
|
+
config = BetterTranslate::Configuration.new
|
|
24
|
+
config.provider = :gemini
|
|
25
|
+
config.gemini_key = ENV["GEMINI_API_KEY"]
|
|
26
|
+
config.source_language = "en"
|
|
27
|
+
config.target_languages = [{ short_name: "fr", name: "French" }]
|
|
28
|
+
config.input_file = "spec/dummy/config/locales/en.yml"
|
|
29
|
+
config.output_folder = test_dir
|
|
30
|
+
config.cache_enabled = false
|
|
31
|
+
config.verbose = true
|
|
32
|
+
config.validate!
|
|
33
|
+
|
|
34
|
+
puts "Starting translation..."
|
|
35
|
+
translator = BetterTranslate::Translator.new(config)
|
|
36
|
+
results = translator.translate_all
|
|
37
|
+
|
|
38
|
+
puts "\nResults: #{results.inspect}"
|
|
39
|
+
puts "\nFiles created:"
|
|
40
|
+
Dir.entries(test_dir).each { |f| puts " - #{f}" unless f.start_with?(".") }
|
|
41
|
+
|
|
42
|
+
if results[:success_count].positive?
|
|
43
|
+
puts "\n✓ Successfully regenerated VCR cassette!"
|
|
44
|
+
else
|
|
45
|
+
puts "\n✗ Translation failed"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -27,12 +27,20 @@ module BetterTranslate
|
|
|
27
27
|
@global_exclusions: Array[String]
|
|
28
28
|
@exclusions_per_language: Hash[String, Array[String]]
|
|
29
29
|
@preserve_variables: bool
|
|
30
|
+
@input_files: (String | Array[String])?
|
|
31
|
+
@create_backup: bool
|
|
32
|
+
@max_backups: Integer
|
|
30
33
|
|
|
31
34
|
attr_accessor provider: (Symbol | nil)
|
|
32
35
|
attr_accessor openai_key: String?
|
|
33
36
|
attr_accessor google_gemini_key: String?
|
|
34
|
-
attr_accessor
|
|
35
|
-
|
|
37
|
+
attr_accessor claude_key: String?
|
|
38
|
+
|
|
39
|
+
# Aliases (these are methods created by alias in configuration.rb)
|
|
40
|
+
alias gemini_key google_gemini_key
|
|
41
|
+
alias gemini_key= google_gemini_key=
|
|
42
|
+
alias anthropic_key claude_key
|
|
43
|
+
alias anthropic_key= claude_key=
|
|
36
44
|
attr_accessor source_language: String?
|
|
37
45
|
attr_accessor target_languages: Array[target_language]
|
|
38
46
|
attr_accessor input_file: String?
|
|
@@ -51,6 +59,9 @@ module BetterTranslate
|
|
|
51
59
|
attr_accessor global_exclusions: Array[String]
|
|
52
60
|
attr_accessor exclusions_per_language: Hash[String, Array[String]]
|
|
53
61
|
attr_accessor preserve_variables: bool
|
|
62
|
+
attr_accessor input_files: (String | Array[String])?
|
|
63
|
+
attr_accessor create_backup: bool
|
|
64
|
+
attr_accessor max_backups: Integer
|
|
54
65
|
|
|
55
66
|
# Provider-specific options
|
|
56
67
|
attr_accessor model: String?
|
|
@@ -36,7 +36,7 @@ module BetterTranslate
|
|
|
36
36
|
|
|
37
37
|
def http_client: () -> Faraday::Connection
|
|
38
38
|
|
|
39
|
-
def with_cache:
|
|
39
|
+
def with_cache: (String cache_key) { () -> String } -> String
|
|
40
40
|
|
|
41
41
|
def build_cache_key: (String text, String target_lang_code) -> String
|
|
42
42
|
end
|
|
@@ -10,6 +10,7 @@ module BetterTranslate
|
|
|
10
10
|
@provider: Providers::BaseHttpProvider
|
|
11
11
|
@yaml_handler: YAMLHandler
|
|
12
12
|
@progress_tracker: ProgressTracker
|
|
13
|
+
@input_files: Array[String]
|
|
13
14
|
|
|
14
15
|
attr_reader config: Configuration
|
|
15
16
|
|
|
@@ -19,6 +20,16 @@ module BetterTranslate
|
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
+
def resolve_input_files: () -> Array[String]
|
|
24
|
+
|
|
25
|
+
def translate_parallel: (Hash[String, untyped] source_strings, (YAMLHandler | JsonHandler) handler) -> translation_results
|
|
26
|
+
|
|
27
|
+
def translate_sequential: (Hash[String, untyped] source_strings, (YAMLHandler | JsonHandler) handler) -> translation_results
|
|
28
|
+
|
|
29
|
+
def translate_language: (Hash[String, untyped] source_strings, Hash[Symbol, String] lang, (YAMLHandler | JsonHandler) handler) -> void
|
|
30
|
+
|
|
31
|
+
def record_error: (translation_results results, Hash[Symbol, String] lang, Exception error) -> void
|
|
32
|
+
|
|
33
|
+
def build_output_path_for_file: (String input_file, String target_lang_code) -> String
|
|
23
34
|
end
|
|
24
35
|
end
|