better_translate 1.0.0.1 → 1.1.1
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/.rubocop.yml +28 -0
- data/.rubocop_todo.yml +291 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +12 -7
- data/CONTRIBUTING.md +432 -0
- data/README.md +240 -1
- data/Rakefile +14 -1
- data/SECURITY.md +160 -0
- data/Steepfile +0 -1
- data/brakeman.yml +37 -0
- data/codecov.yml +34 -0
- data/lib/better_translate/analyzer/code_scanner.rb +149 -0
- data/lib/better_translate/analyzer/key_scanner.rb +109 -0
- data/lib/better_translate/analyzer/orphan_detector.rb +91 -0
- data/lib/better_translate/analyzer/reporter.rb +155 -0
- data/lib/better_translate/cli.rb +81 -2
- data/lib/better_translate/configuration.rb +76 -3
- data/lib/better_translate/errors.rb +9 -0
- data/lib/better_translate/json_handler.rb +227 -0
- data/lib/better_translate/translator.rb +205 -23
- data/lib/better_translate/version.rb +1 -1
- data/lib/better_translate/yaml_handler.rb +59 -0
- data/lib/better_translate.rb +7 -0
- data/lib/generators/better_translate/install/install_generator.rb +2 -2
- data/lib/generators/better_translate/install/templates/initializer.rb.tt +22 -34
- data/lib/generators/better_translate/translate/translate_generator.rb +65 -46
- data/lib/tasks/better_translate.rake +62 -45
- data/sig/better_translate/analyzer/code_scanner.rbs +59 -0
- data/sig/better_translate/analyzer/key_scanner.rbs +40 -0
- data/sig/better_translate/analyzer/orphan_detector.rbs +43 -0
- data/sig/better_translate/analyzer/reporter.rbs +70 -0
- data/sig/better_translate/cli.rbs +2 -0
- data/sig/better_translate/configuration.rbs +6 -0
- data/sig/better_translate/errors.rbs +4 -0
- data/sig/better_translate/json_handler.rbs +65 -0
- data/sig/better_translate/progress_tracker.rbs +1 -1
- data/sig/better_translate/translator.rbs +12 -1
- data/sig/better_translate/yaml_handler.rbs +6 -0
- data/sig/better_translate.rbs +4 -0
- data/sig/csv.rbs +16 -0
- metadata +32 -3
- data/regenerate_vcr.rb +0 -47
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "csv"
|
|
5
|
+
|
|
6
|
+
module BetterTranslate
|
|
7
|
+
module Analyzer
|
|
8
|
+
# Generates reports for orphan key analysis
|
|
9
|
+
#
|
|
10
|
+
# Supports multiple output formats: text, JSON, CSV
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# reporter = Reporter.new(
|
|
14
|
+
# orphans: ["orphan_key"],
|
|
15
|
+
# orphan_details: { "orphan_key" => "Unused" },
|
|
16
|
+
# total_keys: 10,
|
|
17
|
+
# used_keys: 9,
|
|
18
|
+
# usage_percentage: 90.0,
|
|
19
|
+
# format: :text
|
|
20
|
+
# )
|
|
21
|
+
# puts reporter.generate
|
|
22
|
+
#
|
|
23
|
+
class Reporter
|
|
24
|
+
# @return [Array<String>] List of orphan keys
|
|
25
|
+
attr_reader :orphans
|
|
26
|
+
|
|
27
|
+
# @return [Hash] Orphan keys with their values
|
|
28
|
+
attr_reader :orphan_details
|
|
29
|
+
|
|
30
|
+
# @return [Integer] Total number of keys
|
|
31
|
+
attr_reader :total_keys
|
|
32
|
+
|
|
33
|
+
# @return [Integer] Number of used keys
|
|
34
|
+
attr_reader :used_keys
|
|
35
|
+
|
|
36
|
+
# @return [Float] Usage percentage
|
|
37
|
+
attr_reader :usage_percentage
|
|
38
|
+
|
|
39
|
+
# @return [Symbol] Output format (:text, :json, :csv)
|
|
40
|
+
attr_reader :format
|
|
41
|
+
|
|
42
|
+
# Initialize reporter
|
|
43
|
+
#
|
|
44
|
+
# @param orphans [Array<String>] List of orphan keys
|
|
45
|
+
# @param orphan_details [Hash] Orphan keys with values
|
|
46
|
+
# @param total_keys [Integer] Total number of keys
|
|
47
|
+
# @param used_keys [Integer] Number of used keys
|
|
48
|
+
# @param usage_percentage [Float] Usage percentage
|
|
49
|
+
# @param format [Symbol] Output format (:text, :json, :csv)
|
|
50
|
+
#
|
|
51
|
+
def initialize(orphans:, orphan_details:, total_keys:, used_keys:, usage_percentage:, format: :text)
|
|
52
|
+
@orphans = orphans
|
|
53
|
+
@orphan_details = orphan_details
|
|
54
|
+
@total_keys = total_keys
|
|
55
|
+
@used_keys = used_keys
|
|
56
|
+
@usage_percentage = usage_percentage
|
|
57
|
+
@format = format
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Generate report in specified format
|
|
61
|
+
#
|
|
62
|
+
# @return [String] Generated report
|
|
63
|
+
#
|
|
64
|
+
def generate
|
|
65
|
+
case format
|
|
66
|
+
when :json
|
|
67
|
+
generate_json
|
|
68
|
+
when :csv
|
|
69
|
+
generate_csv
|
|
70
|
+
else
|
|
71
|
+
generate_text
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Save report to file
|
|
76
|
+
#
|
|
77
|
+
# @param file_path [String] Output file path
|
|
78
|
+
#
|
|
79
|
+
def save_to_file(file_path)
|
|
80
|
+
File.write(file_path, generate)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Generate text format report
|
|
86
|
+
#
|
|
87
|
+
# @return [String] Text report
|
|
88
|
+
#
|
|
89
|
+
def generate_text
|
|
90
|
+
lines = [] # : Array[String]
|
|
91
|
+
lines << "=" * 60
|
|
92
|
+
lines << "Orphan Keys Analysis Report"
|
|
93
|
+
lines << "=" * 60
|
|
94
|
+
lines << ""
|
|
95
|
+
lines << "Statistics:"
|
|
96
|
+
lines << " Total keys: #{total_keys}"
|
|
97
|
+
lines << " Used keys: #{used_keys}"
|
|
98
|
+
lines << " Orphan keys: #{orphans.size}"
|
|
99
|
+
lines << " Usage: #{usage_percentage}%"
|
|
100
|
+
lines << ""
|
|
101
|
+
|
|
102
|
+
if orphans.empty?
|
|
103
|
+
lines << "✓ No orphan keys found! All translation keys are being used."
|
|
104
|
+
else
|
|
105
|
+
lines << "Orphan Keys (#{orphans.size}):"
|
|
106
|
+
lines << "-" * 60
|
|
107
|
+
|
|
108
|
+
orphans.each do |key|
|
|
109
|
+
value = orphan_details[key]
|
|
110
|
+
lines << ""
|
|
111
|
+
lines << " Key: #{key}"
|
|
112
|
+
lines << " Value: #{value}" if value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
lines << ""
|
|
117
|
+
lines << "=" * 60
|
|
118
|
+
|
|
119
|
+
lines.join("\n")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Generate JSON format report
|
|
123
|
+
#
|
|
124
|
+
# @return [String] JSON report
|
|
125
|
+
#
|
|
126
|
+
def generate_json
|
|
127
|
+
data = {
|
|
128
|
+
orphans: orphans,
|
|
129
|
+
orphan_details: orphan_details,
|
|
130
|
+
orphan_count: orphans.size,
|
|
131
|
+
total_keys: total_keys,
|
|
132
|
+
used_keys: used_keys,
|
|
133
|
+
usage_percentage: usage_percentage
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
JSON.pretty_generate(data)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Generate CSV format report
|
|
140
|
+
#
|
|
141
|
+
# @return [String] CSV report
|
|
142
|
+
#
|
|
143
|
+
def generate_csv
|
|
144
|
+
CSV.generate do |csv|
|
|
145
|
+
csv << %w[Key Value]
|
|
146
|
+
|
|
147
|
+
orphans.each do |key|
|
|
148
|
+
value = orphan_details[key]
|
|
149
|
+
csv << [key, value]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/better_translate/cli.rb
CHANGED
|
@@ -53,6 +53,8 @@ module BetterTranslate
|
|
|
53
53
|
run_generate
|
|
54
54
|
when "direct"
|
|
55
55
|
run_direct
|
|
56
|
+
when "analyze"
|
|
57
|
+
run_analyze
|
|
56
58
|
when "--version", "-v"
|
|
57
59
|
puts "BetterTranslate version #{VERSION}"
|
|
58
60
|
when "--help", "-h", nil
|
|
@@ -79,6 +81,7 @@ module BetterTranslate
|
|
|
79
81
|
translate Translate YAML files using config file
|
|
80
82
|
generate OUTPUT_FILE Generate sample config file
|
|
81
83
|
direct TEXT Translate text directly
|
|
84
|
+
analyze Analyze YAML files for orphan keys
|
|
82
85
|
|
|
83
86
|
Options:
|
|
84
87
|
--help, -h Show this help message
|
|
@@ -88,6 +91,7 @@ module BetterTranslate
|
|
|
88
91
|
better_translate translate --config config.yml
|
|
89
92
|
better_translate generate config.yml
|
|
90
93
|
better_translate direct "Hello" --to it --provider chatgpt --api-key KEY
|
|
94
|
+
better_translate analyze --source config/locales/en.yml --scan-path app/
|
|
91
95
|
HELP
|
|
92
96
|
end
|
|
93
97
|
|
|
@@ -221,8 +225,8 @@ module BetterTranslate
|
|
|
221
225
|
"dry_run" => false,
|
|
222
226
|
"translation_mode" => "override",
|
|
223
227
|
"preserve_variables" => true,
|
|
224
|
-
"global_exclusions" => [],
|
|
225
|
-
"exclusions_per_language" => {},
|
|
228
|
+
"global_exclusions" => [], # : Array[String]
|
|
229
|
+
"exclusions_per_language" => {}, # : Hash[String, Array[String]]
|
|
226
230
|
"model" => nil,
|
|
227
231
|
"temperature" => 0.3,
|
|
228
232
|
"max_tokens" => 2000,
|
|
@@ -300,5 +304,80 @@ module BetterTranslate
|
|
|
300
304
|
rescue StandardError => e
|
|
301
305
|
puts "Error: #{e.message}"
|
|
302
306
|
end
|
|
307
|
+
|
|
308
|
+
# Run analyze command
|
|
309
|
+
#
|
|
310
|
+
# @return [void]
|
|
311
|
+
# @api private
|
|
312
|
+
#
|
|
313
|
+
def run_analyze
|
|
314
|
+
# @type var options: Hash[Symbol, String]
|
|
315
|
+
options = {}
|
|
316
|
+
OptionParser.new do |opts|
|
|
317
|
+
opts.on("--source FILE", "Source YAML file path") { |v| options[:source] = v }
|
|
318
|
+
opts.on("--scan-path PATH", "Path to scan for code files") { |v| options[:scan_path] = v }
|
|
319
|
+
opts.on("--format FORMAT", "Output format (text, json, csv)") { |v| options[:format] = v }
|
|
320
|
+
opts.on("--output FILE", "Output file path") { |v| options[:output] = v }
|
|
321
|
+
end.parse!(args[1..])
|
|
322
|
+
|
|
323
|
+
# Validate required options
|
|
324
|
+
unless options[:source]
|
|
325
|
+
puts "Error: --source is required"
|
|
326
|
+
return
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
unless options[:scan_path]
|
|
330
|
+
puts "Error: --scan-path is required"
|
|
331
|
+
return
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Validate paths exist
|
|
335
|
+
unless File.exist?(options[:source])
|
|
336
|
+
puts "Error: Source file not found: #{options[:source]}"
|
|
337
|
+
return
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
unless File.exist?(options[:scan_path])
|
|
341
|
+
puts "Error: Scan path not found: #{options[:scan_path]}"
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Default format
|
|
346
|
+
format = (options[:format] || "text").to_sym
|
|
347
|
+
|
|
348
|
+
# Scan keys from YAML
|
|
349
|
+
key_scanner = Analyzer::KeyScanner.new(options[:source])
|
|
350
|
+
all_keys = key_scanner.scan
|
|
351
|
+
|
|
352
|
+
# Scan code for used keys
|
|
353
|
+
code_scanner = Analyzer::CodeScanner.new(options[:scan_path])
|
|
354
|
+
used_keys = code_scanner.scan
|
|
355
|
+
|
|
356
|
+
# Detect orphans
|
|
357
|
+
detector = Analyzer::OrphanDetector.new(all_keys, used_keys)
|
|
358
|
+
orphans = detector.detect
|
|
359
|
+
|
|
360
|
+
# Generate report
|
|
361
|
+
reporter = Analyzer::Reporter.new(
|
|
362
|
+
orphans: orphans,
|
|
363
|
+
orphan_details: detector.orphan_details,
|
|
364
|
+
total_keys: all_keys.size,
|
|
365
|
+
used_keys: used_keys.size,
|
|
366
|
+
usage_percentage: detector.usage_percentage,
|
|
367
|
+
format: format
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
report = reporter.generate
|
|
371
|
+
|
|
372
|
+
# Output or save
|
|
373
|
+
if options[:output]
|
|
374
|
+
reporter.save_to_file(options[:output])
|
|
375
|
+
puts "Report saved to #{options[:output]}"
|
|
376
|
+
else
|
|
377
|
+
puts report
|
|
378
|
+
end
|
|
379
|
+
rescue StandardError => e
|
|
380
|
+
puts "Error: #{e.message}"
|
|
381
|
+
end
|
|
303
382
|
end
|
|
304
383
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module BetterTranslate
|
|
4
7
|
# Configuration class for BetterTranslate
|
|
5
8
|
#
|
|
@@ -13,6 +16,15 @@ module BetterTranslate
|
|
|
13
16
|
# config.target_languages = [{ short_name: "it", name: "Italian" }]
|
|
14
17
|
# config.validate!
|
|
15
18
|
#
|
|
19
|
+
# @example Provider-specific options
|
|
20
|
+
# config.model = "gpt-5-nano" # Specify model (optional, provider-specific)
|
|
21
|
+
# config.temperature = 0.7 # Control creativity (0.0-2.0, default: 0.3)
|
|
22
|
+
# config.max_tokens = 1500 # Limit response length (default: 2000)
|
|
23
|
+
#
|
|
24
|
+
# @example Backup configuration
|
|
25
|
+
# config.create_backup = true # Enable automatic backups (default: true)
|
|
26
|
+
# config.max_backups = 5 # Keep up to 5 backup files (default: 3)
|
|
27
|
+
#
|
|
16
28
|
class Configuration
|
|
17
29
|
# @return [Symbol] The translation provider (:chatgpt, :gemini, :anthropic)
|
|
18
30
|
attr_accessor :provider
|
|
@@ -40,9 +52,12 @@ module BetterTranslate
|
|
|
40
52
|
# @return [Array<Hash>] Target languages with :short_name and :name
|
|
41
53
|
attr_accessor :target_languages
|
|
42
54
|
|
|
43
|
-
# @return [String] Path to input YAML file
|
|
55
|
+
# @return [String] Path to input YAML file (for backward compatibility, use input_files for multiple files)
|
|
44
56
|
attr_accessor :input_file
|
|
45
57
|
|
|
58
|
+
# @return [Array<String>, String] Multiple input files (array or glob pattern)
|
|
59
|
+
attr_accessor :input_files
|
|
60
|
+
|
|
46
61
|
# @return [String] Output folder for translated files
|
|
47
62
|
attr_accessor :output_folder
|
|
48
63
|
|
|
@@ -88,6 +103,21 @@ module BetterTranslate
|
|
|
88
103
|
# @return [Boolean] Preserve interpolation variables during translation (default: true)
|
|
89
104
|
attr_accessor :preserve_variables
|
|
90
105
|
|
|
106
|
+
# @return [String, nil] AI model to use (provider-specific, e.g., "gpt-5-nano", "gemini-2.0-flash-exp")
|
|
107
|
+
attr_accessor :model
|
|
108
|
+
|
|
109
|
+
# @return [Float] Temperature for AI generation (0.0-2.0, higher = more creative)
|
|
110
|
+
attr_accessor :temperature
|
|
111
|
+
|
|
112
|
+
# @return [Integer] Maximum tokens for AI response
|
|
113
|
+
attr_accessor :max_tokens
|
|
114
|
+
|
|
115
|
+
# @return [Boolean] Create backup files before overwriting (default: true)
|
|
116
|
+
attr_accessor :create_backup
|
|
117
|
+
|
|
118
|
+
# @return [Integer] Maximum number of backup files to keep (default: 3)
|
|
119
|
+
attr_accessor :max_backups
|
|
120
|
+
|
|
91
121
|
# Initialize a new configuration with defaults
|
|
92
122
|
def initialize
|
|
93
123
|
@translation_mode = :override
|
|
@@ -104,6 +134,11 @@ module BetterTranslate
|
|
|
104
134
|
@exclusions_per_language = {}
|
|
105
135
|
@target_languages = []
|
|
106
136
|
@preserve_variables = true
|
|
137
|
+
@model = nil
|
|
138
|
+
@temperature = 0.3
|
|
139
|
+
@max_tokens = 2000
|
|
140
|
+
@create_backup = true
|
|
141
|
+
@max_backups = 3
|
|
107
142
|
end
|
|
108
143
|
|
|
109
144
|
# Validate the configuration
|
|
@@ -176,9 +211,20 @@ module BetterTranslate
|
|
|
176
211
|
# @return [void]
|
|
177
212
|
# @api private
|
|
178
213
|
def validate_files!
|
|
179
|
-
|
|
214
|
+
# Check if either input_file or input_files is set
|
|
215
|
+
has_input = (input_file && !input_file.empty?) || input_files
|
|
216
|
+
|
|
217
|
+
raise ConfigurationError, "Input file or input_files must be set" unless has_input
|
|
180
218
|
raise ConfigurationError, "Output folder must be set" if output_folder.nil? || output_folder.empty?
|
|
181
|
-
|
|
219
|
+
|
|
220
|
+
# Only validate input_file exists if using single file mode (not glob pattern or array)
|
|
221
|
+
return unless input_file && !input_file.empty? && !input_files
|
|
222
|
+
|
|
223
|
+
# Create input file if it doesn't exist
|
|
224
|
+
return if File.exist?(input_file)
|
|
225
|
+
|
|
226
|
+
create_default_input_file!(input_file)
|
|
227
|
+
puts "Created empty input file: #{input_file}" if verbose
|
|
182
228
|
end
|
|
183
229
|
|
|
184
230
|
# Validate optional settings (timeouts, retries, cache, etc.)
|
|
@@ -196,6 +242,33 @@ module BetterTranslate
|
|
|
196
242
|
raise ConfigurationError, "Request timeout must be positive" if request_timeout <= 0
|
|
197
243
|
raise ConfigurationError, "Max retries must be non-negative" if max_retries.negative?
|
|
198
244
|
raise ConfigurationError, "Cache size must be positive" if cache_size <= 0
|
|
245
|
+
|
|
246
|
+
# Validate temperature range (AI providers typically accept 0.0-2.0)
|
|
247
|
+
if temperature && (temperature < 0.0 || temperature > 2.0)
|
|
248
|
+
raise ConfigurationError, "Temperature must be between 0.0 and 2.0"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Validate max_tokens is positive
|
|
252
|
+
raise ConfigurationError, "Max tokens must be positive" if max_tokens && max_tokens <= 0
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Create a default input file with root language key
|
|
256
|
+
#
|
|
257
|
+
# @param file_path [String] Path to the input file
|
|
258
|
+
# @return [void]
|
|
259
|
+
# @api private
|
|
260
|
+
def create_default_input_file!(file_path)
|
|
261
|
+
# Create directory if needed
|
|
262
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
263
|
+
|
|
264
|
+
# Determine file format (YAML or JSON)
|
|
265
|
+
content = if file_path.end_with?(".json")
|
|
266
|
+
JSON.pretty_generate({ source_language => {} })
|
|
267
|
+
else
|
|
268
|
+
{ source_language => {} }.to_yaml
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
File.write(file_path, content)
|
|
199
272
|
end
|
|
200
273
|
end
|
|
201
274
|
end
|
|
@@ -90,6 +90,15 @@ module BetterTranslate
|
|
|
90
90
|
# )
|
|
91
91
|
class YamlError < Error; end
|
|
92
92
|
|
|
93
|
+
# Raised when JSON parsing fails
|
|
94
|
+
#
|
|
95
|
+
# @example JSON syntax error
|
|
96
|
+
# raise JsonError.new(
|
|
97
|
+
# "Invalid JSON syntax",
|
|
98
|
+
# context: { file_path: "config/locales/en.json", error: "unexpected token" }
|
|
99
|
+
# )
|
|
100
|
+
class JsonError < Error; end
|
|
101
|
+
|
|
93
102
|
# Raised when a provider is not found
|
|
94
103
|
#
|
|
95
104
|
# @example Provider not found
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module BetterTranslate
|
|
7
|
+
# Handles JSON file operations
|
|
8
|
+
#
|
|
9
|
+
# Provides methods for:
|
|
10
|
+
# - Reading and parsing JSON files
|
|
11
|
+
# - Writing JSON files with proper formatting
|
|
12
|
+
# - Merging translations (incremental mode)
|
|
13
|
+
# - Handling exclusions
|
|
14
|
+
# - Flattening/unflattening nested structures
|
|
15
|
+
#
|
|
16
|
+
# @example Reading a JSON file
|
|
17
|
+
# handler = JsonHandler.new(config)
|
|
18
|
+
# data = handler.read_json("config/locales/en.json")
|
|
19
|
+
#
|
|
20
|
+
# @example Writing translations
|
|
21
|
+
# handler.write_json("config/locales/it.json", { "it" => { "greeting" => "Ciao" } })
|
|
22
|
+
#
|
|
23
|
+
class JsonHandler
|
|
24
|
+
# @return [Configuration] Configuration object
|
|
25
|
+
attr_reader :config
|
|
26
|
+
|
|
27
|
+
# Initialize JSON handler
|
|
28
|
+
#
|
|
29
|
+
# @param config [Configuration] Configuration object
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# config = Configuration.new
|
|
33
|
+
# handler = JsonHandler.new(config)
|
|
34
|
+
#
|
|
35
|
+
def initialize(config)
|
|
36
|
+
@config = config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read and parse JSON file
|
|
40
|
+
#
|
|
41
|
+
# @param file_path [String] Path to JSON file
|
|
42
|
+
# @return [Hash] Parsed JSON content
|
|
43
|
+
# @raise [FileError] if file cannot be read
|
|
44
|
+
# @raise [JsonError] if JSON is invalid
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# data = handler.read_json("config/locales/en.json")
|
|
48
|
+
# #=> { "en" => { "greeting" => "Hello" } }
|
|
49
|
+
#
|
|
50
|
+
def read_json(file_path)
|
|
51
|
+
Validator.validate_file_exists!(file_path)
|
|
52
|
+
|
|
53
|
+
content = File.read(file_path)
|
|
54
|
+
return {} if content.strip.empty?
|
|
55
|
+
|
|
56
|
+
JSON.parse(content)
|
|
57
|
+
rescue Errno::ENOENT => e
|
|
58
|
+
raise FileError.new("File does not exist: #{file_path}", context: { error: e.message })
|
|
59
|
+
rescue JSON::ParserError => e
|
|
60
|
+
raise JsonError.new("Invalid JSON syntax in #{file_path}", context: { error: e.message })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Write hash to JSON file
|
|
64
|
+
#
|
|
65
|
+
# @param file_path [String] Output file path
|
|
66
|
+
# @param data [Hash] Data to write
|
|
67
|
+
# @param diff_preview [DiffPreview, nil] Optional diff preview instance
|
|
68
|
+
# @return [Hash, nil] Summary hash if dry_run, nil otherwise
|
|
69
|
+
# @raise [FileError] if file cannot be written
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# handler.write_json("config/locales/it.json", { "it" => { "greeting" => "Ciao" } })
|
|
73
|
+
#
|
|
74
|
+
def write_json(file_path, data, diff_preview: nil)
|
|
75
|
+
summary = nil
|
|
76
|
+
|
|
77
|
+
# Show diff preview if in dry run mode
|
|
78
|
+
if config.dry_run && diff_preview
|
|
79
|
+
existing_data = File.exist?(file_path) ? read_json(file_path) : {} # : Hash[untyped, untyped]
|
|
80
|
+
summary = diff_preview.show_diff(existing_data, data, file_path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return summary if config.dry_run
|
|
84
|
+
|
|
85
|
+
# Create backup if enabled and file exists
|
|
86
|
+
create_backup_file(file_path) if config.create_backup && File.exist?(file_path)
|
|
87
|
+
|
|
88
|
+
# Ensure output directory exists
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
90
|
+
|
|
91
|
+
# Write JSON with proper indentation
|
|
92
|
+
File.write(file_path, JSON.pretty_generate(data))
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
rescue Errno::EACCES => e
|
|
96
|
+
raise FileError.new("Permission denied: #{file_path}", context: { error: e.message })
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
raise FileError.new("Failed to write JSON: #{file_path}", context: { error: e.message })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get translatable strings from source JSON
|
|
102
|
+
#
|
|
103
|
+
# Reads the input file and returns a flattened hash of strings.
|
|
104
|
+
# Removes the root language key if present.
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash] Flattened hash of translatable strings
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# strings = handler.get_source_strings
|
|
110
|
+
# #=> { "greeting" => "Hello", "nav.home" => "Home" }
|
|
111
|
+
#
|
|
112
|
+
def get_source_strings
|
|
113
|
+
return {} unless config.input_file
|
|
114
|
+
|
|
115
|
+
source_data = read_json(config.input_file)
|
|
116
|
+
# Remove root language key if present (e.g., "en:")
|
|
117
|
+
source_data = source_data[config.source_language] || source_data
|
|
118
|
+
|
|
119
|
+
Utils::HashFlattener.flatten(source_data)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Filter out excluded keys for a specific language
|
|
123
|
+
#
|
|
124
|
+
# @param strings [Hash] Flattened strings
|
|
125
|
+
# @param target_lang_code [String] Target language code
|
|
126
|
+
# @return [Hash] Filtered strings
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# filtered = handler.filter_exclusions(strings, "it")
|
|
130
|
+
#
|
|
131
|
+
def filter_exclusions(strings, target_lang_code)
|
|
132
|
+
excluded_keys = config.global_exclusions.dup
|
|
133
|
+
excluded_keys += config.exclusions_per_language[target_lang_code] || []
|
|
134
|
+
|
|
135
|
+
strings.reject { |key, _| excluded_keys.include?(key) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Merge translated strings with existing file (incremental mode)
|
|
139
|
+
#
|
|
140
|
+
# @param file_path [String] Existing file path
|
|
141
|
+
# @param new_translations [Hash] New translations (flattened)
|
|
142
|
+
# @return [Hash] Merged translations (nested)
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# merged = handler.merge_translations("config/locales/it.json", new_translations)
|
|
146
|
+
#
|
|
147
|
+
def merge_translations(file_path, new_translations)
|
|
148
|
+
if File.exist?(file_path)
|
|
149
|
+
existing = read_json(file_path)
|
|
150
|
+
# Extract actual translations (remove language wrapper if present)
|
|
151
|
+
target_lang = config.target_languages.first[:short_name]
|
|
152
|
+
existing = existing[target_lang] || existing
|
|
153
|
+
else
|
|
154
|
+
existing = {} # : Hash[untyped, untyped]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
existing_flat = Utils::HashFlattener.flatten(existing)
|
|
158
|
+
|
|
159
|
+
# Merge: existing takes precedence
|
|
160
|
+
merged = new_translations.merge(existing_flat)
|
|
161
|
+
|
|
162
|
+
Utils::HashFlattener.unflatten(merged)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Build output file path for target language
|
|
166
|
+
#
|
|
167
|
+
# @param target_lang_code [String] Target language code
|
|
168
|
+
# @return [String] Output file path
|
|
169
|
+
#
|
|
170
|
+
# @example
|
|
171
|
+
# path = handler.build_output_path("it")
|
|
172
|
+
# #=> "config/locales/it.json"
|
|
173
|
+
#
|
|
174
|
+
def build_output_path(target_lang_code)
|
|
175
|
+
return "#{target_lang_code}.json" unless config.output_folder
|
|
176
|
+
|
|
177
|
+
File.join(config.output_folder, "#{target_lang_code}.json")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# Create backup file with rotation support
|
|
183
|
+
#
|
|
184
|
+
# @param file_path [String] Path to file to backup
|
|
185
|
+
# @return [void]
|
|
186
|
+
# @api private
|
|
187
|
+
#
|
|
188
|
+
def create_backup_file(file_path)
|
|
189
|
+
return unless File.exist?(file_path)
|
|
190
|
+
|
|
191
|
+
# Rotate existing backups if max_backups > 1
|
|
192
|
+
rotate_backups(file_path) if config.max_backups > 1
|
|
193
|
+
|
|
194
|
+
# Create primary backup
|
|
195
|
+
backup_path = "#{file_path}.bak"
|
|
196
|
+
FileUtils.cp(file_path, backup_path)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Rotate backup files, keeping only max_backups
|
|
200
|
+
#
|
|
201
|
+
# @param file_path [String] Base file path
|
|
202
|
+
# @return [void]
|
|
203
|
+
# @api private
|
|
204
|
+
#
|
|
205
|
+
def rotate_backups(file_path)
|
|
206
|
+
primary_backup = "#{file_path}.bak"
|
|
207
|
+
return unless File.exist?(primary_backup)
|
|
208
|
+
|
|
209
|
+
# Clean up ANY backups that would exceed max_backups after rotation
|
|
210
|
+
10.downto(config.max_backups) do |i|
|
|
211
|
+
numbered_backup = "#{file_path}.bak.#{i}"
|
|
212
|
+
FileUtils.rm_f(numbered_backup) if File.exist?(numbered_backup)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Rotate numbered backups from high to low to avoid overwrites
|
|
216
|
+
(config.max_backups - 2).downto(1) do |i|
|
|
217
|
+
old_path = "#{file_path}.bak.#{i}"
|
|
218
|
+
new_path = "#{file_path}.bak.#{i + 1}"
|
|
219
|
+
|
|
220
|
+
FileUtils.mv(old_path, new_path) if File.exist?(old_path)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Move primary backup to .bak.1
|
|
224
|
+
FileUtils.mv(primary_backup, "#{file_path}.bak.1")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|