├── generators ├── db_backend.rb └── templates │ └── db_backend_migration.rb ├── test ├── data │ ├── locale │ │ ├── root.yml │ │ ├── all.yml │ │ ├── de-DE.yml │ │ ├── en-US.yml │ │ ├── fi-FI │ │ │ └── module.yml │ │ └── en-US │ │ │ └── module.yml │ ├── no_globalize_schema.rb │ ├── post.rb │ └── schema.rb ├── test_helper.rb ├── i18n │ └── missing_translations_test.rb ├── translation_test.rb ├── load_path_test.rb ├── model │ └── active_record │ │ ├── sti_translated_test.rb │ │ ├── migration_test.rb │ │ └── translated_test.rb ├── backends │ ├── pluralizing_test.rb │ ├── static_test.rb │ └── chained_test.rb └── locale │ ├── language_tag_test.rb │ └── fallbacks_test.rb ├── .gitignore ├── init.rb ├── lib ├── globalize.rb ├── locale │ └── root.yml ├── globalize │ ├── i18n │ │ ├── missing_translations_raise_handler.rb │ │ └── missing_translations_log_handler.rb │ ├── translation.rb │ ├── backend │ │ ├── pluralizing.rb │ │ ├── static.rb │ │ └── chain.rb │ ├── model │ │ ├── active_record.rb │ │ └── active_record │ │ │ ├── adapter.rb │ │ │ └── translated.rb │ ├── locale │ │ ├── fallbacks.rb │ │ └── language_tag.rb │ └── load_path.rb └── rails_edge_load_path_patch.rb ├── rails └── init.rb ├── LICENSE ├── notes.textile ├── globalize2.gemspec └── README.textile /generators/db_backend.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/data/locale/root.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | spec/spec/db/* 3 | vendor 4 | NOTES -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__)+'/rails/init' -------------------------------------------------------------------------------- /lib/globalize.rb: -------------------------------------------------------------------------------- 1 | 2 | module Globalize 3 | end 4 | -------------------------------------------------------------------------------- /lib/locale/root.yml: -------------------------------------------------------------------------------- 1 | root: 2 | bidi: 3 | direction: left-to-right -------------------------------------------------------------------------------- /test/data/locale/all.yml: -------------------------------------------------------------------------------- 1 | en-US: 2 | from-all-file: From the "all" file. 3 | -------------------------------------------------------------------------------- /test/data/locale/de-DE.yml: -------------------------------------------------------------------------------- 1 | de-DE: 2 | from-locale-file: Aus der Locale Datei. -------------------------------------------------------------------------------- /test/data/locale/en-US.yml: -------------------------------------------------------------------------------- 1 | en-US: 2 | from-locale-file: From the locale file. 3 | -------------------------------------------------------------------------------- /test/data/locale/fi-FI/module.yml: -------------------------------------------------------------------------------- 1 | fi-FI: 2 | from-locale-dir: Locale hakemistosta. -------------------------------------------------------------------------------- /test/data/locale/en-US/module.yml: -------------------------------------------------------------------------------- 1 | en-US: 2 | from-locale-dir: From the locale directory. 3 | -------------------------------------------------------------------------------- /test/data/no_globalize_schema.rb: -------------------------------------------------------------------------------- 1 | # This schema creates tables without columns for the translated fields 2 | ActiveRecord::Schema.define do 3 | create_table :blogs, :force => true do |t| 4 | t.string :name 5 | end 6 | 7 | create_table :posts, :force => true do |t| 8 | t.references :blog 9 | end 10 | end 11 | 12 | -------------------------------------------------------------------------------- /rails/init.rb: -------------------------------------------------------------------------------- 1 | require 'rails_edge_load_path_patch.rb' unless I18n.respond_to?(:load_path) 2 | 3 | ActiveRecord::Base.send :include, Globalize::Model::ActiveRecord::Translated 4 | 5 | I18n.backend = Globalize::Backend::Static.new 6 | 7 | I18n.load_path = Globalize::LoadPath.new I18n.load_path 8 | I18n.load_path << "#{File.dirname(__FILE__)}/lib/locale" 9 | I18n.load_path << "#{RAILS_ROOT}/lib/locale" -------------------------------------------------------------------------------- /test/data/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ActiveRecord::Base 2 | translates :subject, :content 3 | validates_presence_of :subject 4 | end 5 | 6 | class Blog < ActiveRecord::Base 7 | has_many :posts, :order => 'id ASC' 8 | end 9 | 10 | class Parent < ActiveRecord::Base 11 | translates :content 12 | end 13 | 14 | class Child < Parent 15 | end 16 | 17 | class Comment < ActiveRecord::Base 18 | validates_presence_of :content 19 | belongs_to :post 20 | end 21 | 22 | class TranslatedComment < Comment 23 | translates :content 24 | end -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'test/unit' 3 | require 'active_support' 4 | require 'active_support/test_case' 5 | require 'mocha' 6 | 7 | $LOAD_PATH << File.expand_path( File.dirname(__FILE__) + '/../lib' ) 8 | 9 | class ActiveSupport::TestCase 10 | def reset_db!( schema_path ) 11 | ::ActiveRecord::Migration.verbose = false # Quiet down the migration engine 12 | ::ActiveRecord::Base.establish_connection({ 13 | :adapter => 'sqlite3', 14 | :dbfile => ':memory:' 15 | }) 16 | ::ActiveRecord::Base.silence do 17 | load schema_path 18 | end 19 | end 20 | 21 | def assert_member(item, arr) 22 | assert_block "Item #{item} is not in array #{arr}" do 23 | arr.member? item 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /generators/templates/db_backend_migration.rb: -------------------------------------------------------------------------------- 1 | class ActsAsTaggableMigration < ActiveRecord::Migration 2 | def self.up 3 | create_table :globalize_translations do |t| 4 | t.string :locale, :null => false 5 | t.string :key, :null => false 6 | t.string :translation 7 | t.timestamps 8 | end 9 | 10 | # TODO: FINISH DOING MIGRATION -- stopped in the middle 11 | 12 | create_table :globalize_translations_map do |t| 13 | t.string :key, :null => false 14 | t.integer :translation_id, :null => false 15 | end 16 | 17 | add_index :taggings, :tag_id 18 | add_index :taggings, [:taggable_id, :taggable_type] 19 | end 20 | 21 | def self.down 22 | drop_table :globalize_translations 23 | drop_table :tags 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/globalize/i18n/missing_translations_raise_handler.rb: -------------------------------------------------------------------------------- 1 | # A simple exception handler that behaves like the default exception handler 2 | # but also raises on missing translations. 3 | # 4 | # Useful for identifying missing translations during testing. 5 | # 6 | # E.g. 7 | # 8 | # require 'globalize/i18n/missing_translations_raise_handler 9 | # I18n.exception_handler = :missing_translations_raise_handler 10 | module I18n 11 | class << self 12 | def missing_translations_raise_handler(exception, locale, key, options) 13 | raise exception 14 | end 15 | end 16 | 17 | # self.exception_handler = :missing_translations_raise_handler 18 | end 19 | 20 | I18n.exception_handler = :missing_translations_raise_handler 21 | 22 | ActionView::Helpers::TranslationHelper.module_eval do 23 | def translate(key, options = {}) 24 | I18n.translate(key, options) 25 | end 26 | alias :t :translate 27 | end 28 | -------------------------------------------------------------------------------- /lib/globalize/translation.rb: -------------------------------------------------------------------------------- 1 | module Globalize 2 | # Translations are simple value objects that carry some context information 3 | # alongside the actual translation string. 4 | 5 | class Translation < String 6 | class Attribute < Translation 7 | attr_accessor :requested_locale, :locale, :key 8 | end 9 | 10 | class Static < Translation 11 | attr_accessor :requested_locale, :locale, :key, :options, :plural_key, :original 12 | 13 | def initialize(string, meta = nil) 14 | self.original = string 15 | super 16 | end 17 | end 18 | 19 | def initialize(string, meta = nil) 20 | set_meta meta 21 | super string 22 | end 23 | 24 | def fallback? 25 | locale.to_sym != requested_locale.to_sym 26 | end 27 | 28 | def set_meta(meta) 29 | meta.each {|name, value| send :"#{name}=", value } if meta 30 | end 31 | end 32 | end -------------------------------------------------------------------------------- /test/data/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | 3 | create_table :blogs, :force => true do |t| 4 | t.string :description 5 | end 6 | 7 | create_table :posts, :force => true do |t| 8 | t.references :blog 9 | end 10 | 11 | create_table :post_translations, :force => true do |t| 12 | t.string :locale 13 | t.references :post 14 | t.string :subject 15 | t.text :content 16 | end 17 | 18 | create_table :parents, :force => true do |t| 19 | end 20 | 21 | create_table :parent_translations, :force => true do |t| 22 | t.string :locale 23 | t.references :parent 24 | t.text :content 25 | t.string :type 26 | end 27 | 28 | create_table :comments, :force => true do |t| 29 | t.references :post 30 | end 31 | 32 | create_table :translated_comment_translations, :force => true do |t| 33 | t.string :locale 34 | t.references :comment 35 | t.string :subject 36 | t.text :content 37 | end 38 | 39 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2008, 2009 Joshua Harvey 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /lib/globalize/backend/pluralizing.rb: -------------------------------------------------------------------------------- 1 | require 'i18n/backend/simple' 2 | 3 | module Globalize 4 | module Backend 5 | class Pluralizing < I18n::Backend::Simple 6 | def pluralize(locale, entry, count) 7 | return entry unless entry.is_a?(Hash) and count 8 | key = :zero if count == 0 && entry.has_key?(:zero) 9 | key ||= pluralizer(locale).call(count) 10 | raise InvalidPluralizationData.new(entry, count) unless entry.has_key?(key) 11 | translation entry[key], :plural_key => key 12 | end 13 | 14 | def add_pluralizer(locale, pluralizer) 15 | pluralizers[locale.to_sym] = pluralizer 16 | end 17 | 18 | def pluralizer(locale) 19 | pluralizers[locale.to_sym] || default_pluralizer 20 | end 21 | 22 | protected 23 | def default_pluralizer 24 | pluralizers[:en] 25 | end 26 | 27 | def pluralizers 28 | @pluralizers ||= { :en => lambda{|n| n == 1 ? :one : :other } } 29 | end 30 | 31 | # Overwrite this method to return something other than a String 32 | def translation(string, attributes) 33 | string 34 | end 35 | end 36 | end 37 | end -------------------------------------------------------------------------------- /lib/rails_edge_load_path_patch.rb: -------------------------------------------------------------------------------- 1 | module I18n 2 | @@load_path = nil 3 | @@default_locale = :'en-US' 4 | 5 | class << self 6 | def load_path 7 | @@load_path ||= [] 8 | end 9 | 10 | def load_path=(load_path) 11 | @@load_path = load_path 12 | end 13 | end 14 | end 15 | 16 | I18n::Backend::Simple.module_eval do 17 | def initialized? 18 | @initialized ||= false 19 | end 20 | 21 | protected 22 | 23 | def init_translations 24 | load_translations(*I18n.load_path) 25 | @initialized = true 26 | end 27 | 28 | def lookup(locale, key, scope = []) 29 | return unless key 30 | init_translations unless initialized? 31 | keys = I18n.send :normalize_translation_keys, locale, key, scope 32 | keys.inject(translations){|result, k| result[k.to_sym] or return nil } 33 | end 34 | end 35 | 36 | rails_dir = File.expand_path "#{File.dirname(__FILE__)}/../../../rails/" 37 | paths = %w(actionpack/lib/action_view/locale/en-US.yml 38 | activerecord/lib/active_record/locale/en-US.yml 39 | activesupport/lib/active_support/locale/en-US.yml) 40 | paths.each{|path| I18n.load_path << "#{rails_dir}/#{path}" } 41 | -------------------------------------------------------------------------------- /lib/globalize/i18n/missing_translations_log_handler.rb: -------------------------------------------------------------------------------- 1 | # A simple exception handler that behaves like the default exception handler 2 | # but additionally logs missing translations to a given log. 3 | # 4 | # Useful for identifying missing translations during testing. 5 | # 6 | # E.g. 7 | # 8 | # require 'globalize/i18n/missing_translations_log_handler 9 | # I18n.missing_translations_logger = RAILS_DEFAULT_LOGGER 10 | # I18n.exception_handler = :missing_translations_log_handler 11 | # 12 | # To set up a different log file: 13 | # 14 | # logger = Logger.new("#{RAILS_ROOT}/log/missing_translations.log") 15 | # I18n.missing_translations_logger = logger 16 | 17 | module I18n 18 | @@missing_translations_logger = nil 19 | 20 | class << self 21 | def missing_translations_logger 22 | @@missing_translations_logger ||= begin 23 | require 'logger' unless defined?(Logger) 24 | Logger.new(STDOUT) 25 | end 26 | end 27 | 28 | def missing_translations_logger=(logger) 29 | @@missing_translations_logger = logger 30 | end 31 | 32 | def missing_translations_log_handler(exception, locale, key, options) 33 | if MissingTranslationData === exception 34 | missing_translations_logger.warn(exception.message) 35 | return exception.message 36 | else 37 | raise exception 38 | end 39 | end 40 | end 41 | end -------------------------------------------------------------------------------- /test/i18n/missing_translations_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', 'test_helper' ) 2 | require 'globalize/i18n/missing_translations_log_handler' 3 | 4 | class MissingTranslationsTest < ActiveSupport::TestCase 5 | test "defines I18n.missing_translations_logger accessor" do 6 | assert I18n.respond_to?(:missing_translations_logger) 7 | end 8 | 9 | test "defines I18n.missing_translations_logger= writer" do 10 | assert I18n.respond_to?(:missing_translations_logger=) 11 | end 12 | end 13 | 14 | class TestLogger < String 15 | def warn(msg) self.concat msg; end 16 | end 17 | 18 | class LogMissingTranslationsTest < ActiveSupport::TestCase 19 | def setup 20 | @locale, @key, @options = :en, :foo, {} 21 | @exception = I18n::MissingTranslationData.new(@locale, @key, @options) 22 | 23 | @logger = TestLogger.new 24 | I18n.missing_translations_logger = @logger 25 | end 26 | 27 | test "still returns the exception message for MissingTranslationData exceptions" do 28 | result = I18n.send(:missing_translations_log_handler, @exception, @locale, @key, @options) 29 | assert_equal 'translation missing: en, foo', result 30 | end 31 | 32 | test "logs the missing translation to I18n.missing_translations_logger" do 33 | I18n.send(:missing_translations_log_handler, @exception, @locale, @key, @options) 34 | assert_equal 'translation missing: en, foo', @logger 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/globalize/model/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'globalize/translation' 2 | require 'globalize/locale/fallbacks' 3 | require 'globalize/model/active_record/adapter' 4 | require 'globalize/model/active_record/translated' 5 | 6 | module Globalize 7 | module Model 8 | module ActiveRecord 9 | class << self 10 | def create_proxy_class(klass) 11 | short_name = klass.name.gsub(/.+::/, '') 12 | klass.parent.const_set "#{short_name}Translation", Class.new(::ActiveRecord::Base){ 13 | set_table_name "#{klass.name}Translation".pluralize.underscore.gsub('/', '_') 14 | belongs_to "#{short_name.underscore}".intern, :class_name => klass.name, :foreign_key => "#{short_name.underscore}_id" 15 | 16 | def locale 17 | read_attribute(:locale).to_sym 18 | end 19 | 20 | def locale=(locale) 21 | write_attribute(:locale, locale.to_s) 22 | end 23 | } 24 | end 25 | 26 | def define_accessors(klass, attr_names) 27 | attr_names.each do |attr_name| 28 | klass.send :define_method, attr_name, lambda { 29 | globalize.fetch self.class.locale, attr_name 30 | } 31 | klass.send :define_method, "#{attr_name}=", lambda {|val| 32 | globalize.stash self.class.locale, attr_name, val 33 | self[attr_name] = val 34 | } 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /test/translation_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), 'test_helper' ) 2 | require 'globalize/translation' 3 | 4 | class TranslationTest < ActiveSupport::TestCase 5 | include Globalize 6 | 7 | def setup 8 | @translation = Translation::Static.new 'foo', :locale => :'en-US' 9 | end 10 | 11 | test "responds to fallback?" do 12 | assert @translation.respond_to?( :fallback? ) 13 | end 14 | 15 | test "returns true when :locale and :requested_locale are not equal" do 16 | @translation.requested_locale = :'de-DE' 17 | assert @translation.fallback? 18 | end 19 | 20 | test "returns false when :locale and :requested_locale are equal" do 21 | @translation.requested_locale = :'en-US' 22 | assert !@translation.fallback? 23 | end 24 | 25 | test "has the attribute :locale" do 26 | assert @translation.respond_to?( :locale ) 27 | end 28 | 29 | test "has the attribute :requested_locale" do 30 | assert @translation.respond_to?( :requested_locale ) 31 | end 32 | 33 | test "has the attribute :options" do 34 | assert @translation.respond_to?( :options ) 35 | end 36 | 37 | test "has the attribute :plural_key" do 38 | assert @translation.respond_to?( :plural_key ) 39 | end 40 | 41 | test "has the attribute :original" do 42 | assert @translation.respond_to?( :original ) 43 | end 44 | 45 | test "Translation::Attribute has the attribute :locale" do 46 | translation = Translation::Attribute.new 'foo' 47 | assert translation.respond_to?( :locale ) 48 | end 49 | 50 | test "Translation::Attribute has the attribute :requested_locale" do 51 | translation = Translation::Attribute.new 'foo' 52 | assert translation.respond_to?( :requested_locale ) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/globalize/locale/fallbacks.rb: -------------------------------------------------------------------------------- 1 | require 'globalize/locale/language_tag' 2 | 3 | module I18n 4 | @@fallbacks = nil 5 | 6 | class << self 7 | # Returns the current fallbacks. Defaults to +Globalize::Locale::Fallbacks+. 8 | def fallbacks 9 | @@fallbacks ||= Globalize::Locale::Fallbacks.new 10 | end 11 | 12 | # Sets the current fallbacks. Used to set a custom fallbacks instance. 13 | def fallbacks=(fallbacks) 14 | @@fallbacks = fallbacks 15 | end 16 | end 17 | end 18 | 19 | module Globalize 20 | module Locale 21 | class Fallbacks < Hash 22 | def initialize(*defaults) 23 | @map = {} 24 | map defaults.pop if defaults.last.is_a?(Hash) 25 | 26 | defaults = [I18n.default_locale.to_sym] if defaults.empty? 27 | self.defaults = defaults 28 | end 29 | 30 | def defaults=(defaults) 31 | @defaults = defaults.map{|default| compute(default, false) }.flatten << :root 32 | end 33 | attr_reader :defaults 34 | 35 | def [](tag) 36 | tag = tag.to_sym 37 | has_key?(tag) ? fetch(tag) : store(tag, compute(tag)) 38 | end 39 | 40 | def map(mappings) 41 | mappings.each do |from, to| 42 | from, to = from.to_sym, Array(to) 43 | to.each do |to| 44 | @map[from] ||= [] 45 | @map[from] << to.to_sym 46 | end 47 | end 48 | end 49 | 50 | protected 51 | 52 | def compute(tags, include_defaults = true) 53 | result = Array(tags).collect do |tag| 54 | tags = LanguageTag::tag(tag.to_sym).parents(true).map! {|t| t.to_sym } 55 | tags.each{|tag| tags += compute(@map[tag]) if @map[tag] } 56 | tags 57 | end.flatten 58 | result.push *defaults if include_defaults 59 | result.uniq 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/load_path_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), 'test_helper' ) 2 | require 'globalize/load_path' 3 | 4 | class LoadPathTest < ActiveSupport::TestCase 5 | def setup 6 | @plugin_dir = "#{File.dirname(__FILE__)}/.." 7 | @locale_dir = "#{File.dirname(__FILE__)}/data/locale" 8 | @load_path = Globalize::LoadPath.new 9 | end 10 | 11 | test "returns glob patterns for all locales and ruby + yaml files by default" do 12 | patterns = %w(locales/all.rb 13 | locales/*.rb 14 | locales/*/**/*.rb 15 | locales/all.yml 16 | locales/*.yml 17 | locales/*/**/*.yml) 18 | assert_equal patterns, @load_path.send(:patterns, 'locales') 19 | end 20 | 21 | test "returns the glob patterns for registered locales and extensions" do 22 | @load_path.locales = [:en, :de] 23 | @load_path.extensions = [:sql] 24 | patterns = %w(locales/all.sql 25 | locales/en.sql 26 | locales/en/**/*.sql 27 | locales/de.sql 28 | locales/de/**/*.sql) 29 | assert_equal patterns, @load_path.send(:patterns, 'locales') 30 | end 31 | 32 | test "expands paths using yml as a default file extension" do 33 | @load_path << @locale_dir 34 | expected = %w(all.yml de-DE.yml en-US.yml en-US/module.yml fi-FI/module.yml root.yml) 35 | assert_equal expected, @load_path.map{|path| path.sub("#{@locale_dir}\/", '')} 36 | end 37 | 38 | test "appends new paths to the collection so earlier collected paths preceed later collected ones" do 39 | @load_path.locales = [:root] 40 | @load_path << "#{@plugin_dir}/lib/locale" 41 | @load_path << @locale_dir 42 | 43 | expected = %W(#{@plugin_dir}/lib/locale/root.yml 44 | #{@locale_dir}/all.yml 45 | #{@locale_dir}/root.yml) 46 | assert_equal expected, @load_path 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /lib/globalize/load_path.rb: -------------------------------------------------------------------------------- 1 | # Locale load_path and Locale loading support. 2 | # 3 | # To use this include the Globalize::LoadPath::I18n module to I18n like this: 4 | # 5 | # I18n.send :include, Globalize::LoadPath::I18n 6 | # 7 | # Clients can add load_paths using: 8 | # 9 | # I18n.load_path.add load_path, 'rb', 'yml' # pass any number of extensions like this 10 | # I18n.load_path << 'path/to/dir' # usage without an extension, defaults to 'yml' 11 | # 12 | # And load locale data using either of: 13 | # 14 | # I18n.load_locales 'en-US', 'de-DE' 15 | # I18n.load_locale 'en-US' 16 | # 17 | # This will lookup all files named like: 18 | # 19 | # 'path/to/dir/all.yml' 20 | # 'path/to/dir/en-US.yml' 21 | # 'path/to/dir/en-US/*.yml' 22 | # 23 | # The filenames will be passed to I18n.load_translations which delegates to 24 | # the backend. So the actual behaviour depends on the implementation of the 25 | # backend. I18n::Backend::Simple will be able to read YAML and plain Ruby 26 | # files. See the documentation for I18n.load_translations for details. 27 | 28 | module Globalize 29 | class LoadPath < Array 30 | def extensions 31 | @extensions ||= ['rb', 'yml'] 32 | end 33 | attr_writer :extensions 34 | 35 | def locales 36 | @locales ||= ['*'] 37 | end 38 | attr_writer :locales 39 | 40 | def <<(path) 41 | push path 42 | end 43 | 44 | def push(*paths) 45 | super(*paths.map{|path| filenames(path) }.flatten.uniq.sort) 46 | end 47 | 48 | protected 49 | 50 | def filenames(path) 51 | return [path] if File.file? path 52 | patterns(path).map{|pattern| Dir[pattern] } 53 | end 54 | 55 | def patterns(path) 56 | locales.map do |locale| 57 | extensions.map do |extension| 58 | %W(#{path}/all.#{extension} #{path}/#{locale}.#{extension} #{path}/#{locale}/**/*.#{extension}) 59 | end 60 | end.flatten.uniq 61 | end 62 | end 63 | end -------------------------------------------------------------------------------- /lib/globalize/backend/static.rb: -------------------------------------------------------------------------------- 1 | require 'globalize/backend/pluralizing' 2 | require 'globalize/locale/fallbacks' 3 | require 'globalize/translation' 4 | 5 | module Globalize 6 | module Backend 7 | class Static < Pluralizing 8 | def initialize(*args) 9 | add(*args) unless args.empty? 10 | end 11 | 12 | def translate(locale, key, options = {}) 13 | result, default, fallback = nil, options.delete(:default), nil 14 | I18n.fallbacks[locale].each do |fallback| 15 | begin 16 | result = super(fallback, key, options) and break 17 | rescue I18n::MissingTranslationData 18 | end 19 | end 20 | result ||= default locale, default, options 21 | 22 | attrs = {:requested_locale => locale, :locale => fallback, :key => key, :options => options} 23 | translation(result, attrs) || raise(I18n::MissingTranslationData.new(locale, key, options)) 24 | end 25 | 26 | protected 27 | 28 | alias :orig_interpolate :interpolate unless method_defined? :orig_interpolate 29 | def interpolate(locale, string, values = {}) 30 | result = orig_interpolate(locale, string, values) 31 | translation = translation(string) 32 | translation.nil? ? result : translation.replace(result) 33 | end 34 | 35 | def translation(result, meta = nil) 36 | return unless result 37 | 38 | case result 39 | when Numeric 40 | result 41 | when String 42 | result = Translation::Static.new(result) unless result.is_a? Translation::Static 43 | result.set_meta meta 44 | result 45 | when Hash 46 | Hash[*result.map do |key, value| 47 | [key, translation(value, meta)] 48 | end.flatten] 49 | when Array 50 | result.map do |value| 51 | translation(value, meta) 52 | end 53 | else 54 | result 55 | # raise "unexpected translation type: #{result.inspect}" 56 | end 57 | end 58 | end 59 | end 60 | end -------------------------------------------------------------------------------- /notes.textile: -------------------------------------------------------------------------------- 1 | Stopped DB Backend in the middle, here's where we left off: 2 | 3 | h1. Some Notes 4 | 5 | * Started doing the migration generator in generators/db_backend.rb 6 | * Translation keys will be in dotted string format 7 | * Question: Do we need a plural_key column, or can we build it in to the dotted key? 8 | * We will probably have to code the following methods from scratch, to optimize db calls: 9 | ** translate 10 | ** localize 11 | ** pluralize 12 | * We should refactor @interpolation@ code so that it can be included into backend code without inheriting SimpleBackend 13 | ** Rationale: interpolation is something done entirely after a string is fetched from the data store 14 | ** Alternately, it could be done from within the I18n module 15 | 16 | h1. Schema 17 | 18 | There will be two db tables. 19 | 20 | # globalize_translations will have: locale, key, translation, created_at, updated_at. 21 | # globalize_translations_map will have: key, translation_id. 22 | 23 | globalize_translations_map will let us easily fetch entire sub-trees of namespaces. 24 | However, this table may not be necessary, as it may be feasible to just use key LIKE "some.namespace.%". 25 | 26 | h1. Caching 27 | 28 | We'll almost certainly want to implement caching in the backend. Should probably be a customized 29 | implementation based on the Rails caching mechanism, to support memcached, etc. 30 | 31 | h1. Queries 32 | 33 | We'll want to pull in lots of stuff at once and return a single translation based on some 34 | quick Ruby selection. The query will look something like this: 35 | 36 |
37 | 
38 | SELECT * FROM globalize_translations
39 | WHERE locale in () AND
40 | key IN (key, default_key)
41 | 
42 | 
43 | 44 | The Ruby code would then pick the first translation that satisfies a fallback, in fallback order. 45 | Of course, the records with the supplied key would take precedence of those with the default key. 46 | 47 | h1. Misc 48 | 49 | We should revisit the :zero plural code. On the one hand it's certainly useful for 50 | many apps in many languages. On the other hand it's not mentioned in CLDR, and not a real 51 | concept in language pluralization. Right now, I'm feeling it's still a good idea to keep it in. 52 | -------------------------------------------------------------------------------- /test/model/active_record/sti_translated_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', '..', 'test_helper' ) 2 | require 'active_record' 3 | require 'globalize/model/active_record' 4 | 5 | # Hook up model translation 6 | ActiveRecord::Base.send(:include, Globalize::Model::ActiveRecord::Translated) 7 | 8 | # Load Post model 9 | require File.join( File.dirname(__FILE__), '..', '..', 'data', 'post' ) 10 | 11 | class StiTranslatedTest < ActiveSupport::TestCase 12 | def setup 13 | I18n.locale = :'en-US' 14 | I18n.fallbacks.clear 15 | reset_db! File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'data', 'schema.rb')) 16 | end 17 | 18 | def teardown 19 | I18n.fallbacks.clear 20 | end 21 | 22 | test "works with simple dynamic finders" do 23 | foo = Child.create :content => 'foo' 24 | Child.create :content => 'bar' 25 | child = Child.find_by_content('foo') 26 | assert_equal foo, child 27 | end 28 | 29 | test 'change attribute on globalized model' do 30 | child = Child.create :content => 'foo' 31 | assert_equal [], child.changed 32 | child.content = 'bar' 33 | assert_equal [ 'content' ], child.changed 34 | child.content = 'baz' 35 | assert_member 'content', child.changed 36 | end 37 | 38 | test 'change attribute on globalized model after locale switching' do 39 | child = Child.create :content => 'foo' 40 | assert_equal [], child.changed 41 | child.content = 'bar' 42 | I18n.locale = :de 43 | assert_equal [ 'content' ], child.changed 44 | end 45 | 46 | test 'fallbacks with lots of locale switching' do 47 | I18n.fallbacks.map :'de-DE' => [ :'en-US' ] 48 | child = Child.create :content => 'foo' 49 | 50 | I18n.locale = :'de-DE' 51 | assert_equal 'foo', child.content 52 | 53 | I18n.locale = :'en-US' 54 | child.update_attribute :content, 'bar' 55 | 56 | I18n.locale = :'de-DE' 57 | assert_equal 'bar', child.content 58 | end 59 | 60 | test "saves all locales, even after locale switching" do 61 | child = Child.new :content => 'foo' 62 | I18n.locale = 'de-DE' 63 | child.content = 'bar' 64 | I18n.locale = 'he-IL' 65 | child.content = 'baz' 66 | child.save 67 | I18n.locale = 'en-US' 68 | child = Child.first 69 | assert_equal 'foo', child.content 70 | I18n.locale = 'de-DE' 71 | assert_equal 'bar', child.content 72 | I18n.locale = 'he-IL' 73 | assert_equal 'baz', child.content 74 | end 75 | end -------------------------------------------------------------------------------- /test/backends/pluralizing_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', 'test_helper' ) 2 | require 'globalize/backend/pluralizing' 3 | 4 | class PluralizingTest < ActiveSupport::TestCase 5 | def setup 6 | @backend = Globalize::Backend::Pluralizing.new 7 | @cz_pluralizer = lambda{|c| c == 1 ? :one : (2..4).include?(c) ? :few : :other } 8 | end 9 | 10 | test "#pluralizer returns the pluralizer for a given locale if defined" do 11 | assert_instance_of Proc, @backend.pluralizer(:en) 12 | end 13 | 14 | test "#pluralizer returns the default pluralizer if no pluralizer is defined for the given locale" do 15 | assert_equal @backend.pluralizer(:en), @backend.pluralizer(:de) 16 | end 17 | 18 | test "#add_pluralizer allows to store a pluralizer per locale" do 19 | assert_nothing_raised { @backend.add_pluralizer(:cz, @cz_pluralizer) } 20 | assert_equal @cz_pluralizer, @backend.pluralizer(:cz) 21 | end 22 | 23 | end 24 | 25 | class PluralizePluralizingTest < ActiveSupport::TestCase 26 | def setup 27 | @backend = Globalize::Backend::Pluralizing.new 28 | @cz_pluralizer = lambda{|c| c == 1 ? :one : (2..4).include?(c) ? :few : :other } 29 | @backend.store_translations :en, :foo => {:one => 'one en foo', :other => 'many en foos'} 30 | @backend.store_translations :cz, :foo => {:one => 'one cz foo', :few => 'few cz foos', :other => 'many cz foos'} 31 | end 32 | 33 | test "looks up the :one translation when count is 1" do 34 | assert_equal 'one en foo', @backend.translate(:en, :foo, :count => 1) 35 | end 36 | 37 | test "looks up the :other translation when count is 2" do 38 | assert_equal 'many en foos', @backend.translate(:en, :foo, :count => 2) 39 | end 40 | end 41 | 42 | class CzPluralizingTest < ActiveSupport::TestCase 43 | def setup 44 | @backend = Globalize::Backend::Pluralizing.new 45 | @cz_pluralizer = lambda{|c| c == 1 ? :one : (2..4).include?(c) ? :few : :other } 46 | @backend.store_translations :en, :foo => {:one => 'one en foo', :other => 'many en foos'} 47 | @backend.store_translations :cz, :foo => {:one => 'one cz foo', :few => 'few cz foos', :other => 'many cz foos'} 48 | @backend.add_pluralizer(:cz, @cz_pluralizer) 49 | end 50 | 51 | test "looks up the :one translation when count is 1 (:cz)" do 52 | assert_equal 'one cz foo', @backend.translate(:cz, :foo, :count => 1) 53 | end 54 | 55 | test "looks up the :few translation when count is 2 (:cz)" do 56 | assert_equal 'few cz foos', @backend.translate(:cz, :foo, :count => 2) 57 | end 58 | 59 | test "looks up the :other translation when count is 5 (:cz)" do 60 | assert_equal 'many cz foos', @backend.translate(:cz, :foo, :count => 5) 61 | end 62 | 63 | end 64 | -------------------------------------------------------------------------------- /lib/globalize/locale/language_tag.rb: -------------------------------------------------------------------------------- 1 | # for specifications see http://en.wikipedia.org/wiki/IETF_language_tag 2 | # 3 | # SimpleParser does not implement advanced usages such as grandfathered tags 4 | 5 | module Globalize 6 | module Locale 7 | module Rfc4646 8 | SUBTAGS = [:language, :script, :region, :variant, :extension, :privateuse, :grandfathered] 9 | FORMATS = {:language => :downcase, :script => :capitalize, :region => :upcase, :variant => :downcase} 10 | end 11 | 12 | class LanguageTag < Struct.new(*Rfc4646::SUBTAGS) 13 | class << self 14 | def parser 15 | @@parser ||= SimpleParser 16 | end 17 | 18 | def parser=(parser) 19 | @@parser = parser 20 | end 21 | 22 | def tag(tag) 23 | matches = parser.match(tag) 24 | new *matches if matches 25 | end 26 | end 27 | 28 | Rfc4646::FORMATS.each do |name, format| 29 | define_method(name) { self[name].send(format) unless self[name].nil? } 30 | end 31 | 32 | def to_sym 33 | to_s.to_sym 34 | end 35 | 36 | def to_s 37 | @tag ||= to_a.compact.join("-") 38 | end 39 | 40 | def to_a 41 | members.collect {|attr| self.send(attr) } 42 | end 43 | 44 | def parent 45 | segs = to_a.compact 46 | segs.length < 2 ? nil : LanguageTag.tag(segs[0..(segs.length-2)].join('-')) 47 | end 48 | 49 | def parents(include_self = true) 50 | result, parent = [], self.dup 51 | result << parent if include_self 52 | while parent = parent.parent 53 | result << parent 54 | end 55 | result 56 | end 57 | 58 | module SimpleParser 59 | PATTERN = %r{\A(?: 60 | ([a-z]{2,3}(?:(?:-[a-z]{3}){0,3})?|[a-z]{4}|[a-z]{5,8}) # language 61 | (?:-([a-z]{4}))? # script 62 | (?:-([a-z]{2}|\d{3}))? # region 63 | (?:-([0-9a-z]{5,8}|\d[0-9a-z]{3}))* # variant 64 | (?:-([0-9a-wyz](?:-[0-9a-z]{2,8})+))* # extension 65 | (?:-(x(?:-[0-9a-z]{1,8})+))?| # privateuse subtag 66 | (x(?:-[0-9a-z]{1,8})+)| # privateuse tag 67 | /* ([a-z]{1,3}(?:-[0-9a-z]{2,8}){1,2}) */ # grandfathered 68 | )\z}xi 69 | 70 | class << self 71 | def match(tag) 72 | c = PATTERN.match(tag.to_s).captures 73 | c[0..4] << (c[5].nil? ? c[6] : c[5]) << c[7] # TODO c[7] is grandfathered, throw a NotImplemented exception here? 74 | rescue 75 | false 76 | end 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /globalize2.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = %q{globalize2} 5 | s.version = "0.0.6" 6 | 7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 8 | s.authors = ["Josh Harvey"] 9 | s.date = %q{2009-06-30} 10 | s.description = %q{The second incarnation of Globalize for Rails} 11 | s.email = %q{joshmh@gmail.com} 12 | s.files = ["lib/globalize/backend/chain.rb", "lib/globalize/backend/pluralizing.rb", "lib/globalize/backend/static.rb", "lib/globalize/i18n/missing_translations_log_handler.rb", "lib/globalize/i18n/missing_translations_raise_handler.rb", "lib/globalize/load_path.rb", "lib/globalize/locale/fallbacks.rb", "lib/globalize/locale/language_tag.rb", "lib/globalize/model/active_record/adapter.rb", "lib/globalize/model/active_record/translated.rb", "lib/globalize/model/active_record.rb", "lib/globalize/translation.rb", "lib/globalize.rb", "lib/rails_edge_load_path_patch.rb", "rails/init.rb", "test/backends/chained_test.rb", "test/backends/pluralizing_test.rb", "test/backends/static_test.rb", "test/data/no_globalize_schema.rb", "test/data/post.rb", "test/data/schema.rb", "test/i18n/missing_translations_test.rb", "test/load_path_test.rb", "test/locale/fallbacks_test.rb", "test/locale/language_tag_test.rb", "test/model/active_record/migration_test.rb", "test/model/active_record/sti_translated_test.rb", "test/model/active_record/translated_test.rb", "test/test_helper.rb", "test/translation_test.rb", "generators/db_backend.rb", "generators/templates/db_backend_migration.rb", "LICENSE", "README.textile", "notes.textile", "init.rb", "lib/locale/root.yml", "test/data/locale/all.yml", "test/data/locale/de-DE.yml", "test/data/locale/en-US/module.yml", "test/data/locale/en-US.yml", "test/data/locale/fi-FI/module.yml", "test/data/locale/root.yml"] 13 | s.homepage = %q{http://github.com/joshmh/globalize2} 14 | s.require_paths = ["lib"] 15 | s.rubygems_version = %q{1.3.3} 16 | s.summary = %q{The second incarnation of Globalize for Rails} 17 | s.test_files = ["test/backends/chained_test.rb", "test/backends/pluralizing_test.rb", "test/backends/static_test.rb", "test/data/no_globalize_schema.rb", "test/data/post.rb", "test/data/schema.rb", "test/i18n/missing_translations_test.rb", "test/load_path_test.rb", "test/locale/fallbacks_test.rb", "test/locale/language_tag_test.rb", "test/model/active_record/migration_test.rb", "test/model/active_record/sti_translated_test.rb", "test/model/active_record/translated_test.rb", "test/test_helper.rb", "test/translation_test.rb", "test/data/locale/all.yml", "test/data/locale/de-DE.yml", "test/data/locale/en-US/module.yml", "test/data/locale/en-US.yml", "test/data/locale/fi-FI/module.yml", "test/data/locale/root.yml"] 18 | 19 | if s.respond_to? :specification_version then 20 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 21 | s.specification_version = 3 22 | 23 | if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then 24 | else 25 | end 26 | else 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/model/active_record/migration_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', '..', 'test_helper' ) 2 | require 'active_record' 3 | require 'globalize/model/active_record' 4 | 5 | # Hook up model translation 6 | ActiveRecord::Base.send(:include, Globalize::Model::ActiveRecord::Translated) 7 | 8 | # Load Post model 9 | require File.join( File.dirname(__FILE__), '..', '..', 'data', 'post' ) 10 | 11 | class MigrationTest < ActiveSupport::TestCase 12 | def setup 13 | reset_db! File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'data', 'no_globalize_schema.rb')) 14 | end 15 | 16 | test 'globalize table added' do 17 | assert !Post.connection.table_exists?( :post_translations ) 18 | Post.create_translation_table! :subject => :string, :content => :text 19 | assert Post.connection.table_exists?( :post_translations ) 20 | columns = Post.connection.columns( :post_translations ) 21 | assert locale = columns.detect {|c| c.name == 'locale' } 22 | assert_equal :string, locale.type 23 | assert subject = columns.detect {|c| c.name == 'subject' } 24 | assert_equal :string, subject.type 25 | assert content = columns.detect {|c| c.name == 'content' } 26 | assert_equal :text, content.type 27 | assert post_id = columns.detect {|c| c.name == 'post_id' } 28 | assert_equal :integer, post_id.type 29 | assert created_at = columns.detect {|c| c.name == 'created_at' } 30 | assert_equal :datetime, created_at.type 31 | assert updated_at = columns.detect {|c| c.name == 'updated_at' } 32 | assert_equal :datetime, updated_at.type 33 | end 34 | 35 | test 'globalize table dropped' do 36 | assert !Post.connection.table_exists?( :post_translations ) 37 | Post.create_translation_table! :subject => :string, :content => :text 38 | assert Post.connection.table_exists?( :post_translations ) 39 | Post.drop_translation_table! 40 | assert !Post.connection.table_exists?( :post_translations ) 41 | end 42 | 43 | test 'exception on untranslated field inputs' do 44 | assert_raise Globalize::Model::UntranslatedMigrationField do 45 | Post.create_translation_table! :subject => :string, :content => :text, :bogus => :string 46 | end 47 | end 48 | 49 | test 'exception on missing field inputs' do 50 | assert_raise Globalize::Model::MigrationMissingTranslatedField do 51 | Post.create_translation_table! :content => :text 52 | end 53 | end 54 | 55 | test 'exception on bad input type' do 56 | assert_raise Globalize::Model::BadMigrationFieldType do 57 | Post.create_translation_table! :subject => :string, :content => :integer 58 | end 59 | end 60 | 61 | test 'create_translation_table! should not be called on non-translated models' do 62 | assert_raise NoMethodError do 63 | Blog.create_translation_table! :name => :string 64 | end 65 | end 66 | 67 | test 'drop_translation_table! should not be called on non-translated models' do 68 | assert_raise NoMethodError do 69 | Blog.drop_translation_table! 70 | end 71 | end 72 | 73 | end -------------------------------------------------------------------------------- /lib/globalize/model/active_record/adapter.rb: -------------------------------------------------------------------------------- 1 | module Globalize 2 | module Model 3 | class AttributeStash < Hash 4 | def contains?(locale, attr_name) 5 | locale = locale.to_sym 6 | self[locale] ||= {} 7 | self[locale].has_key? attr_name 8 | end 9 | 10 | def read(locale, attr_name) 11 | locale = locale.to_sym 12 | self[locale] ||= {} 13 | self[locale][attr_name] 14 | end 15 | 16 | def write(locale, attr_name, value) 17 | locale = locale.to_sym 18 | self[locale] ||= {} 19 | self[locale][attr_name] = value 20 | end 21 | end 22 | 23 | class Adapter 24 | def initialize(record) 25 | @record = record 26 | 27 | # TODO what exactly are the roles of cache and stash 28 | @cache = AttributeStash.new 29 | @stash = AttributeStash.new 30 | end 31 | 32 | def fetch(locale, attr_name) 33 | # locale = I18n.locale 34 | is_cached = @cache.contains?(locale, attr_name) 35 | is_cached ? @cache.read(locale, attr_name) : begin 36 | value = fetch_attribute locale, attr_name 37 | @cache.write locale, attr_name, value if value && value.locale == locale 38 | value 39 | end 40 | end 41 | 42 | def stash(locale, attr_name, value) 43 | @stash.write locale, attr_name, value 44 | @cache.write locale, attr_name, value 45 | end 46 | 47 | def update_translations! 48 | @stash.each do |locale, attrs| 49 | translation = @record.globalize_translations.find_or_initialize_by_locale(locale.to_s) 50 | attrs.each{|attr_name, value| translation[attr_name] = value } 51 | translation.save! 52 | end 53 | @stash.clear 54 | end 55 | 56 | # Clears the cache 57 | def clear 58 | @cache.clear 59 | @stash.clear 60 | end 61 | 62 | private 63 | 64 | def fetch_attribute(locale, attr_name) 65 | fallbacks = I18n.fallbacks[locale].map{|tag| tag.to_s}.map(&:to_sym) 66 | 67 | # If the translations were included with 68 | # :include => globalize_translations 69 | # there is no need to query them again. 70 | unless @record.globalize_translations.loaded? 71 | translations = @record.globalize_translations.by_locales(fallbacks) 72 | else 73 | translations = @record.globalize_translations 74 | end 75 | result, requested_locale = nil, locale 76 | 77 | # Walk through the fallbacks, starting with the current locale itself, and moving 78 | # to the next best choice, until we find a match. 79 | # Check the @globalize_set_translations cache first to see if we've just changed the 80 | # attribute and not saved yet. 81 | fallbacks.each do |fallback| 82 | # TODO should we be checking stash or just cache? 83 | result = @stash.read(fallback, attr_name) || begin 84 | translation = translations.detect {|tr| tr.locale == fallback } 85 | translation && translation.send(attr_name) 86 | end 87 | if result 88 | locale = fallback 89 | break 90 | end 91 | end 92 | result && Translation::Attribute.new(result, :locale => locale, :requested_locale => requested_locale) 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/globalize/backend/chain.rb: -------------------------------------------------------------------------------- 1 | module I18n 2 | class << self 3 | def chain_backends(*args) 4 | self.backend = Globalize::Backend::Chain.new(*args) 5 | end 6 | end 7 | end 8 | 9 | module Globalize 10 | module Backend 11 | class Chain 12 | def initialize(*args) 13 | add(*args) unless args.empty? 14 | end 15 | 16 | # Change this to a) accept any number of backends and b) accept classes. 17 | # When classes are passed instantiate them and add the instances as backends. 18 | # Return the added backends from #add. 19 | # 20 | # Add an initialize method that accepts the same arguments and passes them 21 | # to #add, so we could: 22 | # I18n.backend = Globalize::Backend::Chain.new(Globalize::Backend::Foo, Globalize::Backend::Bar) 23 | # Globalize::Backend::Chain.new(:foo, :bar) 24 | # Globalize.chain_backends :foo, :bar 25 | def add(*backends) 26 | backends.each do |backend| 27 | backend = Globalize::Backend.const_get(backend.to_s.capitalize) if backend.is_a? Symbol 28 | backend = backend.new if backend.is_a? Class 29 | self.backends << backend 30 | end 31 | end 32 | 33 | def load_translations(*args) 34 | backends.each{|backend| backend.load_translations(*args) } 35 | end 36 | 37 | # For defaults: 38 | # Never pass any default option to the backends but instead implement our own default 39 | # mechanism (e.g. symbols as defaults would need to be passed to the whole chain to 40 | # be translated). 41 | # 42 | # For namespace lookup: 43 | # Only return if the result is not a hash OR count is not present, otherwise merge them. 44 | # So in effect the count variable would control whether we have a namespace lookup or a 45 | # pluralization going on. 46 | # 47 | # Exceptions: 48 | # Make sure that we catch MissingTranslationData exceptions and raise 49 | # one in the end when no translation was found at all. 50 | # 51 | # For bulk translation: 52 | # If the key is an array we need to call #translate for each of the 53 | # keys and collect the results. 54 | 55 | def translate(locale, key, options = {}) 56 | raise I18n::InvalidLocale.new(locale) if locale.nil? 57 | return key.map{|k| translate locale, k, options } if key.is_a? Array 58 | 59 | default = options.delete(:default) 60 | result = backends.inject({}) do |namespace, backend| 61 | begin 62 | translation = backend.translate(locale.to_sym, key, options) 63 | if namespace_lookup?(translation, options) 64 | namespace.merge! translation 65 | elsif translation 66 | return translation 67 | end 68 | rescue I18n::MissingTranslationData 69 | end 70 | end 71 | result || default(locale, default, options) || raise(I18n::MissingTranslationData.new(locale, key, options)) 72 | end 73 | 74 | def localize(locale, object, format = :default) 75 | backends.each do |backend| 76 | result = backend.localize(locale, object, format) and return result 77 | end 78 | end 79 | 80 | protected 81 | def backends 82 | @backends ||= [] 83 | end 84 | 85 | def default(locale, default, options = {}) 86 | case default 87 | when String then default 88 | when Symbol then translate locale, default, options 89 | when Array then default.each do |obj| 90 | result = default(locale, obj, options.dup) and return result 91 | end and nil 92 | end 93 | rescue I18n::MissingTranslationData 94 | nil 95 | end 96 | 97 | def namespace_lookup?(result, options) 98 | result.is_a?(Hash) and not options.has_key?(:count) 99 | end 100 | end 101 | end 102 | end -------------------------------------------------------------------------------- /test/locale/language_tag_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', 'test_helper' ) 2 | require 'globalize/locale/language_tag' 3 | 4 | include Globalize::Locale 5 | 6 | class LanguageTagTest < ActiveSupport::TestCase 7 | test "given a valid tag 'de' returns an LanguageTag from #tag" do 8 | assert_instance_of LanguageTag, LanguageTag.tag('de') 9 | end 10 | 11 | test "given a valid tag 'de' returns an array of subtags" do 12 | assert_equal ['de', nil, nil, nil, nil, nil, nil], LanguageTag::SimpleParser.match('de') 13 | end 14 | 15 | test "given a valid tag 'de-DE' returns an array of subtags" do 16 | assert_equal ['de', nil, 'DE', nil, nil, nil, nil], LanguageTag::SimpleParser.match('de-DE') 17 | end 18 | 19 | test "given a valid lowercase tag 'de-latn-de-variant-x-phonebk' returns an array of subtags" do 20 | assert_equal ['de', 'latn', 'de', 'variant', nil, 'x-phonebk', nil], 21 | LanguageTag::SimpleParser.match('de-latn-de-variant-x-phonebk') 22 | end 23 | 24 | test "given a valid uppercase tag 'DE-LATN-DE-VARIANT-X-PHONEBK' returns an array of subtags" do 25 | assert_equal ['DE', 'LATN', 'DE', 'VARIANT', nil, 'X-PHONEBK', nil], 26 | LanguageTag::SimpleParser.match('DE-LATN-DE-VARIANT-X-PHONEBK') 27 | end 28 | 29 | test "given an invalid tag 'a-DE' test returns false" do 30 | assert !LanguageTag::SimpleParser.match('a-DE') 31 | end 32 | 33 | test "given an invalid tag 'de-419-DE' test returns false" do 34 | assert !LanguageTag::SimpleParser.match('de-419-DE') 35 | end 36 | end 37 | 38 | class DeLatnLanguageTagTest < ActiveSupport::TestCase 39 | def setup 40 | subtags = %w(de Latn DE variant a-ext x-phonebk i-klingon) 41 | @tag = LanguageTag.new *subtags 42 | end 43 | 44 | test "returns 'de' as the language subtag in lowercase" do 45 | assert_equal 'de', @tag.language 46 | end 47 | 48 | test "returns 'Latn' as the script subtag in titlecase" do 49 | assert_equal 'Latn', @tag.script 50 | end 51 | 52 | test "returns 'DE' as the region subtag in uppercase" do 53 | assert_equal 'DE', @tag.region 54 | end 55 | 56 | test "returns 'variant' as the variant subtag in lowercase" do 57 | assert_equal 'variant', @tag.variant 58 | end 59 | 60 | test "returns 'a-ext' as the extension subtag" do 61 | assert_equal 'a-ext', @tag.extension 62 | end 63 | 64 | test "returns 'x-phonebk' as the privateuse subtag" do 65 | assert_equal 'x-phonebk', @tag.privateuse 66 | end 67 | 68 | test "returns 'i-klingon' as the grandfathered subtag" do 69 | assert_equal 'i-klingon', @tag.grandfathered 70 | end 71 | 72 | test "returns a formatted tag string from #to_s" do 73 | assert_equal 'de-Latn-DE-variant-a-ext-x-phonebk-i-klingon', @tag.to_s 74 | end 75 | 76 | test "returns an array containing the formatted subtags from #to_a" do 77 | assert_equal %w(de Latn DE variant a-ext x-phonebk i-klingon), @tag.to_a 78 | end 79 | end 80 | 81 | class InheritanceLanguageTagTest < ActiveSupport::TestCase 82 | test "returns 'de-Latn-DE-variant-a-ext-x-phonebk' as the parent of 'de-Latn-DE-variant-a-ext-x-phonebk-i-klingon'" do 83 | tag = LanguageTag.new *%w(de Latn DE variant a-ext x-phonebk i-klingon) 84 | assert_equal 'de-Latn-DE-variant-a-ext-x-phonebk', tag.parent.to_s 85 | end 86 | 87 | test "returns 'de-Latn-DE-variant-a-ext' as the parent of 'de-Latn-DE-variant-a-ext-x-phonebk'" do 88 | tag = LanguageTag.new *%w(de Latn DE variant a-ext x-phonebk) 89 | assert_equal 'de-Latn-DE-variant-a-ext', tag.parent.to_s 90 | end 91 | 92 | test "returns 'de-Latn-DE-variant' as the parent of 'de-Latn-DE-variant-a-ext'" do 93 | tag = LanguageTag.new *%w(de Latn DE variant a-ext) 94 | assert_equal 'de-Latn-DE-variant', tag.parent.to_s 95 | end 96 | 97 | test "returns 'de-Latn-DE' as the parent of 'de-Latn-DE-variant'" do 98 | tag = LanguageTag.new *%w(de Latn DE variant) 99 | assert_equal 'de-Latn-DE', tag.parent.to_s 100 | end 101 | 102 | test "returns 'de-Latn' as the parent of 'de-Latn-DE'" do 103 | tag = LanguageTag.new *%w(de Latn DE) 104 | assert_equal 'de-Latn', tag.parent.to_s 105 | end 106 | 107 | test "returns 'de' as the parent of 'de-Latn'" do 108 | tag = LanguageTag.new *%w(de Latn) 109 | assert_equal 'de', tag.parent.to_s 110 | end 111 | 112 | # TODO RFC4647 says: "If no language tag matches the request, the "default" value is returned." 113 | # where should we set the default language? 114 | # test "returns '' as the parent of 'de'" do 115 | # tag = LanguageTag.new *%w(de) 116 | # assert_equal '', tag.parent.to_s 117 | # end 118 | 119 | test "returns an array of 5 parents for 'de-Latn-DE-variant-a-ext-x-phonebk-i-klingon'" do 120 | parents = %w(de-Latn-DE-variant-a-ext-x-phonebk-i-klingon 121 | de-Latn-DE-variant-a-ext-x-phonebk 122 | de-Latn-DE-variant-a-ext 123 | de-Latn-DE-variant 124 | de-Latn-DE 125 | de-Latn 126 | de) 127 | tag = LanguageTag.new *%w(de Latn DE variant a-ext x-phonebk i-klingon) 128 | assert_equal parents, tag.parents.map{|tag| tag.to_s} 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/backends/static_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', 'test_helper' ) 2 | require 'globalize/backend/static' 3 | require 'globalize/translation' 4 | require 'action_view' 5 | include ActionView::Helpers::NumberHelper 6 | 7 | I18n.locale = :'en-US' # Need to set this, since I18n defaults to 'en' 8 | 9 | class StaticTest < ActiveSupport::TestCase 10 | def setup 11 | I18n.backend = Globalize::Backend::Static.new 12 | translations = {:"en-US" => {:foo => "foo in en-US", :boz => 'boz', :buz => {:bum => 'bum'}}, 13 | :"en" => {:bar => "bar in en"}, 14 | :"de-DE" => {:baz => "baz in de-DE"}, 15 | :"de" => {:boo => "boo in de", :number => { :currency => { :format => { :unit => '€', :format => '%n %u'}}}}} 16 | translations.each do |locale, data| 17 | I18n.backend.store_translations locale, data 18 | end 19 | I18n.fallbacks.map :"de-DE" => :"en-US", :he => :en 20 | end 21 | 22 | test "returns an instance of Translation:Static" do 23 | translation = I18n.translate :foo 24 | assert_instance_of Globalize::Translation::Static, translation 25 | end 26 | 27 | test "returns the translation in en-US if present" do 28 | assert_equal "foo in en-US", I18n.translate(:foo, :locale => :"en-US") 29 | end 30 | 31 | test "returns the translation in en if en-US is not present" do 32 | assert_equal "bar in en", I18n.translate(:bar, :locale => :"en-US") 33 | end 34 | 35 | test "returns the translation in de-DE if present" do 36 | assert_equal "baz in de-DE", I18n.translate(:baz, :locale => :"de-DE") 37 | end 38 | 39 | test "returns the translation in de if de-DE is not present" do 40 | assert_equal "boo in de", I18n.translate(:boo, :locale => :"de-DE") 41 | end 42 | 43 | test "returns the translation in en-US if none of de-DE and de are present" do 44 | assert_equal "foo in en-US", I18n.translate(:foo, :locale => :"de-DE") 45 | end 46 | 47 | test "returns the translation in en if none of de-DE, de and en-US are present" do 48 | assert_equal "bar in en", I18n.translate(:bar, :locale => :"de-DE") 49 | end 50 | 51 | test "returns the translation in en if none in he is present" do 52 | assert_equal "bar in en", I18n.translate(:bar, :locale => :he) 53 | end 54 | 55 | test "returns the given default String when the key is not present for any locale" do 56 | assert_equal "default", I18n.translate(:missing, :default => "default") 57 | end 58 | 59 | test "returns the fallback translation for the key if present for a fallback locale" do 60 | I18n.backend.store_translations :de, :non_default => "non_default in de" 61 | assert_equal "non_default in de", I18n.translate(:non_default, :default => "default", :locale => :"de-DE") 62 | end 63 | 64 | test "returns an array of translations" do 65 | assert_instance_of Array, I18n.translate([:foo, :boz]) 66 | end 67 | 68 | test "returns an array of instances of Translation::Static" do 69 | assert_equal [Globalize::Translation::Static], I18n.translate([:foo, :boz]).map(&:class).uniq 70 | end 71 | 72 | test "returns a hash of translations" do 73 | assert_instance_of Hash, I18n.translate(:"buz") 74 | end 75 | 76 | test "returns an array of translations 2" do 77 | assert_equal [Globalize::Translation::Static], I18n.translate(:"buz").values.map(&:class) 78 | end 79 | 80 | test "returns currency properly formated" do 81 | currency = number_to_currency(10) 82 | assert_equal "$10.00", currency 83 | end 84 | 85 | test "returns currency properly formated for locale" do 86 | currency = number_to_currency(10, :locale => :'de') 87 | assert_equal "10.000 €", currency 88 | end 89 | 90 | test "returns currency properly formated from parameters" do 91 | currency = number_to_currency(10, :format => "%n %u", :unit => '€') 92 | assert_equal "10.00 €", currency 93 | end 94 | 95 | test "makes sure interpolation does not break even with False as string" do 96 | assert_equal "translation missing: en, support, array, skip_last_comma", I18n.translate(:"support.array.skip_last_comma") 97 | end 98 | end 99 | 100 | class TranslationStaticTest < ActiveSupport::TestCase 101 | def setup 102 | I18n.backend = Globalize::Backend::Static.new 103 | translations = { 104 | :greeting => "Hi {{name}}", 105 | :messages => { :one => "You have one message.", :other => "You have {{count}} messages."} 106 | } 107 | I18n.backend.store_translations :"en", translations 108 | end 109 | 110 | def greeting 111 | I18n.translate :greeting, :locale => :"en-US", :name => "Joshua" 112 | end 113 | 114 | test "stores the actual locale" do 115 | assert_equal :en, greeting.locale 116 | end 117 | 118 | test "stores the requested locale" do 119 | assert_equal :'en-US', greeting.requested_locale 120 | end 121 | 122 | test "stores the requested key" do 123 | assert_equal :greeting, greeting.key 124 | end 125 | 126 | test "stores the options given to #translate" do 127 | assert_equal( {:name => "Joshua"}, greeting.options ) 128 | end 129 | 130 | test "stores the original translation before test was interpolated" do 131 | assert_equal "Hi {{name}}", greeting.original 132 | end 133 | 134 | test "stores the plural_key :one if pluralized as such" do 135 | message = I18n.translate :messages, :locale => :"en-US", :count => 1 136 | assert_equal :one, message.plural_key 137 | end 138 | 139 | test "stores the plural_key :other if pluralized as such" do 140 | messages = I18n.translate :messages, :locale => :"en-US", :count => 2 141 | assert_equal :other, messages.plural_key 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/locale/fallbacks_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', 'test_helper' ) 2 | require 'globalize/locale/fallbacks' 3 | 4 | include Globalize::Locale 5 | I18n.default_locale = :'en-US' # This has to be set explicitly, no longer default for I18n 6 | 7 | class FallbacksTest < ActiveSupport::TestCase 8 | def setup 9 | I18n.fallbacks = Fallbacks.new 10 | end 11 | 12 | def teardown 13 | I18n.default_locale = :'en-US' 14 | end 15 | 16 | test "#[] caches computed results" do 17 | I18n.fallbacks['en'] 18 | assert_equal( { :en => [:en, :"en-US", :root] }, I18n.fallbacks ) 19 | end 20 | 21 | test "#defaults always reflect the I18n.default_locale if no default has been set manually" do 22 | I18n.default_locale = :'en-US' 23 | assert_equal( [:'en-US', :en, :root], I18n.fallbacks.defaults ) 24 | end 25 | 26 | test "#defaults always reflect a manually passed default locale if any" do 27 | I18n.fallbacks = Fallbacks.new(:'fi-FI') 28 | assert_equal( [:'fi-FI', :fi, :root], I18n.fallbacks.defaults ) 29 | I18n.default_locale = :'de-DE' 30 | assert_equal( [:'fi-FI', :fi, :root], I18n.fallbacks.defaults ) 31 | end 32 | 33 | test "#defaults allows to set multiple defaults" do 34 | I18n.fallbacks = Fallbacks.new(:'fi-FI', :'se-FI') 35 | assert_equal( [:'fi-FI', :fi, :'se-FI', :se, :root], I18n.fallbacks.defaults ) 36 | end 37 | end 38 | 39 | class NoMappingFallbacksTest < ActiveSupport::TestCase 40 | def setup 41 | @fallbacks = Fallbacks.new(:'en-US') 42 | end 43 | 44 | test "returns [:es, :en-US, :root] for :es" do 45 | assert_equal [:es, :"en-US", :en, :root], @fallbacks[:es] 46 | end 47 | 48 | test "returns [:es-ES, :es, :en-US, :root] for :es-ES" do 49 | assert_equal [:"es-ES", :es, :"en-US", :en, :root], @fallbacks[:"es-ES"] 50 | end 51 | 52 | test "returns [:es-MX, :es, :en-US, :root] for :es-MX" do 53 | assert_equal [:"es-MX", :es, :"en-US", :en, :root], @fallbacks[:"es-MX"] 54 | end 55 | 56 | test "returns [:es-Latn-ES, :es-Latn, :es, :en-US, :root] for :es-Latn-ES" do 57 | assert_equal [:"es-Latn-ES", :"es-Latn", :es, :"en-US", :en, :root], @fallbacks[:'es-Latn-ES'] 58 | end 59 | 60 | test "returns [:en, :en-US, :root] for :en" do 61 | assert_equal [:en, :"en-US", :root], @fallbacks[:en] 62 | end 63 | 64 | test "returns [:en-US, :en, :root] for :en-US (special case: locale == default)" do 65 | assert_equal [:"en-US", :en, :root], @fallbacks[:"en-US"] 66 | end 67 | end 68 | 69 | class CaMappingFallbacksTest < ActiveSupport::TestCase 70 | # Most people who speak Catalan also live in Spain, so test is safe to assume 71 | # that they also speak Spanish as spoken in Spain. 72 | def setup 73 | @fallbacks = Fallbacks.new(:'en-US') 74 | @fallbacks.map :ca => :"es-ES" 75 | end 76 | 77 | test "returns [:ca, :es-ES, :es, :en-US, :root] for :ca" do 78 | assert_equal [:ca, :"es-ES", :es, :"en-US", :en, :root], @fallbacks[:ca] 79 | end 80 | 81 | test "returns [:ca-ES, :ca, :es-ES, :es, :en-US, :root] for :ca-ES" do 82 | assert_equal [:"ca-ES", :ca, :"es-ES", :es, :"en-US", :en, :root], @fallbacks[:"ca-ES"] 83 | end 84 | end 85 | 86 | class ArMappingFallbacksTest < ActiveSupport::TestCase 87 | # People who speak Arabic as spoken in Palestine often times also speak 88 | # Hebrew as spoken in Israel. However test is in no way safe to assume that 89 | # everybody who speaks Arabic also speaks Hebrew. 90 | def setup 91 | @fallbacks = Fallbacks.new(:'en-US') 92 | @fallbacks.map :"ar-PS" => :"he-IL" 93 | end 94 | 95 | test "returns [:ar, :en-US, :root] for :ar" do 96 | assert_equal [:ar, :"en-US", :en, :root], @fallbacks[:ar] 97 | end 98 | 99 | test "returns [:ar-EG, :ar, :en-US, :root] for :ar-EG" do 100 | assert_equal [:"ar-EG", :ar, :"en-US", :en, :root], @fallbacks[:"ar-EG"] 101 | end 102 | 103 | test "returns [:ar-PS, :ar, :he-IL, :he, :en-US, :root] for :ar-PS" do 104 | assert_equal [:"ar-PS", :ar, :"he-IL", :he, :"en-US", :en, :root], @fallbacks[:"ar-PS"] 105 | end 106 | end 107 | 108 | class SmsMappingFallbacksTest < ActiveSupport::TestCase 109 | # Sami people live in several scandinavian countries. In Finnland many people 110 | # know Swedish and Finnish. Thus, test can be assumed that Sami living in 111 | # Finnland also speak Swedish and Finnish. 112 | def setup 113 | @fallbacks = Fallbacks.new(:'en-US') 114 | @fallbacks.map :sms => [:"se-FI", :"fi-FI"] 115 | end 116 | 117 | test "returns [:sms-FI, :sms, :se-FI, :se, :fi-FI, :fi, :en-US, :root] for :sms-FI" do 118 | assert_equal [:"sms-FI", :sms, :"se-FI", :se, :"fi-FI", :fi, :"en-US", :en, :root], @fallbacks[:"sms-FI"] 119 | end 120 | end 121 | 122 | class DeAtMappingFallbacksTest < ActiveSupport::TestCase 123 | def setup 124 | @fallbacks = Fallbacks.new(:'en-US') 125 | @fallbacks.map :"de-AT" => :"de-DE" 126 | end 127 | 128 | test "returns [:de, :en-US, :root] for de" do 129 | assert_equal [:de, :"en-US", :en, :root], @fallbacks[:"de"] 130 | end 131 | 132 | test "returns [:de-DE, :de, :en-US, :root] for de-DE" do 133 | assert_equal [:"de-DE", :de, :"en-US", :en, :root], @fallbacks[:"de-DE"] 134 | end 135 | 136 | test "returns [:de-AT, :de, :de-DE, :en-US, :root] for de-AT" do 137 | assert_equal [:"de-AT", :de, :"de-DE", :"en-US", :en, :root], @fallbacks[:"de-AT"] 138 | end 139 | end 140 | 141 | class DeMappingFallbacksTest < ActiveSupport::TestCase 142 | def setup 143 | @fallbacks = Fallbacks.new(:'en-US') 144 | @fallbacks.map :de => :en, :he => :en 145 | end 146 | 147 | test "returns [:de, :en, :root] for :de" do 148 | assert_equal [:de, :en, :"en-US", :root], @fallbacks[:de] 149 | end 150 | 151 | test "returns [:he, :en, :root] for :de" do 152 | assert_equal [:he, :en, :"en-US", :root], @fallbacks[:he] 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/globalize/model/active_record/translated.rb: -------------------------------------------------------------------------------- 1 | module Globalize 2 | module Model 3 | 4 | class MigrationError < StandardError; end 5 | class UntranslatedMigrationField < MigrationError; end 6 | class MigrationMissingTranslatedField < MigrationError; end 7 | class BadMigrationFieldType < MigrationError; end 8 | 9 | module ActiveRecord 10 | module Translated 11 | def self.included(base) 12 | base.extend ActMethods 13 | end 14 | 15 | module ActMethods 16 | def translates(*attr_names) 17 | options = attr_names.extract_options! 18 | options[:translated_attributes] = attr_names 19 | 20 | # Only set up once per class 21 | unless included_modules.include? InstanceMethods 22 | class_inheritable_accessor :globalize_options, :globalize_proxy 23 | 24 | include InstanceMethods 25 | extend ClassMethods 26 | 27 | metaclass.alias_method_chain :find_every, :globalize2 28 | metaclass.alias_method_chain :construct_finder_sql, :globalize2 29 | 30 | self.globalize_proxy = Globalize::Model::ActiveRecord.create_proxy_class(self) 31 | has_many( 32 | :globalize_translations, 33 | :class_name => globalize_proxy.name, 34 | :extend => Extensions, 35 | :dependent => :delete_all, 36 | :foreign_key => self.name.gsub(/.+::/, '').foreign_key 37 | ) 38 | 39 | after_save :update_globalize_record 40 | end 41 | 42 | self.globalize_options = options 43 | Globalize::Model::ActiveRecord.define_accessors(self, attr_names) 44 | 45 | # Import any callbacks that have been defined by extensions to Globalize2 46 | # and run them. 47 | extend Callbacks 48 | Callbacks.instance_methods.each {|cb| send cb } 49 | end 50 | 51 | def locale=(locale) 52 | @@locale = locale 53 | end 54 | 55 | def locale 56 | (defined?(@@locale) && @@locale) || I18n.locale 57 | end 58 | end 59 | 60 | # Dummy Callbacks module. Extensions to Globalize2 can insert methods into here 61 | # and they'll be called at the end of the translates class method. 62 | module Callbacks 63 | end 64 | 65 | # Extension to the has_many :globalize_translations association 66 | module Extensions 67 | def by_locales(locales) 68 | find :all, :conditions => { :locale => locales.map(&:to_s) } 69 | end 70 | end 71 | 72 | module ClassMethods 73 | def method_missing(method, *args) 74 | if method.to_s =~ /^find_by_(\w+)$/ && globalize_options[:translated_attributes].include?($1.to_sym) 75 | find(:first, :joins => :globalize_translations, 76 | :conditions => [ "#{i18n_attr($1)} = ? AND #{i18n_attr('locale')} IN (?)", 77 | args.first,I18n.fallbacks[I18n.locale].map{|tag| tag.to_s}]) 78 | else 79 | super 80 | end 81 | end 82 | 83 | def create_translation_table!(fields) 84 | translated_fields = self.globalize_options[:translated_attributes] 85 | translated_fields.each do |f| 86 | raise MigrationMissingTranslatedField, "Missing translated field #{f}" unless fields[f] 87 | end 88 | fields.each do |name, type| 89 | unless translated_fields.member? name 90 | raise UntranslatedMigrationField, "Can't migrate untranslated field: #{name}" 91 | end 92 | unless [ :string, :text ].member? type 93 | raise BadMigrationFieldType, "Bad field type for #{name}, should be :string or :text" 94 | end 95 | end 96 | translation_table_name = self.name.underscore.gsub('/', '_') + '_translations' 97 | self.connection.create_table(translation_table_name) do |t| 98 | t.integer self.name.gsub(/.+::/, '').foreign_key 99 | t.string :locale 100 | fields.each do |name, type| 101 | t.column name, type 102 | end 103 | t.timestamps 104 | end 105 | end 106 | 107 | def drop_translation_table! 108 | translation_table_name = self.name.underscore.gsub('/', '_') + '_translations' 109 | self.connection.drop_table translation_table_name 110 | end 111 | 112 | def find_every_with_globalize2(options) 113 | locale = I18n.locale 114 | locales = I18n.fallbacks[locale].map{ |tag| tag.to_s } 115 | scope_options = { :joins => :globalize_translations, 116 | :conditions => [ "#{i18n_attr('locale')} IN (?)", locales ] } 117 | with_scope(:find => scope_options) do 118 | find_every_without_globalize2(options) 119 | end 120 | end 121 | 122 | def construct_finder_sql_with_globalize2(options) 123 | sql = construct_finder_sql_without_globalize2(options) 124 | sql.sub! /SELECT(\s+DISTINCT)?/, 'SELECT DISTINCT' 125 | sql 126 | end 127 | 128 | private 129 | 130 | def i18n_attr(attribute_name) 131 | self.base_class.name.underscore.gsub('/', '_') + "_translations.#{attribute_name}" 132 | end 133 | end 134 | 135 | module InstanceMethods 136 | def reload(options = nil) 137 | globalize.clear 138 | 139 | # clear all globalized attributes 140 | # TODO what's the best way to handle this? 141 | self.class.globalize_options[:translated_attributes].each do |attr| 142 | @attributes.delete attr.to_s 143 | end 144 | 145 | super options 146 | end 147 | 148 | def globalize 149 | @globalize ||= Adapter.new self 150 | end 151 | 152 | def update_globalize_record 153 | globalize.update_translations! 154 | end 155 | 156 | def translated_locales 157 | globalize_translations.scoped(:select => 'DISTINCT locale').map {|gt| gt.locale.to_sym } 158 | end 159 | 160 | def set_translations options 161 | options.keys.each do |key| 162 | 163 | translation = globalize_translations.find_by_locale(key.to_s) || 164 | globalize_translations.build(:locale => key.to_s) 165 | translation.update_attributes!(options[key]) 166 | end 167 | end 168 | 169 | end 170 | end 171 | end 172 | end 173 | end -------------------------------------------------------------------------------- /test/backends/chained_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', 'test_helper' ) 2 | require 'globalize/backend/chain' 3 | 4 | module Globalize 5 | module Backend 6 | class Dummy 7 | def translate(locale, key, options = {}) 8 | end 9 | end 10 | end 11 | end 12 | 13 | class ChainedTest < ActiveSupport::TestCase 14 | 15 | test "instantiates a chained backend and sets test as backend" do 16 | assert_nothing_raised { I18n.chain_backends } 17 | assert_instance_of Globalize::Backend::Chain, I18n.backend 18 | end 19 | 20 | test "passes all given arguments to the chained backends #initialize method" do 21 | Globalize::Backend::Chain.expects(:new).with(:spec, :simple) 22 | I18n.chain_backends :spec, :simple 23 | end 24 | 25 | test "passes all given arguments to #add assuming that they are backends" do 26 | # no idea how to spec that 27 | end 28 | end 29 | 30 | class AddChainedTest < ActiveSupport::TestCase 31 | def setup 32 | I18n.backend = Globalize::Backend::Chain.new 33 | end 34 | 35 | test "accepts an instance of a backend" do 36 | assert_nothing_raised { I18n.backend.add Globalize::Backend::Dummy.new } 37 | assert_instance_of Globalize::Backend::Dummy, I18n.backend.send(:backends).first 38 | end 39 | 40 | test "accepts a class and instantiates the backend" do 41 | assert_nothing_raised { I18n.backend.add Globalize::Backend::Dummy } 42 | assert_instance_of Globalize::Backend::Dummy, I18n.backend.send(:backends).first 43 | end 44 | 45 | test "accepts a symbol, constantizes test as a backend class and instantiates the backend" do 46 | assert_nothing_raised { I18n.backend.add :dummy } 47 | assert_instance_of Globalize::Backend::Dummy, I18n.backend.send(:backends).first 48 | end 49 | 50 | test "accepts any number of backend instances, classes or symbols" do 51 | assert_nothing_raised { I18n.backend.add Globalize::Backend::Dummy.new, Globalize::Backend::Dummy, :dummy } 52 | assert_instance_of Globalize::Backend::Dummy, I18n.backend.send(:backends).first 53 | assert_equal [ Globalize::Backend::Dummy, Globalize::Backend::Dummy, Globalize::Backend::Dummy ], 54 | I18n.backend.send(:backends).map{|backend| backend.class } 55 | end 56 | 57 | end 58 | 59 | class TranslateChainedTest < ActiveSupport::TestCase 60 | def setup 61 | I18n.locale = :en 62 | I18n.backend = Globalize::Backend::Chain.new 63 | @first_backend = I18n::Backend::Simple.new 64 | @last_backend = I18n::Backend::Simple.new 65 | I18n.backend.add @first_backend 66 | I18n.backend.add @last_backend 67 | end 68 | 69 | test "delegates #translate to all backends in the order they were added" do 70 | @first_backend.expects(:translate).with(:en, :foo, {}) 71 | @last_backend.expects(:translate).with(:en, :foo, {}) 72 | I18n.translate :foo 73 | end 74 | 75 | test "returns the result from #translate from the first backend if test's not nil" do 76 | @first_backend.store_translations :en, {:foo => 'foo from first backend'} 77 | @last_backend.store_translations :en, {:foo => 'foo from last backend'} 78 | result = I18n.translate :foo 79 | assert_equal 'foo from first backend', result 80 | end 81 | 82 | test "returns the result from #translate from the second backend if the first one returned nil" do 83 | @first_backend.store_translations :en, {} 84 | @last_backend.store_translations :en, {:foo => 'foo from last backend'} 85 | result = I18n.translate :foo 86 | assert_equal 'foo from last backend', result 87 | end 88 | 89 | test "looks up a namespace from all backends and merges them (if a result is a hash and no count option is present)" do 90 | @first_backend.store_translations :en, {:foo => {:bar => 'bar from first backend'}} 91 | @last_backend.store_translations :en, {:foo => {:baz => 'baz from last backend'}} 92 | result = I18n.translate :foo 93 | assert_equal( {:bar => 'bar from first backend', :baz => 'baz from last backend'}, result ) 94 | end 95 | 96 | test "raises a MissingTranslationData exception if no translation was found" do 97 | assert_raise( I18n::MissingTranslationData ) { I18n.translate :not_here, :raise => true } 98 | end 99 | 100 | test "raises an InvalidLocale exception if the locale is nil" do 101 | assert_raise( I18n::InvalidLocale ) { Globalize::Backend::Chain.new.translate nil, :foo } 102 | end 103 | 104 | test "bulk translates a number of keys from different backends" do 105 | @first_backend.store_translations :en, {:foo => 'foo from first backend'} 106 | @last_backend.store_translations :en, {:bar => 'bar from last backend'} 107 | result = I18n.translate [:foo, :bar] 108 | assert_equal( ['foo from first backend', 'bar from last backend'], result ) 109 | end 110 | 111 | test "still calls #translate on all the backends" do 112 | @last_backend.expects :translate 113 | I18n.translate :not_here, :default => 'default' 114 | end 115 | 116 | test "returns a given default string when no backend returns a translation" do 117 | result = I18n.translate :not_here, :default => 'default' 118 | assert_equal 'default', result 119 | end 120 | 121 | end 122 | 123 | class CustomLocalizeBackend < I18n::Backend::Simple 124 | def localize(locale, object, format = :default) 125 | "result from custom localize backend" if locale == 'custom' 126 | end 127 | end 128 | 129 | class LocalizeChainedTest < ActiveSupport::TestCase 130 | def setup 131 | I18n.locale = :en 132 | I18n.backend = Globalize::Backend::Chain.new 133 | @first_backend = CustomLocalizeBackend.new 134 | @last_backend = I18n::Backend::Simple.new 135 | I18n.backend.add @first_backend 136 | I18n.backend.add @last_backend 137 | @time = Time.now 138 | end 139 | 140 | test "delegates #localize to all backends in the order they were added" do 141 | @first_backend.expects(:localize).with(:en, @time, :default) 142 | @last_backend.expects(:localize).with(:en, @time, :default) 143 | I18n.localize @time 144 | end 145 | 146 | test "returns the result from #localize from the first backend if test's not nil" do 147 | @last_backend.expects(:localize).never 148 | result = I18n.localize @time, :locale => 'custom' 149 | assert_equal 'result from custom localize backend', result 150 | end 151 | 152 | test "returns the result from #localize from the second backend if the first one returned nil" do 153 | @last_backend.expects(:localize).returns "value from last backend" 154 | result = I18n.localize @time 155 | assert_equal 'value from last backend', result 156 | end 157 | end 158 | 159 | class NamespaceChainedTest < ActiveSupport::TestCase 160 | def setup 161 | @backend = Globalize::Backend::Chain.new 162 | end 163 | 164 | test "returns false if the given result is not a Hash" do 165 | assert !@backend.send(:namespace_lookup?, 'foo', {}) 166 | end 167 | 168 | test "returns false if a count option is present" do 169 | assert !@backend.send(:namespace_lookup?, {:foo => 'foo'}, {:count => 1}) 170 | end 171 | 172 | test "returns true if the given result is a Hash AND no count option is present" do 173 | assert @backend.send(:namespace_lookup?, {:foo => 'foo'}, {}) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. Globalize2 2 | 3 | Globalize2 is the successor of Globalize for Rails. 4 | 5 | It is compatible with and builds on the new "I18n api in Ruby on Rails":http://rails-i18n.org. and adds model translations as well as a bunch of other useful features, such as Locale fallbacks (RFC4647 compliant) and automatic loading of Locale data from defined directory/file locations. 6 | 7 | Globalize2 is much more lightweight and modular than its predecessor was. Content translations in Globalize2 use default ActiveRecord features and do not limit any functionality any more. 8 | 9 | All features and tools in Globalize2 are implemented in the most unobstrusive and loosely-coupled way possible, so you can pick whatever features or tools you need for your application and combine them with other tools from other libraries or plugins. 10 | 11 | h2. Requirements 12 | 13 | Rails 2.2 (currently Rails edge) 14 | 15 | h2. Installation 16 | 17 | To install Globalize2 with its default setup just use: 18 | 19 |

 20 | script/plugin install git://github.com/joshmh/globalize2.git
 21 | 
22 | 23 | This will: 24 | 25 | * activate model translations 26 | * set I18n.load_path to an instance of Globalize::LoadPath 27 | * set I18n.backend to an instance of Globalize::Backend::Static 28 | 29 | h2. Configuration 30 | 31 | You might want to add additional configuration to an initializer, e.g. config/initializers/globalize.rb 32 | 33 | h2. Model translations 34 | 35 | Model translations (or content translations) allow you to translate your models' attribute values. E.g. 36 | 37 |

 38 | class Post < ActiveRecord::Base
 39 |   translates :title, :text
 40 | end
 41 | 
42 | 43 | Allows you to values for the attributes :title and :text per locale: 44 | 45 |

 46 | I18n.locale = :en
 47 | post.title # Globalize2 rocks!
 48 | 
 49 | I18n.locale = :he
 50 | post.title # גלובאלייז2 שולט!
 51 | 
52 | 53 | In order to make this work, you'll need to add the appropriate translation tables. Globalize2 comes with a handy helper method to help you do this. It's called @create_translation_table!@. Here's an example: 54 | 55 |

 56 | class CreatePosts < ActiveRecord::Migration
 57 |   def self.up
 58 |     create_table :posts do |t|
 59 |       t.timestamps
 60 |     end
 61 |     Post.create_translation_table! :title => :string, :text => :text
 62 |   end
 63 |   def self.down
 64 |     drop_table :posts
 65 |     Post.drop_translation_table!
 66 |   end
 67 | end
 68 | 
69 | 70 | Note that the ActiveRecord model @Post@ must already exist and have a @translates@ directive listing the translated fields. 71 | 72 | h2. Globalize::Backend::Static 73 | 74 | Globalize2 ships with a Static backend that builds on the Simple backend from the I18n library (which is shipped with Rails) and adds the following features: 75 | 76 | * It uses locale fallbacks when looking up translation data. 77 | * It returns an instance of Globalize::Translation::Static instead of a plain Ruby String as a translation. 78 | * It allows to hook in custom pluralization logic as lambdas. 79 | 80 | h2. Custom pluralization logic 81 | 82 | The Simple backend has its pluralization algorithm baked in hardcoded. This algorithm is only suitable for English and other languages that have the same pluralization rules. It is not suitable for, e.g., Czech though. 83 | 84 | To add custom pluralization logic to Globalize' Static backend you can do something like this: 85 | 86 |

 87 | @backend.add_pluralizer :cz, lambda{|c| 
 88 |   c == 1 ? :one : (2..4).include?(c) ? :few : :other 
 89 | }
 90 | 
91 | 92 | h2. Locale Fallbacks 93 | 94 | Globalize2 ships with a Locale fallback tool which extends the I18n module to hold a fallbacks instance which is set to an instance of Globalize::Locale::Fallbacks by default but can be swapped with a different implementation. 95 | 96 | Globalize2 fallbacks will compute a number of other locales for a given locale. For example: 97 | 98 |

 99 | I18n.fallbacks[:"es-MX"] # => [:"es-MX", :es, :"en-US", :en]
100 | 
101 | 102 | Globalize2 fallbacks always fall back to 103 | 104 | * all parents of a given locale (e.g. :es for :"es-MX"), 105 | * then to the fallbacks' default locales and all of their parents and 106 | * finally to the :root locale. 107 | 108 | The default locales are set to [:"en-US"] by default but can be set to something else. The root locale is a concept borrowed from "CLDR":http://unicode.org and makes sense for storing common locale data which works as a last default fallback (e.g. "ltr" for bidi directions). 109 | 110 | One can additionally add any number of additional fallback locales manually. These will be added before the default locales to the fallback chain. For example: 111 | 112 |

113 | fb = I18n.fallbacks
114 | 
115 | fb.map :ca => :"es-ES"
116 | fb[:ca] # => [:ca, :"es-ES", :es, :"en-US", :en]
117 | 
118 | fb.map :"ar-PS" => :"he-IL"
119 | fb[:"ar-PS"] # => [:"ar-PS", :ar, :"he-IL", :he, :"en-US", :en]
120 | fb[:"ar-EG"] # => [:"ar-EG", :ar, :"en-US", :en]
121 | 
122 | fb.map :sms => [:"se-FI", :"fi-FI"]
123 | fb[:sms] # => [:sms, :"se-FI", :se, :"fi-FI", :fi, :"en-US", :en]
124 | 
125 | 126 | h2. Globalize::LoadPath 127 | 128 | Globalize2 replaces the plain Ruby array that is set to I18n.load_path by default through an instance of Globalize::LoadPath. 129 | 130 | This object can be populated with both paths to files and directories. If a path to a directory is added to it it will look up all locale data files present in that directory enforcing the following convention: 131 | 132 |

133 | I18n.load_path << "#{RAILS_ROOT}/lib/locales"
134 | 
135 | # will load all the following files if present:
136 | lib/locales/all.yml
137 | lib/locales/fr.yml
138 | lib/locales/fr/*.yaml
139 | lib/locales/ru.yml
140 | lib/locales/ru/*.yaml
141 | ...
142 | 
143 | 144 | One can also specify which locales are used. By default this is set to "*" meaning that files for all locales are added. To define that only files for the locale :es are added one can specify: 145 | 146 |

147 | I18n.load_path.locales = [:es]
148 | 
149 | 150 | One can also specify which file extensions are used. By default this is set to ['rb', 'yml'] so plain Ruby and YAML files are added if found. To define that only *.sql files are added one can specify: 151 | 152 |

153 | I18n.load_path.extensions = ['sql']
154 | 
155 | 156 | Note that Globalize::LoadPath "expands" a directory to its contained file paths immediately when you add it to the load_path. Thus, if you change the locales or extensions settings in the middle of your application the change won't be applied to already added file paths. 157 | 158 | 159 | h2. Globalize::Translation classes 160 | 161 | Globalize2's Static backend as well as Globalize2 model translations return instances of Globalize::Translation classes (instead of plain Ruby Strings). These are simple and lightweight value objects that carry some additional meta data about the translation and how it was looked up. 162 | 163 | Model translations return instances of Globalize::Translation::Attribute, the Static backend returns instances of Globalize::Translation::Static. 164 | 165 | For example: 166 | 167 |

168 | I18n.locale = :de
169 | 
170 | # Translation::Attribute
171 | title = Post.first.title  # assuming that no translation can be found:
172 | title.locale              # => :en
173 | title.requested_locale    # => :de 
174 | title.fallback?           # => true
175 | 
176 | # Translation::Static
177 | rails = I18n.t :rails     # assuming that no translation can be found:
178 | rails.locale              # => :en
179 | rails.requested_locale    # => :de 
180 | rails.fallback?           # => true
181 | rails.options             # returns the options passed to #t
182 | rails.plural_key          # returns the plural_key (e.g. :one, :other)
183 | rails.original            # returns the original translation with no values 
184 |                           # interpolated to it (e.g. "Hi {{name}}!")
185 | 
186 | 187 | h2. Missing Translations Log Handler 188 | 189 | A simple exception handler that behaves like the default exception handler but additionally logs missing translations to a given log. 190 | 191 | Useful for identifying missing translations during testing. 192 | 193 | E.g. 194 | 195 | require 'globalize/i18n/missing_translations_log_handler 196 | I18n.missing_translations_logger = RAILS_DEFAULT_LOGGER 197 | I18n.exception_handler = :missing_translations_log_handler 198 | 199 | To set up a different log file: 200 | 201 | logger = Logger.new("#{RAILS_ROOT}/log/missing_translations.log") 202 | I18n.missing_translations_logger = logger 203 | -------------------------------------------------------------------------------- /test/model/active_record/translated_test.rb: -------------------------------------------------------------------------------- 1 | require File.join( File.dirname(__FILE__), '..', '..', 'test_helper' ) 2 | require 'active_record' 3 | require 'globalize/model/active_record' 4 | 5 | # Hook up model translation 6 | ActiveRecord::Base.send(:include, Globalize::Model::ActiveRecord::Translated) 7 | 8 | # Load Post model 9 | require File.join( File.dirname(__FILE__), '..', '..', 'data', 'post' ) 10 | 11 | class TranslatedTest < ActiveSupport::TestCase 12 | def setup 13 | I18n.locale = :'en-US' 14 | I18n.fallbacks.clear 15 | reset_db! File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'data', 'schema.rb')) 16 | ActiveRecord::Base.locale = nil 17 | end 18 | 19 | def teardown 20 | I18n.fallbacks.clear 21 | end 22 | 23 | test "modifiying translated fields" do 24 | post = Post.create :subject => 'foo' 25 | assert_equal 'foo', post.subject 26 | post.subject = 'bar' 27 | assert_equal 'bar', post.subject 28 | end 29 | 30 | test "modifiying translated fields while switching locales" do 31 | post = Post.create :subject => 'foo' 32 | assert_equal 'foo', post.subject 33 | I18n.locale = :'de-DE' 34 | post.subject = 'bar' 35 | assert_equal 'bar', post.subject 36 | I18n.locale = :'en-US' 37 | assert_equal 'foo', post.subject 38 | I18n.locale = :'de-DE' 39 | post.subject = 'bar' 40 | end 41 | 42 | test "has post_translations" do 43 | post = Post.create 44 | assert_nothing_raised { post.globalize_translations } 45 | end 46 | 47 | test "has German post_translations" do 48 | I18n.locale = :de 49 | post = Post.create :subject => 'foo' 50 | assert_equal 1, post.globalize_translations.size 51 | I18n.locale = :en 52 | assert_equal 1, post.globalize_translations.size 53 | end 54 | 55 | test "returns the value passed to :subject" do 56 | post = Post.new 57 | assert_equal 'foo', (post.subject = 'foo') 58 | end 59 | 60 | test "translates subject and content into en-US" do 61 | post = Post.create :subject => 'foo', :content => 'bar' 62 | assert_equal 'foo', post.subject 63 | assert_equal 'bar', post.content 64 | assert post.save 65 | post.reload 66 | assert_equal 'foo', post.subject 67 | assert_equal 'bar', post.content 68 | end 69 | 70 | test "finds a German post" do 71 | post = Post.create :subject => 'foo (en)', :content => 'bar' 72 | I18n.locale = 'de-DE' 73 | post = Post.first 74 | post.subject = 'baz (de)' 75 | post.save 76 | assert_equal 'baz (de)', Post.first.subject 77 | I18n.locale = :'en-US' 78 | assert_equal 'foo (en)', Post.first.subject 79 | end 80 | 81 | test "saves an English post and loads test correctly" do 82 | assert_nil Post.first 83 | post = Post.create :subject => 'foo', :content => 'bar' 84 | assert post.save 85 | post = Post.first 86 | assert_equal 'foo', post.subject 87 | assert_equal 'bar', post.content 88 | end 89 | 90 | test "updates an attribute" do 91 | post = Post.create :subject => 'foo', :content => 'bar' 92 | post.update_attribute :subject, 'baz' 93 | assert_equal 'baz', Post.first.subject 94 | end 95 | 96 | test "update_attributes failure" do 97 | post = Post.create :subject => 'foo', :content => 'bar' 98 | assert !post.update_attributes( { :subject => '' } ) 99 | assert_nil post.reload.attributes['subject'] 100 | assert_equal 'foo', post.subject 101 | end 102 | 103 | test "validates presence of :subject" do 104 | post = Post.new 105 | assert !post.save 106 | 107 | post = Post.new :subject => 'foo' 108 | assert post.save 109 | end 110 | 111 | test "returns the value for the correct locale, after locale switching" do 112 | post = Post.create :subject => 'foo' 113 | I18n.locale = 'de-DE' 114 | post.subject = 'bar' 115 | post.save 116 | I18n.locale = 'en-US' 117 | post = Post.first 118 | assert_equal 'foo', post.subject 119 | I18n.locale = 'de-DE' 120 | assert_equal 'bar', post.subject 121 | end 122 | 123 | test "keeping one field in new locale when other field is changed" do 124 | I18n.fallbacks.map 'de-DE' => [ 'en-US' ] 125 | post = Post.create :subject => 'foo' 126 | I18n.locale = 'de-DE' 127 | post.content = 'bar' 128 | assert_equal 'foo', post.subject 129 | end 130 | 131 | test "modifying non-required field in a new locale" do 132 | I18n.fallbacks.map 'de-DE' => [ 'en-US' ] 133 | post = Post.create :subject => 'foo' 134 | I18n.locale = 'de-DE' 135 | post.content = 'bar' 136 | assert post.save 137 | end 138 | 139 | test "returns the value for the correct locale, after locale switching, without saving" do 140 | post = Post.create :subject => 'foo' 141 | I18n.locale = 'de-DE' 142 | post.subject = 'bar' 143 | I18n.locale = 'en-US' 144 | assert_equal 'foo', post.subject 145 | I18n.locale = 'de-DE' 146 | assert_equal 'bar', post.subject 147 | end 148 | 149 | test "saves all locales, even after locale switching" do 150 | post = Post.new :subject => 'foo' 151 | I18n.locale = 'de-DE' 152 | post.subject = 'bar' 153 | I18n.locale = 'he-IL' 154 | post.subject = 'baz' 155 | post.save 156 | I18n.locale = 'en-US' 157 | post = Post.first 158 | assert_equal 'foo', post.subject 159 | I18n.locale = 'de-DE' 160 | assert_equal 'bar', post.subject 161 | I18n.locale = 'he-IL' 162 | assert_equal 'baz', post.subject 163 | end 164 | 165 | test "resolves a simple fallback" do 166 | I18n.locale = 'de-DE' 167 | post = Post.create :subject => 'foo' 168 | I18n.locale = 'de' 169 | post.subject = 'baz' 170 | post.content = 'bar' 171 | post.save 172 | I18n.locale = 'de-DE' 173 | assert_equal 'foo', post.subject 174 | assert_equal 'bar', post.content 175 | end 176 | 177 | test "resolves a simple fallback without reloading" do 178 | I18n.locale = 'de-DE' 179 | post = Post.new :subject => 'foo' 180 | I18n.locale = 'de' 181 | post.subject = 'baz' 182 | post.content = 'bar' 183 | I18n.locale = 'de-DE' 184 | assert_equal 'foo', post.subject 185 | assert_equal 'bar', post.content 186 | end 187 | 188 | test "resolves a complex fallback without reloading" do 189 | I18n.fallbacks.map 'de' => %w(en he) 190 | I18n.locale = 'de' 191 | post = Post.new 192 | I18n.locale = 'en' 193 | post.subject = 'foo' 194 | I18n.locale = 'he' 195 | post.subject = 'baz' 196 | post.content = 'bar' 197 | I18n.locale = 'de' 198 | assert_equal 'foo', post.subject 199 | assert_equal 'bar', post.content 200 | end 201 | 202 | test "returns nil if no translations are found" do 203 | post = Post.new :subject => 'foo' 204 | assert_equal 'foo', post.subject 205 | assert_nil post.content 206 | end 207 | 208 | test "returns nil if no translations are found; reloaded" do 209 | post = Post.create :subject => 'foo' 210 | post = Post.first 211 | assert_equal 'foo', post.subject 212 | assert_nil post.content 213 | end 214 | 215 | test "works with associations" do 216 | blog = Blog.create 217 | post1 = blog.posts.create :subject => 'foo' 218 | I18n.locale = 'de-DE' 219 | post2 = blog.posts.create :subject => 'bar' 220 | assert_equal 2, blog.posts.size 221 | I18n.locale = 'en-US' 222 | assert_equal 'foo', blog.posts.first.subject 223 | assert_nil blog.posts.last.subject 224 | I18n.locale = 'de-DE' 225 | assert_equal 'bar', blog.posts.last.subject 226 | end 227 | 228 | test "works with simple dynamic finders" do 229 | foo = Post.create :subject => 'foo' 230 | Post.create :subject => 'bar' 231 | post = Post.find_by_subject('foo') 232 | assert_equal foo, post 233 | end 234 | 235 | test 'change attribute on globalized model' do 236 | post = Post.create :subject => 'foo', :content => 'bar' 237 | assert_equal [], post.changed 238 | post.subject = 'baz' 239 | assert_equal [ 'subject' ], post.changed 240 | post.content = 'quux' 241 | assert_member 'subject', post.changed 242 | assert_member 'content', post.changed 243 | end 244 | 245 | test 'change attribute on globalized model after locale switching' do 246 | post = Post.create :subject => 'foo', :content => 'bar' 247 | assert_equal [], post.changed 248 | post.subject = 'baz' 249 | I18n.locale = :de 250 | assert_equal [ 'subject' ], post.changed 251 | end 252 | 253 | test 'fallbacks with lots of locale switching' do 254 | I18n.fallbacks.map :'de-DE' => [ :'en-US' ] 255 | post = Post.create :subject => 'foo' 256 | 257 | I18n.locale = :'de-DE' 258 | assert_equal 'foo', post.subject 259 | 260 | I18n.locale = :'en-US' 261 | post.update_attribute :subject, 'bar' 262 | 263 | I18n.locale = :'de-DE' 264 | assert_equal 'bar', post.subject 265 | end 266 | 267 | test 'reload' do 268 | post = Post.create :subject => 'foo', :content => 'bar' 269 | post.subject = 'baz' 270 | assert_equal 'foo', post.reload.subject 271 | end 272 | 273 | test 'complex writing and stashing' do 274 | post = Post.create :subject => 'foo', :content => 'bar' 275 | post.subject = nil 276 | assert_nil post.subject 277 | assert !post.valid? 278 | end 279 | 280 | test 'translated class locale setting' do 281 | assert ActiveRecord::Base.respond_to?(:locale) 282 | assert_equal :'en-US', I18n.locale 283 | assert_equal :'en-US', ActiveRecord::Base.locale 284 | I18n.locale = :de 285 | assert_equal :de, I18n.locale 286 | assert_equal :de, ActiveRecord::Base.locale 287 | ActiveRecord::Base.locale = :es 288 | assert_equal :de, I18n.locale 289 | assert_equal :es, ActiveRecord::Base.locale 290 | I18n.locale = :fr 291 | assert_equal :fr, I18n.locale 292 | assert_equal :es, ActiveRecord::Base.locale 293 | end 294 | 295 | test "untranslated class responds to locale" do 296 | assert Blog.respond_to?(:locale) 297 | end 298 | 299 | test "to ensure locales in different classes are the same" do 300 | ActiveRecord::Base.locale = :de 301 | assert_equal :de, ActiveRecord::Base.locale 302 | assert_equal :de, Parent.locale 303 | Parent.locale = :es 304 | assert_equal :es, ActiveRecord::Base.locale 305 | assert_equal :es, Parent.locale 306 | end 307 | 308 | test "attribute saving goes by content locale and not global locale" do 309 | ActiveRecord::Base.locale = :de 310 | assert_equal :'en-US', I18n.locale 311 | Post.create :subject => 'foo' 312 | assert_equal :de, Post.first.globalize_translations.first.locale 313 | end 314 | 315 | test "attribute loading goes by content locale and not global locale" do 316 | post = Post.create :subject => 'foo' 317 | assert_equal :'en-US', ActiveRecord::Base.locale 318 | ActiveRecord::Base.locale = :de 319 | assert_equal :'en-US', I18n.locale 320 | post.update_attribute :subject, 'foo [de]' 321 | assert_equal 'foo [de]', Post.first.subject 322 | ActiveRecord::Base.locale = :'en-US' 323 | assert_equal 'foo', Post.first.subject 324 | end 325 | 326 | test "access content locale before setting" do 327 | Globalize::Model::ActiveRecord::Translated::ActMethods.class_eval "remove_class_variable(:@@locale)" 328 | assert_nothing_raised { ActiveRecord::Base.locale } 329 | end 330 | 331 | test "translated_locales" do 332 | Post.locale = :de 333 | post = Post.create :subject => 'foo' 334 | Post.locale = :es 335 | post.update_attribute :subject, 'bar' 336 | Post.locale = :fr 337 | post.update_attribute :subject, 'baz' 338 | assert_equal [ :de, :es, :fr ], post.translated_locales 339 | assert_equal [ :de, :es, :fr ], Post.first.translated_locales 340 | end 341 | 342 | test "including globalize_translations" do 343 | I18n.locale = :de 344 | Post.create :subject => "Foo1", :content => "Bar1" 345 | Post.create :subject => "Foo2", :content => "Bar2" 346 | 347 | class << Post 348 | def tranlsations_included 349 | self.all(:include => :globalize_translations) 350 | end 351 | end 352 | 353 | default = Post.all.map {|x| [x.subject, x.content]} 354 | with_include = Post.tranlsations_included.map {|x| [x.subject, x.content]} 355 | assert_equal default, with_include 356 | end 357 | 358 | test "setting multiple translations at once with options hash" do 359 | Post.locale = :de 360 | post = Post.create :subject => "foo1", :content => "foo1" 361 | Post.locale = :en 362 | post.update_attributes( :subject => "bar1", :content => "bar1" ) 363 | 364 | options = { :de => {:subject => "foo2", :content => "foo2"}, 365 | :en => {:subject => "bar2", :content => "bar2"} } 366 | post.set_translations options 367 | post.reload 368 | 369 | assert ["bar2", "bar2"], [post.subject, post.content] 370 | Post.locale = :de 371 | assert ["foo2", "foo2"], [post.subject, post.content] 372 | end 373 | 374 | test "setting only one translation with set_translations" do 375 | Post.locale = :de 376 | post = Post.create :subject => "foo1", :content => "foo1" 377 | Post.locale = :en 378 | post.update_attributes( :subject => "bar1", :content => "bar1" ) 379 | 380 | options = { :en => {:subject => "bar2", :content => "bar2"} } 381 | post.set_translations options 382 | post.reload 383 | 384 | assert ["bar2", "bar2"], [post.subject, post.content] 385 | Post.locale = :de 386 | assert ["foo1", "foo1"], [post.subject, post.content] 387 | end 388 | 389 | test "setting only selected attributes with set_translations" do 390 | Post.locale = :de 391 | post = Post.create :subject => "foo1", :content => "foo1" 392 | Post.locale = :en 393 | post.update_attributes( :subject => "bar1", :content => "bar1" ) 394 | 395 | options = { :de => {:content => "foo2"}, :en => {:subject => "bar2"} } 396 | post.set_translations options 397 | post.reload 398 | 399 | assert ["bar2", "bar1"], [post.subject, post.content] 400 | Post.locale = :de 401 | assert ["foo1", "foo2"], [post.subject, post.content] 402 | end 403 | 404 | test "setting invalid attributes raises ArgumentError" do 405 | Post.locale = :de 406 | post = Post.create :subject => "foo1", :content => "foo1" 407 | Post.locale = :en 408 | post.update_attributes( :subject => "bar1", :content => "bar1" ) 409 | 410 | options = { :de => {:fake => "foo2"} } 411 | exception = assert_raise(ActiveRecord::UnknownAttributeError) do 412 | post.set_translations options 413 | end 414 | assert_equal "unknown attribute: fake", exception.message 415 | end 416 | 417 | test "reload accepting find options" do 418 | p = Post.create :subject => "Foo", :content => "Bar" 419 | assert p.reload(:readonly => true, :lock => true) 420 | assert_raise(ArgumentError) { p.reload(:foo => :bar) } 421 | end 422 | 423 | test "dependent destroy of translation" do 424 | p = Post.create :subject => "Foo", :content => "Bar" 425 | assert_equal 1, PostTranslation.count 426 | p.destroy 427 | assert_equal 0, PostTranslation.count 428 | end 429 | 430 | test "translating subclass of untranslated comment model" do 431 | translated_comment = TranslatedComment.create(:post => @post) 432 | assert_nothing_raised { translated_comment.globalize_translations } 433 | end 434 | 435 | test "modifiying translated comments works as expected" do 436 | I18n.locale = :en 437 | translated_comment = TranslatedComment.create(:post => @post, :content => 'foo') 438 | assert_equal 'foo', translated_comment.content 439 | 440 | I18n.locale = :de 441 | translated_comment.content = 'bar' 442 | assert translated_comment.save 443 | assert_equal 'bar', translated_comment.content 444 | 445 | I18n.locale = :en 446 | assert_equal 'foo', translated_comment.content 447 | 448 | assert_equal 2, translated_comment.globalize_translations.size 449 | end 450 | end 451 | 452 | # TODO should validate_presence_of take fallbacks into account? maybe we need 453 | # an extra validation call, or more options for validate_presence_of. 454 | # TODO error checking for fields that exist in main table, don't exist in 455 | # proxy table, aren't strings or text 456 | # 457 | # TODO allow finding by translated attributes in conditions? 458 | # TODO generate advanced dynamic finders? 459 | --------------------------------------------------------------------------------