i18nize 0.5 → 0.6

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: 592b298cbac45e06745596fe3b02025797d39f572214e2dd345ef6f9d39abe33
4
- data.tar.gz: a661e6474225964f79d6d80a38c23f91d531e6c6c58b5c52e3b7b11de211d652
3
+ metadata.gz: ed1bfd2cff604aa20337cfd2e87dd47985ab66d470ee13fa1241378e8c7613d2
4
+ data.tar.gz: 8e2a54ea7acb3ff57233944f81648f8243f732768278cd77478f7bf5fed08d35
5
5
  SHA512:
6
- metadata.gz: 210f54125ca483b27318abcdcb0647d947a83639d11e62ce4ca34506974d3cb0f65d2357954b81106eafd28874c64b1e774338548f48f3898d9e2fac84a1b85c
7
- data.tar.gz: 21ed727606ee36a27b10ab94d7625f4b5deda616b3e4afd2fb745db7b69945b3a5688f0d6ba75d407d7fc4a82ee89eda49475c659f413c75bf03321594550ef0
6
+ metadata.gz: f7322c2b6b1bc505090789774f340404186be3dcaed272629b19ab914bd36af3f8c1d50cde289ce29e203c60fc3273d5a8981c11060fdbfd571d261bc96803a3
7
+ data.tar.gz: ff89ec4d79479de9c40855916fc0a5dfa3f87a6e300459625edcfb7928aafb54ac15faec21e3a9e68d6a8c47543d716ac53a41620496363e86c7abb5314d00e4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
- ## [Unreleased]
1
+ # Changelog
2
2
 
3
- ## [0.1.0] - 2025-07-30
3
+ ## [0.6] - 2025-09-11
4
+ ### Added
5
+ - CLI flag `--only_blank`: fills only missing or blank keys with a placeholder
6
+ (`<locale>.<full.key> missing`) instead of translating with DeepL.
7
+ - Environment variable `I18NIZE_ALLOW_EMPTY=1`: skips keys where the source value is empty
8
+ or whitespace-only, preventing them from being copied/translated.
4
9
 
5
- - Initial release
10
+ ### Changed
11
+ - CLI parsing improved: `--from` can now be used **before or after** the target locale.
12
+ Examples:
13
+ ```bash
14
+ i18nize cs --from de
15
+ i18nize --from de cs
16
+ ````
17
+
18
+ ---
19
+
20
+ ## [0.5.0] - 2025-09-08
21
+ ### Added
22
+ - **Conflict policy**: source locale is now treated as the source of truth.
23
+ If the target locale has a scalar where a nested hash is required, the scalar is **overwritten** with the correct structure from the source.
24
+ - **Pluralization support**: automatic handling of `one`, `other`, and related plural keys. Scalars can be coerced into plural hashes.
25
+ - **Placeholder preservation**: `%{count}` and other I18n-style placeholders are protected from being altered by DeepL.
26
+ - Improved file name detection for `.yml` and `.yaml` formats, including names like `app.en.yml`.
27
+
28
+ ### Changed
29
+ - Default behaviour: conflicts are no longer skipped or raise errors, but are resolved by overwriting with source locale values.
30
+
31
+ ### Removed
32
+ - **Rake task `i18nize:translate`** has been removed.
33
+ Use the CLI instead:
34
+ ```bash
35
+ i18nize cs
36
+ i18nize cs --from en
37
+ i18nize cs --missing
38
+ ```
39
+
40
+ ### Fixed
41
+
42
+ * Eliminated `IndexError: string not matched` errors when attempting to insert into scalar values.
43
+
44
+ ---
45
+
46
+ ## \[0.4.4] - 2025-09-08
47
+
48
+ ### Added
49
+
50
+ * CLI option `--missing [FROM]` to list missing keys without translating.
51
+
52
+ * `i18nize cs --missing` → shows missing Czech keys (from English).
53
+ * `i18nize cs --missing de` → shows missing Czech keys (from German).
54
+
55
+ ---
56
+
57
+ ## \[0.4.2.1] - 2025-09-08
58
+
59
+ ### Fixed
60
+
61
+ * Correct requires for `Translator` and `Inserter` inside CLI (prevented `NameError`).
62
+
63
+ ---
64
+
65
+ ## \[0.4.2] - 2025-09-08
66
+
67
+ ### Added
68
+
69
+ * `.env` file support (via `dotenv` if present).
70
+ * Improved CLI error messages.
71
+
72
+ ---
73
+
74
+ ## \[0.4.1] - 2025-09-08
75
+
76
+ ### Fixed
77
+
78
+ * File path normalization for top-level locales (`en.yml` → `cs.yml`).
79
+ * Safer regex for file name replacement.
80
+
81
+ ---
82
+
83
+ ## \[0.4.0] - 2025-08-25
84
+
85
+ ### Added
86
+
87
+ * Standalone CLI (`i18nize <locale>`) — no need to call only via Rake.
88
+ * Basic `--from` option for source language.
89
+
90
+ ---
91
+
92
+ ## \[0.1.0] - 2025-07-30
93
+
94
+ ### Added
95
+
96
+ * Initial release.
97
+ * DeepL translation of missing keys.
98
+ * Character count estimation and confirmation prompt.
99
+ * Automatic creation of target locale files.
100
+ * Rake task `i18nize:translate[locale]`.
data/README.md CHANGED
@@ -1,19 +1,23 @@
1
+
1
2
  # i18nize
2
3
 
3
- 🧠 Automatically fills in missing I18n YAML keys using DeepL translation API.
4
- Designed to streamline multi-language support in Ruby and Rails projects.
4
+ [![CI](https://github.com/nikolas2145/i18nize/actions/workflows/main.yml/badge.svg)](https://github.com/nikolas2145/i18nize/actions/workflows/main.yml)
5
+ [![Gem Version](https://badge.fury.io/rb/i18nize.svg)](https://badge.fury.io/rb/i18nize)
5
6
 
7
+ 🧠 Automatically fills in missing I18n YAML keys using DeepL translation API.
8
+ Designed to streamline multi-language support in Ruby and Rails projects.
6
9
 
7
10
  ## ✨ Features
8
11
 
9
- - Detects missing or empty translation keys in locale files
10
- - Supports multiple YAML files and nested directories (e.g. `config/locales/api/en.yml`)
11
- - Automatically translates from a source locale (default: `en`) to a target (e.g. `cs`)
12
- - Integrates with DeepL API (Free or Pro)
13
- - Safely merges translations into corresponding `*.yml` files
14
- - Includes character count check with confirmation
15
- - CLI via Rake task
16
-
12
+ * Detects missing or empty translation keys in locale files
13
+ * Supports multiple YAML files and nested directories (e.g. `config/locales/api/en.yml`)
14
+ * Automatically translates from a source locale (**default: `en`**) to a target (e.g. `cs`)
15
+ * **Placeholder mode** to fill only blank/missing keys without DeepL (`--only_blank`)
16
+ * Integrates with DeepL API (Free or Pro)
17
+ * Safely merges translations into corresponding `*.yml` files
18
+ * Handles structural conflicts by overwriting target with source truth
19
+ * Character count check with confirmation prompt
20
+ * CLI (`i18nize`)
17
21
 
18
22
  ## 🔧 Installation
19
23
 
@@ -35,61 +39,95 @@ Or install manually:
35
39
  gem install i18nize
36
40
  ```
37
41
 
38
-
39
42
  ## 🚀 Usage
40
43
 
41
44
  ### 1. Set your DeepL API key
42
45
 
43
- Create a `.env` or export it in your shell:
46
+ Create a `.env` file (loaded automatically if you use `dotenv`) or export it in your shell:
44
47
 
45
48
  ```bash
46
49
  export DEEPL_API_KEY=your-api-key
47
50
  export DEEPL_ENDPOINT=https://api.deepl.com/v2/translate # Only if you are using a DeepL Pro account
48
51
  ```
49
52
 
50
- > You can use the [Free DeepL API](https://www.deepl.com/pro#developer)
53
+ 👉 You can use the [Free DeepL API](https://www.deepl.com/pro#developer).
51
54
 
52
-
53
-
54
- ### 2. Run the translation task
55
+ ### 2. Run translations via CLI
55
56
 
56
57
  ```bash
57
- bundle exec rake "i18nize:translate[language]"
58
+ # Translate from en → cs
59
+ i18nize cs
60
+
61
+ # Translate from de → fr
62
+ i18nize fr --from de
58
63
  ```
59
64
 
60
65
  💬 This will:
61
66
 
62
- * Scan config/locales/**/*en.yml (English is the default source language)
63
- * Detect which `language.yml` files are missing keys
67
+ * Scan `config/locales/**/*en.yml` (or your chosen source language)
68
+ * Detect which `cs.yml` files are missing keys
64
69
  * Use DeepL to translate missing values
65
- * Write them to the appropriate files (e.g. `config/locales/errors/language.yml`)
70
+ * Write them to the appropriate files (creating them if needed)
71
+
72
+ ### 3. Show missing keys only
73
+
74
+ ```bash
75
+ # Missing cs keys (source en)
76
+ i18nize cs --missing
77
+
78
+ # Missing cs keys (source de)
79
+ i18nize cs --missing de
80
+ ```
81
+
82
+ This mode only lists missing keys, no translations are written.
83
+
84
+ ### 4. Fill placeholders for blank/missing keys (no DeepL)
85
+
86
+ ```bash
87
+ # Insert placeholders instead of translating
88
+ i18nize cs --only_blank
89
+ ```
66
90
 
91
+ This scans all keys in the **source** locale (default `en`) and, for any **missing or blank** value in the target locale, inserts a placeholder:
67
92
 
93
+ ```
94
+ <locale>.<full.dot.key> missing
95
+ ```
96
+
97
+ Example:
98
+
99
+ ```yaml
100
+ en:
101
+ test:
102
+ title: "Testing"
103
+ cs:
104
+ test:
105
+ title: "cs.test.title missing"
106
+ ```
107
+
108
+ Existing non-blank values in the target are left untouched.
68
109
 
69
110
  ## 🛡️ Character limit check
70
111
 
71
- Before translating, the task will:
112
+ Before translating, the CLI:
72
113
 
73
- * Count the total number of characters to be translated
74
- * Warn if the count exceeds **500,000** (DeepL Free monthly limit)
75
- * Ask for confirmation:
114
+ * Counts the total number of characters
115
+ * Warns if the count exceeds **500,000** (DeepL Free monthly limit)
116
+ * Asks for confirmation:
76
117
 
77
118
  ```text
78
119
  [i18nize] Estimated character count: 43125
79
120
  Proceed with translation? [y/N]:
80
121
  ```
81
122
 
82
-
83
-
84
123
  ## 🗂️ Locale file support
85
124
 
86
- * Files are grouped per folder and matched:
125
+ * Files are matched by name:
87
126
 
88
- * `config/locales/api/en.yml` → `config/locales/api/cs.yml`
89
- * `config/locales/en.devise.yml` → `config/locales/cs.devise.yml`
127
+ * `config/locales/api/en.yml` → `config/locales/api/cs.yml`
128
+ * `config/locales/en.devise.yml` → `config/locales/cs.devise.yml`
90
129
  * Missing files are created automatically
91
-
92
-
130
+ * If a scalar exists where a nested structure is required, **it is overwritten by source locale structure**
93
131
 
94
132
  ## 🧩 Example structure
95
133
 
@@ -102,7 +140,13 @@ config/locales/
102
140
  │ └── cs.yml
103
141
  ```
104
142
 
105
- After running `rake i18nize:translate[cs]`, the gem will create:
143
+ After running:
144
+
145
+ ```bash
146
+ i18nize cs
147
+ ```
148
+
149
+ the gem will create:
106
150
 
107
151
  ```
108
152
  config/locales/api/cs.yml
@@ -114,16 +158,22 @@ With keys translated from `api/en.yml`.
114
158
 
115
159
  ## 📦 Configuration
116
160
 
117
- For now, configuration is via method arguments and `ENV["DEEPL_API_KEY"]`.
118
- Optional future support: `.i18nize.yml`.
161
+ For now, configuration is via CLI flags and environment variables:
119
162
 
163
+ * `--from LANG` (default `en`)
164
+ * `--missing [FROM]` (list missing keys only)
165
+ * `--only_blank` (fill only blank/missing keys with placeholders, no DeepL)
166
+ * `DEEPL_API_KEY` (required for translation mode)
167
+ * `DEEPL_ENDPOINT` (optional, only for Pro)
168
+ * `I18NIZE_ALLOW_EMPTY == "1"` → **skip empty/whitespace-only values** in the source when inserting or translating
169
+ (i.e., if a source value is `""` or `" "` it will be ignored and not propagated to the target)
120
170
 
171
+ ---
121
172
 
122
173
  ## 🔍 TODO
123
174
 
124
- * Test suite (RSpec)
125
- * YAML key sorting and format preservation
126
- * Smart overwrite protection
127
- * Changeable source language
128
-
175
+ * RSpec test suite
176
+ * YAML key sorting / format preservation
177
+ * Richer conflict resolution strategies (configurable overwrite / skip)
178
+ * Use custom char limit
129
179
 
data/lib/i18nize/cli.rb CHANGED
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
-
5
4
  require "i18nize/translator"
6
5
  require "i18nize/inserter"
7
6
  require "i18nize/comparer"
8
7
 
9
8
  module I18nize
9
+ # CLI for i18nize
10
10
  class CLI
11
11
  def self.run(argv)
12
12
  new(argv).run
@@ -16,107 +16,102 @@ module I18nize
16
16
  @argv = argv.dup
17
17
  @options = {
18
18
  from: "en",
19
- mode: :translate,
19
+ mode: :translate, # :translate | :missing | :only_blank
20
20
  missing_from: nil
21
21
  }
22
22
  end
23
23
 
24
24
  def run
25
- print_help_and_exit if @argv.empty? || help_requested?
26
-
27
- first = @argv.first
28
- parse_flags!(@argv[1..] || [])
29
-
30
- to_locale = parse_positional_locale!(first)
25
+ remaining = parse_flags!(@argv)
26
+ abort usage unless remaining.any?
27
+ to_locale = remaining.shift.strip
31
28
 
32
- if @options[:mode] == :missing
29
+ case @options[:mode]
30
+ when :missing
33
31
  from = @options[:missing_from] || @options[:from]
34
32
  show_missing_translations(from:, to: to_locale)
35
- return 0
33
+ when :only_blank
34
+ run_only_blank(to_locale)
35
+ else
36
+ run_translation(to_locale)
36
37
  end
37
-
38
- run_translation(to_locale)
39
38
  0
40
39
  rescue OptionParser::InvalidOption => e
41
40
  warn red(e.message)
42
- print_help_and_exit(2)
41
+ puts usage
42
+ 2
43
43
  rescue SystemExit => e
44
44
  raise e
45
45
  rescue => e
46
46
  warn red("Error: #{e.class}: #{e.message}")
47
47
  e.backtrace.each { |l| warn l } if ENV["I18NIZE_DEBUG"] == "1"
48
- exit 1
48
+ 1
49
49
  end
50
50
 
51
51
  private
52
52
 
53
- def help_requested?
54
- (@argv & %w[-h --help help]).any?
55
- end
56
-
57
- def parse_positional_locale!(arg)
58
- abort yellow("Usage: i18nize <to_locale> [--from en] [--missing [FROM]]") unless arg
59
- arg.strip
60
- end
61
-
62
- def parse_flags!(rest)
63
- OptionParser.new do |o|
53
+ def parse_flags!(args)
54
+ parser = OptionParser.new do |o|
64
55
  o.banner = "Usage: i18nize <to_locale> [options]"
65
- o.on("--from LANG", "Source language for comparison/translation (default: en)") { |v| @options[:from] = v }
66
-
56
+ o.on("--from LANG", "Source language (default: en)") { |v| @options[:from] = v }
67
57
  o.on("--missing", "--missing [FROM]", "List missing keys only; optionally specify source locale") do |from|
68
58
  @options[:mode] = :missing
69
59
  @options[:missing_from] = from if from && !from.strip.empty?
70
60
  end
61
+ o.on("--only_blank", "Fill only missing/blank keys with '<locale>.<full.key> missing'") do
62
+ @options[:mode] = :only_blank
63
+ end
64
+ o.on("-h", "--help", "Show help") { puts usage; exit 0 }
65
+ end
71
66
 
72
- o.on("-h", "--help", "Show help") { print_help_and_exit }
73
- end.parse!(rest)
67
+ parser.parse!(args)
68
+ args
74
69
  end
75
70
 
76
71
  def show_missing_translations(from:, to:)
77
72
  load_rails_if_available
78
73
  puts cyan("🔍 Missing keys for #{to} (from #{from})")
79
-
80
74
  missing = I18nize::Comparer.missing_translations(from_locale: from, to_locale: to)
81
-
82
75
  if missing.empty?
83
76
  puts green("✅ No missing translations.")
84
77
  return
85
78
  end
86
-
87
- total_keys = missing.total_keys
88
- puts yellow("⚠️ #{total_keys} missing #{total_keys == 1 ? 'key' : 'keys'}:")
89
-
79
+ total = missing.total_keys
80
+ puts yellow("⚠️ #{total} missing #{total == 1 ? 'key' : 'keys'}:")
90
81
  missing.values_by_file.each do |file, keys_hash|
91
82
  puts " #{file}:"
92
- keys_hash.keys.sort.each do |k|
93
- puts " - #{k.sub(/^#{from}\./, '')}"
94
- end
83
+ keys_hash.keys.sort.each { |k| puts " - #{k.sub(/^#{Regexp.escape(from)}\./, "")}" }
95
84
  end
96
85
  end
97
86
 
98
- def run_translation(to_locale)
87
+ def run_only_blank(to_locale)
99
88
  load_env_files
89
+ load_rails_if_available
90
+ puts cyan("Filling only blank/missing keys: from #{@options[:from]} → #{to_locale}")
91
+ I18nize::Inserter.insert_only_blank(
92
+ from_locale: @options[:from],
93
+ to_locale: to_locale
94
+ )
95
+ puts green("✅ Placeholders inserted for blank/missing keys.")
96
+ end
100
97
 
98
+ def run_translation(to_locale)
99
+ load_env_files
101
100
  api_key = ENV["DEEPL_API_KEY"]
102
101
  abort red("Missing DEEPL_API_KEY env variable") if api_key.to_s.strip.empty?
103
-
104
102
  load_rails_if_available
105
103
 
106
104
  puts cyan("Starting translation: from #{@options[:from]} → #{to_locale}")
107
-
108
105
  translator = I18nize::Translator.new(
109
106
  auth_key: api_key,
110
107
  from_lang: @options[:from].upcase,
111
108
  to_lang: to_locale.upcase
112
109
  )
113
-
114
110
  I18nize::Inserter.insert_missing(
115
111
  from_locale: @options[:from],
116
112
  to_locale: to_locale,
117
113
  translator: translator
118
114
  )
119
-
120
115
  puts green("✅ Translation completed.")
121
116
  end
122
117
 
@@ -124,18 +119,37 @@ module I18nize
124
119
  begin
125
120
  require "dotenv/load"
126
121
  rescue LoadError
127
- # Ignore
128
122
  end
129
123
  end
130
124
 
131
125
  def load_rails_if_available
132
126
  return if defined?(Rails)
133
127
 
134
- if File.exist?("config/environment.rb")
135
- require_relative "../../config/environment"
136
- end
128
+ require_relative "../../config/environment" if File.exist?("config/environment.rb")
129
+
137
130
  rescue LoadError
138
- # Ignore
131
+ # Ignored
132
+ end
133
+
134
+ def usage
135
+ <<~HELP
136
+ Usage:
137
+ i18nize <to_locale> [options]
138
+
139
+ Examples:
140
+ i18nize cs # translate missing from en -> cs
141
+ i18nize cs --from de # translate missing from de -> cs
142
+ i18nize --from de cs # flags can be before or after the locale
143
+ i18nize cs --missing # list missing keys for cs (from en)
144
+ i18nize cs --missing de # list missing keys for cs (from de)
145
+ i18nize cs --only_blank # fill only blank/missing keys with placeholders
146
+
147
+ Options:
148
+ --from LANG Source language (default: en)
149
+ --missing [FROM] Only list missing keys (optional override of source)
150
+ --only_blank Insert '<locale>.<full.key> missing' for blank/missing keys
151
+ -h, --help Show this help
152
+ HELP
139
153
  end
140
154
 
141
155
  def color(text, code) = "\e[#{code}m#{text}\e[0m"
@@ -147,24 +161,5 @@ module I18nize
147
161
  def yellow(text) = color(text, 33)
148
162
 
149
163
  def cyan(text) = color(text, 36)
150
-
151
- def print_help_and_exit(code = 0)
152
- puts <<~HELP
153
- Usage:
154
- i18nize <to_locale> [options]
155
-
156
- Examples:
157
- i18nize cs # translate missing from en -> cs
158
- i18nize cs --from de # translate missing from de -> cs
159
- i18nize cs --missing # list missing keys for cs (from en)
160
- i18nize cs --missing de # list missing keys for cs (from de)
161
-
162
- Options:
163
- --from LANG Source language (default: en)
164
- --missing [FROM] Only list missing keys (optional override of source)
165
- -h, --help Show this help
166
- HELP
167
- exit code
168
- end
169
164
  end
170
165
  end
@@ -4,8 +4,9 @@ require_relative "loader"
4
4
  require_relative "missing_set"
5
5
 
6
6
  module I18nize
7
+ # Small utility to compare translations from one locale to another
7
8
  class Comparer
8
- def self.missing_translations(from_locale: "en", to_locale:)
9
+ def self.missing_translations(to_locale:, from_locale: "en") # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
9
10
  from_data = Loader.load_per_file(locale: from_locale)
10
11
  to_data = Loader.load_per_file(locale: to_locale)
11
12
 
@@ -37,7 +38,7 @@ module I18nize
37
38
  MissingSet.new(from_locale: from_locale, to_locale: to_locale, data: result)
38
39
  end
39
40
 
40
- def self.missing_keys(from_locale: "en", to_locale:)
41
+ def self.missing_keys(to_locale:, from_locale: "en")
41
42
  missing_translations(from_locale: from_locale, to_locale: to_locale).keys_by_file
42
43
  end
43
44
  end
@@ -2,13 +2,15 @@
2
2
 
3
3
  require "yaml"
4
4
  require "fileutils"
5
+ require "byebug"
5
6
  require_relative "comparer"
7
+ require_relative "loader"
6
8
 
7
9
  module I18nize
8
10
  class Inserter
9
11
  CONFLICT_POLICY = :overwrite # :overwrite | :skip
10
12
 
11
- def self.insert_missing(from_locale: "en", to_locale:, base_path: "config/locales", translator: nil)
13
+ def self.insert_missing(to_locale:, from_locale: "en", base_path: "config/locales", translator: nil)
12
14
  missing_data = Comparer.missing_translations(from_locale: from_locale, to_locale: to_locale)
13
15
 
14
16
  if translator
@@ -26,7 +28,7 @@ module I18nize
26
28
  overwrites = []
27
29
 
28
30
  missing_data.data.each do |from_rel_path, translations|
29
- dirname = File.dirname(from_rel_path)
31
+ dirname = File.dirname(from_rel_path)
30
32
  filename = File.basename(from_rel_path)
31
33
 
32
34
  locale_re = /(^|[._])#{Regexp.escape(from_locale)}\.(ya?ml)$/
@@ -40,7 +42,7 @@ module I18nize
40
42
  translation_map =
41
43
  if translator
42
44
  src_values = translations.values
43
- translated_values = translator.translate_texts(src_values) # => pole!
45
+ translated_values = translator.translate_texts(src_values) # returns array
44
46
  translations.keys.zip(translated_values).to_h
45
47
  else
46
48
  translations
@@ -57,15 +59,70 @@ module I18nize
57
59
  puts "\e[32m[i18nize] Inserted #{translation_map.size} keys into #{to_full_path}\e[0m"
58
60
  end
59
61
 
60
- if overwrites.any?
62
+ return unless overwrites.any?
61
63
  puts "\n\e[33m[i18nize] Overwrote #{overwrites.size} scalar node(s) to create nested structure:\e[0m"
62
64
  overwrites.each do |c|
63
65
  puts " \e[33m- #{c[:file]}: #{c[:locale]}.#{c[:path].join('.')}\e[0m (was: #{c[:existing_class]})"
64
66
  end
67
+
68
+ end
69
+
70
+ # NEW: fill only missing/blank values with "<locale>.<full.key> missing"
71
+ def self.insert_only_blank(to_locale:, from_locale: "en", base_path: "config/locales")
72
+ from_data = Loader.load_per_file(locale: from_locale, base_path: base_path)
73
+ # to_data = Loader.load_per_file(locale: to_locale, base_path: base_path)
74
+
75
+ updated_files = 0
76
+ overwrites = []
77
+
78
+ from_data.each do |from_rel_path, from_keys|
79
+ dirname = File.dirname(from_rel_path)
80
+ filename = File.basename(from_rel_path)
81
+ locale_re = /(^|[._])#{Regexp.escape(from_locale)}\.(ya?ml)$/
82
+ new_filename = filename.sub(locale_re) { "#{Regexp.last_match(1)}#{to_locale}.#{Regexp.last_match(2)}" }
83
+ to_rel_path = dirname == "." || dirname.empty? ? new_filename : File.join(dirname, new_filename)
84
+ to_full_path = File.join(base_path, to_rel_path)
85
+
86
+ existing = File.exist?(to_full_path) ? YAML.load_file(to_full_path) : {}
87
+ existing[to_locale] ||= {}
88
+
89
+ changes = 0
90
+
91
+ from_keys.each_key do |from_full_key|
92
+ to_full_key = from_full_key.sub(/^#{Regexp.escape(from_locale)}\./, "#{to_locale}.")
93
+ path = to_full_key.sub(/^#{Regexp.escape(to_locale)}\./, "").split(".")
94
+ current = dig_nested(existing[to_locale], path)
95
+ next if current.to_s.strip.empty? && ENV["I18NIZE_ALLOW_EMPTY"] == "1"
96
+ next unless current.nil? || current.to_s.strip.empty?
97
+
98
+ placeholder = "#{to_full_key} missing"
99
+ insert_nested(existing[to_locale], path, placeholder, overwrites, to_rel_path, to_locale, :overwrite)
100
+ changes += 1
101
+ end
102
+
103
+ next unless changes.positive?
104
+
105
+ FileUtils.mkdir_p(File.dirname(to_full_path))
106
+ File.write(to_full_path, existing.to_yaml(line_width: -1))
107
+ updated_files += 1
108
+ puts "\e[32m[i18nize] Inserted #{changes} placeholder#{changes == 1 ? '' : 's'} into #{to_full_path}\e[0m"
109
+ end
110
+
111
+ puts "\e[90m[i18nize] No blank or missing keys found.\e[0m" if updated_files.zero?
112
+
113
+ return unless overwrites.any?
114
+
115
+ puts "\n\e[33m[i18nize] Overwrote #{overwrites.size} scalar node(s) while creating nested structure:\e[0m"
116
+ overwrites.each do |c|
117
+ puts " \e[33m- #{c[:file]}: #{c[:locale]}.#{c[:path].join('.')}\e[0m (was: #{c[:existing_class]})"
65
118
  end
66
119
  end
67
120
 
68
- # conflict_policy: :overwrite (replace scalar with {}), :skip (leave as is)
121
+ def self.dig_nested(hash, path)
122
+ path.reduce(hash) { |h, k| h.is_a?(Hash) ? h[k] : nil }
123
+ end
124
+
125
+ # conflict_policy: :overwrite (replace scalar with {}), :skip
69
126
  def self.insert_nested(base, keys, value, overwrites, file, locale, conflict_policy)
70
127
  key = keys.shift
71
128
 
@@ -79,18 +136,15 @@ module I18nize
79
136
  case cur
80
137
  when nil
81
138
  base[key] = {}
82
- insert_nested(base[key], keys, value, overwrites, file, locale, conflict_policy)
139
+ return insert_nested(base[key], keys, value, overwrites, file, locale, conflict_policy)
83
140
  when Hash
84
- insert_nested(cur, keys, value, overwrites, file, locale, conflict_policy)
141
+ return insert_nested(cur, keys, value, overwrites, file, locale, conflict_policy)
85
142
  else
86
- if conflict_policy == :overwrite
143
+ return false unless conflict_policy == :overwrite
87
144
  overwrites << { file: file, locale: locale, path: [key, *keys], existing_class: cur.class.to_s }
88
145
  base[key] = {}
89
- insert_nested(base[key], keys, value, overwrites, file, locale, conflict_policy)
90
- else
91
- # :skip
92
- false
93
- end
146
+ return insert_nested(base[key], keys, value, overwrites, file, locale, conflict_policy)
147
+
94
148
  end
95
149
  end
96
150
  end
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  module I18nize
6
+ # Loader for i18nize
6
7
  class Loader
7
8
  def self.load_per_file(locale: "en", base_path: "config/locales")
8
9
  files = Dir.glob(File.join(base_path, "**", "*.yml")).select do |f|
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nize
4
- require "byebug"
4
+ # Set of missing translations
5
5
  class MissingSet
6
6
  attr_reader :from_locale, :to_locale, :data
7
7
 
@@ -4,14 +4,15 @@ require "uri"
4
4
  require "json"
5
5
 
6
6
  module I18nize
7
+ # Translator for i18nize
7
8
  class Translator
8
9
  ENDPOINT = ENV.fetch("DEEPL_ENDPOINT", "https://api-free.deepl.com/v2/translate")
9
10
  PLACEHOLDER_RE = /%{\s*[^}]+\s*}/
10
11
 
11
12
  def initialize(auth_key:, from_lang: "EN", to_lang:)
12
- @auth_key = auth_key
13
+ @auth_key = auth_key
13
14
  @from_lang = from_lang
14
- @to_lang = to_lang
15
+ @to_lang = to_lang
15
16
  end
16
17
 
17
18
  def translate_texts(texts)
@@ -30,7 +31,7 @@ module I18nize
30
31
  body_params = [
31
32
  ["auth_key", @auth_key],
32
33
  ["source_lang", @from_lang],
33
- ["target_lang", @to_lang],
34
+ ["target_lang", @to_lang]
34
35
  ] + protected.map { |text| ["text", text] }
35
36
 
36
37
  req = Net::HTTP::Post.new(uri)
@@ -43,7 +44,7 @@ module I18nize
43
44
  json = JSON.parse(res.body)
44
45
  out = json["translations"].map { |t| t["text"] }
45
46
 
46
- out.map { |s| s.gsub(/__I18NIZE_PLH_(\d+)__/) { placeholders[$1.to_i] } }
47
+ out.map { |s| s.gsub(/__I18NIZE_PLH_(\d+)__/) { placeholders[::Regexp.last_match(1).to_i] } }
47
48
  end
48
49
  end
49
50
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nize
4
- VERSION = "0.5"
4
+ VERSION = "0.6"
5
5
  end
metadata CHANGED
@@ -1,16 +1,27 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18nize
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.5'
4
+ version: '0.6'
5
5
  platform: ruby
6
6
  authors:
7
7
  - nikolas2145
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-09-08 00:00:00.000000000 Z
11
+ date: 2025-09-11 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: ''
13
+ description: "i18nize helps Ruby and Rails projects stay multilingual by automatically
14
+ filling in \nmissing translations in I18n YAML locale files. It scans for missing
15
+ or empty keys \nacross nested locale structures, and uses the DeepL API (Free or
16
+ Pro) to translate \nthem from a chosen source locale (default: en).\n\nFeatures:\n-
17
+ Detects and lists missing translation keys\n- Automatically translates and inserts
18
+ values into the correct YAML files\n- Supports nested directories and multiple locale
19
+ files (e.g. config/locales/api/en.yml)\n- Preserves I18n placeholders such as %{count}\n-
20
+ Handles pluralization branches (one, other, etc.)\n- Source locale is treated as
21
+ the single source of truth (conflicts are resolved by overwrite)\n- Simple CLI:
22
+ `i18nize <locale>` or `i18nize <locale> --missing`\n\nThis gem streamlines the translation
23
+ workflow, making it easier to maintain \nconsistent, up-to-date locale files across
24
+ large Ruby on Rails applications.\n"
14
25
  email:
15
26
  - nikolas2145@gmail.com
16
27
  executables:
@@ -18,11 +29,6 @@ executables:
18
29
  extensions: []
19
30
  extra_rdoc_files: []
20
31
  files:
21
- - ".idea/.gitignore"
22
- - ".idea/git_toolbox_prj.xml"
23
- - ".idea/i18nize.iml"
24
- - ".idea/material_theme_project_new.xml"
25
- - ".idea/vcs.xml"
26
32
  - ".rspec"
27
33
  - ".rubocop.yml"
28
34
  - CHANGELOG.md
@@ -66,5 +72,5 @@ requirements: []
66
72
  rubygems_version: 3.4.19
67
73
  signing_key:
68
74
  specification_version: 4
69
- summary: Auto-translates missing I18n keys in YAML files using DeepL.
75
+ summary: Automatic DeepL-powered translations for I18n YAML files
70
76
  test_files: []
data/.idea/.gitignore DELETED
@@ -1,8 +0,0 @@
1
- # Default ignored files
2
- /shelf/
3
- /workspace.xml
4
- # Editor-based HTTP Client requests
5
- /httpRequests/
6
- # Datasource local storage ignored files
7
- /dataSources/
8
- /dataSources.local.xml
@@ -1,15 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="GitToolBoxProjectSettings">
4
- <option name="commitMessageIssueKeyValidationOverride">
5
- <BoolValueOverride>
6
- <option name="enabled" value="true" />
7
- </BoolValueOverride>
8
- </option>
9
- <option name="commitMessageValidationEnabledOverride">
10
- <BoolValueOverride>
11
- <option name="enabled" value="true" />
12
- </BoolValueOverride>
13
- </option>
14
- </component>
15
- </project>
data/.idea/i18nize.iml DELETED
@@ -1,53 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <module version="4">
3
- <component name="ModuleRunConfigurationManager">
4
- <shared />
5
- </component>
6
- <component name="RakeTasksCache-v2">
7
- <option name="myRootTask">
8
- <RakeTaskImpl id="rake">
9
- <subtasks>
10
- <RakeTaskImpl description="Build i18nize-0.4.0.gem into the pkg directory" fullCommand="build" id="build" />
11
- <RakeTaskImpl id="build">
12
- <subtasks>
13
- <RakeTaskImpl description="Generate SHA512 checksum of i18nize-0.4.0.gem into the checksums directory" fullCommand="build:checksum" id="checksum" />
14
- </subtasks>
15
- </RakeTaskImpl>
16
- <RakeTaskImpl description="Remove any temporary products" fullCommand="clean" id="clean" />
17
- <RakeTaskImpl description="Remove any generated files" fullCommand="clobber" id="clobber" />
18
- <RakeTaskImpl id="i18nize">
19
- <subtasks>
20
- <RakeTaskImpl description="Auto-translate missing I18n keys using DeepL (usage: rake 'i18nize:translate[cs]')" fullCommand="i18nize:translate[to_locale]" id="translate[to_locale]" />
21
- <RakeTaskImpl description="" fullCommand="i18nize:translate" id="translate" />
22
- </subtasks>
23
- </RakeTaskImpl>
24
- <RakeTaskImpl description="Build and install i18nize-0.4.0.gem into system gems" fullCommand="install" id="install" />
25
- <RakeTaskImpl id="install">
26
- <subtasks>
27
- <RakeTaskImpl description="Build and install i18nize-0.4.0.gem into system gems without network access" fullCommand="install:local" id="local" />
28
- </subtasks>
29
- </RakeTaskImpl>
30
- <RakeTaskImpl description="Create tag v0.4.0 and build and push i18nize-0.4.0.gem to https://rubygems.org" fullCommand="release[remote]" id="release[remote]" />
31
- <RakeTaskImpl description="Run RuboCop" fullCommand="rubocop" id="rubocop" />
32
- <RakeTaskImpl id="rubocop">
33
- <subtasks>
34
- <RakeTaskImpl description="Autocorrect RuboCop offenses (only when it's safe)" fullCommand="rubocop:autocorrect" id="autocorrect" />
35
- <RakeTaskImpl description="Autocorrect RuboCop offenses (safe and unsafe)" fullCommand="rubocop:autocorrect_all" id="autocorrect_all" />
36
- <RakeTaskImpl description="" fullCommand="rubocop:auto_correct" id="auto_correct" />
37
- </subtasks>
38
- </RakeTaskImpl>
39
- <RakeTaskImpl description="Run RSpec code examples" fullCommand="spec" id="spec" />
40
- <RakeTaskImpl description="" fullCommand="default" id="default" />
41
- <RakeTaskImpl description="" fullCommand="release" id="release" />
42
- <RakeTaskImpl id="release">
43
- <subtasks>
44
- <RakeTaskImpl description="" fullCommand="release:guard_clean" id="guard_clean" />
45
- <RakeTaskImpl description="" fullCommand="release:rubygem_push" id="rubygem_push" />
46
- <RakeTaskImpl description="" fullCommand="release:source_control_push" id="source_control_push" />
47
- </subtasks>
48
- </RakeTaskImpl>
49
- </subtasks>
50
- </RakeTaskImpl>
51
- </option>
52
- </component>
53
- </module>
@@ -1,12 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="MaterialThemeProjectNewConfig">
4
- <option name="metadata">
5
- <MTProjectMetadataState>
6
- <option name="migrated" value="true" />
7
- <option name="pristineConfig" value="false" />
8
- <option name="userId" value="-151e60b4:18eb353ec10:-7ffc" />
9
- </MTProjectMetadataState>
10
- </option>
11
- </component>
12
- </project>
data/.idea/vcs.xml DELETED
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="VcsDirectoryMappings">
4
- <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
- </component>
6
- </project>