├── .gitignore ├── Gemfile ├── example ├── en.yml ├── da.yml ├── session.en.yml └── session.da.yml ├── bin └── iye ├── test ├── test_helper.rb └── unit │ ├── test_app.rb │ ├── test_category.rb │ ├── test_transformation.rb │ ├── test_key.rb │ ├── test_translation.rb │ └── test_store.rb ├── Rakefile ├── views ├── debug.html.erb ├── categories.html.erb ├── translations.html.erb └── layout.erb ├── config.ru ├── lib ├── i18n_yaml_editor.rb └── i18n_yaml_editor │ ├── category.rb │ ├── key.rb │ ├── translation.rb │ ├── app.rb │ ├── transformation.rb │ ├── web.rb │ └── store.rb ├── CHANGES.md ├── LICENSE ├── iye.gemspec └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | -------------------------------------------------------------------------------- /example/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | app_name: 4 | day_names: 5 | multiline: |- 6 | Multiple 7 | lines. 8 | -------------------------------------------------------------------------------- /bin/iye: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "i18n_yaml_editor" 4 | 5 | iye = I18nYamlEditor::App.new(ARGV[0]) 6 | iye.start 7 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "i18n_yaml_editor" 3 | 4 | class Minitest::Test 5 | include I18nYamlEditor 6 | end 7 | -------------------------------------------------------------------------------- /test/unit/test_app.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "test_helper" 4 | require "i18n_yaml_editor/app" 5 | 6 | class TestApp < Minitest::Test 7 | end 8 | -------------------------------------------------------------------------------- /test/unit/test_category.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "test_helper" 4 | require "i18n_yaml_editor/category" 5 | 6 | class TestCategory < Minitest::Test 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.libs << "test" 5 | t.libs << "lib" 6 | t.pattern = "test/**/test_*.rb" 7 | end 8 | 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /views/debug.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |
 4 | <% translations.each do |translation| %>
 5 |  <%= Rack::Utils.escape_html(translation.inspect) %>
 6 | <% end %>
 7 |     
8 | 9 | 10 | -------------------------------------------------------------------------------- /example/da.yml: -------------------------------------------------------------------------------- 1 | --- 2 | da: 3 | app_name: 4 | day_names: 5 | - søndag 6 | - mandag 7 | - tirsdag 8 | - onsdag 9 | - torsdag 10 | - fredag 11 | - lørdag 12 | multiline: |- 13 | Her er 14 | flere 15 | linjer. 16 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | $:.unshift("lib") 2 | 3 | require "i18n_yaml_editor/app" 4 | require "i18n_yaml_editor/web" 5 | 6 | app = I18nYamlEditor::App.new("example") 7 | app.load_translations 8 | app.store.create_missing_keys 9 | 10 | run I18nYamlEditor::Web 11 | -------------------------------------------------------------------------------- /lib/i18n_yaml_editor.rb: -------------------------------------------------------------------------------- 1 | module I18nYamlEditor 2 | class << self 3 | attr_accessor :app 4 | end 5 | end 6 | 7 | require "i18n_yaml_editor/app" 8 | require "i18n_yaml_editor/category" 9 | require "i18n_yaml_editor/key" 10 | require "i18n_yaml_editor/store" 11 | require "i18n_yaml_editor/transformation" 12 | require "i18n_yaml_editor/translation" 13 | require "i18n_yaml_editor/web" 14 | -------------------------------------------------------------------------------- /views/categories.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <% categories.each do |name, category| %> 3 | "> 4 | 7 | 8 | <% end %> 9 |
5 | "><%= category.name %> 6 |
10 | -------------------------------------------------------------------------------- /example/session.en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | new: 4 | email: 'E-mail:' 5 | password: 'Password:' 6 | login: Sign in 7 | session: 8 | new: 9 | email: 10 | password: 11 | login: 12 | remember_me: Remember me 13 | forgot: Forgotten your password? 14 | v1: 15 | create: 16 | logged_in: Welcome! 17 | invalid_login: 18 | destroy: 19 | logged_out: 20 | re_login: 21 | -------------------------------------------------------------------------------- /lib/i18n_yaml_editor/category.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "set" 4 | 5 | module I18nYamlEditor 6 | class Category 7 | attr_accessor :name, :keys 8 | 9 | def initialize attributes={} 10 | @name = attributes[:name] 11 | @keys = Set.new 12 | end 13 | 14 | def add_key key 15 | self.keys.add(key) 16 | end 17 | 18 | def complete? 19 | self.keys.all? {|key| key.complete?} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /example/session.da.yml: -------------------------------------------------------------------------------- 1 | --- 2 | da: 3 | session: 4 | new: 5 | email: 'E-mail:' 6 | password: 7 | login: Log ind 8 | remember_me: Husk mig 9 | forgot: Har du glemt dit kodeord? 10 | v1: Log ind på det gamle Firmafon 11 | create: 12 | logged_in: Velkommen til 13 | invalid_login: Forkert e-mail eller kodeord. 14 | destroy: 15 | logged_out: Logget ud 16 | re_login: Log ind igen. 17 | new: 18 | email: EMAIL 19 | password: pwd 20 | login: log in 21 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 1.1.1 / 2015-04-17 2 | 3 | * Padding on ` 23 | <% else %> 24 | [read only] 25 | <%= translation.text.class.name %> 26 | <%= Rack::Utils.escape_html translation.text.inspect %> 27 | <% end %> 28 | 29 | 30 | <% end %> 31 | 32 | 33 | 34 | <% end %> 35 | 36 | 37 |

38 | 39 | 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IYE 2 | 3 | IYE - short for I18N YAML Editor - makes it easy to translate your Rails I18N files and 4 | keep them up to date. Unlike a lot of other tools in this space, IYE works directly on the 5 | YAML files instead of keeping a separate database in sync. This has several benefits: 6 | 7 | * Branching and diffing is trivial 8 | * It does not alter the workflow for developers etc., whom can continue editing the 9 | YAML files directly 10 | * If your YAML files are organized in subfolders, this structure is kept intact 11 | 12 | ![IYE yaml editor](http://f.cl.ly/items/2K2V2i3N2R2X1L2F051F/Sk%C3%A6rmbillede%202012-09-18%20kl.%2013.36.07.png) 13 | 14 | ## Prerequisites 15 | 16 | You need to understand a few things about IYE for it to make sense, mainly: 17 | 18 | * IYE does not create new keys - keys must exist for at least one locale in the YAML files 19 | * IYE does not create new locales - at least one key must exist for each locale in the YAML files 20 | 21 | ## Workflow 22 | 23 | 1. Install IYE: 24 | 25 | $ gem install iye 26 | 27 | 2. Navigate to the folder containing your YAML files and start IYE: 28 | 29 | $ iye . 30 | 31 | At this point IYE loads all translation keys for all locales, and creates any 32 | keys that might be missing for existing locales. 33 | 34 | 3. Point browser at [http://localhost:5050](http://localhost:5050) 35 | 4. Make changes and press 'Save' - each time you do this, all the keys will be 36 | written to their original YAML files, which you can confirm e.g. by using 37 | `git diff`. 38 | 39 | ## Development 40 | 41 | The source ships with a `config.ru` suitable for development use with [shotgun](https://github.com/rtomayko/shotgun): 42 | 43 | shotgun -p 5050 44 | 45 | To run tests: 46 | 47 | bundle install 48 | bundle exec rake 49 | 50 | ## Troubleshooting 51 | 52 | **``psych.rb:203:in `parse': wrong number of arguments(2 for 1) (ArgumentError)``** 53 | : This is caused by a mismatch of the `psych` in standard library and the gem. The bug is fixed in Ruby 1.9.3-p194. 54 | 55 | ## Build status 56 | 57 | ![](https://travis-ci.org/firmafon/iye.svg?branch=master) 58 | -------------------------------------------------------------------------------- /lib/i18n_yaml_editor/store.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "set" 4 | require "pathname" 5 | 6 | require "i18n_yaml_editor/transformation" 7 | require "i18n_yaml_editor/category" 8 | require "i18n_yaml_editor/key" 9 | require "i18n_yaml_editor/translation" 10 | 11 | module I18nYamlEditor 12 | class DuplicateTranslationError < StandardError; end 13 | 14 | class Store 15 | include Transformation 16 | 17 | attr_accessor :categories, :keys, :translations, :locales 18 | 19 | def initialize 20 | @categories = {} 21 | @keys = {} 22 | @translations = {} 23 | @locales = Set.new 24 | end 25 | 26 | def add_translation translation 27 | if existing = self.translations[translation.name] 28 | message = "#{translation.name} detected in #{translation.file} and #{existing.file}" 29 | raise DuplicateTranslationError.new(message) 30 | end 31 | 32 | self.translations[translation.name] = translation 33 | 34 | add_locale(translation.locale) 35 | 36 | key = (self.keys[translation.key] ||= Key.new(name: translation.key)) 37 | key.add_translation(translation) 38 | 39 | category = (self.categories[key.category] ||= Category.new(name: key.category)) 40 | category.add_key(key) 41 | end 42 | 43 | def add_key key 44 | self.keys[key.name] = key 45 | end 46 | 47 | def add_locale locale 48 | self.locales.add(locale) 49 | end 50 | 51 | def filter_keys options={} 52 | filters = [] 53 | if options.has_key?(:key) 54 | filters << lambda {|k| k.name =~ options[:key]} 55 | end 56 | if options.has_key?(:complete) 57 | filters << lambda {|k| k.complete? == options[:complete]} 58 | end 59 | if options.has_key?(:empty) 60 | filters << lambda {|k| k.empty? == options[:empty]} 61 | end 62 | if options.has_key?(:text) 63 | filters << lambda {|k| 64 | k.translations.any? {|t| t.text =~ options[:text]} 65 | } 66 | end 67 | 68 | self.keys.select {|name, key| 69 | filters.all? {|filter| filter.call(key)} 70 | } 71 | end 72 | 73 | def create_missing_keys 74 | self.keys.each {|name, key| 75 | missing_locales = self.locales - key.translations.map(&:locale) 76 | missing_locales.each {|locale| 77 | translation = key.translations.first 78 | 79 | # this just replaces the locale part of the file name. should 80 | # be possible to do in a simpler way. gsub, baby. 81 | path = Pathname.new(translation.file) 82 | dirs, file = path.split 83 | file = file.to_s.split(".") 84 | file[-2] = locale 85 | file = file.join(".") 86 | path = dirs.join(file).to_s 87 | 88 | new_translation = Translation.new(name: "#{locale}.#{key.name}", file: path) 89 | add_translation(new_translation) 90 | } 91 | } 92 | end 93 | 94 | def from_yaml yaml, file=nil 95 | translations = flatten_hash(yaml) 96 | translations.each {|name, text| 97 | translation = Translation.new(name: name, text: text, file: file) 98 | add_translation(translation) 99 | } 100 | end 101 | 102 | def to_yaml 103 | result = {} 104 | files = self.translations.values.group_by(&:file) 105 | files.each {|file, translations| 106 | file_result = {} 107 | translations.each {|translation| 108 | file_result[translation.name] = translation.text 109 | } 110 | result[file] = nest_hash(file_result) 111 | } 112 | result 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | IYE 4 | 98 | 99 | 100 | 101 | 102 |
103 |

IYE

104 | 105 |
106 | 110 | 111 | 115 | 116 | 120 | 124 | 125 | 126 |
127 | 128 |
129 | 130 |
131 | 132 |
133 |
134 |
135 | 136 |
137 | <%= content %> 138 |
139 | 140 | 141 | -------------------------------------------------------------------------------- /test/unit/test_store.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "test_helper" 4 | require "i18n_yaml_editor/store" 5 | 6 | class TestStore < Minitest::Test 7 | def setup 8 | @store = Store.new 9 | end 10 | 11 | def test_add_translations 12 | translation = Translation.new(:name => "da.session.login") 13 | 14 | @store.add_translation(translation) 15 | 16 | assert_equal 1, @store.translations.size 17 | assert_equal translation, @store.translations[translation.name] 18 | 19 | assert_equal 1, @store.keys.size 20 | assert_equal Set.new([translation]), @store.keys["session.login"].translations 21 | 22 | assert_equal 1, @store.categories.size 23 | assert_equal %w(session.login), @store.categories["session"].keys.map(&:name) 24 | 25 | assert_equal 1, @store.locales.size 26 | assert_equal %w(da), @store.locales.to_a 27 | end 28 | 29 | def test_add_duplicate_translation 30 | t1 = Translation.new(:name => "da.session.login") 31 | t2 = Translation.new(:name => "da.session.login") 32 | @store.add_translation(t1) 33 | 34 | assert_raises(DuplicateTranslationError) { 35 | @store.add_translation(t2) 36 | } 37 | end 38 | 39 | def test_filter_keys_on_key 40 | @store.add_key(Key.new(name: "session.login")) 41 | @store.add_key(Key.new(name: "session.logout")) 42 | 43 | result = @store.filter_keys(key: /login/) 44 | 45 | assert_equal 1, result.size 46 | assert_equal %w(session.login), result.keys 47 | end 48 | 49 | def test_filter_keys_on_complete 50 | @store.add_translation Translation.new(name: "da.session.login", text: "Log ind") 51 | @store.add_translation Translation.new(name: "en.session.login") 52 | @store.add_translation Translation.new(name: "da.session.logout", text: "Log ud") 53 | 54 | result = @store.filter_keys(complete: false) 55 | 56 | assert_equal 1, result.size 57 | assert_equal %w(session.login), result.keys 58 | end 59 | 60 | def test_filter_keys_on_empty 61 | @store.add_translation Translation.new(name: "da.session.login", text: "Log ind") 62 | @store.add_translation Translation.new(name: "da.session.logout") 63 | 64 | result = @store.filter_keys(empty: true) 65 | 66 | assert_equal 1, result.size 67 | assert_equal %w(session.logout), result.keys 68 | end 69 | 70 | def test_filter_keys_on_text 71 | @store.add_translation Translation.new(name: "da.session.login", text: "Log ind") 72 | @store.add_translation Translation.new(name: "da.session.logout", text: "Log ud") 73 | @store.add_translation Translation.new(name: "da.app.name", text: "Translator") 74 | 75 | result = @store.filter_keys(text: /Log/) 76 | 77 | assert_equal 2, result.size 78 | assert_equal %w(session.login session.logout).sort, result.keys.sort 79 | end 80 | 81 | def test_create_missing_translations 82 | @store.add_translation Translation.new(name: "da.session.login", text: "Log ind", file: "/tmp/session.da.yml") 83 | @store.add_locale("en") 84 | 85 | @store.create_missing_keys 86 | 87 | assert(translation = @store.translations["en.session.login"]) 88 | assert_equal "en.session.login", translation.name 89 | assert_equal "/tmp/session.en.yml", translation.file 90 | assert_nil translation.text 91 | end 92 | 93 | def test_create_missing_translations_in_top_level_file 94 | @store.add_translation Translation.new(name: "da.app_name", text: "Oversætter", file: "/tmp/da.yml") 95 | @store.add_locale("en") 96 | 97 | @store.create_missing_keys 98 | 99 | assert(translation = @store.translations["en.app_name"]) 100 | assert_equal "en.app_name", translation.name 101 | assert_equal "/tmp/en.yml", translation.file 102 | assert_nil translation.text 103 | end 104 | 105 | def test_from_yaml 106 | input = { 107 | da: { 108 | session: {login: "Log ind"} 109 | } 110 | } 111 | store = Store.new 112 | 113 | store.from_yaml(input) 114 | 115 | assert_equal 1, store.translations.size 116 | translation = store.translations["da.session.login"] 117 | assert_equal "da.session.login", translation.name 118 | assert_equal "Log ind", translation.text 119 | end 120 | 121 | def test_to_yaml 122 | expected = { 123 | "/tmp/session.da.yml" => { 124 | "da" => { 125 | "session" => { 126 | "login" => "Log ind", 127 | "logout" => "Log ud" 128 | } 129 | } 130 | }, 131 | "/tmp/session.en.yml" => { 132 | "en" => { 133 | "session" => { 134 | "login" => "Sign in" 135 | } 136 | } 137 | }, 138 | "/tmp/da.yml" => { 139 | "da" => { 140 | "app_name" => "Oversætter", 141 | "empty_string" => nil 142 | } 143 | } 144 | } 145 | 146 | store = Store.new 147 | store.add_translation Translation.new(name: "da.session.login", text: "Log ind", file: "/tmp/session.da.yml") 148 | store.add_translation Translation.new(name: "en.session.login", text: "Sign in", file: "/tmp/session.en.yml") 149 | store.add_translation Translation.new(name: "da.session.logout", text: "Log ud", file: "/tmp/session.da.yml") 150 | store.add_translation Translation.new(name: "da.app_name", text: "Oversætter", file: "/tmp/da.yml") 151 | store.add_translation Translation.new(name: "da.empty_string", text: "", file: "/tmp/da.yml") 152 | 153 | assert_equal expected, store.to_yaml 154 | end 155 | end 156 | --------------------------------------------------------------------------------