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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b12a3f21b51efeaa680e5840239fb6026c61d4d0f9ffe786e8e5160e0f0f3c3
4
- data.tar.gz: 856de3b24b775ba1c60c3251f86de82f36569b6ac4252b74f848108b5a0d4dc2
3
+ metadata.gz: 8a2fd9352a79a42b92f80a363f304cee99b7a5b5cbfd5ac4b533f48c5665ccf3
4
+ data.tar.gz: d49cdae044a42f1bc93af5d5e7191926450b1b163d80c05b17c9f8877aec6e25
5
5
  SHA512:
6
- metadata.gz: 8fd16a4033cb63a53a954095f7fbe941f2ea3219f80889652b3ee96b334f1eb24df3af200ac54938e946e557028c86e1f193e45e0662f89cb31757107b8b920e
7
- data.tar.gz: 559a9709997cf81eed47352104e0eaaf4f1606814e286e8055048ca83f8ecc2b05591b22f008a2f22443af543a88a317e7c90394a7eeee34270bd69cb8a10d3b
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.2.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: :rubocop
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('.').map(&:to_i)
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 'optparse'
4
- require 'yaml'
5
- require 'fileutils'
6
- require_relative 'yaml_processor'
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
- help_msg = <<~HELP
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/%{locale}/main.%{locale}.yml' for each locale.
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
- if args.first == 'help' || args.empty? || args.include?('-h') || args.include?('--help')
23
- puts help_msg
24
- return
25
- end
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
- options = { translations: [] }
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
- opts.on('-tVAL', '--translation VAL', '[Optional] Translation entry, can be specified multiple times. Format: locale.dot.separated.key=value') { |v| options[:translations] << v }
31
- opts.on('-f', '--file FILE', '[Optional] File path. Supports %{locale} as a placeholder for the locale. If not specified, defaults to \'config/locales/%{locale}/main.%{locale}.yml\' for each locale.') { |v| options[:file] = v }
32
- opts.on('-h', '--help', 'Show this help message and exit.') do
33
- puts help_msg
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
- # If no -t given, but a single positional argument remains, treat it as a translation
39
- if options[:translations].empty? && args.size == 1 && args[0] =~ /^[a-z]{2}\..+?=.*/
40
- options[:translations] << args.shift
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
- file_arg = options[:file]
44
- translations = options[:translations]
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
- if translations.empty?
47
- puts "\e[31mNo translations provided. Use -t or --translation.\e[0m"
48
- exit 1
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
- # Parse all entries and group by file_path
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
- processor = YamlProcessor.new
70
- processor.process_files(file_map)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nAdd
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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, info|
7
- locale = info[:locale]
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 yaml_indent(level)
90
- ' ' * level
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 yaml_escape_value(val)
94
- if val.include?("\n")
95
- # Multiline block
96
- "|\n" + val.split("\n").map { |l| " " + l }.join("\n")
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
- # Single line, quote if needed
99
- val =~ /[\":{}\[\],#&*!|>'%@`]/ ? '"' + val.gsub('"', '\\"') + '"' : val
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.2.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-03 00:00:00.000000000 Z
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