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
|
@@ -1,292 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module BetterTranslate
|
|
4
|
+
# Main translator class
|
|
5
|
+
#
|
|
6
|
+
# Coordinates the translation process using configuration, providers,
|
|
7
|
+
# strategies, and YAML handling.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# config = Configuration.new
|
|
11
|
+
# config.provider = :chatgpt
|
|
12
|
+
# config.openai_key = ENV['OPENAI_API_KEY']
|
|
13
|
+
# config.source_language = "en"
|
|
14
|
+
# config.target_languages = [{ short_name: "it", name: "Italian" }]
|
|
15
|
+
# config.input_file = "config/locales/en.yml"
|
|
16
|
+
# config.output_folder = "config/locales"
|
|
17
|
+
#
|
|
18
|
+
# translator = Translator.new(config)
|
|
19
|
+
# results = translator.translate_all
|
|
20
|
+
#
|
|
2
21
|
class Translator
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
translations, BetterTranslate.configuration.global_exclusions
|
|
22
|
-
)
|
|
23
|
-
message = "[BetterTranslate] Global exclusions applied."
|
|
24
|
-
BetterTranslate::Utils.logger(message: message)
|
|
25
|
-
|
|
26
|
-
start_time = Time.now
|
|
27
|
-
results = []
|
|
28
|
-
|
|
29
|
-
# Elabora ogni lingua target in sequenza invece di utilizzare thread
|
|
30
|
-
BetterTranslate.configuration.target_languages.each do |target_lang|
|
|
31
|
-
# Phase 2: Apply the target language specific filter
|
|
32
|
-
lang_exclusions = BetterTranslate.configuration.exclusions_per_language[target_lang[:short_name]] || []
|
|
33
|
-
filtered_translations = remove_exclusions(
|
|
34
|
-
global_filtered_translations, lang_exclusions
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
message = "Starting translation from #{BetterTranslate.configuration.source_language} to #{target_lang[:short_name]}"
|
|
38
|
-
BetterTranslate::Utils.logger(message: message)
|
|
39
|
-
message = "\n[BetterTranslate] Starting translation to #{target_lang[:name]} (#{target_lang[:short_name]})..."
|
|
40
|
-
BetterTranslate::Utils.logger(message: message)
|
|
41
|
-
|
|
42
|
-
service = BetterTranslate::Service.new
|
|
43
|
-
translated_data = translate_with_progress(filtered_translations, service, target_lang[:short_name], target_lang[:name])
|
|
44
|
-
|
|
45
|
-
message = "[BetterTranslate] Writing translations for #{target_lang[:short_name]}..."
|
|
46
|
-
BetterTranslate::Utils.logger(message: message)
|
|
47
|
-
BetterTranslate::Writer.write_translations(translated_data, target_lang[:short_name])
|
|
48
|
-
|
|
49
|
-
lang_end_time = Time.now
|
|
50
|
-
duration = lang_end_time - start_time
|
|
51
|
-
BetterTranslate::Utils.track_metric("translation_duration", duration)
|
|
52
|
-
|
|
53
|
-
message = "Translation completed from #{BetterTranslate.configuration.source_language} to #{target_lang[:short_name]} in #{duration.round(2)} seconds"
|
|
54
|
-
BetterTranslate::Utils.logger(message: message)
|
|
55
|
-
message = "[BetterTranslate] Completed translation to #{target_lang[:name]} in #{duration.round(2)} seconds."
|
|
56
|
-
BetterTranslate::Utils.logger(message: message)
|
|
57
|
-
|
|
58
|
-
results << translated_data
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
# Reads the YAML file specified in the configuration.
|
|
65
|
-
# The file path is taken from BetterTranslate.configuration.input_file.
|
|
66
|
-
#
|
|
67
|
-
# @return [Hash] The parsed YAML data structure containing the translations
|
|
68
|
-
# @raise [StandardError] If the input file does not exist or cannot be parsed
|
|
69
|
-
def read_yml_source
|
|
70
|
-
file_path = BetterTranslate.configuration.input_file
|
|
71
|
-
unless File.exist?(file_path)
|
|
72
|
-
raise "File not found: #{file_path}"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
YAML.load_file(file_path)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Removes the global keys to exclude from the data structure,
|
|
79
|
-
# calculating paths starting from the source language content.
|
|
80
|
-
#
|
|
81
|
-
# For example, if the YAML file is:
|
|
82
|
-
# { "en" => { "sample" => { "valid" => "valid", "excluded" => "Excluded" } } }
|
|
83
|
-
# and global_exclusions = ["sample.excluded"],
|
|
84
|
-
# the result will be:
|
|
85
|
-
# { "en" => { "sample" => { "valid" => "valid" } } }
|
|
86
|
-
# Removes specified keys from the translation data structure.
|
|
87
|
-
# Recursively traverses the data structure and excludes any keys that match the exclusion list.
|
|
88
|
-
# Keys can be excluded at any nesting level using dot notation paths.
|
|
89
|
-
#
|
|
90
|
-
# @param data [Hash, Array, Object] The data structure to filter
|
|
91
|
-
# @param exclusion_list [Array<String>] List of dot-separated key paths to exclude
|
|
92
|
-
# @param current_path [Array] The current path in the traversal (used recursively, default: [])
|
|
93
|
-
# @return [Hash, Array, Object] The filtered data structure with excluded keys removed
|
|
94
|
-
def remove_exclusions(data, exclusion_list, current_path = [])
|
|
95
|
-
if data.is_a?(Hash)
|
|
96
|
-
data.each_with_object({}) do |(key, value), result|
|
|
97
|
-
# If we are at the top-level and the key matches the source language,
|
|
98
|
-
# reset the path (to exclude "en" from the final path)
|
|
99
|
-
new_path = if current_path.empty? && key == BetterTranslate.configuration.source_language
|
|
100
|
-
[]
|
|
101
|
-
else
|
|
102
|
-
current_path + [key]
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
path_string = new_path.join(".")
|
|
106
|
-
unless exclusion_list.include?(path_string)
|
|
107
|
-
result[key] = remove_exclusions(value, exclusion_list, new_path)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
elsif data.is_a?(Array)
|
|
111
|
-
data.map.with_index do |item, index|
|
|
112
|
-
remove_exclusions(item, exclusion_list, current_path + [index])
|
|
113
|
-
end
|
|
114
|
-
else
|
|
115
|
-
data
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Recursive method that traverses the structure, translating each string and updating progress.
|
|
120
|
-
#
|
|
121
|
-
# Recursively translates a data structure by traversing it deeply.
|
|
122
|
-
# This method is used for smaller datasets (less than 50 strings) and translates each string individually.
|
|
123
|
-
# It maintains the original structure of the data while replacing string values with their translations.
|
|
124
|
-
#
|
|
125
|
-
# @param data [Hash, Array, String] The data structure to translate
|
|
126
|
-
# @param service [BetterTranslate::Service] The service instance to use for translation
|
|
127
|
-
# @param target_lang_code [String] The target language code (e.g., 'fr', 'es')
|
|
128
|
-
# @param target_lang_name [String] The target language name (e.g., 'French', 'Spanish')
|
|
129
|
-
# @param progress [ProgressBar] A progress bar instance to track translation progress
|
|
130
|
-
# @return [Hash, Array, String] The translated data structure with the same structure as the input
|
|
131
|
-
def deep_translate_with_progress(data, service, target_lang_code, target_lang_name, progress)
|
|
132
|
-
if data.is_a?(Hash)
|
|
133
|
-
data.each_with_object({}) do |(key, value), result|
|
|
134
|
-
result[key] = deep_translate_with_progress(value, service, target_lang_code, target_lang_name, progress)
|
|
135
|
-
end
|
|
136
|
-
elsif data.is_a?(Array)
|
|
137
|
-
data.map do |item|
|
|
138
|
-
deep_translate_with_progress(item, service, target_lang_code, target_lang_name, progress)
|
|
139
|
-
end
|
|
140
|
-
elsif data.is_a?(String)
|
|
141
|
-
progress.increment
|
|
142
|
-
service.translate(data, target_lang_code, target_lang_name)
|
|
143
|
-
else
|
|
144
|
-
data
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Translates the entire data structure with progress monitoring.
|
|
149
|
-
# Automatically selects between batch and deep translation methods based on the number of strings.
|
|
150
|
-
# For datasets with more than 50 strings, batch processing is used for better performance.
|
|
151
|
-
#
|
|
152
|
-
# @param data [Hash, Array, String] The data structure to translate
|
|
153
|
-
# @param service [BetterTranslate::Service] The service instance to use for translation
|
|
154
|
-
# @param target_lang_code [String] The target language code (e.g., 'fr', 'es')
|
|
155
|
-
# @param target_lang_name [String] The target language name (e.g., 'French', 'Spanish')
|
|
156
|
-
# @return [Hash, Array, String] The translated data structure with the same structure as the input
|
|
157
|
-
def translate_with_progress(data, service, target_lang_code, target_lang_name)
|
|
158
|
-
total = count_strings(data)
|
|
159
|
-
message = "[BetterTranslate] Found #{total} strings to translate to #{target_lang_name}"
|
|
160
|
-
BetterTranslate::Utils.logger(message: message)
|
|
161
|
-
|
|
162
|
-
# Creiamo la barra di progresso ma aggiungiamo anche output visibile
|
|
163
|
-
progress = ProgressBar.create(total: total, format: '%a %B %p%% %t')
|
|
164
|
-
|
|
165
|
-
start_time = Time.now
|
|
166
|
-
message = "[BetterTranslate] Using #{total > 50 ? 'batch' : 'deep'} translation method"
|
|
167
|
-
BetterTranslate::Utils.logger(message: message)
|
|
168
|
-
|
|
169
|
-
result = if total > 50 # Usa il batch processing per dataset grandi
|
|
170
|
-
batch_translate_with_progress(data, service, target_lang_code, target_lang_name, progress)
|
|
171
|
-
else
|
|
172
|
-
deep_translate_with_progress(data, service, target_lang_code, target_lang_name, progress)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
duration = Time.now - start_time
|
|
176
|
-
message = "[BetterTranslate] Translation processing completed in #{duration.round(2)} seconds"
|
|
177
|
-
BetterTranslate::Utils.logger(message: message)
|
|
178
|
-
|
|
179
|
-
BetterTranslate::Utils.track_metric("translation_method_duration", {
|
|
180
|
-
method: total > 50 ? 'batch' : 'deep',
|
|
181
|
-
duration: duration,
|
|
182
|
-
total_strings: total
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
result
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# Translates data in batches for improved performance with larger datasets.
|
|
189
|
-
# This method first extracts all translatable strings, processes them in batches of 10,
|
|
190
|
-
# and then reinserts the translations back into the original structure.
|
|
191
|
-
#
|
|
192
|
-
# @param data [Hash, Array, String] The data structure to translate
|
|
193
|
-
# @param service [BetterTranslate::Service] The service instance to use for translation
|
|
194
|
-
# @param target_lang_code [String] The target language code (e.g., 'fr', 'es')
|
|
195
|
-
# @param target_lang_name [String] The target language name (e.g., 'French', 'Spanish')
|
|
196
|
-
# @param progress [ProgressBar] A progress bar instance to track translation progress
|
|
197
|
-
# @return [Hash, Array, String] The translated data structure with the same structure as the input
|
|
198
|
-
def batch_translate_with_progress(data, service, target_lang_code, target_lang_name, progress)
|
|
199
|
-
texts = extract_translatable_texts(data)
|
|
200
|
-
translations = {}
|
|
201
|
-
|
|
202
|
-
texts.each_slice(10).each_with_index do |batch, index|
|
|
203
|
-
batch_start = Time.now
|
|
204
|
-
|
|
205
|
-
batch_translations = batch.map do |text|
|
|
206
|
-
translated = service.translate(text, target_lang_code, target_lang_name)
|
|
207
|
-
progress.increment
|
|
208
|
-
[text, translated]
|
|
209
|
-
end.to_h
|
|
210
|
-
|
|
211
|
-
translations.merge!(batch_translations)
|
|
212
|
-
|
|
213
|
-
batch_duration = Time.now - batch_start
|
|
214
|
-
BetterTranslate::Utils.track_metric("batch_translation_duration", {
|
|
215
|
-
batch_number: index + 1,
|
|
216
|
-
size: batch.size,
|
|
217
|
-
duration: batch_duration
|
|
218
|
-
})
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
replace_translations(data, translations)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Extracts all unique translatable strings from a data structure.
|
|
225
|
-
# This is used by the batch translation method to collect all strings for efficient processing.
|
|
226
|
-
# Only non-empty strings are included in the result.
|
|
227
|
-
#
|
|
228
|
-
# @param data [Hash, Array, String] The data structure to extract strings from
|
|
229
|
-
# @return [Array<String>] An array of unique strings found in the data structure
|
|
230
|
-
def extract_translatable_texts(data)
|
|
231
|
-
texts = Set.new
|
|
232
|
-
traverse_structure(data) do |value|
|
|
233
|
-
texts.add(value) if value.is_a?(String) && !value.strip.empty?
|
|
234
|
-
end
|
|
235
|
-
texts.to_a
|
|
236
|
-
end
|
|
22
|
+
# @return [Configuration] Configuration object
|
|
23
|
+
attr_reader :config
|
|
24
|
+
|
|
25
|
+
# Initialize translator
|
|
26
|
+
#
|
|
27
|
+
# @param config [Configuration] Configuration object
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# translator = Translator.new(config)
|
|
31
|
+
#
|
|
32
|
+
def initialize(config)
|
|
33
|
+
@config = config
|
|
34
|
+
@config.validate!
|
|
35
|
+
provider_name = config.provider || :chatgpt
|
|
36
|
+
@provider = ProviderFactory.create(provider_name, config)
|
|
37
|
+
@yaml_handler = YAMLHandler.new(config)
|
|
38
|
+
@progress_tracker = ProgressTracker.new(enabled: config.verbose)
|
|
39
|
+
end
|
|
237
40
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
41
|
+
# Translate to all target languages
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash] Results hash with :success_count, :failure_count, :errors
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# results = translator.translate_all
|
|
47
|
+
# #=> { success_count: 2, failure_count: 0, errors: [] }
|
|
48
|
+
#
|
|
49
|
+
def translate_all
|
|
50
|
+
source_strings = @yaml_handler.get_source_strings
|
|
51
|
+
|
|
52
|
+
# @type var results: translation_results
|
|
53
|
+
results = {
|
|
54
|
+
success_count: 0,
|
|
55
|
+
failure_count: 0,
|
|
56
|
+
errors: []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
config.target_languages.each do |lang|
|
|
60
|
+
translate_language(source_strings, lang)
|
|
61
|
+
results[:success_count] += 1
|
|
62
|
+
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)
|
|
253
72
|
end
|
|
254
73
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
# Handles Hash, Array, and scalar values recursively.
|
|
258
|
-
#
|
|
259
|
-
# @param data [Hash, Array, Object] The data structure to traverse
|
|
260
|
-
# @yield [Object] Yields each value in the data structure to the block
|
|
261
|
-
# @return [Hash, Array, Object] The transformed data structure after applying the block
|
|
262
|
-
def traverse_structure(data, &block)
|
|
263
|
-
case data
|
|
264
|
-
when Hash
|
|
265
|
-
data.transform_values { |v| traverse_structure(v, &block) }
|
|
266
|
-
when Array
|
|
267
|
-
data.map { |v| traverse_structure(v, &block) }
|
|
268
|
-
else
|
|
269
|
-
yield data
|
|
270
|
-
end
|
|
271
|
-
end
|
|
74
|
+
results
|
|
75
|
+
end
|
|
272
76
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Translate to a single language
|
|
80
|
+
#
|
|
81
|
+
# @param source_strings [Hash] Source strings (flattened)
|
|
82
|
+
# @param lang [Hash] Language config with :short_name and :name
|
|
83
|
+
# @return [void]
|
|
84
|
+
# @api private
|
|
85
|
+
#
|
|
86
|
+
def translate_language(source_strings, lang)
|
|
87
|
+
target_lang_code = lang[:short_name]
|
|
88
|
+
target_lang_name = lang[:name]
|
|
89
|
+
|
|
90
|
+
# Filter exclusions
|
|
91
|
+
strings_to_translate = @yaml_handler.filter_exclusions(source_strings, target_lang_code)
|
|
92
|
+
|
|
93
|
+
return if strings_to_translate.empty?
|
|
94
|
+
|
|
95
|
+
# Select strategy
|
|
96
|
+
strategy = Strategies::StrategySelector.select(
|
|
97
|
+
strings_to_translate.size,
|
|
98
|
+
config,
|
|
99
|
+
@provider,
|
|
100
|
+
@progress_tracker
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Translate
|
|
104
|
+
@progress_tracker.reset
|
|
105
|
+
translated = strategy.translate(strings_to_translate, target_lang_code, target_lang_name)
|
|
106
|
+
|
|
107
|
+
# Save
|
|
108
|
+
output_path = @yaml_handler.build_output_path(target_lang_code)
|
|
109
|
+
|
|
110
|
+
final_translations = if config.translation_mode == :incremental
|
|
111
|
+
@yaml_handler.merge_translations(output_path, translated)
|
|
112
|
+
else
|
|
113
|
+
Utils::HashFlattener.unflatten(translated)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Wrap in language key (e.g., "it:")
|
|
117
|
+
wrapped = { target_lang_code => final_translations }
|
|
118
|
+
@yaml_handler.write_yaml(output_path, wrapped)
|
|
119
|
+
|
|
120
|
+
@progress_tracker.complete(target_lang_name, translated.size)
|
|
290
121
|
end
|
|
291
122
|
end
|
|
292
|
-
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Utility classes and helpers
|
|
5
|
+
#
|
|
6
|
+
# Contains various utility classes used throughout BetterTranslate.
|
|
7
|
+
module Utils
|
|
8
|
+
# Utilities for flattening and unflattening nested hashes
|
|
9
|
+
#
|
|
10
|
+
# Used to convert nested YAML structures to flat key-value pairs
|
|
11
|
+
# and back again.
|
|
12
|
+
#
|
|
13
|
+
# @example Flatten nested hash
|
|
14
|
+
# nested = { "user" => { "name" => "John", "age" => 30 } }
|
|
15
|
+
# flat = HashFlattener.flatten(nested)
|
|
16
|
+
# #=> { "user.name" => "John", "user.age" => 30 }
|
|
17
|
+
#
|
|
18
|
+
# @example Unflatten to nested hash
|
|
19
|
+
# flat = { "user.name" => "John", "user.age" => 30 }
|
|
20
|
+
# HashFlattener.unflatten(flat)
|
|
21
|
+
# #=> { "user" => { "name" => "John", "age" => 30 } }
|
|
22
|
+
#
|
|
23
|
+
class HashFlattener
|
|
24
|
+
# Flatten a nested hash to dot-notation keys
|
|
25
|
+
#
|
|
26
|
+
# Recursively converts nested hashes into a single-level hash
|
|
27
|
+
# with keys joined by a separator (default: ".").
|
|
28
|
+
#
|
|
29
|
+
# @param hash [Hash] Nested hash to flatten
|
|
30
|
+
# @param parent_key [String] Parent key prefix (used internally for recursion)
|
|
31
|
+
# @param separator [String] Key separator (default: ".")
|
|
32
|
+
# @return [Hash] Flattened hash
|
|
33
|
+
#
|
|
34
|
+
# @example Basic flattening
|
|
35
|
+
# nested = {
|
|
36
|
+
# "config" => {
|
|
37
|
+
# "database" => {
|
|
38
|
+
# "host" => "localhost"
|
|
39
|
+
# }
|
|
40
|
+
# }
|
|
41
|
+
# }
|
|
42
|
+
# HashFlattener.flatten(nested)
|
|
43
|
+
# #=> { "config.database.host" => "localhost" }
|
|
44
|
+
#
|
|
45
|
+
# @example Custom separator
|
|
46
|
+
# HashFlattener.flatten(nested, "", "/")
|
|
47
|
+
# #=> { "config/database/host" => "localhost" }
|
|
48
|
+
#
|
|
49
|
+
def self.flatten(hash, parent_key = "", separator = ".")
|
|
50
|
+
initial_hash = {} #: Hash[String, untyped]
|
|
51
|
+
hash.each_with_object(initial_hash) do |(key, value), result|
|
|
52
|
+
new_key = parent_key.empty? ? key.to_s : "#{parent_key}#{separator}#{key}"
|
|
53
|
+
|
|
54
|
+
if value.is_a?(Hash)
|
|
55
|
+
result.merge!(flatten(value, new_key, separator))
|
|
56
|
+
else
|
|
57
|
+
result[new_key] = value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Unflatten a hash with dot-notation keys to nested structure
|
|
63
|
+
#
|
|
64
|
+
# Converts a flat hash with delimited keys back into a nested
|
|
65
|
+
# hash structure.
|
|
66
|
+
#
|
|
67
|
+
# @param hash [Hash] Flattened hash
|
|
68
|
+
# @param separator [String] Key separator (default: ".")
|
|
69
|
+
# @return [Hash] Nested hash
|
|
70
|
+
#
|
|
71
|
+
# @example Basic unflattening
|
|
72
|
+
# flat = { "config.database.host" => "localhost" }
|
|
73
|
+
# HashFlattener.unflatten(flat)
|
|
74
|
+
# #=> {
|
|
75
|
+
# # "config" => {
|
|
76
|
+
# # "database" => {
|
|
77
|
+
# # "host" => "localhost"
|
|
78
|
+
# # }
|
|
79
|
+
# # }
|
|
80
|
+
# # }
|
|
81
|
+
#
|
|
82
|
+
# @example Custom separator
|
|
83
|
+
# flat = { "config/database/host" => "localhost" }
|
|
84
|
+
# HashFlattener.unflatten(flat, "/")
|
|
85
|
+
# #=> { "config" => { "database" => { "host" => "localhost" } } }
|
|
86
|
+
#
|
|
87
|
+
def self.unflatten(hash, separator = ".")
|
|
88
|
+
initial_hash = {} #: Hash[String, untyped]
|
|
89
|
+
hash.each_with_object(initial_hash) do |(key, value), result|
|
|
90
|
+
keys = key.split(separator)
|
|
91
|
+
last_key = keys.pop
|
|
92
|
+
|
|
93
|
+
# Build nested structure
|
|
94
|
+
nested = keys.reduce(result) do |memo, k|
|
|
95
|
+
memo[k] ||= {}
|
|
96
|
+
memo[k]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
nested[last_key] = value if last_key
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterTranslate
|
|
4
|
+
# Input validation utilities
|
|
5
|
+
#
|
|
6
|
+
# Validates language codes, text, paths, and other inputs.
|
|
7
|
+
#
|
|
8
|
+
# @example Validate language code
|
|
9
|
+
# Validator.validate_language_code!("en") #=> true
|
|
10
|
+
# Validator.validate_language_code!("invalid") # raises ValidationError
|
|
11
|
+
#
|
|
12
|
+
class Validator
|
|
13
|
+
# Validate a language code
|
|
14
|
+
#
|
|
15
|
+
# Language codes must be 2-letter strings (e.g., "en", "it", "fr").
|
|
16
|
+
#
|
|
17
|
+
# @param code [String] Language code to validate
|
|
18
|
+
# @raise [ValidationError] if code is invalid
|
|
19
|
+
# @return [true] if valid
|
|
20
|
+
#
|
|
21
|
+
# @example Valid codes
|
|
22
|
+
# Validator.validate_language_code!("en") #=> true
|
|
23
|
+
# Validator.validate_language_code!("IT") #=> true
|
|
24
|
+
#
|
|
25
|
+
# @example Invalid codes
|
|
26
|
+
# Validator.validate_language_code!("eng") # raises ValidationError
|
|
27
|
+
# Validator.validate_language_code!(nil) # raises ValidationError
|
|
28
|
+
#
|
|
29
|
+
def self.validate_language_code!(code)
|
|
30
|
+
raise ValidationError, "Language code cannot be nil" if code.nil?
|
|
31
|
+
raise ValidationError, "Language code must be a String" unless code.is_a?(String)
|
|
32
|
+
raise ValidationError, "Language code cannot be empty" if code.empty?
|
|
33
|
+
raise ValidationError, "Language code must be 2 letters" unless code.match?(/^[a-z]{2}$/i)
|
|
34
|
+
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validate text for translation
|
|
39
|
+
#
|
|
40
|
+
# Text must be a non-empty string.
|
|
41
|
+
#
|
|
42
|
+
# @param text [String] Text to validate
|
|
43
|
+
# @raise [ValidationError] if text is invalid
|
|
44
|
+
# @return [true] if valid
|
|
45
|
+
#
|
|
46
|
+
# @example Valid text
|
|
47
|
+
# Validator.validate_text!("Hello world") #=> true
|
|
48
|
+
#
|
|
49
|
+
# @example Invalid text
|
|
50
|
+
# Validator.validate_text!("") # raises ValidationError
|
|
51
|
+
# Validator.validate_text!(" ") # raises ValidationError
|
|
52
|
+
#
|
|
53
|
+
def self.validate_text!(text)
|
|
54
|
+
raise ValidationError, "Text cannot be nil" if text.nil?
|
|
55
|
+
raise ValidationError, "Text must be a String" unless text.is_a?(String)
|
|
56
|
+
raise ValidationError, "Text cannot be empty" if text.strip.empty?
|
|
57
|
+
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validate a file path exists
|
|
62
|
+
#
|
|
63
|
+
# @param path [String] File path to validate
|
|
64
|
+
# @raise [FileError] if path is invalid
|
|
65
|
+
# @return [true] if valid
|
|
66
|
+
#
|
|
67
|
+
# @example Valid path
|
|
68
|
+
# Validator.validate_file_exists!("config/locales/en.yml") #=> true
|
|
69
|
+
#
|
|
70
|
+
# @example Invalid path
|
|
71
|
+
# Validator.validate_file_exists!("/nonexistent/file.yml") # raises FileError
|
|
72
|
+
#
|
|
73
|
+
def self.validate_file_exists!(path)
|
|
74
|
+
raise FileError, "File path cannot be nil" if path.nil?
|
|
75
|
+
raise FileError, "File path must be a String" unless path.is_a?(String)
|
|
76
|
+
raise FileError, "File does not exist: #{path}" unless File.exist?(path)
|
|
77
|
+
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Validate an API key
|
|
82
|
+
#
|
|
83
|
+
# API keys must be non-empty strings.
|
|
84
|
+
#
|
|
85
|
+
# @param key [String] API key to validate
|
|
86
|
+
# @param provider [Symbol] Provider name for error message
|
|
87
|
+
# @raise [ConfigurationError] if key is invalid
|
|
88
|
+
# @return [true] if valid
|
|
89
|
+
#
|
|
90
|
+
# @example Valid API key
|
|
91
|
+
# Validator.validate_api_key!("sk-test123", provider: :chatgpt) #=> true
|
|
92
|
+
#
|
|
93
|
+
# @example Invalid API key
|
|
94
|
+
# Validator.validate_api_key!(nil, provider: :chatgpt) # raises ConfigurationError
|
|
95
|
+
# Validator.validate_api_key!("", provider: :gemini) # raises ConfigurationError
|
|
96
|
+
#
|
|
97
|
+
def self.validate_api_key!(key, provider:)
|
|
98
|
+
raise ConfigurationError, "API key for #{provider} cannot be nil" if key.nil?
|
|
99
|
+
raise ConfigurationError, "API key for #{provider} must be a String" unless key.is_a?(String)
|
|
100
|
+
raise ConfigurationError, "API key for #{provider} cannot be empty" if key.strip.empty?
|
|
101
|
+
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|