better_translate 0.5.0 → 1.0.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/.env.example +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +125 -114
- data/CLAUDE.md +385 -0
- data/README.md +629 -244
- data/Rakefile +7 -1
- data/Steepfile +29 -0
- data/docs/implementation/00-overview.md +220 -0
- data/docs/implementation/01-setup_dependencies.md +668 -0
- data/docs/implementation/02-error_handling.md +65 -0
- data/docs/implementation/03-core_components.md +457 -0
- data/docs/implementation/03.5-variable_preservation.md +509 -0
- data/docs/implementation/04-provider_architecture.md +571 -0
- data/docs/implementation/05-translation_logic.md +1065 -0
- data/docs/implementation/06-main_module_api.md +122 -0
- data/docs/implementation/07-direct_translation_helpers.md +582 -0
- data/docs/implementation/08-rails_integration.md +323 -0
- data/docs/implementation/09-testing_suite.md +228 -0
- data/docs/implementation/10-documentation_examples.md +150 -0
- data/docs/implementation/11-quality_security.md +65 -0
- data/docs/implementation/12-cli_standalone.md +698 -0
- data/exe/better_translate +9 -0
- data/lib/better_translate/cache.rb +125 -0
- data/lib/better_translate/cli.rb +304 -0
- data/lib/better_translate/configuration.rb +201 -0
- data/lib/better_translate/direct_translator.rb +131 -0
- data/lib/better_translate/errors.rb +101 -0
- data/lib/better_translate/progress_tracker.rb +157 -0
- data/lib/better_translate/provider_factory.rb +45 -0
- data/lib/better_translate/providers/anthropic_provider.rb +154 -0
- data/lib/better_translate/providers/base_http_provider.rb +239 -0
- data/lib/better_translate/providers/chatgpt_provider.rb +138 -44
- data/lib/better_translate/providers/gemini_provider.rb +123 -61
- data/lib/better_translate/railtie.rb +18 -0
- data/lib/better_translate/rate_limiter.rb +90 -0
- data/lib/better_translate/strategies/base_strategy.rb +58 -0
- data/lib/better_translate/strategies/batch_strategy.rb +56 -0
- data/lib/better_translate/strategies/deep_strategy.rb +45 -0
- data/lib/better_translate/strategies/strategy_selector.rb +43 -0
- data/lib/better_translate/translator.rb +115 -284
- data/lib/better_translate/utils/hash_flattener.rb +104 -0
- data/lib/better_translate/validator.rb +105 -0
- data/lib/better_translate/variable_extractor.rb +259 -0
- data/lib/better_translate/version.rb +2 -9
- data/lib/better_translate/yaml_handler.rb +168 -0
- data/lib/better_translate.rb +97 -73
- data/lib/generators/better_translate/analyze/USAGE +12 -0
- data/lib/generators/better_translate/analyze/analyze_generator.rb +94 -0
- data/lib/generators/better_translate/install/USAGE +13 -0
- data/lib/generators/better_translate/install/install_generator.rb +71 -0
- data/lib/generators/better_translate/install/templates/README +20 -0
- data/lib/generators/better_translate/install/templates/initializer.rb.tt +47 -0
- data/lib/generators/better_translate/translate/USAGE +13 -0
- data/lib/generators/better_translate/translate/translate_generator.rb +114 -0
- data/lib/tasks/better_translate.rake +136 -0
- data/sig/better_translate/cache.rbs +28 -0
- data/sig/better_translate/cli.rbs +24 -0
- data/sig/better_translate/configuration.rbs +78 -0
- data/sig/better_translate/direct_translator.rbs +18 -0
- data/sig/better_translate/errors.rbs +46 -0
- data/sig/better_translate/progress_tracker.rbs +29 -0
- data/sig/better_translate/provider_factory.rbs +8 -0
- data/sig/better_translate/providers/anthropic_provider.rbs +27 -0
- data/sig/better_translate/providers/base_http_provider.rbs +44 -0
- data/sig/better_translate/providers/chatgpt_provider.rbs +25 -0
- data/sig/better_translate/providers/gemini_provider.rbs +22 -0
- data/sig/better_translate/railtie.rbs +7 -0
- data/sig/better_translate/rate_limiter.rbs +20 -0
- data/sig/better_translate/strategies/base_strategy.rbs +19 -0
- data/sig/better_translate/strategies/batch_strategy.rbs +13 -0
- data/sig/better_translate/strategies/deep_strategy.rbs +11 -0
- data/sig/better_translate/strategies/strategy_selector.rbs +10 -0
- data/sig/better_translate/translator.rbs +24 -0
- data/sig/better_translate/utils/hash_flattener.rbs +14 -0
- data/sig/better_translate/validator.rbs +14 -0
- data/sig/better_translate/variable_extractor.rbs +40 -0
- data/sig/better_translate/version.rbs +4 -0
- data/sig/better_translate/yaml_handler.rbs +29 -0
- data/sig/better_translate.rbs +32 -2
- data/sig/faraday.rbs +22 -0
- data/sig/generators/better_translate/analyze/analyze_generator.rbs +18 -0
- data/sig/generators/better_translate/install/install_generator.rbs +14 -0
- data/sig/generators/better_translate/translate/translate_generator.rbs +10 -0
- data/sig/optparse.rbs +9 -0
- data/sig/psych.rbs +5 -0
- data/sig/rails.rbs +34 -0
- metadata +89 -203
- data/lib/better_translate/helper.rb +0 -83
- data/lib/better_translate/providers/base_provider.rb +0 -102
- data/lib/better_translate/service.rb +0 -144
- data/lib/better_translate/similarity_analyzer.rb +0 -218
- data/lib/better_translate/utils.rb +0 -55
- data/lib/better_translate/writer.rb +0 -75
- data/lib/generators/better_translate/analyze_generator.rb +0 -57
- data/lib/generators/better_translate/install_generator.rb +0 -14
- data/lib/generators/better_translate/templates/better_translate.rb +0 -56
- data/lib/generators/better_translate/translate_generator.rb +0 -84
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Extracts and preserves interpolation variables during translation
|
|
5
|
+
#
|
|
6
|
+
# Supports multiple variable formats:
|
|
7
|
+
# - Rails I18n: %{name}, %{count}
|
|
8
|
+
# - I18n.js: {{user}}, {{email}}
|
|
9
|
+
# - ES6 templates: ${var}
|
|
10
|
+
# - Simple braces: {name}
|
|
11
|
+
#
|
|
12
|
+
# Variables are extracted before translation, replaced with safe placeholders,
|
|
13
|
+
# and then restored after translation to ensure they remain unchanged.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# extractor = VariableExtractor.new("Hello %{name}, you have {{count}} messages")
|
|
17
|
+
# safe_text = extractor.extract
|
|
18
|
+
# #=> "Hello __VAR_0__, you have __VAR_1__ messages"
|
|
19
|
+
#
|
|
20
|
+
# translated = translate(safe_text) # "Ciao __VAR_0__, hai __VAR_1__ messaggi"
|
|
21
|
+
# final = extractor.restore(translated)
|
|
22
|
+
# #=> "Ciao %{name}, hai {{count}} messaggi"
|
|
23
|
+
#
|
|
24
|
+
# @example Variable validation
|
|
25
|
+
# extractor = VariableExtractor.new("Total: %{amount}")
|
|
26
|
+
# extractor.extract
|
|
27
|
+
# extractor.validate_variables!("Totale: %{amount}") #=> true
|
|
28
|
+
# extractor.validate_variables!("Totale:") # raises ValidationError
|
|
29
|
+
#
|
|
30
|
+
class VariableExtractor
|
|
31
|
+
# Variable patterns to detect and preserve
|
|
32
|
+
VARIABLE_PATTERNS = {
|
|
33
|
+
rails_template: /%\{[^}]+\}/, # %{name}, %{count}
|
|
34
|
+
rails_annotated: /%<[^>]+>[a-z]/i, # %<name>s, %<count>d
|
|
35
|
+
i18n_js: /\{\{[^}]+\}\}/, # {{user}}, {{email}}
|
|
36
|
+
es6: /\$\{[^}]+\}/, # ${var}
|
|
37
|
+
simple: /\{[a-zA-Z_][a-zA-Z0-9_]*\}/ # {name} but not {1,2,3}
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Combined pattern to match any variable format
|
|
41
|
+
COMBINED_PATTERN = Regexp.union(*VARIABLE_PATTERNS.values).freeze
|
|
42
|
+
|
|
43
|
+
# Placeholder prefix
|
|
44
|
+
PLACEHOLDER_PREFIX = "__VAR_"
|
|
45
|
+
|
|
46
|
+
# Placeholder suffix
|
|
47
|
+
PLACEHOLDER_SUFFIX = "__"
|
|
48
|
+
|
|
49
|
+
# @return [String] Original text with variables
|
|
50
|
+
attr_reader :original_text
|
|
51
|
+
|
|
52
|
+
# @return [Array<String>] Extracted variables in order
|
|
53
|
+
attr_reader :variables
|
|
54
|
+
|
|
55
|
+
# @return [Hash<String, String>] Mapping of placeholders to original variables
|
|
56
|
+
attr_reader :placeholder_map
|
|
57
|
+
|
|
58
|
+
# Initialize extractor with text
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] Text containing variables
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
64
|
+
#
|
|
65
|
+
def initialize(text)
|
|
66
|
+
@original_text = text
|
|
67
|
+
@variables = []
|
|
68
|
+
@placeholder_map = {}
|
|
69
|
+
@reverse_map = {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Extract variables and replace with placeholders
|
|
73
|
+
#
|
|
74
|
+
# Scans the text for all supported variable formats and replaces them
|
|
75
|
+
# with numbered placeholders (__VAR_0__, __VAR_1__, etc.).
|
|
76
|
+
#
|
|
77
|
+
# @return [String] Text with variables replaced by placeholders
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
81
|
+
# extractor.extract #=> "Hello __VAR_0__"
|
|
82
|
+
#
|
|
83
|
+
def extract
|
|
84
|
+
return "" if original_text.nil? || original_text.empty?
|
|
85
|
+
|
|
86
|
+
result = original_text.dup
|
|
87
|
+
index = 0
|
|
88
|
+
|
|
89
|
+
# Find and replace all variables
|
|
90
|
+
result.gsub!(COMBINED_PATTERN) do |match|
|
|
91
|
+
placeholder = "#{PLACEHOLDER_PREFIX}#{index}#{PLACEHOLDER_SUFFIX}"
|
|
92
|
+
@variables << match
|
|
93
|
+
@placeholder_map[placeholder] = match
|
|
94
|
+
@reverse_map[match] = placeholder
|
|
95
|
+
index += 1
|
|
96
|
+
placeholder
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Restore variables from placeholders in translated text
|
|
103
|
+
#
|
|
104
|
+
# Replaces all placeholders with their original variable formats.
|
|
105
|
+
# In strict mode, validates that all original variables are present.
|
|
106
|
+
#
|
|
107
|
+
# @param translated_text [String] Translated text with placeholders
|
|
108
|
+
# @param strict [Boolean] If true, raises error if variables are missing
|
|
109
|
+
# @return [String] Translated text with original variables restored
|
|
110
|
+
# @raise [ValidationError] if strict mode and variables are missing
|
|
111
|
+
#
|
|
112
|
+
# @example Successful restore
|
|
113
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
114
|
+
# extractor.extract
|
|
115
|
+
# extractor.restore("Ciao __VAR_0__") #=> "Ciao %{name}"
|
|
116
|
+
#
|
|
117
|
+
# @example Strict mode with missing variable
|
|
118
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
119
|
+
# extractor.extract
|
|
120
|
+
# extractor.restore("Ciao", strict: true) # raises ValidationError
|
|
121
|
+
#
|
|
122
|
+
def restore(translated_text, strict: true)
|
|
123
|
+
return "" if translated_text.nil? || translated_text.empty?
|
|
124
|
+
|
|
125
|
+
result = translated_text.dup
|
|
126
|
+
|
|
127
|
+
# Restore all placeholders
|
|
128
|
+
@placeholder_map.each do |placeholder, original_var|
|
|
129
|
+
result.gsub!(placeholder, original_var)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Validate all variables are present
|
|
133
|
+
validate_variables!(result) if strict
|
|
134
|
+
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Check if text contains variables
|
|
139
|
+
#
|
|
140
|
+
# @return [Boolean] true if variables are present
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
144
|
+
# extractor.extract
|
|
145
|
+
# extractor.variables? #=> true
|
|
146
|
+
#
|
|
147
|
+
def variables?
|
|
148
|
+
!@variables.empty?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get count of variables
|
|
152
|
+
#
|
|
153
|
+
# @return [Integer] Number of variables
|
|
154
|
+
#
|
|
155
|
+
# @example
|
|
156
|
+
# extractor = VariableExtractor.new("Hi %{name}, {{count}} items")
|
|
157
|
+
# extractor.extract
|
|
158
|
+
# extractor.variable_count #=> 2
|
|
159
|
+
#
|
|
160
|
+
def variable_count
|
|
161
|
+
@variables.size
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Validate that all original variables are present in text
|
|
165
|
+
#
|
|
166
|
+
# Checks that:
|
|
167
|
+
# 1. All original variables are still present
|
|
168
|
+
# 2. No unexpected/extra variables have been added
|
|
169
|
+
#
|
|
170
|
+
# @param text [String] Text to validate
|
|
171
|
+
# @raise [ValidationError] if variables are missing or modified
|
|
172
|
+
# @return [true] if all variables are present
|
|
173
|
+
#
|
|
174
|
+
# @example Valid text
|
|
175
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
176
|
+
# extractor.extract
|
|
177
|
+
# extractor.validate_variables!("Ciao %{name}") #=> true
|
|
178
|
+
#
|
|
179
|
+
# @example Missing variable
|
|
180
|
+
# extractor = VariableExtractor.new("Hello %{name}")
|
|
181
|
+
# extractor.extract
|
|
182
|
+
# extractor.validate_variables!("Ciao") # raises ValidationError
|
|
183
|
+
#
|
|
184
|
+
def validate_variables!(text)
|
|
185
|
+
# @type var missing: Array[String]
|
|
186
|
+
missing = []
|
|
187
|
+
# @type var extra: Array[String]
|
|
188
|
+
extra = []
|
|
189
|
+
|
|
190
|
+
# Check for missing variables
|
|
191
|
+
@variables.each do |var|
|
|
192
|
+
var_str = var.is_a?(String) ? var : var.to_s
|
|
193
|
+
missing << var_str unless text.include?(var_str)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Check for extra/unknown variables (potential corruption)
|
|
197
|
+
found_vars = text.scan(COMBINED_PATTERN)
|
|
198
|
+
found_vars.each do |var|
|
|
199
|
+
var_str = var.is_a?(String) ? var : var.to_s
|
|
200
|
+
extra << var_str unless @variables.include?(var_str)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if missing.any? || extra.any?
|
|
204
|
+
# @type var error_msg: Array[String]
|
|
205
|
+
error_msg = []
|
|
206
|
+
error_msg << "Missing variables: #{missing.join(", ")}" if missing.any?
|
|
207
|
+
error_msg << "Unexpected variables: #{extra.join(", ")}" if extra.any?
|
|
208
|
+
|
|
209
|
+
raise ValidationError.new(
|
|
210
|
+
"Variable validation failed: #{error_msg.join("; ")}",
|
|
211
|
+
context: {
|
|
212
|
+
original_variables: @variables,
|
|
213
|
+
missing: missing,
|
|
214
|
+
extra: extra,
|
|
215
|
+
text: text
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
true
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Extract variables from text without creating instance
|
|
224
|
+
#
|
|
225
|
+
# Static method to find all variables in text without needing
|
|
226
|
+
# to instantiate the extractor.
|
|
227
|
+
#
|
|
228
|
+
# @param text [String] Text to analyze
|
|
229
|
+
# @return [Array<String>] List of variables found
|
|
230
|
+
#
|
|
231
|
+
# @example
|
|
232
|
+
# VariableExtractor.find_variables("Hi %{name}, {{count}} items")
|
|
233
|
+
# #=> ["%{name}", "{{count}}"]
|
|
234
|
+
#
|
|
235
|
+
def self.find_variables(text)
|
|
236
|
+
return [] if text.nil? || text.empty?
|
|
237
|
+
|
|
238
|
+
text.scan(COMBINED_PATTERN)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Check if text contains variables
|
|
242
|
+
#
|
|
243
|
+
# Static method to quickly check if text contains any supported
|
|
244
|
+
# variable format.
|
|
245
|
+
#
|
|
246
|
+
# @param text [String] Text to check
|
|
247
|
+
# @return [Boolean] true if variables are present
|
|
248
|
+
#
|
|
249
|
+
# @example
|
|
250
|
+
# VariableExtractor.contains_variables?("Hello %{name}") #=> true
|
|
251
|
+
# VariableExtractor.contains_variables?("Hello world") #=> false
|
|
252
|
+
#
|
|
253
|
+
def self.contains_variables?(text)
|
|
254
|
+
return false if text.nil? || text.empty?
|
|
255
|
+
|
|
256
|
+
text.match?(COMBINED_PATTERN)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module BetterTranslate
|
|
4
|
-
# Current version of
|
|
5
|
-
|
|
6
|
-
# The versioning follows Semantic Versioning 2.0.0 (https://semver.org/):
|
|
7
|
-
# - MAJOR version for incompatible API changes
|
|
8
|
-
# - MINOR version for backwards-compatible functionality additions
|
|
9
|
-
# - PATCH version for backwards-compatible bug fixes
|
|
10
|
-
#
|
|
11
|
-
# @return [String] The current version in the format "MAJOR.MINOR.PATCH"
|
|
12
|
-
VERSION = "0.5.0"
|
|
4
|
+
# Current version of BetterTranslate gem
|
|
5
|
+
VERSION = "1.0.0"
|
|
13
6
|
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module BetterTranslate
|
|
7
|
+
# Handles YAML file operations
|
|
8
|
+
#
|
|
9
|
+
# Provides methods for:
|
|
10
|
+
# - Reading and parsing YAML files
|
|
11
|
+
# - Writing YAML files with proper formatting
|
|
12
|
+
# - Merging translations (incremental mode)
|
|
13
|
+
# - Handling exclusions
|
|
14
|
+
# - Flattening/unflattening nested structures
|
|
15
|
+
#
|
|
16
|
+
# @example Reading a YAML file
|
|
17
|
+
# handler = YAMLHandler.new(config)
|
|
18
|
+
# data = handler.read_yaml("config/locales/en.yml")
|
|
19
|
+
#
|
|
20
|
+
# @example Writing translations
|
|
21
|
+
# handler.write_yaml("config/locales/it.yml", { "it" => { "greeting" => "Ciao" } })
|
|
22
|
+
#
|
|
23
|
+
class YAMLHandler
|
|
24
|
+
# @return [Configuration] Configuration object
|
|
25
|
+
attr_reader :config
|
|
26
|
+
|
|
27
|
+
# Initialize YAML handler
|
|
28
|
+
#
|
|
29
|
+
# @param config [Configuration] Configuration object
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# config = Configuration.new
|
|
33
|
+
# handler = YAMLHandler.new(config)
|
|
34
|
+
#
|
|
35
|
+
def initialize(config)
|
|
36
|
+
@config = config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read and parse YAML file
|
|
40
|
+
#
|
|
41
|
+
# @param file_path [String] Path to YAML file
|
|
42
|
+
# @return [Hash] Parsed YAML content
|
|
43
|
+
# @raise [FileError] if file cannot be read
|
|
44
|
+
# @raise [YamlError] if YAML is invalid
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# data = handler.read_yaml("config/locales/en.yml")
|
|
48
|
+
# #=> { "en" => { "greeting" => "Hello" } }
|
|
49
|
+
#
|
|
50
|
+
def read_yaml(file_path)
|
|
51
|
+
Validator.validate_file_exists!(file_path)
|
|
52
|
+
|
|
53
|
+
content = File.read(file_path)
|
|
54
|
+
YAML.safe_load(content) || {}
|
|
55
|
+
rescue Errno::ENOENT => e
|
|
56
|
+
raise FileError.new("File not found: #{file_path}", context: { error: e.message })
|
|
57
|
+
rescue Psych::SyntaxError => e
|
|
58
|
+
raise YamlError.new("Invalid YAML syntax in #{file_path}", context: { error: e.message })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Write hash to YAML file
|
|
62
|
+
#
|
|
63
|
+
# @param file_path [String] Output file path
|
|
64
|
+
# @param data [Hash] Data to write
|
|
65
|
+
# @param diff_preview [DiffPreview, nil] Optional diff preview instance
|
|
66
|
+
# @return [Hash, nil] Summary hash if dry_run, nil otherwise
|
|
67
|
+
# @raise [FileError] if file cannot be written
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# handler.write_yaml("config/locales/it.yml", { "it" => { "greeting" => "Ciao" } })
|
|
71
|
+
#
|
|
72
|
+
def write_yaml(file_path, data, diff_preview: nil)
|
|
73
|
+
summary = nil
|
|
74
|
+
|
|
75
|
+
# Show diff preview if in dry run mode
|
|
76
|
+
if config.dry_run && diff_preview
|
|
77
|
+
# @type var existing_data: Hash[String, untyped]
|
|
78
|
+
existing_data = File.exist?(file_path) ? read_yaml(file_path) : {}
|
|
79
|
+
summary = diff_preview.show_diff(existing_data, data, file_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return summary if config.dry_run
|
|
83
|
+
|
|
84
|
+
# Ensure output directory exists
|
|
85
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
86
|
+
|
|
87
|
+
File.write(file_path, YAML.dump(data))
|
|
88
|
+
|
|
89
|
+
nil
|
|
90
|
+
rescue Errno::EACCES => e
|
|
91
|
+
raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
raise FileError.new("Failed to write YAML: #{file_path}", context: { error: e.message })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get translatable strings from source YAML
|
|
97
|
+
#
|
|
98
|
+
# Reads the input file and returns a flattened hash of strings.
|
|
99
|
+
# Removes the root language key if present.
|
|
100
|
+
#
|
|
101
|
+
# @return [Hash] Flattened hash of translatable strings
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# strings = handler.get_source_strings
|
|
105
|
+
# #=> { "greeting" => "Hello", "nav.home" => "Home" }
|
|
106
|
+
#
|
|
107
|
+
def get_source_strings
|
|
108
|
+
return {} unless config.input_file
|
|
109
|
+
|
|
110
|
+
source_data = read_yaml(config.input_file)
|
|
111
|
+
# Remove root language key if present (e.g., "en:")
|
|
112
|
+
source_data = source_data[config.source_language] || source_data
|
|
113
|
+
|
|
114
|
+
Utils::HashFlattener.flatten(source_data)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Filter out excluded keys for a specific language
|
|
118
|
+
#
|
|
119
|
+
# @param strings [Hash] Flattened strings
|
|
120
|
+
# @param target_lang_code [String] Target language code
|
|
121
|
+
# @return [Hash] Filtered strings
|
|
122
|
+
#
|
|
123
|
+
# @example
|
|
124
|
+
# filtered = handler.filter_exclusions(strings, "it")
|
|
125
|
+
#
|
|
126
|
+
def filter_exclusions(strings, target_lang_code)
|
|
127
|
+
excluded_keys = config.global_exclusions.dup
|
|
128
|
+
excluded_keys += config.exclusions_per_language[target_lang_code] || []
|
|
129
|
+
|
|
130
|
+
strings.reject { |key, _| excluded_keys.include?(key) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Merge translated strings with existing file (incremental mode)
|
|
134
|
+
#
|
|
135
|
+
# @param file_path [String] Existing file path
|
|
136
|
+
# @param new_translations [Hash] New translations (flattened)
|
|
137
|
+
# @return [Hash] Merged translations (nested)
|
|
138
|
+
#
|
|
139
|
+
# @example
|
|
140
|
+
# merged = handler.merge_translations("config/locales/it.yml", new_translations)
|
|
141
|
+
#
|
|
142
|
+
def merge_translations(file_path, new_translations)
|
|
143
|
+
# @type var existing: Hash[String, untyped]
|
|
144
|
+
existing = File.exist?(file_path) ? read_yaml(file_path) : {}
|
|
145
|
+
existing_flat = Utils::HashFlattener.flatten(existing)
|
|
146
|
+
|
|
147
|
+
# Merge: existing takes precedence
|
|
148
|
+
merged = new_translations.merge(existing_flat)
|
|
149
|
+
|
|
150
|
+
Utils::HashFlattener.unflatten(merged)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Build output file path for target language
|
|
154
|
+
#
|
|
155
|
+
# @param target_lang_code [String] Target language code
|
|
156
|
+
# @return [String] Output file path
|
|
157
|
+
#
|
|
158
|
+
# @example
|
|
159
|
+
# path = handler.build_output_path("it")
|
|
160
|
+
# #=> "config/locales/it.yml"
|
|
161
|
+
#
|
|
162
|
+
def build_output_path(target_lang_code)
|
|
163
|
+
return "#{target_lang_code}.yml" unless config.output_folder
|
|
164
|
+
|
|
165
|
+
File.join(config.output_folder, "#{target_lang_code}.yml")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/better_translate.rb
CHANGED
|
@@ -1,99 +1,123 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
require "ruby-progressbar"
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
require_relative "better_translate/version"
|
|
4
|
+
require_relative "better_translate/errors"
|
|
5
|
+
require_relative "better_translate/configuration"
|
|
6
|
+
require_relative "better_translate/cache"
|
|
7
|
+
require_relative "better_translate/rate_limiter"
|
|
8
|
+
require_relative "better_translate/validator"
|
|
9
|
+
require_relative "better_translate/variable_extractor"
|
|
10
|
+
require_relative "better_translate/utils/hash_flattener"
|
|
11
|
+
require_relative "better_translate/providers/base_http_provider"
|
|
12
|
+
require_relative "better_translate/providers/chatgpt_provider"
|
|
13
|
+
require_relative "better_translate/providers/gemini_provider"
|
|
14
|
+
require_relative "better_translate/providers/anthropic_provider"
|
|
15
|
+
require_relative "better_translate/provider_factory"
|
|
16
|
+
require_relative "better_translate/yaml_handler"
|
|
17
|
+
require_relative "better_translate/progress_tracker"
|
|
18
|
+
require_relative "better_translate/strategies/base_strategy"
|
|
19
|
+
require_relative "better_translate/strategies/deep_strategy"
|
|
20
|
+
require_relative "better_translate/strategies/batch_strategy"
|
|
21
|
+
require_relative "better_translate/strategies/strategy_selector"
|
|
22
|
+
require_relative "better_translate/translator"
|
|
23
|
+
require_relative "better_translate/direct_translator"
|
|
24
|
+
require_relative "better_translate/cli"
|
|
11
25
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
require 'better_translate/providers/gemini_provider'
|
|
26
|
+
# Load Rails integration if Rails is present
|
|
27
|
+
require_relative "better_translate/railtie" if defined?(Rails::Railtie)
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
#
|
|
29
|
+
# BetterTranslate - AI-powered YAML locale file translator
|
|
30
|
+
#
|
|
31
|
+
# Automatically translate YAML locale files using AI providers (ChatGPT, Gemini, Claude).
|
|
32
|
+
# Features intelligent caching, batch processing, and Rails integration.
|
|
20
33
|
#
|
|
21
|
-
# @example Basic
|
|
34
|
+
# @example Basic usage
|
|
22
35
|
# BetterTranslate.configure do |config|
|
|
23
36
|
# config.provider = :chatgpt
|
|
24
37
|
# config.openai_key = ENV['OPENAI_API_KEY']
|
|
25
|
-
# config.
|
|
26
|
-
# config.target_languages = [
|
|
38
|
+
# config.source_language = "en"
|
|
39
|
+
# config.target_languages = [
|
|
40
|
+
# { short_name: "it", name: "Italian" },
|
|
41
|
+
# { short_name: "fr", name: "French" }
|
|
42
|
+
# ]
|
|
43
|
+
# config.input_file = "config/locales/en.yml"
|
|
44
|
+
# config.output_folder = "config/locales"
|
|
27
45
|
# end
|
|
28
|
-
#
|
|
29
|
-
# BetterTranslate.
|
|
46
|
+
#
|
|
47
|
+
# BetterTranslate.translate_files
|
|
48
|
+
#
|
|
30
49
|
module BetterTranslate
|
|
31
50
|
class << self
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
#
|
|
51
|
+
# Configure BetterTranslate
|
|
52
|
+
#
|
|
53
|
+
# @yieldparam config [Configuration] Configuration object to modify
|
|
54
|
+
# @return [Configuration] The configuration object
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# BetterTranslate.configure do |config|
|
|
58
|
+
# config.provider = :chatgpt
|
|
59
|
+
# config.openai_key = ENV['OPENAI_API_KEY']
|
|
60
|
+
# config.source_language = "en"
|
|
61
|
+
# config.target_languages = [{ short_name: "it", name: "Italian" }]
|
|
62
|
+
# end
|
|
38
63
|
#
|
|
39
|
-
# @yield [configuration] Yields the configuration object to the block
|
|
40
|
-
# @yieldparam configuration [OpenStruct] The configuration object
|
|
41
|
-
# @return [OpenStruct] The updated configuration object
|
|
42
64
|
def configure
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
yield(configuration)
|
|
66
|
+
configuration
|
|
45
67
|
end
|
|
46
68
|
|
|
47
|
-
#
|
|
48
|
-
# Copies the template initializer to the Rails config/initializers directory.
|
|
49
|
-
# Only works in a Rails environment.
|
|
69
|
+
# Get current configuration
|
|
50
70
|
#
|
|
51
|
-
# @return [
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Builds the path to the template folder inside the gem
|
|
61
|
-
source = File.expand_path("../generators/better_translate/templates/better_translate.rb", __dir__)
|
|
62
|
-
destination = File.join(Rails.root, "config", "initializers", "better_translate.rb")
|
|
71
|
+
# @return [Configuration] Current configuration instance
|
|
72
|
+
#
|
|
73
|
+
# @example
|
|
74
|
+
# config = BetterTranslate.configuration
|
|
75
|
+
# config.provider #=> :chatgpt
|
|
76
|
+
#
|
|
77
|
+
def configuration
|
|
78
|
+
@configuration ||= Configuration.new
|
|
79
|
+
end
|
|
63
80
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
# Translate files using current configuration
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash] Results with :success_count, :failure_count, :errors
|
|
84
|
+
# @raise [ConfigurationError] if configuration is invalid or missing
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# results = BetterTranslate.translate_files
|
|
88
|
+
# puts "Success: #{results[:success_count]}, Failures: #{results[:failure_count]}"
|
|
89
|
+
#
|
|
90
|
+
def translate_files
|
|
91
|
+
unless @configuration
|
|
92
|
+
raise ConfigurationError,
|
|
93
|
+
"BetterTranslate is not configured. Call BetterTranslate.configure first."
|
|
72
94
|
end
|
|
95
|
+
|
|
96
|
+
translator = Translator.new(configuration)
|
|
97
|
+
translator.translate_all
|
|
73
98
|
end
|
|
74
99
|
|
|
75
|
-
#
|
|
76
|
-
# This is the main entry point for the translation functionality.
|
|
77
|
-
# Logs the start and completion of the translation process.
|
|
100
|
+
# Reset configuration
|
|
78
101
|
#
|
|
79
102
|
# @return [void]
|
|
103
|
+
#
|
|
80
104
|
# @example
|
|
81
|
-
# BetterTranslate.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
message = "\n[BetterTranslate] Starting translation process...\n"
|
|
87
|
-
BetterTranslate::Utils.logger(message: message)
|
|
88
|
-
|
|
89
|
-
BetterTranslate::Translator.work
|
|
90
|
-
|
|
91
|
-
message = "Magic method invoked: Translation completed successfully!"
|
|
92
|
-
BetterTranslate::Utils.logger(message: message)
|
|
93
|
-
# Utilizziamo il logger per tutti i messaggi
|
|
94
|
-
message = "\n[BetterTranslate] Translation completed successfully!\n"
|
|
95
|
-
BetterTranslate::Utils.logger(message: message)
|
|
105
|
+
# BetterTranslate.reset!
|
|
106
|
+
# BetterTranslate.configuration.provider #=> nil
|
|
107
|
+
#
|
|
108
|
+
def reset!
|
|
109
|
+
@configuration = nil
|
|
96
110
|
end
|
|
97
111
|
|
|
112
|
+
# Get gem version
|
|
113
|
+
#
|
|
114
|
+
# @return [String] Version string
|
|
115
|
+
#
|
|
116
|
+
# @example
|
|
117
|
+
# BetterTranslate.version #=> "0.1.0"
|
|
118
|
+
#
|
|
119
|
+
def version
|
|
120
|
+
VERSION
|
|
121
|
+
end
|
|
98
122
|
end
|
|
99
|
-
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Analyzes YAML locale file structure and provides statistics.
|
|
3
|
+
|
|
4
|
+
This will show you:
|
|
5
|
+
- Total number of translation strings
|
|
6
|
+
- File structure with nested keys
|
|
7
|
+
- String counts per section
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
bin/rails generate better_translate:analyze config/locales/en.yml
|
|
11
|
+
|
|
12
|
+
This will analyze the en.yml file and display statistics.
|