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
@@ -13,6 +13,15 @@ module BetterTranslate
13
13
  # config.target_languages = [{ short_name: "it", name: "Italian" }]
14
14
  # config.validate!
15
15
  #
16
+ # @example Provider-specific options
17
+ # config.model = "gpt-5-nano" # Specify model (optional, provider-specific)
18
+ # config.temperature = 0.7 # Control creativity (0.0-2.0, default: 0.3)
19
+ # config.max_tokens = 1500 # Limit response length (default: 2000)
20
+ #
21
+ # @example Backup configuration
22
+ # config.create_backup = true # Enable automatic backups (default: true)
23
+ # config.max_backups = 5 # Keep up to 5 backup files (default: 3)
24
+ #
16
25
  class Configuration
17
26
  # @return [Symbol] The translation provider (:chatgpt, :gemini, :anthropic)
18
27
  attr_accessor :provider
@@ -40,9 +49,12 @@ module BetterTranslate
40
49
  # @return [Array<Hash>] Target languages with :short_name and :name
41
50
  attr_accessor :target_languages
42
51
 
43
- # @return [String] Path to input YAML file
52
+ # @return [String] Path to input YAML file (for backward compatibility, use input_files for multiple files)
44
53
  attr_accessor :input_file
45
54
 
55
+ # @return [Array<String>, String] Multiple input files (array or glob pattern)
56
+ attr_accessor :input_files
57
+
46
58
  # @return [String] Output folder for translated files
47
59
  attr_accessor :output_folder
48
60
 
@@ -88,6 +100,21 @@ module BetterTranslate
88
100
  # @return [Boolean] Preserve interpolation variables during translation (default: true)
89
101
  attr_accessor :preserve_variables
90
102
 
103
+ # @return [String, nil] AI model to use (provider-specific, e.g., "gpt-5-nano", "gemini-2.0-flash-exp")
104
+ attr_accessor :model
105
+
106
+ # @return [Float] Temperature for AI generation (0.0-2.0, higher = more creative)
107
+ attr_accessor :temperature
108
+
109
+ # @return [Integer] Maximum tokens for AI response
110
+ attr_accessor :max_tokens
111
+
112
+ # @return [Boolean] Create backup files before overwriting (default: true)
113
+ attr_accessor :create_backup
114
+
115
+ # @return [Integer] Maximum number of backup files to keep (default: 3)
116
+ attr_accessor :max_backups
117
+
91
118
  # Initialize a new configuration with defaults
92
119
  def initialize
93
120
  @translation_mode = :override
@@ -104,6 +131,11 @@ module BetterTranslate
104
131
  @exclusions_per_language = {}
105
132
  @target_languages = []
106
133
  @preserve_variables = true
134
+ @model = nil
135
+ @temperature = 0.3
136
+ @max_tokens = 2000
137
+ @create_backup = true
138
+ @max_backups = 3
107
139
  end
108
140
 
109
141
  # Validate the configuration
@@ -176,8 +208,14 @@ module BetterTranslate
176
208
  # @return [void]
177
209
  # @api private
178
210
  def validate_files!
179
- raise ConfigurationError, "Input file must be set" if input_file.nil? || input_file.empty?
211
+ # Check if either input_file or input_files is set
212
+ has_input = (input_file && !input_file.empty?) || input_files
213
+
214
+ raise ConfigurationError, "Input file or input_files must be set" unless has_input
180
215
  raise ConfigurationError, "Output folder must be set" if output_folder.nil? || output_folder.empty?
216
+
217
+ # Only validate input_file exists if using single file mode (not glob pattern or array)
218
+ return unless input_file && !input_file.empty? && !input_files
181
219
  raise ConfigurationError, "Input file does not exist: #{input_file}" unless File.exist?(input_file)
182
220
  end
183
221
 
@@ -196,6 +234,14 @@ module BetterTranslate
196
234
  raise ConfigurationError, "Request timeout must be positive" if request_timeout <= 0
197
235
  raise ConfigurationError, "Max retries must be non-negative" if max_retries.negative?
198
236
  raise ConfigurationError, "Cache size must be positive" if cache_size <= 0
237
+
238
+ # Validate temperature range (AI providers typically accept 0.0-2.0)
239
+ if temperature && (temperature < 0.0 || temperature > 2.0)
240
+ raise ConfigurationError, "Temperature must be between 0.0 and 2.0"
241
+ end
242
+
243
+ # Validate max_tokens is positive
244
+ raise ConfigurationError, "Max tokens must be positive" if max_tokens && max_tokens <= 0
199
245
  end
200
246
  end
201
247
  end
@@ -90,6 +90,15 @@ module BetterTranslate
90
90
  # )
91
91
  class YamlError < Error; end
92
92
 
93
+ # Raised when JSON parsing fails
94
+ #
95
+ # @example JSON syntax error
96
+ # raise JsonError.new(
97
+ # "Invalid JSON syntax",
98
+ # context: { file_path: "config/locales/en.json", error: "unexpected token" }
99
+ # )
100
+ class JsonError < Error; end
101
+
93
102
  # Raised when a provider is not found
94
103
  #
95
104
  # @example Provider not found
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module BetterTranslate
7
+ # Handles JSON file operations
8
+ #
9
+ # Provides methods for:
10
+ # - Reading and parsing JSON files
11
+ # - Writing JSON files with proper formatting
12
+ # - Merging translations (incremental mode)
13
+ # - Handling exclusions
14
+ # - Flattening/unflattening nested structures
15
+ #
16
+ # @example Reading a JSON file
17
+ # handler = JsonHandler.new(config)
18
+ # data = handler.read_json("config/locales/en.json")
19
+ #
20
+ # @example Writing translations
21
+ # handler.write_json("config/locales/it.json", { "it" => { "greeting" => "Ciao" } })
22
+ #
23
+ class JsonHandler
24
+ # @return [Configuration] Configuration object
25
+ attr_reader :config
26
+
27
+ # Initialize JSON handler
28
+ #
29
+ # @param config [Configuration] Configuration object
30
+ #
31
+ # @example
32
+ # config = Configuration.new
33
+ # handler = JsonHandler.new(config)
34
+ #
35
+ def initialize(config)
36
+ @config = config
37
+ end
38
+
39
+ # Read and parse JSON file
40
+ #
41
+ # @param file_path [String] Path to JSON file
42
+ # @return [Hash] Parsed JSON content
43
+ # @raise [FileError] if file cannot be read
44
+ # @raise [JsonError] if JSON is invalid
45
+ #
46
+ # @example
47
+ # data = handler.read_json("config/locales/en.json")
48
+ # #=> { "en" => { "greeting" => "Hello" } }
49
+ #
50
+ def read_json(file_path)
51
+ Validator.validate_file_exists!(file_path)
52
+
53
+ content = File.read(file_path)
54
+ return {} if content.strip.empty?
55
+
56
+ JSON.parse(content)
57
+ rescue Errno::ENOENT => e
58
+ raise FileError.new("File does not exist: #{file_path}", context: { error: e.message })
59
+ rescue JSON::ParserError => e
60
+ raise JsonError.new("Invalid JSON syntax in #{file_path}", context: { error: e.message })
61
+ end
62
+
63
+ # Write hash to JSON file
64
+ #
65
+ # @param file_path [String] Output file path
66
+ # @param data [Hash] Data to write
67
+ # @param diff_preview [DiffPreview, nil] Optional diff preview instance
68
+ # @return [Hash, nil] Summary hash if dry_run, nil otherwise
69
+ # @raise [FileError] if file cannot be written
70
+ #
71
+ # @example
72
+ # handler.write_json("config/locales/it.json", { "it" => { "greeting" => "Ciao" } })
73
+ #
74
+ def write_json(file_path, data, diff_preview: nil)
75
+ summary = nil
76
+
77
+ # Show diff preview if in dry run mode
78
+ if config.dry_run && diff_preview
79
+ existing_data = File.exist?(file_path) ? read_json(file_path) : {}
80
+ summary = diff_preview.show_diff(existing_data, data, file_path)
81
+ end
82
+
83
+ return summary if config.dry_run
84
+
85
+ # Create backup if enabled and file exists
86
+ create_backup_file(file_path) if config.create_backup && File.exist?(file_path)
87
+
88
+ # Ensure output directory exists
89
+ FileUtils.mkdir_p(File.dirname(file_path))
90
+
91
+ # Write JSON with proper indentation
92
+ File.write(file_path, JSON.pretty_generate(data))
93
+
94
+ nil
95
+ rescue Errno::EACCES => e
96
+ raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
97
+ rescue StandardError => e
98
+ raise FileError.new("Failed to write JSON: #{file_path}", context: { error: e.message })
99
+ end
100
+
101
+ # Get translatable strings from source JSON
102
+ #
103
+ # Reads the input file and returns a flattened hash of strings.
104
+ # Removes the root language key if present.
105
+ #
106
+ # @return [Hash] Flattened hash of translatable strings
107
+ #
108
+ # @example
109
+ # strings = handler.get_source_strings
110
+ # #=> { "greeting" => "Hello", "nav.home" => "Home" }
111
+ #
112
+ def get_source_strings
113
+ return {} unless config.input_file
114
+
115
+ source_data = read_json(config.input_file)
116
+ # Remove root language key if present (e.g., "en:")
117
+ source_data = source_data[config.source_language] || source_data
118
+
119
+ Utils::HashFlattener.flatten(source_data)
120
+ end
121
+
122
+ # Filter out excluded keys for a specific language
123
+ #
124
+ # @param strings [Hash] Flattened strings
125
+ # @param target_lang_code [String] Target language code
126
+ # @return [Hash] Filtered strings
127
+ #
128
+ # @example
129
+ # filtered = handler.filter_exclusions(strings, "it")
130
+ #
131
+ def filter_exclusions(strings, target_lang_code)
132
+ excluded_keys = config.global_exclusions.dup
133
+ excluded_keys += config.exclusions_per_language[target_lang_code] || []
134
+
135
+ strings.reject { |key, _| excluded_keys.include?(key) }
136
+ end
137
+
138
+ # Merge translated strings with existing file (incremental mode)
139
+ #
140
+ # @param file_path [String] Existing file path
141
+ # @param new_translations [Hash] New translations (flattened)
142
+ # @return [Hash] Merged translations (nested)
143
+ #
144
+ # @example
145
+ # merged = handler.merge_translations("config/locales/it.json", new_translations)
146
+ #
147
+ def merge_translations(file_path, new_translations)
148
+ if File.exist?(file_path)
149
+ existing = read_json(file_path)
150
+ # Extract actual translations (remove language wrapper if present)
151
+ target_lang = config.target_languages.first[:short_name]
152
+ existing = existing[target_lang] || existing
153
+ else
154
+ existing = {}
155
+ end
156
+
157
+ existing_flat = Utils::HashFlattener.flatten(existing)
158
+
159
+ # Merge: existing takes precedence
160
+ merged = new_translations.merge(existing_flat)
161
+
162
+ Utils::HashFlattener.unflatten(merged)
163
+ end
164
+
165
+ # Build output file path for target language
166
+ #
167
+ # @param target_lang_code [String] Target language code
168
+ # @return [String] Output file path
169
+ #
170
+ # @example
171
+ # path = handler.build_output_path("it")
172
+ # #=> "config/locales/it.json"
173
+ #
174
+ def build_output_path(target_lang_code)
175
+ return "#{target_lang_code}.json" unless config.output_folder
176
+
177
+ File.join(config.output_folder, "#{target_lang_code}.json")
178
+ end
179
+
180
+ private
181
+
182
+ # Create backup file with rotation support
183
+ #
184
+ # @param file_path [String] Path to file to backup
185
+ # @return [void]
186
+ # @api private
187
+ #
188
+ def create_backup_file(file_path)
189
+ return unless File.exist?(file_path)
190
+
191
+ # Rotate existing backups if max_backups > 1
192
+ rotate_backups(file_path) if config.max_backups > 1
193
+
194
+ # Create primary backup
195
+ backup_path = "#{file_path}.bak"
196
+ FileUtils.cp(file_path, backup_path)
197
+ end
198
+
199
+ # Rotate backup files, keeping only max_backups
200
+ #
201
+ # @param file_path [String] Base file path
202
+ # @return [void]
203
+ # @api private
204
+ #
205
+ def rotate_backups(file_path)
206
+ primary_backup = "#{file_path}.bak"
207
+ return unless File.exist?(primary_backup)
208
+
209
+ # Clean up ANY backups that would exceed max_backups after rotation
210
+ 10.downto(config.max_backups) do |i|
211
+ numbered_backup = "#{file_path}.bak.#{i}"
212
+ FileUtils.rm_f(numbered_backup) if File.exist?(numbered_backup)
213
+ end
214
+
215
+ # Rotate numbered backups from high to low to avoid overwrites
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
226
+ end
227
+ end
@@ -4,7 +4,7 @@ module BetterTranslate
4
4
  module Providers
5
5
  # Anthropic Claude translation provider
6
6
  #
7
- # Uses claude-3-5-sonnet-20241022 model for high-quality translations.
7
+ # Uses claude-haiku-4-5 model for fast, efficient translations.
8
8
  #
9
9
  # @example Basic usage
10
10
  # config = Configuration.new
@@ -18,7 +18,7 @@ module BetterTranslate
18
18
  API_URL = "https://api.anthropic.com/v1/messages"
19
19
 
20
20
  # Model to use for translations
21
- MODEL = "claude-3-5-sonnet-20241022"
21
+ MODEL = "claude-haiku-4-5"
22
22
 
23
23
  # API version
24
24
  API_VERSION = "2023-06-01"
@@ -97,7 +97,8 @@ module BetterTranslate
97
97
  #
98
98
  def build_system_message(target_lang_name)
99
99
  base_message = "You are a professional translator. Translate the following text to #{target_lang_name}. " \
100
- "Return ONLY the translated text, without any explanations or additional text."
100
+ "Return ONLY the translated text, without any explanations or additional text. " \
101
+ "Words like VARIABLE_0, VARIABLE_1, etc. are placeholders and must be kept unchanged in the translation."
101
102
 
102
103
  if config.translation_context && !config.translation_context.empty?
103
104
  base_message += "\n\nContext: #{config.translation_context}"
@@ -95,7 +95,8 @@ module BetterTranslate
95
95
  #
96
96
  def build_system_message(target_lang_name)
97
97
  base_message = "You are a professional translator. Translate the following text to #{target_lang_name}. " \
98
- "Return ONLY the translated text, without any explanations or additional text."
98
+ "Return ONLY the translated text, without any explanations or additional text. " \
99
+ "Words like VARIABLE_0, VARIABLE_1, etc. are placeholders and must be kept unchanged in the translation."
99
100
 
100
101
  if config.translation_context && !config.translation_context.empty?
101
102
  base_message += "\n\nContext: #{config.translation_context}"
@@ -4,7 +4,7 @@ module BetterTranslate
4
4
  module Providers
5
5
  # Google Gemini translation provider
6
6
  #
7
- # Uses gemini-2.0-flash-exp model for fast, high-quality translations.
7
+ # Uses gemini-2.5-flash-lite model for fast, high-quality translations.
8
8
  #
9
9
  # @example Basic usage
10
10
  # config = Configuration.new
@@ -15,10 +15,10 @@ module BetterTranslate
15
15
  #
16
16
  class GeminiProvider < BaseHttpProvider
17
17
  # Google Gemini API endpoint
18
- API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent"
18
+ API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"
19
19
 
20
20
  # Model to use for translations
21
- MODEL = "gemini-2.0-flash-exp"
21
+ MODEL = "gemini-2.5-flash-lite"
22
22
 
23
23
  # Translate a single text
24
24
  #
@@ -77,7 +77,8 @@ module BetterTranslate
77
77
  #
78
78
  def build_prompt(text, target_lang_name)
79
79
  base_prompt = "Translate the following text to #{target_lang_name}. " \
80
- "Return ONLY the translated text, without any explanations.\n\n" \
80
+ "Return ONLY the translated text, without any explanations. " \
81
+ "Words like VARIABLE_0, VARIABLE_1, etc. are placeholders and must be kept unchanged in the translation.\n\n" \
81
82
  "Text: #{text}"
82
83
 
83
84
  if config.translation_context && !config.translation_context.empty?
@@ -11,7 +11,8 @@ module BetterTranslate
11
11
  #
12
12
  class Railtie < Rails::Railtie
13
13
  rake_tasks do
14
- rake_file = File.expand_path("../tasks/better_translate.rake", __dir__)
14
+ dir = __dir__
15
+ rake_file = File.expand_path("../tasks/better_translate.rake", dir) if dir
15
16
  load rake_file if rake_file
16
17
  end
17
18
  end
@@ -52,7 +52,10 @@ module BetterTranslate
52
52
  @mutex.synchronize do
53
53
  return if @last_request_time.nil?
54
54
 
55
- elapsed = Time.now - @last_request_time
55
+ last_time = @last_request_time
56
+ return unless last_time
57
+
58
+ elapsed = Time.now - last_time
56
59
  sleep_time = @delay - elapsed.to_f
57
60
 
58
61
  sleep(sleep_time) if sleep_time.positive?
@@ -37,7 +37,7 @@ module BetterTranslate
37
37
  progress_tracker.update(
38
38
  language: target_lang_name,
39
39
  current_key: "Batch #{batch_index + 1}/#{total_batches}",
40
- progress: ((batch_index + 1).to_f / total_batches * 100.0).round(1)
40
+ progress: ((batch_index + 1).to_f / total_batches * 100.0).round(1).to_f
41
41
  )
42
42
 
43
43
  translated_batch = provider.translate_batch(batch, target_lang_code, target_lang_name)
@@ -32,7 +32,7 @@ module BetterTranslate
32
32
  progress_tracker.update(
33
33
  language: target_lang_name,
34
34
  current_key: key,
35
- progress: ((index + 1).to_f / total * 100.0).round(1)
35
+ progress: ((index + 1).to_f / total * 100.0).round(1).to_f
36
36
  )
37
37
 
38
38
  translated[key] = provider.translate_text(value, target_lang_code, target_lang_name)