globalize2 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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