simonmenke-globalize2 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/LICENSE +21 -0
  2. data/README.textile +202 -0
  3. data/generators/db_backend.rb +0 -0
  4. data/generators/templates/db_backend_migration.rb +25 -0
  5. data/init.rb +1 -0
  6. data/lib/globalize/backend/chain.rb +102 -0
  7. data/lib/globalize/backend/pluralizing.rb +37 -0
  8. data/lib/globalize/backend/static.rb +60 -0
  9. data/lib/globalize/i18n/missing_translations_log_handler.rb +41 -0
  10. data/lib/globalize/i18n/missing_translations_raise_handler.rb +27 -0
  11. data/lib/globalize/load_path.rb +63 -0
  12. data/lib/globalize/locale/fallbacks.rb +63 -0
  13. data/lib/globalize/locale/language_tag.rb +81 -0
  14. data/lib/globalize/model/active_record.rb +38 -0
  15. data/lib/globalize/model/active_record/adapter.rb +96 -0
  16. data/lib/globalize/model/active_record/translated.rb +154 -0
  17. data/lib/globalize/translation.rb +32 -0
  18. data/lib/locale/root.yml +3 -0
  19. data/lib/rails_edge_load_path_patch.rb +40 -0
  20. data/notes.textile +51 -0
  21. data/rails/init.rb +9 -0
  22. data/test/backends/chained_test.rb +175 -0
  23. data/test/backends/pluralizing_test.rb +63 -0
  24. data/test/backends/static_test.rb +143 -0
  25. data/test/data/locale/all.yml +2 -0
  26. data/test/data/locale/de-DE.yml +2 -0
  27. data/test/data/locale/en-US.yml +2 -0
  28. data/test/data/locale/en-US/module.yml +2 -0
  29. data/test/data/locale/fi-FI/module.yml +2 -0
  30. data/test/data/locale/root.yml +0 -0
  31. data/test/data/no_globalize_schema.rb +11 -0
  32. data/test/data/post.rb +24 -0
  33. data/test/data/schema.rb +39 -0
  34. data/test/i18n/missing_translations_test.rb +36 -0
  35. data/test/load_path_test.rb +49 -0
  36. data/test/locale/fallbacks_test.rb +154 -0
  37. data/test/locale/language_tag_test.rb +130 -0
  38. data/test/model/active_record/migration_test.rb +73 -0
  39. data/test/model/active_record/sti_translated_test.rb +75 -0
  40. data/test/model/active_record/translated_test.rb +458 -0
  41. data/test/test_helper.rb +26 -0
  42. data/test/translation_test.rb +54 -0
  43. metadata +114 -0
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,81 @@
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
@@ -0,0 +1,38 @@
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
+ Object.const_set "#{klass.name}Translation", Class.new(::ActiveRecord::Base){
12
+ belongs_to "#{klass.name.underscore}".intern
13
+
14
+ def locale
15
+ read_attribute(:locale).to_sym
16
+ end
17
+
18
+ def locale=(locale)
19
+ write_attribute(:locale, locale.to_s)
20
+ end
21
+ }
22
+ end
23
+
24
+ def define_accessors(klass, attr_names)
25
+ attr_names.each do |attr_name|
26
+ klass.send :define_method, attr_name, lambda {
27
+ globalize.fetch self.class.locale, attr_name
28
+ }
29
+ klass.send :define_method, "#{attr_name}=", lambda {|val|
30
+ globalize.stash self.class.locale, attr_name, val
31
+ self[attr_name] = val
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,96 @@
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
@@ -0,0 +1,154 @@
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
+ 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 {|cb| send cb }
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
+ fields.each do |name, type|
86
+ unless translated_fields.member? name
87
+ raise UntranslatedMigrationField, "Can't migrate untranslated field: #{name}"
88
+ end
89
+ unless [ :string, :text ].member? type
90
+ raise BadMigrationFieldType, "Bad field type for #{name}, should be :string or :text"
91
+ end
92
+ end
93
+ translation_table_name = self.name.underscore + '_translations'
94
+ self.connection.create_table(translation_table_name) do |t|
95
+ t.references self.table_name.singularize
96
+ t.string :locale
97
+ fields.each do |name, type|
98
+ t.column name, type
99
+ end
100
+ t.timestamps
101
+ end
102
+ end
103
+
104
+ def drop_translation_table!
105
+ translation_table_name = self.name.underscore + '_translations'
106
+ self.connection.drop_table translation_table_name
107
+ end
108
+
109
+ private
110
+
111
+ def i18n_attr(attribute_name)
112
+ self.base_class.name.underscore + "_translations.#{attribute_name}"
113
+ end
114
+ end
115
+
116
+ module InstanceMethods
117
+ def reload(options = nil)
118
+ globalize.clear
119
+
120
+ # clear all globalized attributes
121
+ # TODO what's the best way to handle this?
122
+ self.class.globalize_options[:translated_attributes].each do |attr|
123
+ @attributes.delete attr.to_s
124
+ end
125
+
126
+ super options
127
+ end
128
+
129
+ def globalize
130
+ @globalize ||= Adapter.new self
131
+ end
132
+
133
+ def update_globalize_record
134
+ globalize.update_translations!
135
+ end
136
+
137
+ def translated_locales
138
+ globalize_translations.scoped(:select => 'DISTINCT locale').map {|gt| gt.locale.to_sym }
139
+ end
140
+
141
+ def set_translations options
142
+ options.keys.each do |key|
143
+
144
+ translation = globalize_translations.find_by_locale(key.to_s) ||
145
+ globalize_translations.build(:locale => key.to_s)
146
+ translation.update_attributes!(options[key])
147
+ end
148
+ end
149
+
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end