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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +14 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +8 -0
  5. data/.yardopts +10 -0
  6. data/CHANGELOG.md +125 -114
  7. data/CLAUDE.md +385 -0
  8. data/README.md +629 -244
  9. data/Rakefile +7 -1
  10. data/Steepfile +29 -0
  11. data/docs/implementation/00-overview.md +220 -0
  12. data/docs/implementation/01-setup_dependencies.md +668 -0
  13. data/docs/implementation/02-error_handling.md +65 -0
  14. data/docs/implementation/03-core_components.md +457 -0
  15. data/docs/implementation/03.5-variable_preservation.md +509 -0
  16. data/docs/implementation/04-provider_architecture.md +571 -0
  17. data/docs/implementation/05-translation_logic.md +1065 -0
  18. data/docs/implementation/06-main_module_api.md +122 -0
  19. data/docs/implementation/07-direct_translation_helpers.md +582 -0
  20. data/docs/implementation/08-rails_integration.md +323 -0
  21. data/docs/implementation/09-testing_suite.md +228 -0
  22. data/docs/implementation/10-documentation_examples.md +150 -0
  23. data/docs/implementation/11-quality_security.md +65 -0
  24. data/docs/implementation/12-cli_standalone.md +698 -0
  25. data/exe/better_translate +9 -0
  26. data/lib/better_translate/cache.rb +125 -0
  27. data/lib/better_translate/cli.rb +304 -0
  28. data/lib/better_translate/configuration.rb +201 -0
  29. data/lib/better_translate/direct_translator.rb +131 -0
  30. data/lib/better_translate/errors.rb +101 -0
  31. data/lib/better_translate/progress_tracker.rb +157 -0
  32. data/lib/better_translate/provider_factory.rb +45 -0
  33. data/lib/better_translate/providers/anthropic_provider.rb +154 -0
  34. data/lib/better_translate/providers/base_http_provider.rb +239 -0
  35. data/lib/better_translate/providers/chatgpt_provider.rb +138 -44
  36. data/lib/better_translate/providers/gemini_provider.rb +123 -61
  37. data/lib/better_translate/railtie.rb +18 -0
  38. data/lib/better_translate/rate_limiter.rb +90 -0
  39. data/lib/better_translate/strategies/base_strategy.rb +58 -0
  40. data/lib/better_translate/strategies/batch_strategy.rb +56 -0
  41. data/lib/better_translate/strategies/deep_strategy.rb +45 -0
  42. data/lib/better_translate/strategies/strategy_selector.rb +43 -0
  43. data/lib/better_translate/translator.rb +115 -284
  44. data/lib/better_translate/utils/hash_flattener.rb +104 -0
  45. data/lib/better_translate/validator.rb +105 -0
  46. data/lib/better_translate/variable_extractor.rb +259 -0
  47. data/lib/better_translate/version.rb +2 -9
  48. data/lib/better_translate/yaml_handler.rb +168 -0
  49. data/lib/better_translate.rb +97 -73
  50. data/lib/generators/better_translate/analyze/USAGE +12 -0
  51. data/lib/generators/better_translate/analyze/analyze_generator.rb +94 -0
  52. data/lib/generators/better_translate/install/USAGE +13 -0
  53. data/lib/generators/better_translate/install/install_generator.rb +71 -0
  54. data/lib/generators/better_translate/install/templates/README +20 -0
  55. data/lib/generators/better_translate/install/templates/initializer.rb.tt +47 -0
  56. data/lib/generators/better_translate/translate/USAGE +13 -0
  57. data/lib/generators/better_translate/translate/translate_generator.rb +114 -0
  58. data/lib/tasks/better_translate.rake +136 -0
  59. data/sig/better_translate/cache.rbs +28 -0
  60. data/sig/better_translate/cli.rbs +24 -0
  61. data/sig/better_translate/configuration.rbs +78 -0
  62. data/sig/better_translate/direct_translator.rbs +18 -0
  63. data/sig/better_translate/errors.rbs +46 -0
  64. data/sig/better_translate/progress_tracker.rbs +29 -0
  65. data/sig/better_translate/provider_factory.rbs +8 -0
  66. data/sig/better_translate/providers/anthropic_provider.rbs +27 -0
  67. data/sig/better_translate/providers/base_http_provider.rbs +44 -0
  68. data/sig/better_translate/providers/chatgpt_provider.rbs +25 -0
  69. data/sig/better_translate/providers/gemini_provider.rbs +22 -0
  70. data/sig/better_translate/railtie.rbs +7 -0
  71. data/sig/better_translate/rate_limiter.rbs +20 -0
  72. data/sig/better_translate/strategies/base_strategy.rbs +19 -0
  73. data/sig/better_translate/strategies/batch_strategy.rbs +13 -0
  74. data/sig/better_translate/strategies/deep_strategy.rbs +11 -0
  75. data/sig/better_translate/strategies/strategy_selector.rbs +10 -0
  76. data/sig/better_translate/translator.rbs +24 -0
  77. data/sig/better_translate/utils/hash_flattener.rbs +14 -0
  78. data/sig/better_translate/validator.rbs +14 -0
  79. data/sig/better_translate/variable_extractor.rbs +40 -0
  80. data/sig/better_translate/version.rbs +4 -0
  81. data/sig/better_translate/yaml_handler.rbs +29 -0
  82. data/sig/better_translate.rbs +32 -2
  83. data/sig/faraday.rbs +22 -0
  84. data/sig/generators/better_translate/analyze/analyze_generator.rbs +18 -0
  85. data/sig/generators/better_translate/install/install_generator.rbs +14 -0
  86. data/sig/generators/better_translate/translate/translate_generator.rbs +10 -0
  87. data/sig/optparse.rbs +9 -0
  88. data/sig/psych.rbs +5 -0
  89. data/sig/rails.rbs +34 -0
  90. metadata +89 -203
  91. data/lib/better_translate/helper.rb +0 -83
  92. data/lib/better_translate/providers/base_provider.rb +0 -102
  93. data/lib/better_translate/service.rb +0 -144
  94. data/lib/better_translate/similarity_analyzer.rb +0 -218
  95. data/lib/better_translate/utils.rb +0 -55
  96. data/lib/better_translate/writer.rb +0 -75
  97. data/lib/generators/better_translate/analyze_generator.rb +0 -57
  98. data/lib/generators/better_translate/install_generator.rb +0 -14
  99. data/lib/generators/better_translate/templates/better_translate.rb +0 -56
  100. 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
- class << self
4
- # Main method that orchestrates the translation process.
5
- # Reads the source file, applies exclusions, and translates the content
6
- # to all configured target languages sequentially.
7
- #
8
- # @return [void]
9
- def work
10
- message = "\n[BetterTranslate] Reading source file: #{BetterTranslate.configuration.input_file}\n"
11
- BetterTranslate::Utils.logger(message: message)
12
-
13
- translations = read_yml_source
14
- message = "[BetterTranslate] Source file loaded successfully."
15
- BetterTranslate::Utils.logger(message: message)
16
-
17
- # Removes the keys to exclude (global_exclusions) from the read structure
18
- message = "[BetterTranslate] Applying global exclusions..."
19
- BetterTranslate::Utils.logger(message: message)
20
- global_filtered_translations = remove_exclusions(
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
- # Replaces strings in the original data structure with their translations.
239
- # Used by the batch translation method to reinsert translated strings into the original structure.
240
- # Only non-empty strings that have translations in the provided hash are replaced.
241
- #
242
- # @param data [Hash, Array, String] The original data structure
243
- # @param translations [Hash] A hash mapping original strings to their translations
244
- # @return [Hash, Array, String] The data structure with strings replaced by translations
245
- def replace_translations(data, translations)
246
- traverse_structure(data) do |value|
247
- if value.is_a?(String) && !value.strip.empty? && translations.key?(value)
248
- translations[value]
249
- else
250
- value
251
- end
252
- end
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
- # Traverses a nested data structure and applies a block to each element.
256
- # This is a utility method used by extract_translatable_texts and replace_translations.
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
- # Counts the number of translatable strings in a data structure.
274
- # Used to determine the total number of strings for progress tracking and method selection.
275
- # Recursively traverses Hash and Array structures, counting each String as 1.
276
- #
277
- # @param data [Hash, Array, String, Object] The data structure to count strings in
278
- # @return [Integer] The total number of strings found in the data structure
279
- def count_strings(data)
280
- if data.is_a?(Hash)
281
- data.values.sum { |v| count_strings(v) }
282
- elsif data.is_a?(Array)
283
- data.sum { |item| count_strings(item) }
284
- elsif data.is_a?(String)
285
- 1
286
- else
287
- 0
288
- end
289
- end
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