globalize2 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. data/.gitignore +4 -0
  2. data/LICENSE +21 -0
  3. data/README.textile +202 -0
  4. data/Rakefile +39 -0
  5. data/VERSION +1 -0
  6. data/generators/db_backend.rb +0 -0
  7. data/generators/templates/db_backend_migration.rb +25 -0
  8. data/globalize2.gemspec +100 -0
  9. data/init.rb +8 -0
  10. data/lib/globalize/backend/chain.rb +102 -0
  11. data/lib/globalize/backend/pluralizing.rb +37 -0
  12. data/lib/globalize/backend/static.rb +61 -0
  13. data/lib/globalize/i18n/missing_translations_log_handler.rb +41 -0
  14. data/lib/globalize/i18n/missing_translations_raise_handler.rb +27 -0
  15. data/lib/globalize/load_path.rb +63 -0
  16. data/lib/globalize/locale/fallbacks.rb +63 -0
  17. data/lib/globalize/locale/language_tag.rb +81 -0
  18. data/lib/globalize/model/active_record.rb +56 -0
  19. data/lib/globalize/model/active_record/adapter.rb +100 -0
  20. data/lib/globalize/model/active_record/translated.rb +174 -0
  21. data/lib/globalize/translation.rb +32 -0
  22. data/lib/locale/root.yml +3 -0
  23. data/lib/rails_edge_load_path_patch.rb +40 -0
  24. data/notes.textile +51 -0
  25. data/test/all.rb +2 -0
  26. data/test/backends/chained_test.rb +175 -0
  27. data/test/backends/pluralizing_test.rb +63 -0
  28. data/test/backends/static_test.rb +147 -0
  29. data/test/data/locale/all.yml +2 -0
  30. data/test/data/locale/de-DE.yml +2 -0
  31. data/test/data/locale/en-US.yml +2 -0
  32. data/test/data/locale/en-US/module.yml +2 -0
  33. data/test/data/locale/fi-FI/module.yml +2 -0
  34. data/test/data/locale/root.yml +0 -0
  35. data/test/data/models.rb +40 -0
  36. data/test/data/no_globalize_schema.rb +11 -0
  37. data/test/data/schema.rb +39 -0
  38. data/test/i18n/missing_translations_test.rb +36 -0
  39. data/test/load_path_test.rb +49 -0
  40. data/test/locale/fallbacks_test.rb +154 -0
  41. data/test/locale/language_tag_test.rb +130 -0
  42. data/test/model/active_record/migration_test.rb +123 -0
  43. data/test/model/active_record/sti_translated_test.rb +75 -0
  44. data/test/model/active_record/translated_test.rb +487 -0
  45. data/test/test_helper.rb +36 -0
  46. data/test/translation_test.rb +54 -0
  47. metadata +116 -0
@@ -0,0 +1,174 @@
1
+ require 'digest/sha1'
2
+
3
+ module Globalize
4
+ module Model
5
+ class MigrationError < StandardError; 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
+ self.globalize_proxy = Globalize::Model::ActiveRecord.create_proxy_class(self)
28
+ has_many(
29
+ :globalize_translations,
30
+ :class_name => globalize_proxy.name,
31
+ :extend => Extensions,
32
+ :dependent => :delete_all,
33
+ :foreign_key => class_name.foreign_key
34
+ )
35
+
36
+ after_save :update_globalize_record
37
+ end
38
+
39
+ self.globalize_options = options
40
+ Globalize::Model::ActiveRecord.define_accessors(self, attr_names)
41
+
42
+ # Import any callbacks that have been defined by extensions to Globalize2
43
+ # and run them.
44
+ extend Callbacks
45
+ Callbacks.instance_methods.each { |callback| send(callback) }
46
+ end
47
+
48
+ def locale=(locale)
49
+ @@locale = locale
50
+ end
51
+
52
+ def locale
53
+ (defined?(@@locale) && @@locale) || I18n.locale
54
+ end
55
+ end
56
+
57
+ # Dummy Callbacks module. Extensions to Globalize2 can insert methods into here
58
+ # and they'll be called at the end of the translates class method.
59
+ module Callbacks
60
+ end
61
+
62
+ # Extension to the has_many :globalize_translations association
63
+ module Extensions
64
+ def by_locales(locales)
65
+ find :all, :conditions => { :locale => locales.map(&:to_s) }
66
+ end
67
+ end
68
+
69
+ module ClassMethods
70
+ def method_missing(method, *args)
71
+ if method.to_s =~ /^find_by_(\w+)$/ && globalize_options[:translated_attributes].include?($1.to_sym)
72
+ find(:first, :joins => :globalize_translations,
73
+ :conditions => [ "#{i18n_attr($1)} = ? AND #{i18n_attr('locale')} IN (?)",
74
+ args.first,I18n.fallbacks[I18n.locale].map{|tag| tag.to_s}])
75
+ else
76
+ super
77
+ end
78
+ end
79
+
80
+ def create_translation_table!(fields)
81
+ translated_fields = self.globalize_options[:translated_attributes]
82
+ translated_fields.each do |f|
83
+ raise MigrationMissingTranslatedField, "Missing translated field #{f}" unless fields[f]
84
+ end
85
+
86
+ fields.each do |name, type|
87
+ if translated_fields.include?(name) && ![:string, :text].include?(type)
88
+ raise BadMigrationFieldType, "Bad field type for #{name}, should be :string or :text"
89
+ end
90
+ end
91
+
92
+ self.connection.create_table(translation_table_name) do |t|
93
+ t.references self.table_name.singularize
94
+ t.string :locale
95
+ fields.each do |name, type|
96
+ t.column name, type
97
+ end
98
+ t.timestamps
99
+ end
100
+
101
+ self.connection.add_index(translation_table_name, "#{self.table_name.singularize}_id", :name => translation_index_name)
102
+ end
103
+
104
+ def set_translation_table_name(table_name)
105
+ globalize_proxy.set_table_name(table_name)
106
+ end
107
+
108
+ def translation_table_name
109
+ globalize_proxy.table_name
110
+ end
111
+
112
+ def translation_index_name
113
+ # FIXME what's the max size of an index name?
114
+ index_name = "index_#{translation_table_name}_on_#{self.table_name.singularize}_id"
115
+ index_name.size < 50 ? index_name : "index_#{Digest::SHA1.hexdigest(index_name)}"
116
+ end
117
+
118
+ def drop_translation_table!
119
+ self.connection.remove_index(translation_table_name, :name => translation_index_name)
120
+ self.connection.drop_table translation_table_name
121
+ end
122
+
123
+ private
124
+
125
+ def i18n_attr(attribute_name)
126
+ self.base_class.name.underscore.gsub('/', '_') + "_translations.#{attribute_name}"
127
+ end
128
+ end
129
+
130
+ module InstanceMethods
131
+ def reload(options = nil)
132
+ globalize.clear_cache
133
+
134
+ # clear all globalized attributes
135
+ # TODO what's the best way to handle this?
136
+ self.class.globalize_options[:translated_attributes].each do |attr|
137
+ @attributes.delete(attr.to_s)
138
+ end
139
+
140
+ super(options)
141
+ end
142
+
143
+ def translated_attributes
144
+ self.class.globalize_options[:translated_attributes].inject({}) {|h, tf| h[tf] = send(tf); h }
145
+ end
146
+
147
+ def globalize
148
+ @globalize ||= Adapter.new self
149
+ end
150
+
151
+ def update_globalize_record
152
+ globalize.update_translations!
153
+ end
154
+
155
+ def translated_locales
156
+ globalize_translations.scoped(:select => 'DISTINCT locale').map do |translation|
157
+ translation.locale.to_sym
158
+ end
159
+ end
160
+
161
+ def set_translations(options)
162
+ options.keys.each do |key|
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
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ ActiveRecord::Base.send(:include, Globalize::Model::ActiveRecord::Translated)
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,3 @@
1
+ root:
2
+ bidi:
3
+ direction: left-to-right
@@ -0,0 +1,40 @@
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}" }
@@ -0,0 +1,51 @@
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
+ <pre>
37
+ <code>
38
+ SELECT * FROM globalize_translations
39
+ WHERE locale in (<fallbacks>) AND
40
+ key IN (key, default_key)
41
+ </code>
42
+ </pre>
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.
@@ -0,0 +1,2 @@
1
+ files = Dir[File.dirname(__FILE__) + '/**/*_test.rb']
2
+ files.each { |file| require file }
@@ -0,0 +1,175 @@
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