i18n_uno 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a96558cb92d39d42fcaafb70c1ff3b9ca633cc2d4fb4849bb7bf7243ea09954e
4
+ data.tar.gz: eab75bee802e842847a77288aaec1dfc7bb1ee9785bdf290687c973249d6dd40
5
+ SHA512:
6
+ metadata.gz: ac9a40dbfad9a8b5ef8e29a3fb69f78c9d0fd5311691a240c0a9227f93606ac536e65afce3bead3820c39f3d9778eb0663ebb5acdb66df302605fd7f35e9c4a7
7
+ data.tar.gz: 0ef6c4fcbd31728906dfe936dbb55af29a8165e09a5e1096be623b8cc302b2c4a90701dcacd53b5170ad1ae874af8555d2066a2cfade60a631dda9c7e05c5216
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in i18n_uno.gemspec
8
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,34 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ i18n_uno (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ diff-lcs (1.5.1)
10
+ rake (10.5.0)
11
+ rspec (3.13.0)
12
+ rspec-core (~> 3.13.0)
13
+ rspec-expectations (~> 3.13.0)
14
+ rspec-mocks (~> 3.13.0)
15
+ rspec-core (3.13.0)
16
+ rspec-support (~> 3.13.0)
17
+ rspec-expectations (3.13.0)
18
+ diff-lcs (>= 1.2.0, < 2.0)
19
+ rspec-support (~> 3.13.0)
20
+ rspec-mocks (3.13.0)
21
+ diff-lcs (>= 1.2.0, < 2.0)
22
+ rspec-support (~> 3.13.0)
23
+ rspec-support (3.13.1)
24
+
25
+ PLATFORMS
26
+ ruby
27
+
28
+ DEPENDENCIES
29
+ i18n_uno!
30
+ rake (~> 10.0)
31
+ rspec (~> 3.0)
32
+
33
+ BUNDLED WITH
34
+ 1.17.3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 pythagora-north
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # i18nUno
2
+
3
+ i18nUno is a gem that helps you translate your Rails application into any language you choose. It utilizes the OpenAI API for translations and supports all languages. It's easy to use, and by implementing Git hooks, you can set it up to automatically add new translations or delete old ones as a pre-commit hook.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ group :development do
11
+ ...
12
+ gem 'i18n_uno'
13
+ end
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ $ bundle install
19
+
20
+ ## Prerequisites
21
+
22
+ To use the gem, adhere to the standard procedures outlined in the [Rails I18n support guide](https://guides.rubyonrails.org/i18n.html). While you may organize your folders as you wish, all localization files must be named following the `$locale.yml` format, such as `en.yml` for English. The i18n Uno gem will leverage these files to generate new ones for additional languages you wish to include, for instance, `es.yml` for Spanish, `de.yml` for German etc.
23
+
24
+ ## Configuration
25
+
26
+ To utilize the gem, create a file `config/initializers/i18n_uno.rb` and configure the gem with your OpenAI credentials. Additional settings are available for customization.
27
+
28
+ ```ruby
29
+ I18nUno.configure do |config|
30
+ config.open_ai_key = ENV['OPEN_AI_API_KEY'] # Required: Found at https://platform.openai.com/account/api-keys
31
+ config.default_locale # Required: You can set it to I18n.config.default_locale
32
+ config.available_locales # Required: I18n.config.default_locale and locales you want to translate to
33
+ config.open_ai_model # Optional: Defaults to 'gpt-4-0613'
34
+ config.load_path # Optional: Defaults to 'config/locales'
35
+ end
36
+ ```
37
+
38
+ Although changing the model is supported, it is strongly recommended to stick with gpt-4 models due to the significant quality difference compared to gpt-3 models.
39
+
40
+ ## Usage
41
+
42
+ Before running the gem, ensure you've added the desired languages to `config.available_locales` or in the `config/application.rb` file.
43
+
44
+ ```ruby
45
+ config.i18n.available_locales = [:en, :bs, :de]
46
+ ```
47
+
48
+ With these settings, translations will be executed to add all necessary files to support these languages. From this point, simply run:
49
+
50
+ ```bash
51
+ rake i18n_uno:translate
52
+ ```
53
+
54
+ from your application folder. i18n Uno will then add new translation files, and you'll be ready to go. If new keys are added during development, running the above command again will automatically add and translate these new keys. Key removal is also supported and will be propagated to all language files.
55
+
56
+ ## Continuous Management of Internationalization
57
+
58
+ Your default locale serves as the foundational reference for your application's internationalization. Whenever modifications are made to the localization files, executing the previously mentioned gem command will automatically update translations across all other supported languages, adding or removing them as necessary.
59
+
60
+ To ensure seamless integration of this process, it's advisable to configure a pre-commit hook that triggers the above command. This step helps maintain consistency by preventing the introduction of new keys without their corresponding translations.
61
+
62
+ It's important to note that the gem is designed to respect the integrity of your source of truth files (the default locale) and, as such, will not alter these files directly. Additionally, it does not track changes to existing keys, focusing instead on the addition or removal of translations based on the current state of your default locale files.
63
+
64
+ ## Setting up application for internationalization
65
+
66
+ If you are not already supporting internationalization in your rails application you can do that simply by adding `locale` field to your `user` model.
67
+
68
+ ```ruby
69
+ class AddLocaleToUsers < ActiveRecord::Migration
70
+ def change
71
+ add_column :users, :locale, :string, default: 'en'
72
+ end
73
+ end
74
+ ```
75
+
76
+ From there adding you simply need to add before action to `app/controllers/authenticated_controller.rb`. This would very based on your application but this would be the standard way.
77
+
78
+ ```ruby
79
+ before_action :set_locale!
80
+
81
+ def set_locale!
82
+ I18n.locale = current_user.locale
83
+ end
84
+ ```
85
+
86
+ That would be it, magic of rails will take care of the rest.
87
+
88
+ ## Contributing
89
+
90
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/i18n_uno.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/i18n_uno.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'i18n_uno/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'i18n_uno'
9
+ spec.version = I18nUno::VERSION
10
+ spec.authors = ['Haris Krajina', 'hkraji']
11
+ spec.email = ['haris@pythagoranorth.com']
12
+
13
+ spec.summary = 'i18n Uno levrages power of ChatGPT API to translate your i18n files.'
14
+ spec.description = 'i18n Uno is simple CLI tool that will completly translate your application to any desired language.'
15
+ spec.homepage = 'https://github.com/pythagora-north/i18n_uno'
16
+ spec.license = 'MIT'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://github.com/pythagora-north/i18n_uno'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/pythagora-north/i18n_uno/blob/main/Changes.md'
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
26
+ end
27
+ spec.bindir = 'exe'
28
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ['lib']
30
+
31
+ spec.add_development_dependency 'rake', '~> 10.0'
32
+ spec.add_development_dependency 'rspec', '~> 3.0'
33
+ end
data/lib/.DS_Store ADDED
Binary file
Binary file
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ class Arbiter
5
+ attr_reader :translators, :trees, :source_of_truth_tree, :target_locales
6
+
7
+ def initialize
8
+ @translators = []
9
+ @trees = {}
10
+
11
+ setup_configurations
12
+ end
13
+
14
+ def translate
15
+ print_translations_info
16
+
17
+ target_locales.each do |target_locale|
18
+ collect_locale_changes(target_locale)
19
+ process_changes(target_locale)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def setup_configurations
26
+ check_locale_availability
27
+ setup_signal_handling
28
+ setup_target_locales
29
+ setup_translation_trees
30
+ end
31
+
32
+ def check_locale_availability
33
+ if I18nUno.config.available_locales.nil?
34
+ raise I18nUno::ConfigurationError, 'No available locales specified. Please set the available locales in the configuration file.'
35
+ end
36
+ end
37
+
38
+ def setup_target_locales
39
+ @target_locales = I18nUno.config.available_locales - [I18nUno.config.default_locale]
40
+ end
41
+
42
+ def setup_translation_trees
43
+ @target_locales.each { |locale| @trees[locale] = I18nUno::Tree.new(locale) }
44
+ @source_of_truth_tree = I18nUno::Tree.new(I18nUno.config.default_locale)
45
+ end
46
+
47
+ def collect_locale_changes(target_locale)
48
+ puts "\nChecking for translation changes for locale '#{target_locale}'\n"
49
+
50
+ source_of_truth_tree.each do |sot_file|
51
+ target_file = @trees[target_locale].find_or_create_file(sot_file)
52
+
53
+ comparer = I18nUno::Comparer.new(sot_file, target_file)
54
+ diff_delta = comparer.compare
55
+ @trees[target_locale].add_delta!(target_file, diff_delta)
56
+ end
57
+ end
58
+
59
+ def process_changes(target_locale)
60
+ @trees[target_locale].process_delta_changes(source_of_truth_tree)
61
+ end
62
+
63
+ def setup_signal_handling
64
+ Signal.trap('SIGINT') do
65
+ puts "\nExiting I18n Uno ..."
66
+ target_locales.each do |target_locale|
67
+ @trees[target_locale].clean_not_processed_files
68
+ end
69
+ exit
70
+ end
71
+ end
72
+
73
+ def print_translations_info
74
+ if source_of_truth_tree.empty?
75
+ puts "No translation files found in the specified path (#{I18nUno.config.load_path}}/**/#{I18nUno.config.default_locale}.yml). Please check the path and try again."
76
+ exit(0)
77
+ else
78
+ puts "Listing translation files for the default locale '#{I18nUno.config.default_locale}':\n\n"
79
+ source_of_truth_tree.files.each_slice(2) do |file_pair|
80
+ file_pair.map! { |file| file.file_path.gsub(%r{#{I18nUno.config.load_path}/?}, '') }
81
+ print_string = file_pair.size.even? ? "- %-40s - %-40s\n" : "- %-40s\n"
82
+ printf(print_string, *file_pair)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ class Error < StandardError; end
5
+
6
+ class Comparer
7
+ attr_accessor :source_file, :target_file
8
+
9
+ def initialize(source_file, target_file)
10
+ @source_file = source_file
11
+ @target_file = target_file
12
+ end
13
+
14
+ def compare
15
+ source_keys = flatten_keys(normalize_data(source_file.content_hash))
16
+ target_keys = flatten_keys(normalize_data(target_file.content_hash))
17
+
18
+ new_keys = compare_keys(target_keys, source_keys)
19
+ removed_keys = compare_keys(source_keys, target_keys)
20
+
21
+ I18nUno::Delta.new(
22
+ new_keys: new_keys,
23
+ removed_keys: removed_keys,
24
+ target_keys_size: target_keys.size
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def compare_keys(source_keys, target_keys)
31
+ target_keys - source_keys
32
+ end
33
+
34
+ # Flattens the nested hash of translation keys into a flat array of dot-prefixed keys.
35
+ # @param data [Hash] the translation file content
36
+ # @param prefix [String] the current prefix to prepend to keys (used in recursion)
37
+ # @return [Array] an array of flattened key strings
38
+ def flatten_keys(data, prefix = '')
39
+ data.each_with_object([]) do |(key, value), keys|
40
+ full_key = "#{prefix}#{key}"
41
+ if value.is_a?(Hash)
42
+ keys.concat(flatten_keys(value, "#{full_key}."))
43
+ else
44
+ keys << full_key unless value.nil?
45
+ end
46
+ end
47
+ end
48
+
49
+
50
+ # Removes the first key for the data, since that is locale for that file content
51
+ # @param data [Hash] the translation file content
52
+ # @return [Hash] the translation file content without the locale key
53
+ def normalize_data(data)
54
+ if data.keys.first.length == 2 && data.keys.length == 1
55
+ data[data.keys.first]
56
+ else
57
+ data
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ class Configuration
5
+ attr_accessor :load_path, :open_ai_model, :open_ai_key, :default_locale, :available_locales
6
+
7
+ def initialize
8
+ raise ConfigurationError, 'Rails must be present to use I18nUno' unless defined?(Rails)
9
+
10
+ @load_path = ::File.join(Rails.root, 'config', 'locales')
11
+ @open_ai_model = 'gpt-4-0613'
12
+ @open_ai_key = nil
13
+ @default_locale = nil
14
+ @available_locales = nil
15
+ end
16
+
17
+ def validate!
18
+ raise ConfigurationError, 'open_ai_key must be set for API operations' unless open_ai_key
19
+ raise ConfigurationError, 'available_locales must be set' unless available_locales
20
+ raise ConfigurationError, 'default_locale must be included in available_locales' if available_locales && !available_locales.include?(default_locale)
21
+ end
22
+ end
23
+
24
+ class ConfigurationError < StandardError; end
25
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ class Delta
5
+ attr_accessor :new_keys, :removed_keys, :values, :translated_values, :target_file
6
+
7
+ # @param new_keys [Array<String>] keys that are newly added.
8
+ # @param removed_keys [Array<String>] keys that have been removed.
9
+ # @param target_keys_size [Integer] the total number of keys in the target file.
10
+ def initialize(new_keys: [], removed_keys: [], target_keys_size: 0)
11
+ @new_keys = new_keys
12
+ @removed_keys = removed_keys
13
+ @values = []
14
+ @translated_values = []
15
+ @complete_file_diff = calculate_complete_file_diff(new_keys, target_keys_size)
16
+ end
17
+
18
+ # Calculates if the delta represents a complete difference of a file.
19
+ # @return [Boolean] true if the new keys are equal to the total number of keys in the target.
20
+ def calculate_complete_file_diff(new_keys, target_keys_size)
21
+ new_keys.any? && new_keys.size == target_keys_size
22
+ end
23
+
24
+ # Determines if the entire file has been changed based on the delta.
25
+ # @return [Boolean] true if all keys in the file are considered new.
26
+ def complete_file_diff?
27
+ @complete_file_diff
28
+ end
29
+
30
+ # Checks if there are any changes (new or removed keys).
31
+ # @return [Boolean] true if there are any new or removed keys.
32
+ def any_changes?
33
+ any_new_keys? || any_removed_keys?
34
+ end
35
+
36
+ # Checks if there are any new keys in the delta.
37
+ # @return [Boolean] true if there are new keys present.
38
+ def any_new_keys?
39
+ new_keys.any?
40
+ end
41
+
42
+ # Checks if there are any removed keys in the delta.
43
+ # @return [Boolean] true if there are keys that have been removed.
44
+ def any_removed_keys?
45
+ removed_keys.any?
46
+ end
47
+
48
+ # Determines if there are any values ready to be translated.
49
+ # This checks for the presence of non-nil entries in the values list.
50
+ # @return [Boolean] true if there are non-nil values to translate.
51
+ def anything_to_translate?
52
+ values.compact.any?
53
+ end
54
+
55
+ # Populates the values list from a specified file based on the new keys.
56
+ # This is intended to gather the actual values to be translated.
57
+ # @param file [Object] typically an instance of a File class, used to extract values.
58
+ def values_from!(file)
59
+ @values = file.values_from_keys(new_keys)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'i18n_uno/files/content_change'
5
+
6
+ module I18nUno
7
+ class File
8
+ include I18nUno::Files::ContentChange
9
+
10
+ attr_accessor :content_hash
11
+ attr_reader :file_path, :locale
12
+
13
+ def initialize(file_path)
14
+ @file_path = file_path
15
+
16
+ @locale = ::File.basename(file_path, '.yml')
17
+ @content_hash = YAML.load_file(file_path)
18
+ end
19
+
20
+ def pp_file_name
21
+ file_path.gsub(%r{#{I18nUno.config.load_path}/?}, '')
22
+ end
23
+
24
+ def file_identifier
25
+ file_path.gsub(%r{/#{locale}\.yml$}, '')
26
+ end
27
+
28
+ def create_in_locale(locale, save_file = false)
29
+ new_path = path_in_locale(locale)
30
+ FileUtils.cp(file_path, path_in_locale(locale))
31
+
32
+ file = I18nUno::File.new(new_path)
33
+ file.setup_new_file(save_file)
34
+ file
35
+ end
36
+
37
+ def path_in_locale(locale)
38
+ new_path = file_path.gsub(%r{/#{self.locale}\.yml$}, "/#{locale}.yml")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ module Files
5
+ module ContentChange
6
+ # Reorders keys in the file to match the order of the source file.
7
+ # @param sot_file [I18nUno::File] The source of truth file with the desired key order.
8
+ def match_order_as_file!(sot_file)
9
+ ordered_hash = {}
10
+ ordered_hash[locale] = merge_preserving_key_order(
11
+ sot_file.content_hash.values.first,
12
+ content_hash.values.first
13
+ )
14
+
15
+ @content_hash = ordered_hash
16
+ end
17
+
18
+ # Adds missing keys to the target file and initializes them to nil.
19
+ # @param delta [I18nUno::Delta] Contains the new keys to be added.
20
+ def add_missing_keys_to_target_file!(delta)
21
+ delta.new_keys.each do |key_path|
22
+ keys = key_path.split('.').unshift(locale)
23
+ deep_set_to_nil!(content_hash, keys)
24
+ end
25
+ end
26
+
27
+ # Retrieves values from the file using dot-separated keys.
28
+ # @param dot_keys [Array<String>] Array of keys to retrieve values for.
29
+ # @return [Array] The values corresponding to the dot_keys.
30
+ def values_from_keys(dot_keys)
31
+ dot_keys.collect do |dot_keys|
32
+ keys = dot_keys.split('.').unshift(locale)
33
+ value = content_hash
34
+
35
+ loop do
36
+ value = value[keys.shift]
37
+ break if keys.empty?
38
+ end
39
+
40
+ value
41
+ end
42
+ end
43
+
44
+ # Updates the file with values from translated deltas.
45
+ # @param delta [I18nUno::Delta] Contains the keys and their translated values.
46
+ def set_values_from_delta!(delta)
47
+ delta.new_keys.each_with_index do |key_path, index|
48
+ keys = key_path.split('.').unshift(locale)
49
+ value = content_hash
50
+
51
+ loop do
52
+ current_key = keys.shift
53
+
54
+ if keys.empty?
55
+ value[current_key] = delta.translated_values[index]
56
+ break
57
+ else
58
+ value = value[current_key]
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # Removes specified keys from the file.
65
+ # @param delta [I18nUno::Delta] Contains the keys to be removed.
66
+ def remove_keys_from_file!(delta)
67
+ delta.removed_keys.each do |key_path|
68
+ keys = key_path.split('.').unshift(locale)
69
+ deep_key_remove!(content_hash, keys)
70
+ end
71
+ save!
72
+ end
73
+
74
+ # Saves the file to HDD
75
+ def save!
76
+ ::File.open(file_path, 'w') do |file|
77
+ yaml_content = content_hash.to_yaml(options: { line_width: -1 })
78
+ yaml_content.sub!(/\A---\s*\n/, '')
79
+ file.write(yaml_content)
80
+ end
81
+ end
82
+
83
+ # Prepares a new file with all primitive values set to nil.
84
+ # @param save_file [Boolean] Determines if the file should be saved immediately after setup.
85
+ def setup_new_file(save_file)
86
+ original_locale = @content_hash.keys.first
87
+ @content_hash[locale] = @content_hash.delete(original_locale)
88
+ set_primitives_to_nil!(@content_hash)
89
+ save! if save_file
90
+ end
91
+
92
+ private
93
+
94
+ # Recursively sets all primitive values in a hash to nil.
95
+ # @param hash [Hash] The hash to modify.
96
+ def set_primitives_to_nil!(hash)
97
+ hash.each do |key, value|
98
+ if value.is_a?(Hash)
99
+ set_primitives_to_nil!(value)
100
+ else
101
+ hash[key] = nil
102
+ end
103
+ end
104
+ end
105
+
106
+ # Merges two hashes preserving the order of keys from the source hash.
107
+ # @param source_hash [Hash] The source hash defining the order of keys.
108
+ # @param target_hash [Hash] The target hash to merge.
109
+ # @return [Hash] The merged hash with preserved key order.
110
+ def merge_preserving_key_order(source_hash, target_hash)
111
+ merged_hash = {}
112
+
113
+ source_hash.each_key do |key|
114
+ merged_hash[key] = if source_hash[key].is_a?(Hash) && target_hash[key].is_a?(Hash)
115
+ merge_preserving_key_order(source_hash[key], target_hash[key])
116
+ else
117
+ target_hash[key]
118
+ end
119
+ end
120
+
121
+ merged_hash
122
+ end
123
+
124
+ # Recursively removes a key from a nested hash.
125
+ # @param obj [Hash] The hash from which to remove the key.
126
+ # @param keys [Array<String>] The path of keys leading to the key to remove.
127
+ def deep_key_remove!(obj, keys)
128
+ first_key = keys.shift
129
+
130
+ if keys.empty?
131
+ obj.delete(first_key)
132
+ else
133
+ deep_key_remove!(obj[first_key], keys)
134
+ end
135
+ end
136
+
137
+ # Sets all leaf nodes of a nested hash to nil.
138
+ # @param obj [Hash] The hash to modify.
139
+ # @param keys [Array<String>] The path of keys.
140
+ def deep_set_to_nil!(obj, keys)
141
+ last_key = keys.pop
142
+ last_hash = keys.inject(obj) do |o, k|
143
+ o[k] ||= {}
144
+ end
145
+ last_hash[last_key] = nil
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ class OpenAIClient
5
+ def initialize
6
+ @connection = Faraday.new(
7
+ url: 'https://api.openai.com',
8
+ headers: { 'Authorization' => "Bearer #{I18nUno.config.open_ai_key}",
9
+ 'Content-Type' => 'application/json; charset=utf-8' }
10
+ ) do |faraday|
11
+ faraday.adapter Faraday.default_adapter
12
+ faraday.options[:timeout] = 600
13
+ faraday.options[:open_timeout] = 30
14
+ end
15
+ end
16
+
17
+ def complete(messages:)
18
+ response = @connection.post('/v1/chat/completions') do |request|
19
+ request.body = {
20
+ model: I18nUno.config.open_ai_model,
21
+ messages: messages.map { |message| { role: :user, content: message } },
22
+ max_tokens: 4096
23
+ }.to_json
24
+ end
25
+
26
+ JSON.parse(response.body)['choices'].first['message']['content']
27
+ rescue StandardError => e
28
+ Rails.logger.error(e)
29
+ raise
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geocoder
4
+ if defined? Rails::Railtie
5
+ require 'rails'
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load 'tasks/i18n_uno.rake'
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'i18n_uno/open_ai_client'
5
+
6
+ module I18nUno
7
+ class Translator
8
+ attr_reader :source_file, :target_file
9
+
10
+ def initialize(sot_file, target_file)
11
+ @source_file = sot_file
12
+ @target_file = target_file
13
+ @target_file_yaml = YAML.load_file(target_file.file_path)
14
+ end
15
+
16
+ def process(delta)
17
+ delta.values_from!(@source_file)
18
+
19
+ if delta.anything_to_translate?
20
+ delta = send_to_chatgpt_api(delta)
21
+
22
+ target_file.add_missing_keys_to_target_file!(delta)
23
+ target_file.match_order_as_file!(source_file)
24
+ target_file.set_values_from_delta!(delta)
25
+ target_file.save!
26
+ else
27
+ puts 'Missing keys are all null, nothing to translate'
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def send_to_chatgpt_api(delta)
34
+ values_to_translate = delta.values
35
+ client = OpenAIClient.new
36
+ translated_values = []
37
+
38
+ values_to_translate.each_slice(200) do |chunk|
39
+ messages = ["Please translate the following list of strings from the source language specified by '#{source_file.locale}' to the target language indicated by '#{target_file.locale}'. The translations should be suitable for use in a user interface, taking into account that shorter strings are likely to be used as button labels or element captions. The output should be in the form of an array containing the translated messages, ensuring that each translated value is accurate and correctly formatted for JSON parsing. Special attention should be paid to maintaining the context and usability of each UI element in the target language. Below is the list for translation:"]
40
+
41
+ messages << chunk.to_json
42
+
43
+ response = client.complete(messages: messages)
44
+ chunk_translated_values = JSON.parse(response)
45
+
46
+ translated_values.concat(chunk_translated_values)
47
+ end
48
+
49
+ delta.translated_values = translated_values
50
+ delta
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ class Tree
5
+ attr_reader :locale
6
+
7
+ def initialize(locale)
8
+ @locale = locale
9
+ @data = [] # [{ file: I18nUno::File, delta: I18nUno::DiffDelta }]
10
+ collect_files
11
+ end
12
+
13
+ def process_delta_changes(source_of_truth_tree)
14
+ entries_with_delta = extract_entries_with_delta
15
+
16
+ if entries_with_delta.empty?
17
+ puts "No changes detected for '#{locale}' locale."
18
+ return
19
+ end
20
+
21
+ files_completed = 0
22
+ puts "Processing locale '#{locale}' there are #{entries_with_delta.count} files with changes. "
23
+ entries_with_delta.each_with_index do |entry, index|
24
+ printf("Processing file %-40s [#{index + 1}/#{entries_with_delta.count}] ... ", "'#{entry[:file].pp_file_name}'")
25
+
26
+ if entry[:delta].any_new_keys?
27
+ sot_file = source_of_truth_tree.find_file(entry[:file])
28
+ translator = I18nUno::Translator.new(sot_file, entry[:file])
29
+ translator.process(entry[:delta])
30
+ end
31
+
32
+ if entry[:delta].any_removed_keys? && !entry[:delta].complete_file_diff?
33
+ entry[:file].remove_keys_from_file!(entry[:delta])
34
+ end
35
+
36
+ entry[:processed] = true
37
+
38
+ puts 'OK'
39
+ end
40
+ end
41
+
42
+ def extract_entries_with_delta
43
+ @data.select do |entry|
44
+ entry[:delta].present? && entry[:delta].any_changes?
45
+ end
46
+ end
47
+
48
+ def add_delta!(file, diff_delta)
49
+ @data.each do |entry|
50
+ if entry[:file] == file
51
+ entry[:delta] = diff_delta
52
+ break
53
+ end
54
+ end
55
+ end
56
+
57
+ def files
58
+ @data.map { |entry| entry[:file] }
59
+ end
60
+
61
+ def any?
62
+ @data.any?
63
+ end
64
+
65
+ def empty?
66
+ @data.empty?
67
+ end
68
+
69
+ def each(&block)
70
+ files.each(&block)
71
+ end
72
+
73
+ def find_file(target_file)
74
+ files.find { |file| file.file_path.match(/#{target_file.file_identifier}/) }
75
+ end
76
+
77
+ def find_or_create_file(sot_file)
78
+ file = find_file(sot_file)
79
+
80
+ if file.nil?
81
+ file = sot_file.create_in_locale(locale)
82
+ @data << { file: file, delta: nil, new_file: true, processed: false }
83
+ end
84
+
85
+ file
86
+ end
87
+
88
+ def clean_not_processed_files
89
+ @data.each do |entry|
90
+ if (entry[:new_file] && !entry[:processed])
91
+ ::File.delete(entry[:file].file_path)
92
+ end
93
+ end
94
+
95
+ end
96
+
97
+ private
98
+
99
+ def collect_files
100
+ path_pattern = ::File.join(I18nUno.config.load_path, '**', "#{locale}.yml")
101
+ Dir.glob(path_pattern).map do |file_path|
102
+ @data << { file: I18nUno::File.new(file_path), delta: nil }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nUno
4
+ VERSION = '0.1.0'
5
+ end
data/lib/i18n_uno.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n_uno/version'
4
+ require 'i18n_uno/configuration'
5
+ require 'i18n_uno/railtie' if defined?(Rails)
6
+ require 'i18n_uno/arbiter'
7
+ require 'i18n_uno/comparer'
8
+ require 'i18n_uno/translator'
9
+ require 'i18n_uno/tree'
10
+ require 'i18n_uno/file'
11
+ require 'i18n_uno/delta'
12
+
13
+ module I18nUno
14
+ class << self
15
+ attr_accessor :config
16
+ end
17
+
18
+ def self.config
19
+ @config ||= I18nUno::Configuration.new
20
+ end
21
+
22
+ def self.configure
23
+ yield(config)
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :i18n_uno do
4
+ desc 'Translate all your I18n files'
5
+ task translate: :environment do
6
+ begin
7
+ I18nUno.config.validate!
8
+
9
+ i18n_uno = I18nUno::Arbiter.new
10
+ i18n_uno.translate
11
+ rescue I18nUno::ConfigurationError => e
12
+ puts("#{e.class}: #{e.message}")
13
+ end
14
+ end
15
+ end
data/rubocop.yml ADDED
@@ -0,0 +1,240 @@
1
+ require:
2
+ - rubocop-performance
3
+ - rubocop-rails
4
+ - rubocop-minitest
5
+
6
+ inherit_mode:
7
+ merge:
8
+ - Exclude
9
+
10
+ AllCops:
11
+ SuggestExtensions: false
12
+ DisabledByDefault: true
13
+ Exclude:
14
+ - "data/**/*"
15
+
16
+ # Align `when` with `end`.
17
+ Layout/CaseIndentation:
18
+ Enabled: true
19
+ EnforcedStyle: end
20
+
21
+ # Align comments with method definitions.
22
+ Layout/CommentIndentation:
23
+ Enabled: true
24
+
25
+ Layout/ElseAlignment:
26
+ Enabled: true
27
+
28
+ Layout/EmptyLineAfterMagicComment:
29
+ Enabled: true
30
+
31
+ Layout/EmptyLinesAroundBlockBody:
32
+ Enabled: true
33
+
34
+ # In a regular class definition, no empty lines around the body.
35
+ Layout/EmptyLinesAroundClassBody:
36
+ Enabled: true
37
+
38
+ # In a regular method definition, no empty lines around the body.
39
+ Layout/EmptyLinesAroundMethodBody:
40
+ Enabled: true
41
+
42
+ # In a regular module definition, no empty lines around the body.
43
+ Layout/EmptyLinesAroundModuleBody:
44
+ Enabled: true
45
+
46
+ # Align `end` with the matching keyword or starting expression except for
47
+ # assignments, where it should be aligned with the LHS.
48
+ Layout/EndAlignment:
49
+ Enabled: true
50
+ EnforcedStyleAlignWith: variable
51
+ AutoCorrect: true
52
+
53
+ # Method definitions after `private` or `protected` isolated calls need one
54
+ # extra level of indentation.
55
+ #
56
+ # We break this rule in context, though, e.g. for private-only concerns,
57
+ # so we leave it disabled.
58
+ Layout/IndentationConsistency:
59
+ Enabled: false
60
+ EnforcedStyle: indented_internal_methods
61
+
62
+ # Detect hard tabs, no hard tabs.
63
+ Layout/IndentationStyle:
64
+ Enabled: true
65
+
66
+ # Two spaces, no tabs (for indentation).
67
+ #
68
+ # Doesn't behave properly with private-only concerns, so it's disabled.
69
+ Layout/IndentationWidth:
70
+ Enabled: false
71
+
72
+ Layout/LeadingCommentSpace:
73
+ Enabled: true
74
+
75
+ Layout/SpaceAfterColon:
76
+ Enabled: true
77
+
78
+ Layout/SpaceAfterComma:
79
+ Enabled: true
80
+
81
+ Layout/SpaceAroundEqualsInParameterDefault:
82
+ Enabled: true
83
+
84
+ Layout/SpaceAroundKeyword:
85
+ Enabled: true
86
+
87
+ # Use `foo {}` not `foo{}`.
88
+ Layout/SpaceBeforeBlockBraces:
89
+ Enabled: true
90
+
91
+ Layout/SpaceBeforeComma:
92
+ Enabled: true
93
+
94
+ Layout/SpaceBeforeFirstArg:
95
+ Enabled: true
96
+
97
+ # Use `->(x, y) { x + y }` not `-> (x, y) { x + y }`
98
+ Layout/SpaceInLambdaLiteral:
99
+ Enabled: true
100
+
101
+ # Use `[ a, [ b, c ] ]` not `[a, [b, c]]`
102
+ # Use `[]` not `[ ]`
103
+ Layout/SpaceInsideArrayLiteralBrackets:
104
+ Enabled: true
105
+ EnforcedStyle: space
106
+ EnforcedStyleForEmptyBrackets: no_space
107
+
108
+ # Use `%w[ a b ]` not `%w[ a b ]`.
109
+ Layout/SpaceInsideArrayPercentLiteral:
110
+ Enabled: true
111
+
112
+ # Use `foo { bar }` not `foo {bar}`.
113
+ # Use `foo { }` not `foo {}`.
114
+ Layout/SpaceInsideBlockBraces:
115
+ Enabled: true
116
+ EnforcedStyleForEmptyBraces: space
117
+
118
+ # Use `{ a: 1 }` not `{a:1}`.
119
+ # Use `{}` not `{ }`.
120
+ Layout/SpaceInsideHashLiteralBraces:
121
+ Enabled: true
122
+ EnforcedStyle: space
123
+ EnforcedStyleForEmptyBraces: no_space
124
+
125
+ # Use `foo(bar)` not `foo( bar )`
126
+ Layout/SpaceInsideParens:
127
+ Enabled: true
128
+
129
+ # Requiring a space is not yet supported as of 0.59.2
130
+ # Use `%w[ foo ]` not `%w[foo]`
131
+ Layout/SpaceInsidePercentLiteralDelimiters:
132
+ Enabled: false
133
+ #EnforcedStyle: space
134
+
135
+ # Use `hash[:key]` not `hash[ :key ]`
136
+ Layout/SpaceInsideReferenceBrackets:
137
+ Enabled: true
138
+
139
+ # Blank lines should not have any spaces.
140
+ Layout/TrailingEmptyLines:
141
+ Enabled: true
142
+
143
+ # No trailing whitespace.
144
+ Layout/TrailingWhitespace:
145
+ Enabled: true
146
+
147
+ Lint/RedundantStringCoercion:
148
+ Enabled: true
149
+
150
+ # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg.
151
+ Lint/RequireParentheses:
152
+ Enabled: true
153
+
154
+ Lint/UriEscapeUnescape:
155
+ Enabled: true
156
+
157
+ Performance:
158
+ Exclude:
159
+ - "test/**/*"
160
+
161
+ Performance/FlatMap:
162
+ Enabled: true
163
+
164
+ Performance/UnfreezeString:
165
+ Enabled: true
166
+
167
+ # Prefer assert_not over assert !
168
+ Rails/AssertNot:
169
+ Include:
170
+ - "test/**/*"
171
+
172
+ # Prefer assert_not_x over refute_x
173
+ Rails/RefuteMethods:
174
+ Include:
175
+ - "test/**/*"
176
+
177
+ # We generally prefer &&/|| but like low-precedence and/or in context
178
+ Style/AndOr:
179
+ Enabled: false
180
+
181
+ # Prefer Foo.method over Foo::method
182
+ Style/ColonMethodCall:
183
+ Enabled: true
184
+
185
+ Style/DefWithParentheses:
186
+ Enabled: true
187
+
188
+ # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }.
189
+ Style/HashSyntax:
190
+ Enabled: true
191
+ EnforcedShorthandSyntax: either
192
+
193
+ # Defining a method with parameters needs parentheses.
194
+ Style/MethodDefParentheses:
195
+ Enabled: true
196
+
197
+ Style/ParenthesesAroundCondition:
198
+ Enabled: true
199
+
200
+ Style/PercentLiteralDelimiters:
201
+ Enabled: true
202
+ PreferredDelimiters:
203
+ default: "()"
204
+ "%i": "[]"
205
+ "%I": "[]"
206
+ "%r": "{}"
207
+ "%w": "[]"
208
+ "%W": "[]"
209
+
210
+ # Use quotes for string literals when they are enough.
211
+ Style/RedundantPercentQ:
212
+ Enabled: false
213
+
214
+ Style/RedundantReturn:
215
+ Enabled: true
216
+ AllowMultipleReturnValues: true
217
+
218
+ Style/Semicolon:
219
+ Enabled: true
220
+ AllowAsExpressionSeparator: true
221
+
222
+ Style/StabbyLambdaParentheses:
223
+ Enabled: true
224
+
225
+ # Use `"foo"` not `'foo'` unless escaping is required
226
+ Style/StringLiterals:
227
+ Enabled: true
228
+ EnforcedStyle: double_quotes
229
+ Include:
230
+ - "app/**/*"
231
+ - "config/**/*"
232
+ - "lib/**/*"
233
+ - "test/**/*"
234
+ - "Gemfile"
235
+
236
+ Style/TrailingCommaInArrayLiteral:
237
+ Enabled: true
238
+
239
+ Style/TrailingCommaInHashLiteral:
240
+ Enabled: true
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: i18n_uno
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Haris Krajina
8
+ - hkraji
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2024-04-20 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rake
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '10.0'
21
+ type: :development
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '10.0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rspec
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '3.0'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ description: i18n Uno is simple CLI tool that will completly translate your application
43
+ to any desired language.
44
+ email:
45
+ - haris@pythagoranorth.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - Gemfile
51
+ - Gemfile.lock
52
+ - LICENSE
53
+ - README.md
54
+ - Rakefile
55
+ - i18n_uno.gemspec
56
+ - lib/.DS_Store
57
+ - lib/i18n_uno.rb
58
+ - lib/i18n_uno/.DS_Store
59
+ - lib/i18n_uno/arbiter.rb
60
+ - lib/i18n_uno/comparer.rb
61
+ - lib/i18n_uno/configuration.rb
62
+ - lib/i18n_uno/delta.rb
63
+ - lib/i18n_uno/file.rb
64
+ - lib/i18n_uno/files/content_change.rb
65
+ - lib/i18n_uno/open_ai_client.rb
66
+ - lib/i18n_uno/railtie.rb
67
+ - lib/i18n_uno/translator.rb
68
+ - lib/i18n_uno/tree.rb
69
+ - lib/i18n_uno/version.rb
70
+ - lib/tasks/i18n_uno.rake
71
+ - rubocop.yml
72
+ homepage: https://github.com/pythagora-north/i18n_uno
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/pythagora-north/i18n_uno
77
+ source_code_uri: https://github.com/pythagora-north/i18n_uno
78
+ changelog_uri: https://github.com/pythagora-north/i18n_uno/blob/main/Changes.md
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.3
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: i18n Uno levrages power of ChatGPT API to translate your i18n files.
98
+ test_files: []