i18n_yaml_editor 2.0.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 +7 -0
- data/README.md +60 -0
- data/Rakefile +73 -0
- data/bin/i18n_yaml_editor +7 -0
- data/i18n_yaml_editor.gemspec +70 -0
- data/lib/i18n_yaml_editor/app.rb +125 -0
- data/lib/i18n_yaml_editor/cast.rb +34 -0
- data/lib/i18n_yaml_editor/category.rb +27 -0
- data/lib/i18n_yaml_editor/core_ext.rb +14 -0
- data/lib/i18n_yaml_editor/filter.rb +39 -0
- data/lib/i18n_yaml_editor/key.rb +33 -0
- data/lib/i18n_yaml_editor/store.rb +115 -0
- data/lib/i18n_yaml_editor/transformation.rb +75 -0
- data/lib/i18n_yaml_editor/translation.rb +22 -0
- data/lib/i18n_yaml_editor/update.rb +53 -0
- data/lib/i18n_yaml_editor/web.rb +58 -0
- data/lib/i18n_yaml_editor.rb +16 -0
- data/test/test_helper.rb +23 -0
- data/test/unit/test_app.rb +58 -0
- data/test/unit/test_category.rb +41 -0
- data/test/unit/test_key.rb +46 -0
- data/test/unit/test_store.rb +129 -0
- data/test/unit/test_transformation.rb +45 -0
- data/test/unit/test_translation.rb +16 -0
- data/views/categories.html.erb +9 -0
- data/views/debug.html.erb +9 -0
- data/views/layout.erb +171 -0
- data/views/translations.html.erb +119 -0
- metadata +401 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 59360f083d7e5071393e6605905f034df8340f64
|
4
|
+
data.tar.gz: 422e871b2b24a8061dc75f12721478e6c3e9c4a7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 469c35174ee3ad8f52526830da4ed069fbdb5e4ad37de79dc770d0a5daaf58efc351c98b4e1622f85e24f64a283af1b49f1b49a4d87aa82c49e71e96ef15a436
|
7
|
+
data.tar.gz: e50602ce147daf56bb1837c13678670e86dc46e5848b2cc6824293bb267e4d5ac4b8036579f3c6204836c174aa8dd2e9fc75c798ce1a8ac10841b5cb75067cec
|
data/README.md
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
# I18n Yaml Editor
|
2
|
+
|
3
|
+
[](https://travis-ci.org/Sage/i18n_yaml_editor) [](https://codeclimate.com/github/Sage/i18n_yaml_editor/maintainability) [](https://codeclimate.com/github/Sage/i18n_yaml_editor/test_coverage) [](http://inch-ci.org/github/Sage/i18n_yaml_editor) [](https://github.com/Sage/i18n_yaml_editor/blob/master/LICENSE) [](https://gemnasium.com/github.com/Sage/i18n_yaml_editor)
|
4
|
+
|
5
|
+
I18n Yaml Editor is based on [IYE](https://github.com/firmafon/iye).
|
6
|
+
I18n Yaml Editor makes it easy to translate your Rails I18N files and keep them up to date.
|
7
|
+
It uses YAML files directly, so you don't need to keep a separate database in sync.
|
8
|
+
This has several benefits:
|
9
|
+
|
10
|
+
* Branching and diffing is trivial
|
11
|
+
* It does not alter the workflow for developers etc., whom can continue editing the
|
12
|
+
YAML files directly
|
13
|
+
* If your YAML files are organized in subfolders, this structure is kept intact
|
14
|
+
|
15
|
+

|
16
|
+
|
17
|
+
## Prerequisites
|
18
|
+
|
19
|
+
You need to understand a few things about I18n Yaml Editor for it to make sense, mainly:
|
20
|
+
|
21
|
+
* I18n Yaml Editor does not create new keys - keys must exist for at least one locale in the YAML files
|
22
|
+
* I18n Yaml Editor does not create new locales - at least one key must exist for each locale in the YAML files
|
23
|
+
|
24
|
+
## Workflow
|
25
|
+
|
26
|
+
1. Install I18n Yaml Editor:
|
27
|
+
|
28
|
+
$ git clone git@github.com:Sage/i18n_yaml_editor.git
|
29
|
+
$ cd i18n_yaml_editor
|
30
|
+
$ gem build i18n_yaml_editor.gemspec
|
31
|
+
$ gem install i18n_yaml_editor-1.3.1.gem
|
32
|
+
|
33
|
+
2. The `i18n_yaml_editor` executable is now available, use it wherever you want.
|
34
|
+
|
35
|
+
$ i18n_yaml_editor path/to/i18n/locales [port]
|
36
|
+
|
37
|
+
At this point I18n Yaml Editor loads all translation keys for all locales, and creates any
|
38
|
+
keys that might be missing for existing locales, the default port is 5050
|
39
|
+
|
40
|
+
3. Point browser at [http://localhost:5050](http://localhost:5050)
|
41
|
+
4. Make changes and press 'Save' - each time you do this, all the keys will be
|
42
|
+
written to their original YAML files.
|
43
|
+
5. Review your changes before committing files, e.g. by using `git diff`.
|
44
|
+
|
45
|
+
## Development
|
46
|
+
|
47
|
+
### Getting started
|
48
|
+
$ git clone git@github.com:Sage/i18n_yaml_editor.git
|
49
|
+
$ cd i18n_yaml_editor
|
50
|
+
$ gem install bundler
|
51
|
+
$ bundle install
|
52
|
+
$ SIMPLE_COV=true bundle exec guard
|
53
|
+
|
54
|
+
|
55
|
+
## License
|
56
|
+
|
57
|
+
This gem is available as open source under the terms of the
|
58
|
+
[MIT licence](LICENSE).
|
59
|
+
|
60
|
+
Copyright (c) 2018 Sage Group Plc. All rights reserved.
|
data/Rakefile
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rake/testtask'
|
4
|
+
require 'rubocop/rake_task'
|
5
|
+
require 'capture_io/capture_io'
|
6
|
+
|
7
|
+
Rake::TestTask.new do |t|
|
8
|
+
t.libs << 'test'
|
9
|
+
t.libs << 'lib'
|
10
|
+
t.pattern = 'test/**/test_*.rb'
|
11
|
+
end
|
12
|
+
|
13
|
+
# Mixin
|
14
|
+
module RuboCop
|
15
|
+
# Mixin
|
16
|
+
class RakeTask
|
17
|
+
private
|
18
|
+
|
19
|
+
def run_cli(verbose, options)
|
20
|
+
require 'rubocop'
|
21
|
+
|
22
|
+
cli = CLI.new
|
23
|
+
puts "\033[1;33m# Running RuboCop...\033[0m" if verbose
|
24
|
+
result = cli.run(options)
|
25
|
+
failed = result.nonzero? && fail_on_error
|
26
|
+
abort("\033[0;31mRuboCop failed!\033[0m\n") if failed
|
27
|
+
puts "\033[0;32mRuboCop passed\033[0m\n\n\n"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
desc 'Run RuboCop'
|
33
|
+
RuboCop::RakeTask.new(:rubocop)
|
34
|
+
|
35
|
+
desc 'Generate documentation in doc/ and check documentation coverage'
|
36
|
+
task :yardoc do
|
37
|
+
require 'yard'
|
38
|
+
puts "\n\033[1;33m# Running Yardoc...\033[0m"
|
39
|
+
yard = `yard stats --list-undoc --compact`
|
40
|
+
if yard =~ /Undocumented Objects/
|
41
|
+
puts yard
|
42
|
+
puts "\033[0;31mYardoc failed - documentation coverage < 100%\033[0m\n"
|
43
|
+
exit 1
|
44
|
+
else
|
45
|
+
puts yard.each_line.to_a[0..-2]
|
46
|
+
puts "\033[0;32mYardoc passed - 100.00% documented\033[0m\n\n\n"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
task :coverage do
|
51
|
+
puts "\033[1;33m# Running Minitest...\033[0m"
|
52
|
+
out, = capture_io do
|
53
|
+
puts `bundle exec rake test`
|
54
|
+
end
|
55
|
+
puts out
|
56
|
+
err = /\d+ tests, \d+ assertions, (\d+) failures, (\d+) errors, \d+ skips/
|
57
|
+
unless out.scan(err).flatten.map(&:to_i).reduce(&:+).zero?
|
58
|
+
puts "\n\033[0;31mMinitest failed\033[0m\n"
|
59
|
+
exit 1
|
60
|
+
end
|
61
|
+
|
62
|
+
if out.lines.last =~ /LOC \(100\.0%\) covered/ ||
|
63
|
+
out.lines[-2] =~ /Coverage is at 100\.0%/
|
64
|
+
puts "\033[0;32mMinitest passed - 100.00% covered\033[0m\n\n"
|
65
|
+
else
|
66
|
+
puts "\n\033[0;31mMinitest failed - Code coverage < 100%\033[0m\n"
|
67
|
+
exit 1
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
task default: %i[coverage yardoc rubocop] do
|
72
|
+
puts "\033[0;32mYAY! - I18n Yaml Editor test suite passed\033[0m\n\n"
|
73
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = 'i18n_yaml_editor'
|
6
|
+
s.version = '2.0.0'
|
7
|
+
s.date = '2015-10-07'
|
8
|
+
s.summary = 'I18n Yaml Editor'
|
9
|
+
s.email = 'wolfgang.teuber@sage.com'
|
10
|
+
s.homepage = 'http://github.com/Sage/i18n_yaml_editor'
|
11
|
+
s.description = 'I18n Yaml Editor'
|
12
|
+
s.authors = ['Harry Vangberg', 'Wolfgang Teuber']
|
13
|
+
s.executables << 'i18n_yaml_editor'
|
14
|
+
s.files = [
|
15
|
+
'README.md',
|
16
|
+
'Rakefile',
|
17
|
+
'i18n_yaml_editor.gemspec',
|
18
|
+
'bin/i18n_yaml_editor',
|
19
|
+
'lib/i18n_yaml_editor.rb',
|
20
|
+
'lib/i18n_yaml_editor/app.rb',
|
21
|
+
'lib/i18n_yaml_editor/category.rb',
|
22
|
+
'lib/i18n_yaml_editor/core_ext.rb',
|
23
|
+
'lib/i18n_yaml_editor/key.rb',
|
24
|
+
'lib/i18n_yaml_editor/store.rb',
|
25
|
+
'lib/i18n_yaml_editor/filter.rb',
|
26
|
+
'lib/i18n_yaml_editor/update.rb',
|
27
|
+
'lib/i18n_yaml_editor/cast.rb',
|
28
|
+
'lib/i18n_yaml_editor/transformation.rb',
|
29
|
+
'lib/i18n_yaml_editor/translation.rb',
|
30
|
+
'lib/i18n_yaml_editor/web.rb',
|
31
|
+
'views/categories.html.erb',
|
32
|
+
'views/debug.html.erb',
|
33
|
+
'views/layout.erb',
|
34
|
+
'views/translations.html.erb'
|
35
|
+
]
|
36
|
+
s.test_files = [
|
37
|
+
'test/test_helper.rb',
|
38
|
+
'test/unit/test_app.rb',
|
39
|
+
'test/unit/test_category.rb',
|
40
|
+
'test/unit/test_key.rb',
|
41
|
+
'test/unit/test_store.rb',
|
42
|
+
'test/unit/test_transformation.rb',
|
43
|
+
'test/unit/test_translation.rb'
|
44
|
+
]
|
45
|
+
s.add_dependency 'activesupport', '>= 4.0.2'
|
46
|
+
s.add_dependency 'cuba', '>= 3'
|
47
|
+
s.add_dependency 'psych', '>= 1.3.4'
|
48
|
+
s.add_dependency 'tilt', '>= 1.3'
|
49
|
+
|
50
|
+
s.add_development_dependency 'awesome_print'
|
51
|
+
s.add_development_dependency 'cane'
|
52
|
+
s.add_development_dependency 'flay'
|
53
|
+
s.add_development_dependency 'flog'
|
54
|
+
s.add_development_dependency 'github-markup'
|
55
|
+
s.add_development_dependency 'guard'
|
56
|
+
s.add_development_dependency 'guard-minitest'
|
57
|
+
s.add_development_dependency 'guard-rubocop'
|
58
|
+
s.add_development_dependency 'inch'
|
59
|
+
s.add_development_dependency 'minitest-line'
|
60
|
+
s.add_development_dependency 'minitest-reporters'
|
61
|
+
s.add_development_dependency 'pry-byebug'
|
62
|
+
s.add_development_dependency 'pry-doc'
|
63
|
+
s.add_development_dependency 'rack-test'
|
64
|
+
s.add_development_dependency 'rake'
|
65
|
+
s.add_development_dependency 'redcarpet'
|
66
|
+
s.add_development_dependency 'rubocop'
|
67
|
+
s.add_development_dependency 'simplecov'
|
68
|
+
s.add_development_dependency 'yard'
|
69
|
+
end
|
70
|
+
# rubocop:enable Metrics/BlockLength
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'psych'
|
4
|
+
require 'yaml'
|
5
|
+
require 'active_support/all'
|
6
|
+
|
7
|
+
require 'i18n_yaml_editor/web'
|
8
|
+
require 'i18n_yaml_editor/store'
|
9
|
+
require 'i18n_yaml_editor/core_ext'
|
10
|
+
|
11
|
+
module I18nYamlEditor
|
12
|
+
# App provides I18n Yaml Editor's top-level functionality:
|
13
|
+
# * Starting I18n Yaml Editor
|
14
|
+
# * Loading Translation files
|
15
|
+
# * Saving Translation files
|
16
|
+
class App
|
17
|
+
attr_accessor :store
|
18
|
+
|
19
|
+
def initialize(path, port = 5050)
|
20
|
+
@path = File.expand_path(path)
|
21
|
+
@port = port || 5050
|
22
|
+
@store = Store.new
|
23
|
+
I18nYamlEditor.app = self
|
24
|
+
end
|
25
|
+
|
26
|
+
# Starts I18n Yaml Editor server
|
27
|
+
def start
|
28
|
+
raise "File #{@path} not found." unless File.exist?(@path)
|
29
|
+
$stdout.puts " * Loading translations from #{@path}"
|
30
|
+
load_translations
|
31
|
+
|
32
|
+
$stdout.puts ' * Creating missing translations'
|
33
|
+
store.create_missing_keys
|
34
|
+
|
35
|
+
$stdout.puts " * Starting I18n Yaml Editor at port #{@port}"
|
36
|
+
Rack::Server.start app: Web, Port: @port
|
37
|
+
end
|
38
|
+
|
39
|
+
# Loads translations from a given path
|
40
|
+
def load_translations
|
41
|
+
files = if File.directory?(@path)
|
42
|
+
Dir[@path + '/**/*.yml']
|
43
|
+
elsif File.file?(@path)
|
44
|
+
detect_list_or_file @path
|
45
|
+
else
|
46
|
+
# rubocop:disable Style/StderrPuts
|
47
|
+
$stderr.puts 'No valid translation file given'
|
48
|
+
# rubocop:enable Style/StderrPuts
|
49
|
+
[]
|
50
|
+
end
|
51
|
+
update_store files
|
52
|
+
end
|
53
|
+
|
54
|
+
# Write the given translations to the appropriate YAML file
|
55
|
+
# example translations:
|
56
|
+
# {"en.day_names"=>"Mon\r\nTue", "en.session.new.password"=>"Password"}
|
57
|
+
def save_translations(translations)
|
58
|
+
store.update(translations)
|
59
|
+
changes = files(translations: translations)
|
60
|
+
changes.each do |file, yaml|
|
61
|
+
File.open(file, 'w', encoding: 'utf-8') do |f|
|
62
|
+
f.puts normalize(yaml)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# Enables I18n Yaml Editor to deal with inputs that reference a file list or
|
70
|
+
# a single translation file
|
71
|
+
def detect_list_or_file(path)
|
72
|
+
file = YAML.load_file(path)
|
73
|
+
file.is_a?(Hash) ? [path] : File.read(path).split
|
74
|
+
end
|
75
|
+
|
76
|
+
def update_store(files)
|
77
|
+
files.each do |file|
|
78
|
+
if File.exist?(file)
|
79
|
+
yaml = YAML.load_file(file)
|
80
|
+
store.from_yaml(yaml, file)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def files(translations: {})
|
86
|
+
store.to_yaml.select do |_, i18n_hash|
|
87
|
+
translations.keys.any? do |i18n_key|
|
88
|
+
key_in_i18n_hash? i18n_key, i18n_hash
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def key_in_i18n_hash?(i18n_key, i18n_hash)
|
94
|
+
!i18n_key.split('.').inject(i18n_hash) do |hash, k|
|
95
|
+
begin
|
96
|
+
hash[k]
|
97
|
+
rescue StandardError
|
98
|
+
{}
|
99
|
+
end
|
100
|
+
end.nil?
|
101
|
+
end
|
102
|
+
|
103
|
+
def normalize(yaml)
|
104
|
+
i18n_yaml = yaml.with_indifferent_access.to_hash.to_yaml
|
105
|
+
i18n_yaml_lines = i18n_yaml.split(/\n/).reject { |e| e == '' }[1..-1]
|
106
|
+
normalize_empty_lines(i18n_yaml_lines) * "\n"
|
107
|
+
end
|
108
|
+
|
109
|
+
def normalize_empty_lines(i18n_yaml_lines)
|
110
|
+
yaml_ary = []
|
111
|
+
i18n_yaml_lines.each_with_index do |line, idx|
|
112
|
+
yaml_ary << line
|
113
|
+
yaml_ary << '' if add_empty_line?(i18n_yaml_lines, line, idx)
|
114
|
+
end
|
115
|
+
yaml_ary
|
116
|
+
end
|
117
|
+
|
118
|
+
def add_empty_line?(process, line, idx)
|
119
|
+
return if process[idx + 1].nil?
|
120
|
+
this_line_spcs = line[/\A\s*/].length
|
121
|
+
next_line_spcs = process[idx + 1][/\A\s*/].length
|
122
|
+
this_line_spcs - next_line_spcs > 2
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module I18nYamlEditor
|
4
|
+
# Transformation provides
|
5
|
+
module Cast
|
6
|
+
# Lists all supported types of conversions
|
7
|
+
TYPES = %i[numeric boolean array].freeze
|
8
|
+
|
9
|
+
# Contains a check method for each supported type
|
10
|
+
CHECK = {
|
11
|
+
numeric: ->(klass) { klass < Numeric },
|
12
|
+
boolean: ->(klass) { [TrueClass, FalseClass].include?(klass) },
|
13
|
+
array: ->(klass) { klass == Array }
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
# Contains a conversion method for each supported type
|
17
|
+
CONVERT = {
|
18
|
+
numeric: lambda do |value|
|
19
|
+
num = BigDecimal(value)
|
20
|
+
num.frac.zero? ? num.to_i : num.to_f
|
21
|
+
end,
|
22
|
+
boolean: ->(value) { value.casecmp('true').zero? },
|
23
|
+
array: ->(value) { value.split("\r\n") }
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
# Converts a given value to a specific data type
|
27
|
+
def cast(klass, value)
|
28
|
+
TYPES.each do |type|
|
29
|
+
return CONVERT[type].call(value) if CHECK[type].call(klass)
|
30
|
+
end
|
31
|
+
value.to_s # String, blank
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
|
5
|
+
module I18nYamlEditor
|
6
|
+
# This is a category
|
7
|
+
class Category
|
8
|
+
attr_accessor :name, :keys
|
9
|
+
|
10
|
+
def initialize(attributes = {})
|
11
|
+
@name = attributes[:name]
|
12
|
+
@keys = Set.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# Adds a given key to this category's list of keys
|
16
|
+
# @param key [Key] key to be added to this category's list of keys
|
17
|
+
def add_key(key)
|
18
|
+
keys.add(key)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Checks and returns if all keys are complete
|
22
|
+
# @return [true, false]
|
23
|
+
def complete?
|
24
|
+
keys.all?(&:complete?)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Extend Hash with sort_by_key method
|
4
|
+
class Hash
|
5
|
+
# Sorts entries alphabetically by key
|
6
|
+
def sort_by_key(recursive = false, &block)
|
7
|
+
keys.sort(&block).each_with_object({}) do |key, seed|
|
8
|
+
seed[key] = self[key]
|
9
|
+
if recursive && seed[key].is_a?(Hash)
|
10
|
+
seed[key] = seed[key].sort_by_key(true, &block)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module I18nYamlEditor
|
4
|
+
# Transformation provides
|
5
|
+
module Filter
|
6
|
+
# Selects keys from this store according to the given filter options
|
7
|
+
def filter_keys(options = {})
|
8
|
+
filters = filters(options)
|
9
|
+
keys.select { |_, key| filters.all? { |filter| filter.call(key) } }
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def filters(options)
|
15
|
+
list = []
|
16
|
+
list << key_filter(options) if options.key?(:key)
|
17
|
+
list << complete_filter(options) if options.key?(:complete)
|
18
|
+
list << empty_filter(options) if options.key?(:empty)
|
19
|
+
list << text_filter(options) if options.key?(:text)
|
20
|
+
list
|
21
|
+
end
|
22
|
+
|
23
|
+
def key_filter(options)
|
24
|
+
->(k) { k.name =~ options[:key] }
|
25
|
+
end
|
26
|
+
|
27
|
+
def complete_filter(options)
|
28
|
+
->(k) { k.complete? == options[:complete] }
|
29
|
+
end
|
30
|
+
|
31
|
+
def empty_filter(options)
|
32
|
+
->(k) { k.empty? == options[:empty] }
|
33
|
+
end
|
34
|
+
|
35
|
+
def text_filter(options)
|
36
|
+
->(k) { k.translations.any? { |t| t.text =~ options[:text] } }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
|
5
|
+
module I18nYamlEditor
|
6
|
+
# This is a key
|
7
|
+
class Key
|
8
|
+
attr_accessor :name, :translations
|
9
|
+
|
10
|
+
def initialize(attributes = {})
|
11
|
+
@name = attributes[:name]
|
12
|
+
@translations = Set.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# Adds a translation to this key's list of translations
|
16
|
+
def add_translation(translation)
|
17
|
+
translations.add(translation)
|
18
|
+
end
|
19
|
+
|
20
|
+
# This key's category
|
21
|
+
def category
|
22
|
+
@category ||= name.split('.').first
|
23
|
+
end
|
24
|
+
|
25
|
+
def complete?
|
26
|
+
translations.all? { |t| t.text.to_s !~ /\A\s*\z/ } || empty?
|
27
|
+
end
|
28
|
+
|
29
|
+
def empty?
|
30
|
+
translations.all? { |t| t.text.to_s =~ /\A\s*\z/ }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
require 'i18n_yaml_editor/transformation'
|
7
|
+
require 'i18n_yaml_editor/filter'
|
8
|
+
require 'i18n_yaml_editor/update'
|
9
|
+
require 'i18n_yaml_editor/category'
|
10
|
+
require 'i18n_yaml_editor/key'
|
11
|
+
require 'i18n_yaml_editor/translation'
|
12
|
+
|
13
|
+
module I18nYamlEditor
|
14
|
+
# Raised when translation entries have the same key
|
15
|
+
class DuplicateTranslationError < StandardError; end
|
16
|
+
|
17
|
+
# Store keeps all i18n data
|
18
|
+
class Store
|
19
|
+
include Transformation
|
20
|
+
include Filter
|
21
|
+
include Update
|
22
|
+
|
23
|
+
attr_accessor :categories, :keys, :translations, :locales
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@categories = {}
|
27
|
+
@keys = {}
|
28
|
+
@translations = {}
|
29
|
+
@locales = Set.new
|
30
|
+
end
|
31
|
+
|
32
|
+
# Adds a given translation to the store
|
33
|
+
def add_translation(translation)
|
34
|
+
check_duplication! translation
|
35
|
+
|
36
|
+
translations[translation.name] = translation
|
37
|
+
|
38
|
+
locales.add(translation.locale)
|
39
|
+
key = init_key(translation)
|
40
|
+
init_category(key)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Generates a new key with the given translation
|
44
|
+
def init_key(translation)
|
45
|
+
key = (keys[translation.key] ||= Key.new(name: translation.key))
|
46
|
+
key.add_translation(translation)
|
47
|
+
key
|
48
|
+
end
|
49
|
+
|
50
|
+
# Generates a new category with the given key
|
51
|
+
def init_category(key)
|
52
|
+
category = (categories[key.category] ||= Category.new(name: key.category))
|
53
|
+
category.add_key(key)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Adds a key to this store
|
57
|
+
def add_key(key)
|
58
|
+
keys[key.name] = key
|
59
|
+
end
|
60
|
+
|
61
|
+
# Creates all keys for each locale that doesn't have all keys from all
|
62
|
+
# other locales
|
63
|
+
def create_missing_keys
|
64
|
+
keys.each do |_name, key|
|
65
|
+
missing_locales = locales - key.translations.map(&:locale)
|
66
|
+
missing_locales.each do |locale|
|
67
|
+
translation = key.translations.first
|
68
|
+
name = "#{locale}.#{key.name}"
|
69
|
+
path = translation_path locale, translation
|
70
|
+
add_translation(Translation.new(name: name, file: path))
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Adds a translation for every entry in a given yaml hash
|
76
|
+
def from_yaml(yaml, file = nil)
|
77
|
+
translations = flatten_hash(yaml)
|
78
|
+
translations.each do |name, text|
|
79
|
+
translation = Translation.new(name: name, text: text, file: file)
|
80
|
+
add_translation(translation)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Returns a hash with the structure of a i18n
|
85
|
+
def to_yaml
|
86
|
+
result = {}
|
87
|
+
files = translations.values.group_by(&:file)
|
88
|
+
files.each do |file, translations|
|
89
|
+
file_result = {}
|
90
|
+
translations.each do |translation|
|
91
|
+
file_result[translation.name] = translation.text
|
92
|
+
end
|
93
|
+
result[file] = nest_hash(file_result)
|
94
|
+
end
|
95
|
+
result
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def translation_path(locale, translation)
|
101
|
+
translation.file.gsub(/(.*)[A-z]{2}(\.yml)\z/, "\\1#{locale}\\2")
|
102
|
+
end
|
103
|
+
|
104
|
+
def check_duplication!(translation)
|
105
|
+
existing = translations[translation.name]
|
106
|
+
return unless existing
|
107
|
+
error_message = error_message(translation, existing)
|
108
|
+
raise DuplicateTranslationError, error_message
|
109
|
+
end
|
110
|
+
|
111
|
+
def error_message(translation, existing)
|
112
|
+
"#{translation.name} detected in #{translation.file} and #{existing.file}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module I18nYamlEditor
|
4
|
+
# Raised when nesting of I18n keys to a Hash fails
|
5
|
+
class TransformationError < StandardError; end
|
6
|
+
|
7
|
+
# Transformation provides
|
8
|
+
module Transformation
|
9
|
+
# Public: Converts a deep hash to one level by generating new keys
|
10
|
+
# by joining the previous key path to the value with a '.'
|
11
|
+
#
|
12
|
+
# hash - The original Hash.
|
13
|
+
# namespace - An optional namespace, default: [].
|
14
|
+
# tree - An optional tree to add values to, default: {}.
|
15
|
+
#
|
16
|
+
# Examples
|
17
|
+
# flatten_hash({da: {session: { login: 'Log ind', logout: 'Log ud' }}})
|
18
|
+
# # => {"da.session.login"=>"Log ind", "da.session.logout"=>"Log ud"}
|
19
|
+
#
|
20
|
+
# Returns the generated Hash.
|
21
|
+
def flatten_hash(hash, namespace = [], tree = {})
|
22
|
+
hash.each do |key, value|
|
23
|
+
child_ns = namespace.dup << key
|
24
|
+
if value.is_a?(Hash)
|
25
|
+
flatten_hash value, child_ns, tree
|
26
|
+
else
|
27
|
+
tree[child_ns.join('.')] = value
|
28
|
+
end
|
29
|
+
end
|
30
|
+
tree
|
31
|
+
end
|
32
|
+
module_function :flatten_hash
|
33
|
+
|
34
|
+
# Public: Converts a flat hash with key path to the value joined
|
35
|
+
# with a '.' to a one level Hash, it's the reverse of flatten_hash
|
36
|
+
#
|
37
|
+
# hash - Hash with keys that represent the path to the value in the new Hash
|
38
|
+
#
|
39
|
+
# Examples
|
40
|
+
# nest_hash({"da.session.login"=>"Log ind", "da.session.logout"=>"Log ud"})
|
41
|
+
# # => {"da"=>{"session"=>{"login"=>"Log ind", "logout"=>"Log ud"}}}
|
42
|
+
#
|
43
|
+
# Returns the generated Hash.
|
44
|
+
def nest_hash(hash)
|
45
|
+
result = {}
|
46
|
+
hash.each do |key, value|
|
47
|
+
begin
|
48
|
+
nest_key result, key, value
|
49
|
+
rescue StandardError
|
50
|
+
raise TransformationError,
|
51
|
+
"Failed to nest key: #{key.inspect} with #{value.inspect}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
result
|
55
|
+
end
|
56
|
+
module_function :nest_hash
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# Recursively nests point-separated keys from a string in a hash and
|
61
|
+
# assigns the given value to this new hash entry
|
62
|
+
def nest_key(result, key, value)
|
63
|
+
sub_result = result
|
64
|
+
keys = key.split('.')
|
65
|
+
keys.each_with_index do |k, idx|
|
66
|
+
if keys.size - 1 == idx
|
67
|
+
sub_result[k] = value
|
68
|
+
else
|
69
|
+
sub_result = (sub_result[k] ||= {})
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
module_function :nest_key
|
74
|
+
end
|
75
|
+
end
|