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,1065 @@
|
|
|
1
|
+
# 05 - Translation Logic
|
|
2
|
+
|
|
3
|
+
[← Previous: 04-Provider Architecture](./04-provider_architecture.md) | [Back to Index](../../IMPLEMENTATION_PLAN.md) | [Next: 06-Main Module Api →](./06-main_module_api.md)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Translation Logic
|
|
8
|
+
|
|
9
|
+
### 5.1 `lib/better_translate/yaml_handler.rb`
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# frozen_string_literal: true
|
|
13
|
+
|
|
14
|
+
require "yaml"
|
|
15
|
+
|
|
16
|
+
module BetterTranslate
|
|
17
|
+
# Handles YAML file operations
|
|
18
|
+
#
|
|
19
|
+
# - Reading and parsing YAML files
|
|
20
|
+
# - Writing YAML files with proper formatting
|
|
21
|
+
# - Merging translations (incremental mode)
|
|
22
|
+
# - Handling exclusions
|
|
23
|
+
# - Flattening/unflattening nested structures
|
|
24
|
+
class YAMLHandler
|
|
25
|
+
# @return [Configuration] Configuration object
|
|
26
|
+
attr_reader :config
|
|
27
|
+
|
|
28
|
+
# Initialize YAML handler
|
|
29
|
+
#
|
|
30
|
+
# @param config [Configuration] Configuration object
|
|
31
|
+
def initialize(config)
|
|
32
|
+
@config = config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Read and parse YAML file
|
|
36
|
+
#
|
|
37
|
+
# @param file_path [String] Path to YAML file
|
|
38
|
+
# @return [Hash] Parsed YAML content
|
|
39
|
+
# @raise [FileError] if file cannot be read
|
|
40
|
+
# @raise [YamlError] if YAML is invalid
|
|
41
|
+
def read_yaml(file_path)
|
|
42
|
+
Validator.validate_file_exists!(file_path)
|
|
43
|
+
|
|
44
|
+
content = File.read(file_path)
|
|
45
|
+
YAML.safe_load(content) || {}
|
|
46
|
+
rescue Errno::ENOENT => e
|
|
47
|
+
raise FileError.new("File not found: #{file_path}", context: { error: e.message })
|
|
48
|
+
rescue Psych::SyntaxError => e
|
|
49
|
+
raise YamlError.new("Invalid YAML syntax in #{file_path}", context: { error: e.message })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Write hash to YAML file
|
|
53
|
+
#
|
|
54
|
+
# @param file_path [String] Output file path
|
|
55
|
+
# @param data [Hash] Data to write
|
|
56
|
+
# @return [void]
|
|
57
|
+
# @raise [FileError] if file cannot be written
|
|
58
|
+
def write_yaml(file_path, data)
|
|
59
|
+
return if config.dry_run
|
|
60
|
+
|
|
61
|
+
# Ensure output directory exists
|
|
62
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
63
|
+
|
|
64
|
+
File.write(file_path, YAML.dump(data))
|
|
65
|
+
rescue Errno::EACCES => e
|
|
66
|
+
raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
raise FileError.new("Failed to write YAML: #{file_path}", context: { error: e.message })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get translatable strings from source YAML
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] Flattened hash of translatable strings
|
|
74
|
+
def get_source_strings
|
|
75
|
+
source_data = read_yaml(config.input_file)
|
|
76
|
+
# Remove root language key if present (e.g., "en:")
|
|
77
|
+
source_data = source_data[config.source_language] || source_data
|
|
78
|
+
|
|
79
|
+
Utils::HashFlattener.flatten(source_data)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Filter out excluded keys for a specific language
|
|
83
|
+
#
|
|
84
|
+
# @param strings [Hash] Flattened strings
|
|
85
|
+
# @param target_lang_code [String] Target language code
|
|
86
|
+
# @return [Hash] Filtered strings
|
|
87
|
+
def filter_exclusions(strings, target_lang_code)
|
|
88
|
+
excluded_keys = config.global_exclusions.dup
|
|
89
|
+
excluded_keys += config.exclusions_per_language[target_lang_code] || []
|
|
90
|
+
|
|
91
|
+
strings.reject { |key, _| excluded_keys.include?(key) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Merge translated strings with existing file (incremental mode)
|
|
95
|
+
#
|
|
96
|
+
# @param file_path [String] Existing file path
|
|
97
|
+
# @param new_translations [Hash] New translations (flattened)
|
|
98
|
+
# @return [Hash] Merged translations
|
|
99
|
+
def merge_translations(file_path, new_translations)
|
|
100
|
+
existing = File.exist?(file_path) ? read_yaml(file_path) : {}
|
|
101
|
+
existing_flat = Utils::HashFlattener.flatten(existing)
|
|
102
|
+
|
|
103
|
+
# Merge: existing takes precedence
|
|
104
|
+
merged = new_translations.merge(existing_flat)
|
|
105
|
+
|
|
106
|
+
Utils::HashFlattener.unflatten(merged)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build output file path for target language
|
|
110
|
+
#
|
|
111
|
+
# @param target_lang_code [String] Target language code
|
|
112
|
+
# @return [String] Output file path
|
|
113
|
+
def build_output_path(target_lang_code)
|
|
114
|
+
File.join(config.output_folder, "#{target_lang_code}.yml")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 5.2 `lib/better_translate/strategies/base_strategy.rb`
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# frozen_string_literal: true
|
|
124
|
+
|
|
125
|
+
module BetterTranslate
|
|
126
|
+
module Strategies
|
|
127
|
+
# Base class for translation strategies
|
|
128
|
+
#
|
|
129
|
+
# @abstract Subclasses must implement {#translate}
|
|
130
|
+
class BaseStrategy
|
|
131
|
+
# @return [Configuration] Configuration object
|
|
132
|
+
attr_reader :config
|
|
133
|
+
|
|
134
|
+
# @return [Providers::BaseHttpProvider] Translation provider
|
|
135
|
+
attr_reader :provider
|
|
136
|
+
|
|
137
|
+
# @return [ProgressTracker] Progress tracker
|
|
138
|
+
attr_reader :progress_tracker
|
|
139
|
+
|
|
140
|
+
# Initialize the strategy
|
|
141
|
+
#
|
|
142
|
+
# @param config [Configuration] Configuration object
|
|
143
|
+
# @param provider [Providers::BaseHttpProvider] Translation provider
|
|
144
|
+
# @param progress_tracker [ProgressTracker] Progress tracker
|
|
145
|
+
def initialize(config, provider, progress_tracker)
|
|
146
|
+
@config = config
|
|
147
|
+
@provider = provider
|
|
148
|
+
@progress_tracker = progress_tracker
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Translate strings
|
|
152
|
+
#
|
|
153
|
+
# @param strings [Hash] Flattened hash of strings to translate
|
|
154
|
+
# @param target_lang_code [String] Target language code
|
|
155
|
+
# @param target_lang_name [String] Target language name
|
|
156
|
+
# @return [Hash] Translated strings (flattened)
|
|
157
|
+
# @raise [NotImplementedError] Must be implemented by subclasses
|
|
158
|
+
def translate(strings, target_lang_code, target_lang_name)
|
|
159
|
+
raise NotImplementedError, "#{self.class} must implement #translate"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 5.3 `lib/better_translate/strategies/deep_strategy.rb`
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# frozen_string_literal: true
|
|
170
|
+
|
|
171
|
+
module BetterTranslate
|
|
172
|
+
module Strategies
|
|
173
|
+
# Deep translation strategy
|
|
174
|
+
#
|
|
175
|
+
# Translates each string individually with detailed progress tracking.
|
|
176
|
+
# Used for smaller files (< 50 strings) to provide more granular progress.
|
|
177
|
+
class DeepStrategy < BaseStrategy
|
|
178
|
+
# Translate strings individually
|
|
179
|
+
#
|
|
180
|
+
# @param strings [Hash] Flattened hash of strings to translate
|
|
181
|
+
# @param target_lang_code [String] Target language code
|
|
182
|
+
# @param target_lang_name [String] Target language name
|
|
183
|
+
# @return [Hash] Translated strings (flattened)
|
|
184
|
+
def translate(strings, target_lang_code, target_lang_name)
|
|
185
|
+
translated = {}
|
|
186
|
+
total = strings.size
|
|
187
|
+
|
|
188
|
+
strings.each_with_index do |(key, value), index|
|
|
189
|
+
progress_tracker.update(
|
|
190
|
+
language: target_lang_name,
|
|
191
|
+
current_key: key,
|
|
192
|
+
progress: ((index + 1).to_f / total * 100).round(1)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
translated[key] = provider.translate_text(value, target_lang_code, target_lang_name)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
translated
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 5.4 `lib/better_translate/strategies/batch_strategy.rb`
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
# frozen_string_literal: true
|
|
209
|
+
|
|
210
|
+
module BetterTranslate
|
|
211
|
+
module Strategies
|
|
212
|
+
# Batch translation strategy
|
|
213
|
+
#
|
|
214
|
+
# Translates strings in batches for improved performance.
|
|
215
|
+
# Used for larger files (>= 50 strings).
|
|
216
|
+
class BatchStrategy < BaseStrategy
|
|
217
|
+
BATCH_SIZE = 10
|
|
218
|
+
|
|
219
|
+
# Translate strings in batches
|
|
220
|
+
#
|
|
221
|
+
# @param strings [Hash] Flattened hash of strings to translate
|
|
222
|
+
# @param target_lang_code [String] Target language code
|
|
223
|
+
# @param target_lang_name [String] Target language name
|
|
224
|
+
# @return [Hash] Translated strings (flattened)
|
|
225
|
+
def translate(strings, target_lang_code, target_lang_name)
|
|
226
|
+
translated = {}
|
|
227
|
+
keys = strings.keys
|
|
228
|
+
values = strings.values
|
|
229
|
+
total_batches = (values.size.to_f / BATCH_SIZE).ceil
|
|
230
|
+
|
|
231
|
+
values.each_slice(BATCH_SIZE).with_index do |batch, batch_index|
|
|
232
|
+
progress_tracker.update(
|
|
233
|
+
language: target_lang_name,
|
|
234
|
+
current_key: "Batch #{batch_index + 1}/#{total_batches}",
|
|
235
|
+
progress: ((batch_index + 1).to_f / total_batches * 100).round(1)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
translated_batch = provider.translate_batch(batch, target_lang_code, target_lang_name)
|
|
239
|
+
|
|
240
|
+
# Map back to keys
|
|
241
|
+
batch_keys = keys[batch_index * BATCH_SIZE, batch.size]
|
|
242
|
+
batch_keys.each_with_index do |key, i|
|
|
243
|
+
translated[key] = translated_batch[i]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
translated
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### 5.5 `lib/better_translate/strategies/strategy_selector.rb`
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
# frozen_string_literal: true
|
|
258
|
+
|
|
259
|
+
module BetterTranslate
|
|
260
|
+
module Strategies
|
|
261
|
+
# Selects the appropriate translation strategy based on content size
|
|
262
|
+
class StrategySelector
|
|
263
|
+
DEEP_STRATEGY_THRESHOLD = 50
|
|
264
|
+
|
|
265
|
+
# Select the appropriate strategy
|
|
266
|
+
#
|
|
267
|
+
# @param strings_count [Integer] Number of strings to translate
|
|
268
|
+
# @param config [Configuration] Configuration object
|
|
269
|
+
# @param provider [Providers::BaseHttpProvider] Translation provider
|
|
270
|
+
# @param progress_tracker [ProgressTracker] Progress tracker
|
|
271
|
+
# @return [BaseStrategy] Selected strategy instance
|
|
272
|
+
def self.select(strings_count, config, provider, progress_tracker)
|
|
273
|
+
if strings_count < DEEP_STRATEGY_THRESHOLD
|
|
274
|
+
DeepStrategy.new(config, provider, progress_tracker)
|
|
275
|
+
else
|
|
276
|
+
BatchStrategy.new(config, provider, progress_tracker)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### 5.6 `lib/better_translate/progress_tracker.rb`
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# frozen_string_literal: true
|
|
288
|
+
|
|
289
|
+
module BetterTranslate
|
|
290
|
+
# Tracks and displays translation progress
|
|
291
|
+
#
|
|
292
|
+
# Shows real-time progress updates with colored console output.
|
|
293
|
+
class ProgressTracker
|
|
294
|
+
# @return [Boolean] Whether to show progress
|
|
295
|
+
attr_reader :enabled
|
|
296
|
+
|
|
297
|
+
# Initialize progress tracker
|
|
298
|
+
#
|
|
299
|
+
# @param enabled [Boolean] Whether to show progress (default: true)
|
|
300
|
+
def initialize(enabled: true)
|
|
301
|
+
@enabled = enabled
|
|
302
|
+
@start_time = Time.now
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Update progress
|
|
306
|
+
#
|
|
307
|
+
# @param language [String] Current language being translated
|
|
308
|
+
# @param current_key [String] Current translation key
|
|
309
|
+
# @param progress [Float] Progress percentage (0-100)
|
|
310
|
+
# @return [void]
|
|
311
|
+
def update(language:, current_key:, progress:)
|
|
312
|
+
return unless enabled
|
|
313
|
+
|
|
314
|
+
elapsed = Time.now - @start_time
|
|
315
|
+
estimated_total = elapsed / (progress / 100.0)
|
|
316
|
+
remaining = estimated_total - elapsed
|
|
317
|
+
|
|
318
|
+
message = format(
|
|
319
|
+
"\r[BetterTranslate] %s | %s | %.1f%% | Elapsed: %s | Remaining: ~%s",
|
|
320
|
+
colorize(language, :cyan),
|
|
321
|
+
truncate(current_key, 40),
|
|
322
|
+
progress,
|
|
323
|
+
format_time(elapsed),
|
|
324
|
+
format_time(remaining)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
print message
|
|
328
|
+
$stdout.flush
|
|
329
|
+
|
|
330
|
+
puts "" if progress >= 100.0 # New line when complete
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Mark translation as complete for a language
|
|
334
|
+
#
|
|
335
|
+
# @param language [String] Language name
|
|
336
|
+
# @param total_strings [Integer] Total number of strings translated
|
|
337
|
+
# @return [void]
|
|
338
|
+
def complete(language, total_strings)
|
|
339
|
+
return unless enabled
|
|
340
|
+
|
|
341
|
+
elapsed = Time.now - @start_time
|
|
342
|
+
puts colorize("✓ #{language}: #{total_strings} strings translated in #{format_time(elapsed)}", :green)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Display an error
|
|
346
|
+
#
|
|
347
|
+
# @param language [String] Language name
|
|
348
|
+
# @param error [StandardError] The error that occurred
|
|
349
|
+
# @return [void]
|
|
350
|
+
def error(language, error)
|
|
351
|
+
return unless enabled
|
|
352
|
+
|
|
353
|
+
puts colorize("✗ #{language}: #{error.message}", :red)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Reset the progress tracker
|
|
357
|
+
#
|
|
358
|
+
# @return [void]
|
|
359
|
+
def reset
|
|
360
|
+
@start_time = Time.now
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
private
|
|
364
|
+
|
|
365
|
+
def format_time(seconds)
|
|
366
|
+
return "0s" if seconds <= 0
|
|
367
|
+
|
|
368
|
+
minutes = (seconds / 60).to_i
|
|
369
|
+
secs = (seconds % 60).to_i
|
|
370
|
+
|
|
371
|
+
if minutes > 0
|
|
372
|
+
"#{minutes}m #{secs}s"
|
|
373
|
+
else
|
|
374
|
+
"#{secs}s"
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def truncate(text, max_length)
|
|
379
|
+
return text if text.length <= max_length
|
|
380
|
+
|
|
381
|
+
"#{text[0...(max_length - 3)]}..."
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def colorize(text, color)
|
|
385
|
+
return text unless $stdout.tty?
|
|
386
|
+
|
|
387
|
+
colors = {
|
|
388
|
+
red: "\e[31m",
|
|
389
|
+
green: "\e[32m",
|
|
390
|
+
cyan: "\e[36m",
|
|
391
|
+
reset: "\e[0m"
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
"#{colors[color]}#{text}#{colors[:reset]}"
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### 5.7 `lib/better_translate/translator.rb`
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
# frozen_string_literal: true
|
|
404
|
+
|
|
405
|
+
module BetterTranslate
|
|
406
|
+
# Main translator class
|
|
407
|
+
#
|
|
408
|
+
# Coordinates the translation process using configuration, providers,
|
|
409
|
+
# strategies, and YAML handling.
|
|
410
|
+
#
|
|
411
|
+
# @example Basic usage
|
|
412
|
+
# translator = Translator.new(config)
|
|
413
|
+
# results = translator.translate_all
|
|
414
|
+
#
|
|
415
|
+
class Translator
|
|
416
|
+
# @return [Configuration] Configuration object
|
|
417
|
+
attr_reader :config
|
|
418
|
+
|
|
419
|
+
# Initialize translator
|
|
420
|
+
#
|
|
421
|
+
# @param config [Configuration] Configuration object
|
|
422
|
+
def initialize(config)
|
|
423
|
+
@config = config
|
|
424
|
+
@config.validate!
|
|
425
|
+
@provider = ProviderFactory.create(config.provider, config)
|
|
426
|
+
@yaml_handler = YAMLHandler.new(config)
|
|
427
|
+
@progress_tracker = ProgressTracker.new(enabled: config.verbose)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Translate to all target languages
|
|
431
|
+
#
|
|
432
|
+
# @return [Hash] Results hash with :success_count, :failure_count, :errors
|
|
433
|
+
def translate_all
|
|
434
|
+
source_strings = @yaml_handler.get_source_strings
|
|
435
|
+
|
|
436
|
+
results = {
|
|
437
|
+
success_count: 0,
|
|
438
|
+
failure_count: 0,
|
|
439
|
+
errors: []
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
config.target_languages.each do |lang|
|
|
443
|
+
begin
|
|
444
|
+
translate_language(source_strings, lang)
|
|
445
|
+
results[:success_count] += 1
|
|
446
|
+
rescue StandardError => e
|
|
447
|
+
results[:failure_count] += 1
|
|
448
|
+
results[:errors] << {
|
|
449
|
+
language: lang[:name],
|
|
450
|
+
error: e.message,
|
|
451
|
+
context: e.respond_to?(:context) ? e.context : {}
|
|
452
|
+
}
|
|
453
|
+
@progress_tracker.error(lang[:name], e)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
results
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private
|
|
461
|
+
|
|
462
|
+
def translate_language(source_strings, lang)
|
|
463
|
+
target_lang_code = lang[:short_name]
|
|
464
|
+
target_lang_name = lang[:name]
|
|
465
|
+
|
|
466
|
+
# Filter exclusions
|
|
467
|
+
strings_to_translate = @yaml_handler.filter_exclusions(source_strings, target_lang_code)
|
|
468
|
+
|
|
469
|
+
return if strings_to_translate.empty?
|
|
470
|
+
|
|
471
|
+
# Select strategy
|
|
472
|
+
strategy = Strategies::StrategySelector.select(
|
|
473
|
+
strings_to_translate.size,
|
|
474
|
+
config,
|
|
475
|
+
@provider,
|
|
476
|
+
@progress_tracker
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Translate
|
|
480
|
+
@progress_tracker.reset
|
|
481
|
+
translated = strategy.translate(strings_to_translate, target_lang_code, target_lang_name)
|
|
482
|
+
|
|
483
|
+
# Save
|
|
484
|
+
output_path = @yaml_handler.build_output_path(target_lang_code)
|
|
485
|
+
|
|
486
|
+
if config.translation_mode == :incremental
|
|
487
|
+
final_translations = @yaml_handler.merge_translations(output_path, translated)
|
|
488
|
+
else
|
|
489
|
+
final_translations = Utils::HashFlattener.unflatten(translated)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Wrap in language key (e.g., "it:")
|
|
493
|
+
wrapped = { target_lang_code => final_translations }
|
|
494
|
+
@yaml_handler.write_yaml(output_path, wrapped)
|
|
495
|
+
|
|
496
|
+
@progress_tracker.complete(target_lang_name, translated.size)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### 5.8 `lib/better_translate/diff_preview.rb`
|
|
503
|
+
|
|
504
|
+
```ruby
|
|
505
|
+
# frozen_string_literal: true
|
|
506
|
+
|
|
507
|
+
module BetterTranslate
|
|
508
|
+
# Displays colored diff preview of translation changes
|
|
509
|
+
#
|
|
510
|
+
# Shows what will be added, modified, or removed before writing files.
|
|
511
|
+
# Works in conjunction with config.dry_run mode.
|
|
512
|
+
#
|
|
513
|
+
# @example
|
|
514
|
+
# preview = DiffPreview.new(config)
|
|
515
|
+
# preview.show_diff(existing_data, new_data, output_path)
|
|
516
|
+
# # Output:
|
|
517
|
+
# # === config/locales/it.yml ===
|
|
518
|
+
# # + welcome: "Benvenuto"
|
|
519
|
+
# # ~ greeting: "Ciao" (was: "Salve")
|
|
520
|
+
# # - old_key: "Vecchio valore"
|
|
521
|
+
#
|
|
522
|
+
class DiffPreview
|
|
523
|
+
# @return [Configuration] Configuration object
|
|
524
|
+
attr_reader :config
|
|
525
|
+
|
|
526
|
+
# Initialize diff preview
|
|
527
|
+
#
|
|
528
|
+
# @param config [Configuration] Configuration object
|
|
529
|
+
def initialize(config)
|
|
530
|
+
@config = config
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Show diff between existing and new data
|
|
534
|
+
#
|
|
535
|
+
# @param existing_data [Hash] Existing YAML data (nested)
|
|
536
|
+
# @param new_data [Hash] New YAML data (nested)
|
|
537
|
+
# @param output_path [String] Output file path
|
|
538
|
+
# @return [Hash] Summary with counts
|
|
539
|
+
def show_diff(existing_data, new_data, output_path)
|
|
540
|
+
return { added: 0, modified: 0, removed: 0 } unless config.dry_run
|
|
541
|
+
|
|
542
|
+
existing_flat = Utils::HashFlattener.flatten(existing_data)
|
|
543
|
+
new_flat = Utils::HashFlattener.flatten(new_data)
|
|
544
|
+
|
|
545
|
+
changes = calculate_changes(existing_flat, new_flat)
|
|
546
|
+
|
|
547
|
+
display_diff(output_path, changes)
|
|
548
|
+
|
|
549
|
+
{
|
|
550
|
+
added: changes[:added].size,
|
|
551
|
+
modified: changes[:modified].size,
|
|
552
|
+
removed: changes[:removed].size
|
|
553
|
+
}
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Show summary for all language files
|
|
557
|
+
#
|
|
558
|
+
# @param summaries [Array<Hash>] Array of summary hashes with :file, :added, :modified, :removed
|
|
559
|
+
# @return [void]
|
|
560
|
+
def show_summary(summaries)
|
|
561
|
+
return unless config.dry_run
|
|
562
|
+
|
|
563
|
+
puts "\n" + colorize("=" * 60, :cyan)
|
|
564
|
+
puts colorize("DRY RUN SUMMARY", :cyan)
|
|
565
|
+
puts colorize("=" * 60, :cyan)
|
|
566
|
+
|
|
567
|
+
total_added = 0
|
|
568
|
+
total_modified = 0
|
|
569
|
+
total_removed = 0
|
|
570
|
+
|
|
571
|
+
summaries.each do |summary|
|
|
572
|
+
total_added += summary[:added]
|
|
573
|
+
total_modified += summary[:modified]
|
|
574
|
+
total_removed += summary[:removed]
|
|
575
|
+
|
|
576
|
+
puts "\n#{summary[:file]}:"
|
|
577
|
+
puts " #{colorize('+', :green)} #{summary[:added]} added"
|
|
578
|
+
puts " #{colorize('~', :yellow)} #{summary[:modified]} modified"
|
|
579
|
+
puts " #{colorize('-', :red)} #{summary[:removed]} removed"
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
puts "\n" + colorize("-" * 60, :cyan)
|
|
583
|
+
puts "Total: #{colorize("+#{total_added}", :green)} | " \
|
|
584
|
+
"#{colorize("~#{total_modified}", :yellow)} | " \
|
|
585
|
+
"#{colorize("-#{total_removed}", :red)}"
|
|
586
|
+
puts colorize("=" * 60, :cyan)
|
|
587
|
+
puts "\n#{colorize('ℹ', :cyan)} No files were modified (dry run mode)"
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
private
|
|
591
|
+
|
|
592
|
+
# Calculate changes between existing and new data
|
|
593
|
+
#
|
|
594
|
+
# @param existing [Hash] Existing flattened data
|
|
595
|
+
# @param new_data [Hash] New flattened data
|
|
596
|
+
# @return [Hash] Changes with :added, :modified, :removed keys
|
|
597
|
+
def calculate_changes(existing, new_data)
|
|
598
|
+
added = []
|
|
599
|
+
modified = []
|
|
600
|
+
removed = []
|
|
601
|
+
|
|
602
|
+
# Find added and modified keys
|
|
603
|
+
new_data.each do |key, new_value|
|
|
604
|
+
if existing.key?(key)
|
|
605
|
+
if existing[key] != new_value
|
|
606
|
+
modified << { key: key, old_value: existing[key], new_value: new_value }
|
|
607
|
+
end
|
|
608
|
+
else
|
|
609
|
+
added << { key: key, value: new_value }
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Find removed keys
|
|
614
|
+
existing.each_key do |key|
|
|
615
|
+
removed << { key: key, value: existing[key] } unless new_data.key?(key)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
{ added: added, modified: modified, removed: removed }
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Display diff with colored output
|
|
622
|
+
#
|
|
623
|
+
# @param file_path [String] Output file path
|
|
624
|
+
# @param changes [Hash] Changes hash
|
|
625
|
+
# @return [void]
|
|
626
|
+
def display_diff(file_path, changes)
|
|
627
|
+
puts "\n" + colorize("=" * 60, :cyan)
|
|
628
|
+
puts colorize("#{file_path}", :cyan)
|
|
629
|
+
puts colorize("=" * 60, :cyan)
|
|
630
|
+
|
|
631
|
+
# Show added keys
|
|
632
|
+
changes[:added].each do |item|
|
|
633
|
+
puts colorize("+ #{item[:key]}: #{format_value(item[:value])}", :green)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Show modified keys
|
|
637
|
+
changes[:modified].each do |item|
|
|
638
|
+
puts colorize("~ #{item[:key]}: #{format_value(item[:new_value])}", :yellow)
|
|
639
|
+
puts colorize(" (was: #{format_value(item[:old_value])})", :yellow)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Show removed keys
|
|
643
|
+
changes[:removed].each do |item|
|
|
644
|
+
puts colorize("- #{item[:key]}: #{format_value(item[:value])}", :red)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Show counts
|
|
648
|
+
puts colorize("-" * 60, :cyan)
|
|
649
|
+
puts "#{colorize('+', :green)} #{changes[:added].size} | " \
|
|
650
|
+
"#{colorize('~', :yellow)} #{changes[:modified].size} | " \
|
|
651
|
+
"#{colorize('-', :red)} #{changes[:removed].size}"
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Format value for display (truncate long strings)
|
|
655
|
+
#
|
|
656
|
+
# @param value [String] Value to format
|
|
657
|
+
# @return [String] Formatted value
|
|
658
|
+
def format_value(value)
|
|
659
|
+
max_length = 60
|
|
660
|
+
return value.inspect if value.length <= max_length
|
|
661
|
+
|
|
662
|
+
"#{value[0...(max_length - 3)].inspect}..."
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Colorize text for terminal output
|
|
666
|
+
#
|
|
667
|
+
# @param text [String] Text to colorize
|
|
668
|
+
# @param color [Symbol] Color name (:red, :green, :yellow, :cyan)
|
|
669
|
+
# @return [String] Colorized text
|
|
670
|
+
def colorize(text, color)
|
|
671
|
+
return text unless $stdout.tty?
|
|
672
|
+
|
|
673
|
+
colors = {
|
|
674
|
+
red: "\e[31m",
|
|
675
|
+
green: "\e[32m",
|
|
676
|
+
yellow: "\e[33m",
|
|
677
|
+
cyan: "\e[36m",
|
|
678
|
+
reset: "\e[0m"
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
"#{colors[color]}#{text}#{colors[:reset]}"
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### 5.9 Update `YAMLHandler` to integrate DiffPreview
|
|
688
|
+
|
|
689
|
+
Update the `write_yaml` method in `lib/better_translate/yaml_handler.rb`:
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
# Write hash to YAML file with optional diff preview
|
|
693
|
+
#
|
|
694
|
+
# @param file_path [String] Output file path
|
|
695
|
+
# @param data [Hash] Data to write
|
|
696
|
+
# @param diff_preview [DiffPreview, nil] Optional diff preview instance
|
|
697
|
+
# @return [Hash, nil] Summary hash if dry_run, nil otherwise
|
|
698
|
+
# @raise [FileError] if file cannot be written
|
|
699
|
+
def write_yaml(file_path, data, diff_preview: nil)
|
|
700
|
+
summary = nil
|
|
701
|
+
|
|
702
|
+
# Show diff preview if in dry run mode
|
|
703
|
+
if config.dry_run && diff_preview
|
|
704
|
+
existing_data = File.exist?(file_path) ? read_yaml(file_path) : {}
|
|
705
|
+
summary = diff_preview.show_diff(existing_data, data, file_path)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
return summary if config.dry_run
|
|
709
|
+
|
|
710
|
+
# Ensure output directory exists
|
|
711
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
712
|
+
|
|
713
|
+
File.write(file_path, YAML.dump(data))
|
|
714
|
+
|
|
715
|
+
nil
|
|
716
|
+
rescue Errno::EACCES => e
|
|
717
|
+
raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
|
|
718
|
+
rescue StandardError => e
|
|
719
|
+
raise FileError.new("Failed to write YAML: #{file_path}", context: { error: e.message })
|
|
720
|
+
end
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
### 5.10 Update `Translator` to use DiffPreview
|
|
724
|
+
|
|
725
|
+
Update `lib/better_translate/translator.rb` to integrate the diff preview:
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
# Initialize translator
|
|
729
|
+
#
|
|
730
|
+
# @param config [Configuration] Configuration object
|
|
731
|
+
def initialize(config)
|
|
732
|
+
@config = config
|
|
733
|
+
@config.validate!
|
|
734
|
+
@provider = ProviderFactory.create(config.provider, config)
|
|
735
|
+
@yaml_handler = YAMLHandler.new(config)
|
|
736
|
+
@progress_tracker = ProgressTracker.new(enabled: config.verbose)
|
|
737
|
+
@diff_preview = DiffPreview.new(config)
|
|
738
|
+
@diff_summaries = []
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Translate to all target languages
|
|
742
|
+
#
|
|
743
|
+
# @return [Hash] Results hash with :success_count, :failure_count, :errors
|
|
744
|
+
def translate_all
|
|
745
|
+
source_strings = @yaml_handler.get_source_strings
|
|
746
|
+
|
|
747
|
+
results = {
|
|
748
|
+
success_count: 0,
|
|
749
|
+
failure_count: 0,
|
|
750
|
+
errors: []
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
config.target_languages.each do |lang|
|
|
754
|
+
begin
|
|
755
|
+
summary = translate_language(source_strings, lang)
|
|
756
|
+
@diff_summaries << summary if summary
|
|
757
|
+
results[:success_count] += 1
|
|
758
|
+
rescue StandardError => e
|
|
759
|
+
results[:failure_count] += 1
|
|
760
|
+
results[:errors] << {
|
|
761
|
+
language: lang[:name],
|
|
762
|
+
error: e.message,
|
|
763
|
+
context: e.respond_to?(:context) ? e.context : {}
|
|
764
|
+
}
|
|
765
|
+
@progress_tracker.error(lang[:name], e)
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# Show summary if dry run
|
|
770
|
+
@diff_preview.show_summary(@diff_summaries) if config.dry_run
|
|
771
|
+
|
|
772
|
+
results
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
private
|
|
776
|
+
|
|
777
|
+
def translate_language(source_strings, lang)
|
|
778
|
+
target_lang_code = lang[:short_name]
|
|
779
|
+
target_lang_name = lang[:name]
|
|
780
|
+
|
|
781
|
+
# Filter exclusions
|
|
782
|
+
strings_to_translate = @yaml_handler.filter_exclusions(source_strings, target_lang_code)
|
|
783
|
+
|
|
784
|
+
return nil if strings_to_translate.empty?
|
|
785
|
+
|
|
786
|
+
# Select strategy
|
|
787
|
+
strategy = Strategies::StrategySelector.select(
|
|
788
|
+
strings_to_translate.size,
|
|
789
|
+
config,
|
|
790
|
+
@provider,
|
|
791
|
+
@progress_tracker
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
# Translate
|
|
795
|
+
@progress_tracker.reset
|
|
796
|
+
translated = strategy.translate(strings_to_translate, target_lang_code, target_lang_name)
|
|
797
|
+
|
|
798
|
+
# Save
|
|
799
|
+
output_path = @yaml_handler.build_output_path(target_lang_code)
|
|
800
|
+
|
|
801
|
+
if config.translation_mode == :incremental
|
|
802
|
+
final_translations = @yaml_handler.merge_translations(output_path, translated)
|
|
803
|
+
else
|
|
804
|
+
final_translations = Utils::HashFlattener.unflatten(translated)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# Wrap in language key (e.g., "it:")
|
|
808
|
+
wrapped = { target_lang_code => final_translations }
|
|
809
|
+
|
|
810
|
+
# Write YAML with diff preview
|
|
811
|
+
summary = @yaml_handler.write_yaml(output_path, wrapped, diff_preview: @diff_preview)
|
|
812
|
+
|
|
813
|
+
@progress_tracker.complete(target_lang_name, translated.size)
|
|
814
|
+
|
|
815
|
+
# Return summary for dry run
|
|
816
|
+
if summary
|
|
817
|
+
{ file: output_path, **summary }
|
|
818
|
+
else
|
|
819
|
+
nil
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
### 5.11 Test: `spec/better_translate/diff_preview_spec.rb`
|
|
825
|
+
|
|
826
|
+
```ruby
|
|
827
|
+
# frozen_string_literal: true
|
|
828
|
+
|
|
829
|
+
RSpec.describe BetterTranslate::DiffPreview do
|
|
830
|
+
let(:config) do
|
|
831
|
+
BetterTranslate::Configuration.new.tap do |c|
|
|
832
|
+
c.dry_run = true
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
subject(:diff_preview) { described_class.new(config) }
|
|
837
|
+
|
|
838
|
+
describe "#show_diff" do
|
|
839
|
+
context "when dry_run is enabled" do
|
|
840
|
+
it "shows added keys" do
|
|
841
|
+
existing = {}
|
|
842
|
+
new_data = { "en" => { "welcome" => "Welcome" } }
|
|
843
|
+
|
|
844
|
+
expect {
|
|
845
|
+
diff_preview.show_diff(existing, new_data, "en.yml")
|
|
846
|
+
}.to output(/\+ welcome: "Welcome"/).to_stdout
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
it "shows modified keys" do
|
|
850
|
+
existing = { "en" => { "welcome" => "Hello" } }
|
|
851
|
+
new_data = { "en" => { "welcome" => "Welcome" } }
|
|
852
|
+
|
|
853
|
+
expect {
|
|
854
|
+
diff_preview.show_diff(existing, new_data, "en.yml")
|
|
855
|
+
}.to output(/~ welcome: "Welcome"/).to_stdout
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
it "shows removed keys" do
|
|
859
|
+
existing = { "en" => { "old_key" => "Old value" } }
|
|
860
|
+
new_data = { "en" => {} }
|
|
861
|
+
|
|
862
|
+
expect {
|
|
863
|
+
diff_preview.show_diff(existing, new_data, "en.yml")
|
|
864
|
+
}.to output(/- old_key: "Old value"/).to_stdout
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
it "returns summary with counts" do
|
|
868
|
+
existing = {
|
|
869
|
+
"en" => {
|
|
870
|
+
"old_key" => "Old",
|
|
871
|
+
"modified" => "Original"
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
new_data = {
|
|
875
|
+
"en" => {
|
|
876
|
+
"modified" => "Changed",
|
|
877
|
+
"new_key" => "New"
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
summary = diff_preview.show_diff(existing, new_data, "en.yml")
|
|
882
|
+
|
|
883
|
+
expect(summary[:added]).to eq(1)
|
|
884
|
+
expect(summary[:modified]).to eq(1)
|
|
885
|
+
expect(summary[:removed]).to eq(1)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
context "when dry_run is disabled" do
|
|
890
|
+
before { config.dry_run = false }
|
|
891
|
+
|
|
892
|
+
it "returns zero counts" do
|
|
893
|
+
existing = {}
|
|
894
|
+
new_data = { "en" => { "welcome" => "Welcome" } }
|
|
895
|
+
|
|
896
|
+
summary = diff_preview.show_diff(existing, new_data, "en.yml")
|
|
897
|
+
|
|
898
|
+
expect(summary[:added]).to eq(0)
|
|
899
|
+
expect(summary[:modified]).to eq(0)
|
|
900
|
+
expect(summary[:removed]).to eq(0)
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
describe "#show_summary" do
|
|
906
|
+
it "displays total summary for all files" do
|
|
907
|
+
summaries = [
|
|
908
|
+
{ file: "it.yml", added: 5, modified: 2, removed: 1 },
|
|
909
|
+
{ file: "fr.yml", added: 3, modified: 1, removed: 0 }
|
|
910
|
+
]
|
|
911
|
+
|
|
912
|
+
expect {
|
|
913
|
+
diff_preview.show_summary(summaries)
|
|
914
|
+
}.to output(/Total: \+8 \| ~3 \| -1/).to_stdout
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
it "shows no files were modified message" do
|
|
918
|
+
summaries = [
|
|
919
|
+
{ file: "it.yml", added: 5, modified: 0, removed: 0 }
|
|
920
|
+
]
|
|
921
|
+
|
|
922
|
+
expect {
|
|
923
|
+
diff_preview.show_summary(summaries)
|
|
924
|
+
}.to output(/No files were modified \(dry run mode\)/).to_stdout
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
describe "#calculate_changes" do
|
|
929
|
+
it "identifies all types of changes" do
|
|
930
|
+
existing = {
|
|
931
|
+
"keep" => "Same",
|
|
932
|
+
"modify" => "Old",
|
|
933
|
+
"remove" => "Gone"
|
|
934
|
+
}
|
|
935
|
+
new_data = {
|
|
936
|
+
"keep" => "Same",
|
|
937
|
+
"modify" => "New",
|
|
938
|
+
"add" => "Fresh"
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
changes = diff_preview.send(:calculate_changes, existing, new_data)
|
|
942
|
+
|
|
943
|
+
expect(changes[:added].size).to eq(1)
|
|
944
|
+
expect(changes[:modified].size).to eq(1)
|
|
945
|
+
expect(changes[:removed].size).to eq(1)
|
|
946
|
+
end
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
## Configuration Option
|
|
954
|
+
|
|
955
|
+
Add to `lib/better_translate/configuration.rb`:
|
|
956
|
+
|
|
957
|
+
```ruby
|
|
958
|
+
# @return [Boolean] Dry run mode - show diff preview without writing files (default: false)
|
|
959
|
+
attr_accessor :dry_run
|
|
960
|
+
|
|
961
|
+
# In initialize method:
|
|
962
|
+
@dry_run = false
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
## Usage Examples
|
|
968
|
+
|
|
969
|
+
### Example 1: Enable Dry Run Mode
|
|
970
|
+
|
|
971
|
+
```ruby
|
|
972
|
+
BetterTranslate.configure do |config|
|
|
973
|
+
config.dry_run = true
|
|
974
|
+
config.input_file = "config/locales/en.yml"
|
|
975
|
+
config.target_languages = [
|
|
976
|
+
{ short_name: "it", name: "Italian" },
|
|
977
|
+
{ short_name: "fr", name: "French" }
|
|
978
|
+
]
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
BetterTranslate.translate_all
|
|
982
|
+
# Output:
|
|
983
|
+
# ============================================================
|
|
984
|
+
# config/locales/it.yml
|
|
985
|
+
# ============================================================
|
|
986
|
+
# + welcome: "Benvenuto"
|
|
987
|
+
# + greeting: "Ciao"
|
|
988
|
+
# ~ goodbye: "Arrivederci" (was: "Addio")
|
|
989
|
+
# - old_message: "Vecchio messaggio"
|
|
990
|
+
# ------------------------------------------------------------
|
|
991
|
+
# + 2 | ~ 1 | - 1
|
|
992
|
+
#
|
|
993
|
+
# [Repeat for fr.yml...]
|
|
994
|
+
#
|
|
995
|
+
# ============================================================
|
|
996
|
+
# DRY RUN SUMMARY
|
|
997
|
+
# ============================================================
|
|
998
|
+
# config/locales/it.yml:
|
|
999
|
+
# + 2 added
|
|
1000
|
+
# ~ 1 modified
|
|
1001
|
+
# - 1 removed
|
|
1002
|
+
#
|
|
1003
|
+
# config/locales/fr.yml:
|
|
1004
|
+
# + 3 added
|
|
1005
|
+
# ~ 0 modified
|
|
1006
|
+
# - 0 removed
|
|
1007
|
+
#
|
|
1008
|
+
# ------------------------------------------------------------
|
|
1009
|
+
# Total: +5 | ~1 | -1
|
|
1010
|
+
# ============================================================
|
|
1011
|
+
# ℹ No files were modified (dry run mode)
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
### Example 2: CLI with Dry Run
|
|
1015
|
+
|
|
1016
|
+
```bash
|
|
1017
|
+
# Using the standalone CLI
|
|
1018
|
+
better_translate translate config/locales/en.yml --to it,fr --dry-run
|
|
1019
|
+
|
|
1020
|
+
# Shows colored diff preview without writing files
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### Example 3: Programmatic Dry Run Check
|
|
1024
|
+
|
|
1025
|
+
```ruby
|
|
1026
|
+
config = BetterTranslate::Configuration.new
|
|
1027
|
+
config.dry_run = true
|
|
1028
|
+
config.input_file = "en.yml"
|
|
1029
|
+
config.target_languages = [{ short_name: "it", name: "Italian" }]
|
|
1030
|
+
|
|
1031
|
+
translator = BetterTranslate::Translator.new(config)
|
|
1032
|
+
results = translator.translate_all
|
|
1033
|
+
|
|
1034
|
+
# In dry run mode, no files are written
|
|
1035
|
+
# Results include success/failure counts but no actual file modifications
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
---
|
|
1039
|
+
|
|
1040
|
+
## Benefits
|
|
1041
|
+
|
|
1042
|
+
1. **Safety**: Preview changes before committing translations
|
|
1043
|
+
2. **Clarity**: Color-coded diff shows exactly what will change
|
|
1044
|
+
3. **Non-Destructive**: No files modified in dry-run mode
|
|
1045
|
+
4. **Summary**: Clear overview of all changes across all language files
|
|
1046
|
+
5. **CI/CD Integration**: Useful for validation in automated pipelines
|
|
1047
|
+
|
|
1048
|
+
---
|
|
1049
|
+
|
|
1050
|
+
## Implementation Checklist
|
|
1051
|
+
|
|
1052
|
+
- [ ] Create `lib/better_translate/diff_preview.rb`
|
|
1053
|
+
- [ ] Update `YAMLHandler#write_yaml` to accept `diff_preview` parameter
|
|
1054
|
+
- [ ] Update `Translator` to instantiate and use `DiffPreview`
|
|
1055
|
+
- [ ] Add `dry_run` configuration option
|
|
1056
|
+
- [ ] Create comprehensive test suite in `spec/better_translate/diff_preview_spec.rb`
|
|
1057
|
+
- [ ] Update CLI to support `--dry-run` flag (Phase 12)
|
|
1058
|
+
- [ ] Add YARD documentation for all methods
|
|
1059
|
+
- [ ] Update README with dry-run examples
|
|
1060
|
+
|
|
1061
|
+
---
|
|
1062
|
+
|
|
1063
|
+
---
|
|
1064
|
+
|
|
1065
|
+
[← Previous: 04-Provider Architecture](./04-provider_architecture.md) | [Back to Index](../../IMPLEMENTATION_PLAN.md) | [Next: 06-Main Module Api →](./06-main_module_api.md)
|