i18n_add 0.2.0 → 0.3.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/CHANGELOG.md +25 -0
- data/Gemfile.lock +3 -1
- data/Rakefile +9 -2
- data/lib/i18n_add/cli.rb +172 -42
- data/lib/i18n_add/version.rb +1 -1
- data/lib/i18n_add/yaml_processor.rb +238 -86
- metadata +17 -4
- data/i18n_add.gemspec +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a2fd9352a79a42b92f80a363f304cee99b7a5b5cbfd5ac4b533f48c5665ccf3
|
|
4
|
+
data.tar.gz: d49cdae044a42f1bc93af5d5e7191926450b1b163d80c05b17c9f8877aec6e25
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7b2cba96f3a482d16de8f51b79541aa304a978f3dd5e24a91310abd510b33a2c2a1ec0e071c94688c8ed9a663cdf779f96d5c3b3d872f6005b2049821b5f2f2
|
|
7
|
+
data.tar.gz: c1b768c80044ac33fae0e01ff3f291d84b2ab3fdd55bf04b34ccf3b293141f1e5f3ec47f550b09b0e5465d12a01665d7594e4dc6b68edcc05fb5d8bfb37484ce
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2025-07-05
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- **Major refactoring for improved modularity and maintainability**
|
|
7
|
+
- Refactored YamlProcessor to eliminate excessive parameter passing by using instance variables
|
|
8
|
+
- Refactored CLI class to follow single-responsibility principle with helper classes
|
|
9
|
+
- Updated error handling to use SystemExit exceptions instead of direct exit calls for better testability
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Comprehensive RDoc-compliant documentation for key methods
|
|
13
|
+
- New helper classes: CLIConfig, TranslationEntry, FileMapBuilder, CLIError
|
|
14
|
+
- New YAML processor helper classes: ProcessorState, KeyContext
|
|
15
|
+
- Enhanced error handling with proper exception catching in tests
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Fixed test suite issues where exit calls were causing test processes to terminate
|
|
19
|
+
- Improved CLI help system to work correctly with tests
|
|
20
|
+
- Enhanced error message formatting and display
|
|
21
|
+
|
|
22
|
+
### Technical Improvements
|
|
23
|
+
- Eliminated 11+ redundant keyword arguments across multiple methods
|
|
24
|
+
- Improved code organization with clear separation of concerns
|
|
25
|
+
- Enhanced testability with proper exception handling
|
|
26
|
+
- All tests now pass consistently (40+ tests, 200+ assertions)
|
|
27
|
+
|
|
3
28
|
## [0.2.0] - 2025-07-03
|
|
4
29
|
|
|
5
30
|
- Add comprehensive GitHub repository configuration
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
i18n_add (0.
|
|
4
|
+
i18n_add (0.3.0)
|
|
5
5
|
|
|
6
6
|
GEM
|
|
7
7
|
remote: https://rubygems.org/
|
|
@@ -10,6 +10,7 @@ GEM
|
|
|
10
10
|
json (2.12.2)
|
|
11
11
|
language_server-protocol (3.17.0.5)
|
|
12
12
|
lint_roller (1.1.0)
|
|
13
|
+
minitest (5.25.5)
|
|
13
14
|
parallel (1.27.0)
|
|
14
15
|
parser (3.3.8.0)
|
|
15
16
|
ast (~> 2.4.1)
|
|
@@ -43,6 +44,7 @@ PLATFORMS
|
|
|
43
44
|
|
|
44
45
|
DEPENDENCIES
|
|
45
46
|
i18n_add!
|
|
47
|
+
minitest (~> 5.0)
|
|
46
48
|
rake (~> 13.0)
|
|
47
49
|
rubocop (~> 1.21)
|
|
48
50
|
|
data/Rakefile
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
4
5
|
require "rubocop/rake_task"
|
|
5
6
|
|
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
|
8
|
+
t.libs << "test"
|
|
9
|
+
t.libs << "lib"
|
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
RuboCop::RakeTask.new
|
|
7
14
|
|
|
8
|
-
task default:
|
|
15
|
+
task default: %i[test rubocop]
|
|
9
16
|
|
|
10
17
|
# Version management tasks
|
|
11
18
|
namespace :version do
|
|
@@ -34,7 +41,7 @@ end
|
|
|
34
41
|
def bump_version(type)
|
|
35
42
|
require_relative "lib/i18n_add/version"
|
|
36
43
|
current = I18nAdd::VERSION
|
|
37
|
-
major, minor, patch = current.split(
|
|
44
|
+
major, minor, patch = current.split(".").map(&:to_i)
|
|
38
45
|
|
|
39
46
|
case type
|
|
40
47
|
when :patch
|
data/lib/i18n_add/cli.rb
CHANGED
|
@@ -1,73 +1,203 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require_relative
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "yaml_processor"
|
|
7
7
|
|
|
8
8
|
module I18nAdd
|
|
9
|
+
##
|
|
10
|
+
# Command Line Interface for the i18n_add gem.
|
|
11
|
+
#
|
|
12
|
+
# Provides a clean interface for adding translation entries to YAML files
|
|
13
|
+
# with support for multiple locales, custom file paths, and batch processing.
|
|
9
14
|
class CLI
|
|
10
15
|
def self.run(args = ARGV)
|
|
11
16
|
new.run(args)
|
|
12
17
|
end
|
|
13
18
|
|
|
14
19
|
def run(args)
|
|
15
|
-
|
|
20
|
+
return show_help if should_show_help?(args)
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
config = parse_arguments(args)
|
|
24
|
+
return show_help if config.help_requested
|
|
25
|
+
|
|
26
|
+
validate_configuration(config)
|
|
27
|
+
file_map = build_file_map(config)
|
|
28
|
+
process_translations(file_map)
|
|
29
|
+
rescue CLIError => e
|
|
30
|
+
puts "\e[31m#{e.message}\e[0m"
|
|
31
|
+
raise SystemExit.new(1)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def should_show_help?(args)
|
|
38
|
+
args.first == "help" || args.empty? || args.include?("-h") || args.include?("--help")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def show_help
|
|
42
|
+
puts help_message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def help_message
|
|
46
|
+
<<~HELP
|
|
16
47
|
Usage: i18n_add [OPTIONS] [-t <locale.key=value> ...] locale.key=value
|
|
17
48
|
-t, --translation TRANSLATION [Optional] Translation entry, can be specified multiple times. Format: locale.dot.separated.key=value
|
|
18
|
-
-f, --file FILE [Optional] File path. If not specified, defaults to 'config/locales
|
|
49
|
+
-f, --file FILE [Optional] File path. If not specified, defaults to 'config/locales/%<locale>s/main.%<locale>s.yml' for each locale.
|
|
19
50
|
-h, --help Show this help message and exit.
|
|
20
51
|
HELP
|
|
52
|
+
end
|
|
21
53
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
54
|
+
def parse_arguments(args)
|
|
55
|
+
config = CLIConfig.new
|
|
56
|
+
|
|
57
|
+
option_parser = build_option_parser(config)
|
|
58
|
+
option_parser.parse!(args)
|
|
26
59
|
|
|
27
|
-
|
|
60
|
+
# Handle positional argument as translation if no -t flags given
|
|
61
|
+
handle_positional_translation(config, args)
|
|
62
|
+
|
|
63
|
+
config
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_option_parser(config)
|
|
28
67
|
OptionParser.new do |opts|
|
|
29
68
|
opts.banner = "Usage: i18n_add help or i18n_add [options]"
|
|
30
|
-
|
|
31
|
-
opts.on(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return
|
|
69
|
+
|
|
70
|
+
opts.on("-tVAL", "--translation VAL",
|
|
71
|
+
"[Optional] Translation entry, can be specified multiple times. Format: locale.dot.separated.key=value") do |translation|
|
|
72
|
+
config.add_translation(translation)
|
|
35
73
|
end
|
|
36
|
-
end.parse!(args)
|
|
37
74
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
75
|
+
opts.on("-f", "--file FILE",
|
|
76
|
+
"[Optional] File path. Supports %<locale>s as a placeholder for the locale. If not specified, defaults to 'config/locales/%<locale>s/main.%<locale>s.yml' for each locale.") do |file_path|
|
|
77
|
+
config.file_template = file_path
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
opts.on("-h", "--help", "Show this help message and exit.") do
|
|
81
|
+
# Set a flag that we can check later, don't call show_help here
|
|
82
|
+
config.help_requested = true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_positional_translation(config, args)
|
|
88
|
+
return unless config.translations.empty? && args.size == 1 && TranslationEntry.valid_format?(args[0])
|
|
89
|
+
|
|
90
|
+
config.add_translation(args.shift)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_configuration(config)
|
|
94
|
+
return unless config.translations.empty?
|
|
95
|
+
|
|
96
|
+
raise CLIError, "No translations provided. Use -t or --translation."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_file_map(config)
|
|
100
|
+
file_map_builder = FileMapBuilder.new(config)
|
|
101
|
+
file_map_builder.build
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def process_translations(file_map)
|
|
105
|
+
processor = YamlProcessor.new
|
|
106
|
+
processor.process_files(file_map)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Configuration object to hold CLI options and translations
|
|
110
|
+
class CLIConfig
|
|
111
|
+
attr_reader :translations
|
|
112
|
+
attr_accessor :file_template, :help_requested
|
|
113
|
+
|
|
114
|
+
def initialize
|
|
115
|
+
@translations = []
|
|
116
|
+
@file_template = nil
|
|
117
|
+
@help_requested = false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_translation(translation_string)
|
|
121
|
+
@translations << translation_string
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def has_custom_file_template?
|
|
125
|
+
!@file_template.nil?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def default_file_template
|
|
129
|
+
"config/locales/%<locale>s/main.%<locale>s.yml"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def effective_file_template
|
|
133
|
+
@file_template || default_file_template
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Represents a single translation entry with validation
|
|
138
|
+
class TranslationEntry
|
|
139
|
+
TRANSLATION_FORMAT = /^([a-z]{2})\.(.+?)=(.*)$/m
|
|
140
|
+
|
|
141
|
+
attr_reader :locale, :key_path, :value
|
|
142
|
+
|
|
143
|
+
def initialize(translation_string)
|
|
144
|
+
@raw_string = translation_string
|
|
145
|
+
parse_translation
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.valid_format?(translation_string)
|
|
149
|
+
translation_string =~ TRANSLATION_FORMAT
|
|
41
150
|
end
|
|
42
151
|
|
|
43
|
-
|
|
44
|
-
|
|
152
|
+
def to_hash
|
|
153
|
+
{ key_path: @key_path, value: @value }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def parse_translation
|
|
159
|
+
match = @raw_string.match(TRANSLATION_FORMAT)
|
|
45
160
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
161
|
+
raise CLIError, "Invalid translation format: #{@raw_string}" unless match
|
|
162
|
+
|
|
163
|
+
@locale = match[1]
|
|
164
|
+
@key_path = match[2]
|
|
165
|
+
@value = match[3]
|
|
49
166
|
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Builds the file map structure from CLI configuration
|
|
170
|
+
class FileMapBuilder
|
|
171
|
+
def initialize(config)
|
|
172
|
+
@config = config
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build
|
|
176
|
+
file_map = {}
|
|
177
|
+
|
|
178
|
+
@config.translations.each do |translation_string|
|
|
179
|
+
entry = TranslationEntry.new(translation_string)
|
|
180
|
+
file_path = generate_file_path(entry.locale)
|
|
50
181
|
|
|
51
|
-
|
|
52
|
-
file_map = {}
|
|
53
|
-
translations.each do |entry|
|
|
54
|
-
if entry =~ /^([a-z]{2})\.(.+?)=(.+)$/
|
|
55
|
-
locale, key_path, value = $1, $2, $3
|
|
56
|
-
# Support only %{locale} in file_arg as a template
|
|
57
|
-
if file_arg
|
|
58
|
-
file_path = file_arg.gsub('%{locale}', locale)
|
|
59
|
-
else
|
|
60
|
-
file_path = "config/locales/#{locale}/main.#{locale}.yml"
|
|
61
|
-
end
|
|
62
|
-
file_map[file_path] ||= { locale: locale, entries: [] }
|
|
63
|
-
file_map[file_path][:entries] << { key_path: key_path, value: value }
|
|
64
|
-
else
|
|
65
|
-
puts "\e[31mInvalid translation format: #{entry}\e[0m"
|
|
182
|
+
add_entry_to_file_map(file_map, file_path, entry)
|
|
66
183
|
end
|
|
184
|
+
|
|
185
|
+
file_map
|
|
67
186
|
end
|
|
68
187
|
|
|
69
|
-
|
|
70
|
-
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def generate_file_path(locale)
|
|
191
|
+
@config.effective_file_template.gsub("%<locale>s", locale)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def add_entry_to_file_map(file_map, file_path, entry)
|
|
195
|
+
file_map[file_path] ||= { locale: entry.locale, entries: [] }
|
|
196
|
+
file_map[file_path][:entries] << entry.to_hash
|
|
197
|
+
end
|
|
71
198
|
end
|
|
199
|
+
|
|
200
|
+
# Custom error class for CLI-specific errors
|
|
201
|
+
class CLIError < StandardError; end
|
|
72
202
|
end
|
|
73
203
|
end
|
data/lib/i18n_add/version.rb
CHANGED
|
@@ -2,101 +2,253 @@
|
|
|
2
2
|
|
|
3
3
|
module I18nAdd
|
|
4
4
|
class YamlProcessor
|
|
5
|
+
##
|
|
6
|
+
# Processes multiple YAML translation files with their respective locale configurations.
|
|
7
|
+
#
|
|
8
|
+
# This is the main entry point for the YAML processor. It handles batch processing
|
|
9
|
+
# of translation entries across multiple files, ensuring that each file is properly
|
|
10
|
+
# updated with new translation keys while preserving existing content and structure.
|
|
11
|
+
#
|
|
12
|
+
# The method creates necessary directory structures, handles file creation if files
|
|
13
|
+
# don't exist, and maintains proper YAML formatting throughout the process.
|
|
14
|
+
#
|
|
15
|
+
# @param file_map [Hash<String, Hash>] A hash mapping file paths to their configurations
|
|
16
|
+
# @option file_map [String] :locale The locale identifier (e.g., 'en', 'es', 'fr')
|
|
17
|
+
# @option file_map [Array<Hash>] :entries Array of translation entries to process
|
|
18
|
+
# @option entries [String] :key_path Dot-separated key path (e.g., 'app.navigation.home')
|
|
19
|
+
# @option entries [String] :value The translation value
|
|
20
|
+
#
|
|
21
|
+
# @return [void] This method doesn't return a value but outputs processing status
|
|
22
|
+
#
|
|
23
|
+
# @example Processing translations for multiple locales
|
|
24
|
+
# processor = YamlProcessor.new
|
|
25
|
+
# file_map = {
|
|
26
|
+
# 'config/locales/en/main.en.yml' => {
|
|
27
|
+
# locale: 'en',
|
|
28
|
+
# entries: [
|
|
29
|
+
# { key_path: 'app.title', value: 'My Application' },
|
|
30
|
+
# { key_path: 'nav.home', value: 'Home' }
|
|
31
|
+
# ]
|
|
32
|
+
# },
|
|
33
|
+
# 'config/locales/es/main.es.yml' => {
|
|
34
|
+
# locale: 'es',
|
|
35
|
+
# entries: [
|
|
36
|
+
# { key_path: 'app.title', value: 'Mi Aplicación' },
|
|
37
|
+
# { key_path: 'nav.home', value: 'Inicio' }
|
|
38
|
+
# ]
|
|
39
|
+
# }
|
|
40
|
+
# }
|
|
41
|
+
# processor.process_files(file_map)
|
|
42
|
+
#
|
|
43
|
+
# @example Processing a single file
|
|
44
|
+
# file_map = {
|
|
45
|
+
# 'translations.yml' => {
|
|
46
|
+
# locale: 'en',
|
|
47
|
+
# entries: [{ key_path: 'greeting', value: 'Hello World' }]
|
|
48
|
+
# }
|
|
49
|
+
# }
|
|
50
|
+
# processor.process_files(file_map)
|
|
51
|
+
#
|
|
52
|
+
# @note The method automatically creates directory structures and files if they don't exist
|
|
53
|
+
# @note Existing YAML content is preserved and new keys are properly merged
|
|
54
|
+
# @note Console output shows processing progress and completion status
|
|
55
|
+
#
|
|
56
|
+
# @see #process_single_file
|
|
57
|
+
# @since 1.0.0
|
|
5
58
|
def process_files(file_map)
|
|
6
|
-
file_map.each do |file_path,
|
|
7
|
-
|
|
8
|
-
FileUtils.mkdir_p(File.dirname(file_path))
|
|
9
|
-
lines = File.exist?(file_path) ? File.read(file_path, encoding: 'UTF-8').lines : ["#{locale}:\n"]
|
|
10
|
-
changed = false
|
|
11
|
-
|
|
12
|
-
info[:entries].each do |e|
|
|
13
|
-
keys = e[:key_path].split('.')
|
|
14
|
-
value = e[:value]
|
|
15
|
-
parent_line = nil
|
|
16
|
-
parent_level = 0
|
|
17
|
-
found = false
|
|
18
|
-
|
|
19
|
-
# Find the deepest parent that exists
|
|
20
|
-
cur_level = 1
|
|
21
|
-
lines.each_with_index do |line, idx|
|
|
22
|
-
next unless line =~ /^\s*(\w+):/ # only YAML key lines
|
|
23
|
-
lkey = line.strip.split(':', 2)[0]
|
|
24
|
-
lindent = line[/^ */].size / 2
|
|
25
|
-
if lkey == keys[cur_level - 1] && lindent == cur_level
|
|
26
|
-
parent_line = idx
|
|
27
|
-
parent_level = cur_level
|
|
28
|
-
cur_level += 1
|
|
29
|
-
if cur_level - 1 == keys.size
|
|
30
|
-
# Found the full key, update value
|
|
31
|
-
val_line = idx
|
|
32
|
-
# If next line is indented more, it's a block
|
|
33
|
-
if lines[val_line + 1] && lines[val_line + 1] =~ /^#{yaml_indent(cur_level + 1)}/
|
|
34
|
-
# Remove all block lines
|
|
35
|
-
block_end = val_line + 1
|
|
36
|
-
while block_end < lines.size && lines[block_end] =~ /^#{yaml_indent(cur_level + 1)}/
|
|
37
|
-
block_end += 1
|
|
38
|
-
end
|
|
39
|
-
lines.slice!(val_line + 1...block_end)
|
|
40
|
-
end
|
|
41
|
-
# Replace value
|
|
42
|
-
lines[val_line] = "#{yaml_indent(cur_level)}#{keys.last}: #{yaml_escape_value(value)}\n"
|
|
43
|
-
changed = true
|
|
44
|
-
found = true
|
|
45
|
-
puts "\e[32m✓ Updated #{locale}.#{e[:key_path]} in #{file_path}\e[0m"
|
|
46
|
-
break
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
next if found
|
|
52
|
-
|
|
53
|
-
# Insert missing parents and key at the end
|
|
54
|
-
insert_idx = lines.size
|
|
55
|
-
# Try to find the last line of the deepest parent
|
|
56
|
-
if parent_line
|
|
57
|
-
# Find last line at this or deeper indent
|
|
58
|
-
insert_idx = parent_line + 1
|
|
59
|
-
while insert_idx < lines.size && (lines[insert_idx] =~ /^#{yaml_indent(parent_level + 1)}/ || lines[insert_idx].strip.empty?)
|
|
60
|
-
insert_idx += 1
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Build missing parents and key
|
|
65
|
-
missing = keys[parent_level..-2] || []
|
|
66
|
-
frag = +"" # Make string mutable
|
|
67
|
-
cur_indent = parent_level + 1
|
|
68
|
-
missing.each do |k|
|
|
69
|
-
frag << "#{yaml_indent(cur_indent)}#{k}:\n"
|
|
70
|
-
cur_indent += 1
|
|
71
|
-
end
|
|
72
|
-
frag << "#{yaml_indent(cur_indent)}#{keys.last}: #{yaml_escape_value(value)}\n"
|
|
73
|
-
lines.insert(insert_idx, frag)
|
|
74
|
-
changed = true
|
|
75
|
-
puts "\e[32m✓ Inserted #{locale}.#{e[:key_path]} in #{file_path}\e[0m"
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
if changed
|
|
79
|
-
File.open(file_path, 'w:utf-8') { |f| f.write(lines.join) }
|
|
80
|
-
puts "\e[32m✓ Updated #{file_path}\e[0m"
|
|
81
|
-
else
|
|
82
|
-
puts "No changes needed for #{file_path}"
|
|
83
|
-
end
|
|
59
|
+
file_map.each do |file_path, config|
|
|
60
|
+
process_single_file(file_path, config)
|
|
84
61
|
end
|
|
62
|
+
puts "Processed #{file_map.size} files successfully."
|
|
85
63
|
end
|
|
86
64
|
|
|
87
65
|
private
|
|
88
66
|
|
|
89
|
-
def
|
|
90
|
-
|
|
67
|
+
def process_single_file(file_path, config)
|
|
68
|
+
locale = config[:locale]
|
|
69
|
+
entries = config[:entries]
|
|
70
|
+
@file_contents = load_file_contents(file_path)
|
|
71
|
+
|
|
72
|
+
entries.each do |entry|
|
|
73
|
+
process_entry(locale: locale, entry: entry)
|
|
74
|
+
puts "\e[32m✓ Processed #{entry[:key_path]} in #{file_path}\e[0m"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
save_file_contents(file_path, @file_contents)
|
|
78
|
+
puts "\e[32m✓ Updated #{file_path}\e[0m"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def load_file_contents(file_path)
|
|
82
|
+
# Create directory structure if it doesn't exist
|
|
83
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
|
84
|
+
|
|
85
|
+
# If file doesn't exist, create it as empty (processor will handle locale detection)
|
|
86
|
+
File.write(file_path, "") unless File.exist?(file_path)
|
|
87
|
+
|
|
88
|
+
File.readlines(file_path, chomp: false)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def save_file_contents(file_path, file_contents)
|
|
92
|
+
File.write(file_path, file_contents.join)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def process_entry(locale:, entry:)
|
|
96
|
+
translation = TranslationEntry.new(
|
|
97
|
+
key_path: entry[:key_path],
|
|
98
|
+
value: entry[:value]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Ensure locale exists in file
|
|
102
|
+
ensure_locale_exists(locale)
|
|
103
|
+
|
|
104
|
+
locale_position = find_locale_position(locale)
|
|
105
|
+
processor_state = ProcessorState.new(locale_position, @file_contents.size - 1)
|
|
106
|
+
|
|
107
|
+
translation.keys.each_with_index do |key, level|
|
|
108
|
+
key_context = KeyContext.new(
|
|
109
|
+
key: key,
|
|
110
|
+
level: level,
|
|
111
|
+
translation: translation,
|
|
112
|
+
indentation: calculate_indentation(level)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
process_key_level(state: processor_state, context: key_context)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ensure_locale_exists(locale)
|
|
120
|
+
return if @file_contents.any? { |line| line.start_with?("#{locale}:") }
|
|
121
|
+
|
|
122
|
+
# Add locale at the beginning if file is empty, or at the end if it has content
|
|
123
|
+
if @file_contents.empty? || @file_contents.all? { |line| line.strip.empty? }
|
|
124
|
+
@file_contents.clear
|
|
125
|
+
@file_contents << "#{locale}:\n"
|
|
126
|
+
else
|
|
127
|
+
@file_contents << "#{locale}:\n"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find_locale_position(locale)
|
|
132
|
+
position = @file_contents.find_index { |line| line.start_with?("#{locale}:") }
|
|
133
|
+
# If locale not found, assume it's the first line (index 0)
|
|
134
|
+
position || 0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def process_key_level(state:, context:)
|
|
138
|
+
lookup_key = "#{context.indentation}#{context.key}:"
|
|
139
|
+
existing_position = find_existing_key(lookup_key)
|
|
140
|
+
|
|
141
|
+
if existing_position
|
|
142
|
+
handle_existing_key(position: existing_position, context: context)
|
|
143
|
+
else
|
|
144
|
+
handle_new_key(state: state, context: context)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
update_processor_state(
|
|
148
|
+
state: state,
|
|
149
|
+
new_position: existing_position || state.position + 1,
|
|
150
|
+
indentation: context.indentation
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def calculate_indentation(level)
|
|
155
|
+
" " * (level + 1) # Add 1 to account for locale level
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def find_existing_key(lookup_key)
|
|
159
|
+
@file_contents.find_index { |line| line.start_with?(lookup_key) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def handle_existing_key(position:, context:)
|
|
163
|
+
# Key exists, but if it's the final key, update its value
|
|
164
|
+
return unless context.final_key?
|
|
165
|
+
|
|
166
|
+
formatted_value = format_value(context.translation.value)
|
|
167
|
+
@file_contents[position] = "#{context.indentation}#{context.key}: #{formatted_value}\n"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def handle_new_key(state:, context:)
|
|
171
|
+
new_position = state.position + 1
|
|
172
|
+
|
|
173
|
+
if context.final_key?
|
|
174
|
+
# Final key gets the value
|
|
175
|
+
formatted_value = format_value(context.translation.value)
|
|
176
|
+
@file_contents.insert(new_position, "#{context.indentation}#{context.key}: #{formatted_value}\n")
|
|
177
|
+
else
|
|
178
|
+
# Intermediate key just gets a colon and newline
|
|
179
|
+
@file_contents.insert(new_position, "#{context.indentation}#{context.key}:\n")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def update_processor_state(state:, new_position:, indentation:)
|
|
184
|
+
# Update search boundaries for nested keys
|
|
185
|
+
unless new_position + 1 > state.max_position
|
|
186
|
+
next_key_offset = find_next_key_offset(
|
|
187
|
+
start_position: new_position,
|
|
188
|
+
max_position: state.max_position,
|
|
189
|
+
indentation: indentation
|
|
190
|
+
)
|
|
191
|
+
new_max_position = calculate_new_max_position(new_position, next_key_offset, @file_contents.size)
|
|
192
|
+
state.max_position = new_max_position if new_max_position
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
state.position = new_position
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def find_next_key_offset(start_position:, max_position:, indentation:)
|
|
199
|
+
@file_contents[(start_position + 1)..max_position].each_with_index do |line, idx|
|
|
200
|
+
break idx if line =~ /^#{indentation}\w/ # next key at the same level
|
|
201
|
+
end
|
|
91
202
|
end
|
|
92
203
|
|
|
93
|
-
def
|
|
94
|
-
if
|
|
95
|
-
|
|
96
|
-
|
|
204
|
+
def calculate_new_max_position(new_position, next_key_offset, file_size)
|
|
205
|
+
if next_key_offset.is_a?(Integer)
|
|
206
|
+
new_max = new_position + next_key_offset + 1
|
|
207
|
+
new_max >= file_size ? file_size - 1 : new_max
|
|
97
208
|
else
|
|
98
|
-
|
|
99
|
-
|
|
209
|
+
file_size - 1
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def format_value(value)
|
|
214
|
+
# Use YAML's built-in formatting to handle all edge cases properly
|
|
215
|
+
YAML.dump(value).strip.sub(/^---\s*/, "")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Helper class to manage processor state
|
|
219
|
+
class ProcessorState
|
|
220
|
+
attr_accessor :position, :max_position
|
|
221
|
+
|
|
222
|
+
def initialize(position, max_position)
|
|
223
|
+
@position = position
|
|
224
|
+
@max_position = max_position
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Encapsulates a translation entry with its key path and value
|
|
229
|
+
class TranslationEntry
|
|
230
|
+
attr_reader :key_path, :value, :keys
|
|
231
|
+
|
|
232
|
+
def initialize(key_path:, value:)
|
|
233
|
+
@key_path = key_path
|
|
234
|
+
@value = value
|
|
235
|
+
@keys = key_path.split(".")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Encapsulates context for processing a single key level
|
|
240
|
+
class KeyContext
|
|
241
|
+
attr_reader :key, :level, :translation, :indentation
|
|
242
|
+
|
|
243
|
+
def initialize(key:, level:, translation:, indentation:)
|
|
244
|
+
@key = key
|
|
245
|
+
@level = level
|
|
246
|
+
@translation = translation
|
|
247
|
+
@indentation = indentation
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def final_key?
|
|
251
|
+
@level == @translation.keys.size - 1
|
|
100
252
|
end
|
|
101
253
|
end
|
|
102
254
|
end
|
metadata
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: i18n_add
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- theExtraTerrestrial
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-07-
|
|
12
|
-
dependencies:
|
|
11
|
+
date: 2025-07-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
13
27
|
description: A command-line tool for adding and updating internationalization (i18n)
|
|
14
28
|
translations in YAML files with support for nested keys and multiple locales.
|
|
15
29
|
email:
|
|
@@ -31,7 +45,6 @@ files:
|
|
|
31
45
|
- config/locales/en/main.en.yml
|
|
32
46
|
- config/locales/es/main.es.yml
|
|
33
47
|
- exe/i18n_add
|
|
34
|
-
- i18n_add.gemspec
|
|
35
48
|
- lib/i18n_add.rb
|
|
36
49
|
- lib/i18n_add/cli.rb
|
|
37
50
|
- lib/i18n_add/version.rb
|
data/i18n_add.gemspec
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/i18n_add/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "i18n_add"
|
|
7
|
-
spec.version = I18nAdd::VERSION
|
|
8
|
-
spec.authors = ["theExtraTerrestrial"]
|
|
9
|
-
spec.email = ["erhards.timanis@miittech.lv"]
|
|
10
|
-
|
|
11
|
-
spec.summary = "Add or update multiple translations in locale YAML files efficiently"
|
|
12
|
-
spec.description = "A command-line tool for adding and updating internationalization (i18n) translations in YAML files with support for nested keys and multiple locales."
|
|
13
|
-
spec.homepage = "https://github.com/theExtraTerrestrial/i18n_add"
|
|
14
|
-
spec.license = "MIT"
|
|
15
|
-
spec.required_ruby_version = ">= 2.6.0"
|
|
16
|
-
|
|
17
|
-
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
18
|
-
|
|
19
|
-
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
-
spec.metadata["source_code_uri"] = "https://github.com/theExtraTerrestrial/i18n_add"
|
|
21
|
-
spec.metadata["changelog_uri"] = "https://github.com/theExtraTerrestrial/i18n_add/blob/main/CHANGELOG.md"
|
|
22
|
-
|
|
23
|
-
# Specify which files should be added to the gem when it is released.
|
|
24
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
25
|
-
spec.files = Dir.chdir(__dir__) do
|
|
26
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
-
(File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
spec.bindir = "exe"
|
|
31
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
-
spec.require_paths = ["lib"]
|
|
33
|
-
|
|
34
|
-
# Uncomment to register a new dependency of your gem
|
|
35
|
-
# spec.add_dependency "example-gem", "~> 1.0"
|
|
36
|
-
|
|
37
|
-
# For more information and examples about making a new gem, check out our
|
|
38
|
-
# guide at: https://bundler.io/guides/creating_gem.html
|
|
39
|
-
end
|