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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +28 -0
  3. data/.rubocop_todo.yml +291 -0
  4. data/CHANGELOG.md +88 -0
  5. data/README.md +262 -2
  6. data/RELEASE_NOTES_v1.0.0.md +240 -0
  7. data/Steepfile +2 -2
  8. data/docs/implementation/00-overview.md +1 -1
  9. data/docs/implementation/04-provider_architecture.md +5 -5
  10. data/lib/better_translate/analyzer/code_scanner.rb +151 -0
  11. data/lib/better_translate/analyzer/key_scanner.rb +109 -0
  12. data/lib/better_translate/analyzer/orphan_detector.rb +88 -0
  13. data/lib/better_translate/analyzer/reporter.rb +155 -0
  14. data/lib/better_translate/cache.rb +2 -1
  15. data/lib/better_translate/cli.rb +81 -2
  16. data/lib/better_translate/configuration.rb +48 -2
  17. data/lib/better_translate/errors.rb +9 -0
  18. data/lib/better_translate/json_handler.rb +227 -0
  19. data/lib/better_translate/providers/anthropic_provider.rb +4 -3
  20. data/lib/better_translate/providers/chatgpt_provider.rb +2 -1
  21. data/lib/better_translate/providers/gemini_provider.rb +5 -4
  22. data/lib/better_translate/railtie.rb +2 -1
  23. data/lib/better_translate/rate_limiter.rb +4 -1
  24. data/lib/better_translate/strategies/batch_strategy.rb +1 -1
  25. data/lib/better_translate/strategies/deep_strategy.rb +1 -1
  26. data/lib/better_translate/translator.rb +204 -19
  27. data/lib/better_translate/utils/hash_flattener.rb +2 -2
  28. data/lib/better_translate/variable_extractor.rb +7 -7
  29. data/lib/better_translate/version.rb +1 -1
  30. data/lib/better_translate/yaml_handler.rb +59 -0
  31. data/lib/better_translate.rb +5 -0
  32. data/lib/generators/better_translate/analyze/analyze_generator.rb +2 -1
  33. data/lib/generators/better_translate/install/install_generator.rb +4 -3
  34. data/lib/generators/better_translate/install/templates/initializer.rb.tt +39 -7
  35. data/lib/generators/better_translate/translate/translate_generator.rb +2 -1
  36. data/regenerate_vcr.rb +47 -0
  37. data/sig/better_translate/configuration.rbs +13 -2
  38. data/sig/better_translate/errors.rbs +4 -0
  39. data/sig/better_translate/providers/base_http_provider.rbs +1 -1
  40. data/sig/better_translate/translator.rbs +12 -1
  41. data/sig/better_translate/variable_extractor.rbs +1 -1
  42. data/sig/better_translate/yaml_handler.rbs +6 -0
  43. data/sig/better_translate.rbs +2 -1
  44. 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
- @yaml_handler = YAMLHandler.new(config)
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
- source_strings = @yaml_handler.get_source_strings
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[:failure_count] += 1
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
- private
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 = @yaml_handler.filter_exclusions(source_strings, target_lang_code)
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 = @yaml_handler.build_output_path(target_lang_code)
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
- @yaml_handler.merge_translations(output_path, translated)
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
- @yaml_handler.write_yaml(output_path, wrapped)
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 = {} #: Hash[String, untyped]
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 = {} #: Hash[String, untyped]
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 __VAR_0__, you have __VAR_1__ messages"
18
+ # #=> "Hello VARIABLE_0, you have VARIABLE_1 messages"
19
19
  #
20
- # translated = translate(safe_text) # "Ciao __VAR_0__, hai __VAR_1__ messaggi"
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 = "__VAR_"
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 (__VAR_0__, __VAR_1__, etc.).
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 __VAR_0__"
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 __VAR_0__") #=> "Ciao %{name}"
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}")
@@ -2,5 +2,5 @@
2
2
 
3
3
  module BetterTranslate
4
4
  # Current version of BetterTranslate gem
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
@@ -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
@@ -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
- source_root File.expand_path("templates", __dir__)
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
- source_root File.expand_path("templates", __dir__)
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" => Array.new,
50
- "exclusions_per_language" => Hash.new,
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
- config.source_language = "en"
18
- config.target_languages = [
19
- { short_name: "it", name: "Italian" },
20
- { short_name: "es", name: "Spanish" },
21
- { short_name: "fr", name: "French" }
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
- config.input_file = Rails.root.join("config", "locales", "en.yml").to_s
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
- source_root File.expand_path("templates", __dir__)
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 gemini_key: String?
35
- attr_accessor anthropic_key: String?
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?
@@ -40,6 +40,10 @@ module BetterTranslate
40
40
  class YamlError < Error
41
41
  end
42
42
 
43
+ # Raised when JSON parsing fails
44
+ class JsonError < Error
45
+ end
46
+
43
47
  # Raised when a provider is not found
44
48
  class ProviderNotFoundError < Error
45
49
  end
@@ -36,7 +36,7 @@ module BetterTranslate
36
36
 
37
37
  def http_client: () -> Faraday::Connection
38
38
 
39
- def with_cache: [T] (String cache_key) { () -> T } -> T
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 translate_language: (Hash[String, untyped] source_strings, Hash[Symbol, String] lang) -> void
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