i18n 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of i18n might be problematic. Click here for more details.

Files changed (77) hide show
  1. data/README.textile +44 -9
  2. data/Rakefile +2 -1
  3. data/VERSION +1 -1
  4. data/lib/i18n.rb +60 -15
  5. data/lib/i18n/backend.rb +14 -0
  6. data/lib/i18n/backend/active_record.rb +69 -0
  7. data/lib/i18n/backend/active_record/store_procs.rb +37 -0
  8. data/lib/i18n/backend/active_record/translation.rb +82 -0
  9. data/lib/i18n/backend/active_record_missing.rb +55 -0
  10. data/lib/i18n/backend/base.rb +235 -0
  11. data/lib/i18n/backend/cache.rb +71 -0
  12. data/lib/i18n/backend/chain.rb +74 -0
  13. data/lib/i18n/backend/fallbacks.rb +51 -0
  14. data/lib/i18n/backend/gettext.rb +75 -0
  15. data/lib/i18n/backend/helpers.rb +53 -0
  16. data/lib/i18n/backend/metadata.rb +73 -0
  17. data/lib/i18n/backend/pluralization.rb +57 -0
  18. data/lib/i18n/backend/simple.rb +15 -227
  19. data/lib/i18n/core_ext/object/meta_class.rb +5 -0
  20. data/lib/i18n/{string.rb → core_ext/string/interpolate.rb} +2 -0
  21. data/lib/i18n/exceptions.rb +2 -0
  22. data/lib/i18n/gettext.rb +25 -0
  23. data/lib/i18n/helpers.rb +5 -0
  24. data/lib/i18n/helpers/gettext.rb +64 -0
  25. data/lib/i18n/locale.rb +6 -0
  26. data/lib/i18n/locale/fallbacks.rb +98 -0
  27. data/lib/i18n/locale/tag.rb +28 -0
  28. data/lib/i18n/locale/tag/parents.rb +24 -0
  29. data/lib/i18n/locale/tag/rfc4646.rb +76 -0
  30. data/lib/i18n/locale/tag/simple.rb +41 -0
  31. data/test/all.rb +7 -2
  32. data/test/api/basics.rb +3 -1
  33. data/test/api/interpolation.rb +35 -4
  34. data/test/api/lambda.rb +5 -3
  35. data/test/api/link.rb +4 -2
  36. data/test/api/localization/date.rb +2 -0
  37. data/test/api/localization/date_time.rb +3 -1
  38. data/test/api/localization/lambda.rb +4 -2
  39. data/test/api/localization/time.rb +3 -1
  40. data/test/api/pluralization.rb +12 -15
  41. data/test/api/translation.rb +5 -3
  42. data/test/backend/active_record/active_record_test.rb +40 -0
  43. data/test/backend/active_record/all.rb +3 -0
  44. data/test/backend/active_record/api_test.rb +54 -0
  45. data/test/backend/active_record/setup.rb +166 -0
  46. data/test/backend/active_record_missing/active_record_missing_test.rb +63 -0
  47. data/test/backend/all/api_test.rb +88 -0
  48. data/test/backend/cache/cache_test.rb +69 -0
  49. data/test/backend/chain/api_test.rb +80 -0
  50. data/test/backend/chain/chain_test.rb +64 -0
  51. data/test/backend/fallbacks/api_test.rb +84 -0
  52. data/test/backend/fallbacks/fallbacks_test.rb +57 -0
  53. data/test/backend/metadata/metadata_test.rb +65 -0
  54. data/test/backend/pluralization/api_test.rb +86 -0
  55. data/test/backend/pluralization/pluralization_test.rb +43 -0
  56. data/test/backend/simple/all.rb +2 -0
  57. data/test/backend/simple/api_test.rb +27 -20
  58. data/test/backend/simple/helpers_test.rb +26 -0
  59. data/test/backend/simple/lookup_test.rb +2 -1
  60. data/test/backend/simple/{setup/localization.rb → setup.rb} +29 -11
  61. data/test/backend/simple/translations_test.rb +1 -6
  62. data/test/{string_test.rb → core_ext/string/interpolate_test.rb} +4 -2
  63. data/test/fixtures/locales/de.po +67 -0
  64. data/test/fixtures/locales/en.rb +2 -0
  65. data/test/fixtures/locales/plurals.rb +113 -0
  66. data/test/gettext/api_test.rb +204 -0
  67. data/test/gettext/backend_test.rb +84 -0
  68. data/test/i18n_exceptions_test.rb +3 -1
  69. data/test/i18n_load_path_test.rb +8 -1
  70. data/test/i18n_test.rb +30 -7
  71. data/test/locale/fallbacks_test.rb +128 -0
  72. data/test/locale/tag/rfc4646_test.rb +145 -0
  73. data/test/locale/tag/simple_test.rb +35 -0
  74. data/test/test_helper.rb +11 -5
  75. data/test/with_options.rb +2 -0
  76. metadata +75 -11
  77. data/test/backend/simple/setup/base.rb +0 -21
@@ -0,0 +1,235 @@
1
+ # encoding: utf-8
2
+
3
+ require 'yaml'
4
+
5
+ module I18n
6
+ module Backend
7
+ module Base
8
+ include I18n::Backend::Helpers
9
+
10
+ RESERVED_KEYS = [:scope, :default, :separator]
11
+ INTERPOLATION_SYNTAX_PATTERN = /(\\)?\{\{([^\}]+)\}\}/
12
+
13
+ # Accepts a list of paths to translation files. Loads translations from
14
+ # plain Ruby (*.rb) or YAML files (*.yml). See #load_rb and #load_yml
15
+ # for details.
16
+ def load_translations(*filenames)
17
+ filenames.each { |filename| load_file(filename) }
18
+ end
19
+
20
+ # Stores translations for the given locale in memory.
21
+ # This uses a deep merge for the translations hash, so existing
22
+ # translations will be overwritten by new ones only at the deepest
23
+ # level of the hash.
24
+ def store_translations(locale, data)
25
+ merge_translations(locale, data)
26
+ end
27
+
28
+ def translate(locale, key, options = {})
29
+ raise InvalidLocale.new(locale) if locale.nil?
30
+ return key.map { |k| translate(locale, k, options) } if key.is_a?(Array)
31
+
32
+ count, scope, default, separator = options.values_at(:count, *RESERVED_KEYS)
33
+ values = options.reject { |name, value| RESERVED_KEYS.include?(name) }
34
+
35
+ entry = lookup(locale, key, scope, separator)
36
+ entry = entry.nil? ? default(locale, key, default, options) : resolve(locale, key, entry, options)
37
+
38
+ raise(I18n::MissingTranslationData.new(locale, key, options)) if entry.nil?
39
+ entry = pluralize(locale, entry, count)
40
+ entry = interpolate(locale, entry, values)
41
+ entry
42
+ end
43
+
44
+ # Acts the same as +strftime+, but uses a localized version of the
45
+ # format string. Takes a key from the date/time formats translations as
46
+ # a format argument (<em>e.g.</em>, <tt>:short</tt> in <tt>:'date.formats'</tt>).
47
+ def localize(locale, object, format = :default, options = {})
48
+ raise ArgumentError, "Object must be a Date, DateTime or Time object. #{object.inspect} given." unless object.respond_to?(:strftime)
49
+
50
+ if Symbol === format
51
+ key = format
52
+ type = object.respond_to?(:sec) ? 'time' : 'date'
53
+ format = lookup(locale, :"#{type}.formats.#{key}")
54
+ raise(MissingTranslationData.new(locale, key, options)) if format.nil?
55
+ end
56
+
57
+ format = resolve(locale, object, format, options)
58
+ format = format.to_s.gsub(/%[aAbBp]/) do |match|
59
+ case match
60
+ when '%a' then I18n.t(:"date.abbr_day_names", :locale => locale, :format => format)[object.wday]
61
+ when '%A' then I18n.t(:"date.day_names", :locale => locale, :format => format)[object.wday]
62
+ when '%b' then I18n.t(:"date.abbr_month_names", :locale => locale, :format => format)[object.mon]
63
+ when '%B' then I18n.t(:"date.month_names", :locale => locale, :format => format)[object.mon]
64
+ when '%p' then I18n.t(:"time.#{object.hour < 12 ? :am : :pm}", :locale => locale, :format => format) if object.respond_to? :hour
65
+ end
66
+ end
67
+
68
+ object.strftime(format)
69
+ end
70
+
71
+ def initialized?
72
+ @initialized ||= false
73
+ end
74
+
75
+ # Returns an array of locales for which translations are available
76
+ # ignoring the reserved translation meta data key :i18n.
77
+ def available_locales
78
+ init_translations unless initialized?
79
+ translations.inject([]) do |locales, (locale, data)|
80
+ locales << locale unless (data.keys - [:i18n]).empty?
81
+ locales
82
+ end
83
+ end
84
+
85
+ def reload!
86
+ @initialized = false
87
+ @translations = nil
88
+ end
89
+
90
+ protected
91
+ def init_translations
92
+ load_translations(*I18n.load_path.flatten)
93
+ @initialized = true
94
+ end
95
+
96
+ def translations
97
+ @translations ||= {}
98
+ end
99
+
100
+ # Looks up a translation from the translations hash. Returns nil if
101
+ # eiher key is nil, or locale, scope or key do not exist as a key in the
102
+ # nested translations hash. Splits keys or scopes containing dots
103
+ # into multiple keys, i.e. <tt>currency.format</tt> is regarded the same as
104
+ # <tt>%w(currency format)</tt>.
105
+ def lookup(locale, key, scope = [], separator = nil)
106
+ return unless key
107
+ init_translations unless initialized?
108
+ keys = I18n.send(:normalize_translation_keys, locale, key, scope, separator)
109
+ keys.inject(translations) do |result, key|
110
+ key = key.to_sym
111
+ if result.respond_to?(:has_key?) and result.has_key?(key)
112
+ result[key]
113
+ else
114
+ return nil
115
+ end
116
+ end
117
+ end
118
+
119
+ # Evaluates defaults.
120
+ # If given subject is an Array, it walks the array and returns the
121
+ # first translation that can be resolved. Otherwise it tries to resolve
122
+ # the translation directly.
123
+ def default(locale, object, subject, options = {})
124
+ options = options.dup.reject { |key, value| key == :default }
125
+ case subject
126
+ when Array
127
+ subject.each do |subject|
128
+ result = resolve(locale, object, subject, options) and return result
129
+ end and nil
130
+ else
131
+ resolve(locale, object, subject, options)
132
+ end
133
+ end
134
+
135
+ # Resolves a translation.
136
+ # If the given subject is a Symbol, it will be translated with the
137
+ # given options. If it is a Proc then it will be evaluated. All other
138
+ # subjects will be returned directly.
139
+ def resolve(locale, object, subject, options = {})
140
+ case subject
141
+ when Symbol
142
+ I18n.translate(subject, options.merge(:locale => locale, :raise => true))
143
+ when Proc
144
+ resolve(locale, object, subject.call(object, options), options = {})
145
+ else
146
+ subject
147
+ end
148
+ rescue MissingTranslationData
149
+ nil
150
+ end
151
+
152
+ # Picks a translation from an array according to English pluralization
153
+ # rules. It will pick the first translation if count is not equal to 1
154
+ # and the second translation if it is equal to 1. Other backends can
155
+ # implement more flexible or complex pluralization rules.
156
+ def pluralize(locale, entry, count)
157
+ return entry unless entry.is_a?(Hash) and count
158
+
159
+ key = :zero if count == 0 && entry.has_key?(:zero)
160
+ key ||= count == 1 ? :one : :other
161
+ raise InvalidPluralizationData.new(entry, count) unless entry.has_key?(key)
162
+ entry[key]
163
+ end
164
+
165
+ # Interpolates values into a given string.
166
+ #
167
+ # interpolate "file {{file}} opened by \\{{user}}", :file => 'test.txt', :user => 'Mr. X'
168
+ # # => "file test.txt opened by {{user}}"
169
+ #
170
+ # Note that you have to double escape the <tt>\\</tt> when you want to escape
171
+ # the <tt>{{...}}</tt> key in a string (once for the string and once for the
172
+ # interpolation).
173
+ def interpolate(locale, string, values = {})
174
+ return string unless string.is_a?(String) && !values.empty?
175
+
176
+ s = string.gsub(INTERPOLATION_SYNTAX_PATTERN) do
177
+ escaped, key = $1, $2.to_sym
178
+ if escaped
179
+ "{{#{key}}}"
180
+ elsif RESERVED_KEYS.include?(key)
181
+ raise ReservedInterpolationKey.new(key, string)
182
+ else
183
+ "%{#{key}}"
184
+ end
185
+ end
186
+ values.each { |key, value| values[key] = value.call if interpolate_lambda?(value, s, key) }
187
+ s % values
188
+
189
+ rescue KeyError => e
190
+ raise MissingInterpolationArgument.new(values, string)
191
+ end
192
+
193
+ # returns true when the given value responds to :call and the key is
194
+ # an interpolation placeholder in the given string
195
+ def interpolate_lambda?(object, string, key)
196
+ object.respond_to?(:call) && string =~ /%\{#{key}\}|%\<#{key}>.*?\d*\.?\d*[bBdiouxXeEfgGcps]\}/
197
+ end
198
+
199
+ # Loads a single translations file by delegating to #load_rb or
200
+ # #load_yml depending on the file extension and directly merges the
201
+ # data to the existing translations. Raises I18n::UnknownFileType
202
+ # for all other file extensions.
203
+ def load_file(filename)
204
+ type = File.extname(filename).tr('.', '').downcase
205
+ raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}")
206
+ data = send :"load_#{type}", filename # TODO raise a meaningful exception if this does not yield a Hash
207
+ data.each { |locale, d| merge_translations(locale, d) }
208
+ end
209
+
210
+ # Loads a plain Ruby translations file. eval'ing the file must yield
211
+ # a Hash containing translation data with locales as toplevel keys.
212
+ def load_rb(filename)
213
+ eval(IO.read(filename), binding, filename)
214
+ end
215
+
216
+ # Loads a YAML translations file. The data must have locales as
217
+ # toplevel keys.
218
+ def load_yml(filename)
219
+ YAML::load(IO.read(filename))
220
+ end
221
+
222
+ # Deep merges the given translations hash with the existing translations
223
+ # for the given locale
224
+ def merge_translations(locale, data)
225
+ locale = locale.to_sym
226
+ translations[locale] ||= {}
227
+ data = deep_symbolize_keys(data)
228
+
229
+ # deep_merge by Stefan Rusterholz, see http://www.ruby-forum.com/topic/142809
230
+ merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
231
+ translations[locale].merge!(data, &merger)
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,71 @@
1
+ # encoding: utf-8
2
+
3
+ # This module allows you to easily cache all responses from the backend - thus
4
+ # speeding up the I18n aspects of your application quite a bit.
5
+ #
6
+ # To enable caching you can simply include the Cache module to the Simple
7
+ # backend - or whatever other backend you are using:
8
+ #
9
+ # I18n::Backend::Simple.send(:include, I18n::Backend::Cache)
10
+ #
11
+ # You will also need to set a cache store implementation that you want to use:
12
+ #
13
+ # I18n.cache_store = ActiveSupport::Cache.lookup_store(:memory_store)
14
+ #
15
+ # You can use any cache implementation you want that provides the same API as
16
+ # ActiveSupport::Cache (only the methods #fetch and #write are being used).
17
+ #
18
+ # The cache_key implementation assumes that you only pass values to
19
+ # I18n.translate that return a valid key from #hash (see
20
+ # http://www.ruby-doc.org/core/classes/Object.html#M000337).
21
+ module I18n
22
+ class << self
23
+ @@cache_store = nil
24
+ @@cache_namespace = nil
25
+
26
+ def cache_store
27
+ @@cache_store
28
+ end
29
+
30
+ def cache_store=(store)
31
+ @@cache_store = store
32
+ end
33
+
34
+ def cache_namespace
35
+ @@cache_namespace
36
+ end
37
+
38
+ def cache_namespace=(namespace)
39
+ @@cache_namespace = namespace
40
+ end
41
+
42
+ def perform_caching?
43
+ !cache_store.nil?
44
+ end
45
+ end
46
+
47
+ module Backend
48
+ module Cache
49
+ def translate(*args)
50
+ I18n.perform_caching? ? fetch(*args) { super } : super
51
+ end
52
+
53
+ protected
54
+
55
+ def fetch(*args, &block)
56
+ result = I18n.cache_store.fetch(cache_key(*args), &block)
57
+ raise result if result.is_a?(Exception)
58
+ result
59
+ rescue MissingTranslationData => exception
60
+ I18n.cache_store.write(cache_key(*args), exception)
61
+ raise exception
62
+ end
63
+
64
+ def cache_key(*args)
65
+ # this assumes that only simple, native Ruby values are passed to I18n.translate
66
+ keys = ['i18n', I18n.cache_namespace, args.hash]
67
+ keys.compact.join('-')
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,74 @@
1
+ # encoding: utf-8
2
+
3
+ module I18n
4
+ module Backend
5
+ # Backend that chains multiple other backends and checks each of them when
6
+ # a translation needs to be looked up. This is useful when you want to use
7
+ # standard translations with a Simple backend but store custom application
8
+ # translations in a database or other backends.
9
+ #
10
+ # To use the Chain backend instantiate it and set it to the I18n module.
11
+ # You can add chained backends through the initializer or backends
12
+ # accessor:
13
+ #
14
+ # # preserves the existing Simple backend set to I18n.backend
15
+ # I18n.backend = I18n::Backend::Chain.new(I18n::Backend::ActiveRecord.new, I18n.backend)
16
+ #
17
+ # The implementation assumes that all backends added to the Chain implement
18
+ # a lookup method with the same API as Simple backend does.
19
+ class Chain < Simple
20
+ attr_accessor :backends
21
+
22
+ def initialize(*backends)
23
+ self.backends = backends
24
+ end
25
+
26
+ def reload!
27
+ backends.each { |backend| backend.reload! }
28
+ end
29
+
30
+ def store_translations(locale, data)
31
+ backends.first.store_translations(locale, data)
32
+ end
33
+
34
+ def available_locales
35
+ backends.map { |backend| backend.available_locales }.flatten.uniq
36
+ end
37
+
38
+ def translate(locale, key, options = {})
39
+ return key.map { |k| translate(locale, k, options) } if key.is_a?(Array)
40
+
41
+ default = options.delete(:default)
42
+ namespace = {}
43
+ backends.each do |backend|
44
+ begin
45
+ options.update(:default => default) if default and backend == backends.last
46
+ translation = backend.translate(locale, key, options)
47
+ if namespace_lookup?(translation, options)
48
+ namespace.update(translation)
49
+ elsif translation
50
+ return translation
51
+ end
52
+ rescue MissingTranslationData
53
+ end
54
+ end
55
+ return namespace unless namespace.empty?
56
+ raise(I18n::MissingTranslationData.new(locale, key, options))
57
+ end
58
+
59
+ def localize(locale, object, format = :default, options = {})
60
+ backends.each do |backend|
61
+ begin
62
+ result = backend.localize(locale, object, format, options) and return result
63
+ rescue MissingTranslationData
64
+ end
65
+ end or nil
66
+ end
67
+
68
+ protected
69
+ def namespace_lookup?(result, options)
70
+ result.is_a?(Hash) and not options.has_key?(:count)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,51 @@
1
+ # encoding: utf-8
2
+
3
+ # I18n locale fallbacks are useful when you want your application to use
4
+ # translations from other locales when translations for the current locale are
5
+ # missing. E.g. you might want to use :en translations when translations in
6
+ # your applications main locale :de are missing.
7
+ #
8
+ # To enable locale fallbacks you can simply include the Fallbacks module to
9
+ # the Simple backend - or whatever other backend you are using:
10
+ #
11
+ # I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks)
12
+ module I18n
13
+ @@fallbacks = nil
14
+
15
+ class << self
16
+ # Returns the current fallbacks implementation. Defaults to +I18n::Locale::Fallbacks+.
17
+ def fallbacks
18
+ @@fallbacks ||= I18n::Locale::Fallbacks.new
19
+ end
20
+
21
+ # Sets the current fallbacks implementation. Use this to set a different fallbacks implementation.
22
+ def fallbacks=(fallbacks)
23
+ @@fallbacks = fallbacks
24
+ end
25
+ end
26
+
27
+ module Backend
28
+ module Fallbacks
29
+ # Overwrites the Base backend translate method so that it will try each
30
+ # locale given by I18n.fallbacks for the given locale. E.g. for the
31
+ # locale :"de-DE" it might try the locales :"de-DE", :de and :en
32
+ # (depends on the fallbacks implementation) until it finds a result with
33
+ # the given options. If it does not find any result for any of the
34
+ # locales it will then raise a MissingTranslationData exception as
35
+ # usual.
36
+ #
37
+ # The default option takes precedence over fallback locales, i.e. it
38
+ # will first evaluate a given default option before falling back to
39
+ # another locale.
40
+ def translate(locale, key, options = {})
41
+ I18n.fallbacks[locale].each do |fallback|
42
+ begin
43
+ result = super(fallback, key, options) and return result
44
+ rescue I18n::MissingTranslationData
45
+ end
46
+ end
47
+ raise(I18n::MissingTranslationData.new(locale, key, options))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,75 @@
1
+ # encoding: utf-8
2
+
3
+ require 'i18n/gettext'
4
+ require File.expand_path(File.dirname(__FILE__) + '/../../../vendor/po_parser.rb')
5
+
6
+ # Experimental support for using Gettext po files to store translations.
7
+ #
8
+ # To use this you can simply include the module to the Simple backend - or
9
+ # whatever other backend you are using.
10
+ #
11
+ # I18n::Backend::Simple.send(:include, I18n::Backend::Gettext)
12
+ #
13
+ # Now you should be able to include your Gettext translation (*.po) files to
14
+ # the I18n.load_path so they're loaded to the backend and you can use them as
15
+ # usual:
16
+ #
17
+ # I18n.load_path += Dir["path/to/locales/*.po"]
18
+ #
19
+ # Following the Gettext convention this implementation expects that your
20
+ # translation files are named by their locales. E.g. the file en.po would
21
+ # contain the translations for the English locale.
22
+ module I18n
23
+ module Backend
24
+ module Gettext
25
+ class PoData < Hash
26
+ def set_comment(msgid_or_sym, comment)
27
+ # ignore
28
+ end
29
+ end
30
+
31
+ protected
32
+ def load_po(filename)
33
+ locale = ::File.basename(filename, '.po').to_sym
34
+ data = normalize(locale, parse(filename))
35
+ { locale => data }
36
+ end
37
+
38
+ def parse(filename)
39
+ GetText::PoParser.new.parse(::File.read(filename), PoData.new)
40
+ end
41
+
42
+ def normalize(locale, data)
43
+ data.inject({}) do |result, (key, value)|
44
+ unless key.blank?
45
+ key, value = normalize_pluralization(locale, key, value) if key.index("\000")
46
+
47
+ parts = key.split('|').reverse
48
+ normalized = parts.inject({}) do |normalized, part|
49
+ normalized = { part => normalized.empty? ? value : normalized }
50
+ end
51
+
52
+ # deep_merge by Stefan Rusterholz, see http://www.ruby-forum.com/topic/142809
53
+ merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
54
+ result.merge!(normalized, &merger)
55
+ end
56
+ result
57
+ end
58
+ end
59
+
60
+ def normalize_pluralization(locale, key, value)
61
+ # FIXME po_parser includes \000 chars that can not be turned into Symbols
62
+ key = key.gsub("\000", I18n::Gettext::PLURAL_SEPARATOR).split(I18n::Gettext::PLURAL_SEPARATOR).first
63
+
64
+ keys = I18n::Gettext.plural_keys(locale)
65
+ values = value.split("\000")
66
+ raise "invalid number of plurals: #{values.size}, keys: #{keys.inspect}" if values.size != keys.size
67
+
68
+ result = {}
69
+ values.each_with_index { |value, ix| result[keys[ix]] = value }
70
+ [key, result]
71
+ end
72
+
73
+ end
74
+ end
75
+ end