1 # encoding: utf-8
2
3 require 'yaml'
4 require 'i18n/core_ext/hash'
5
6 module I18n
7 module Backend
8 module Base
9 include I18n::Backend::Transliterator
10
11 RESERVED_KEYS = [:scope, :default, :separator, :resolve]
12 RESERVED_KEYS_PATTERN = /%\{(#{RESERVED_KEYS.join("|")})\}/
13 DEPRECATED_INTERPOLATION_SYNTAX_PATTERN = /(\\)?\{\{([^\}]+)\}\}/
14 INTERPOLATION_SYNTAX_PATTERN = /%\{([^\}]+)\}/
15
16 # Accepts a list of paths to translation files. Loads translations from
17 # plain Ruby (*.rb) or YAML files (*.yml). See #load_rb and #load_yml
18 # for details.
19 def load_translations(*filenames)
20 filenames = I18n.load_path.flatten if filenames.empty?
21 filenames.each { |filename| load_file(filename) }
22 end
23
24 # This method receives a locale, a data hash and options for storing translations.
25 # Should be implemented
26 def store_translations(locale, data, options = {})
27 raise NotImplementedError
28 end
29
30 def translate(locale, key, options = {})
31 raise InvalidLocale.new(locale) unless locale
32 return key.map { |k| translate(locale, k, options) } if key.is_a?(Array)
33
34 entry = key && lookup(locale, key, options[:scope], options)
35
36 if options.empty?
37 entry = resolve(locale, key, entry, options)
38 else
39 count, default = options.values_at(:count, :default)
40 values = options.except(*RESERVED_KEYS)
41 entry = entry.nil? && default ?
42 default(locale, key, default, options) : resolve(locale, key, entry, options)
43 end
44
45 raise(I18n::MissingTranslationData.new(locale, key, options)) if entry.nil?
46 entry = entry.dup if entry.is_a?(String)
47
48 entry = pluralize(locale, entry, count) if count
49 entry = interpolate(locale, entry, values) if values
50 entry
51 end
52
53 # Acts the same as +strftime+, but uses a localized version of the
54 # format string. Takes a key from the date/time formats translations as
55 # a format argument (<em>e.g.</em>, <tt>:short</tt> in <tt>:'date.formats'</tt>).
56 def localize(locale, object, format = :default, options = {})
57 raise ArgumentError, "Object must be a Date, DateTime or Time object. #{object.inspect} given." unless object.respond_to?(:strftime)
58
59 if Symbol === format
60 key = format
61 type = object.respond_to?(:sec) ? 'time' : 'date'
62 format = I18n.t(:"#{type}.formats.#{key}", options.merge(:raise => true, :object => object, :locale => locale))
63 end
64
65 # format = resolve(locale, object, format, options)
66 format = format.to_s.gsub(/%[aAbBp]/) do |match|
67 case match
68 when '%a' then I18n.t(:"date.abbr_day_names", :locale => locale, :format => format)[object.wday]
69 when '%A' then I18n.t(:"date.day_names", :locale => locale, :format => format)[object.wday]
70 when '%b' then I18n.t(:"date.abbr_month_names", :locale => locale, :format => format)[object.mon]
71 when '%B' then I18n.t(:"date.month_names", :locale => locale, :format => format)[object.mon]
72 when '%p' then I18n.t(:"time.#{object.hour < 12 ? :am : :pm}", :locale => locale, :format => format) if object.respond_to? :hour
73 end
74 end
75
76 object.strftime(format)
77 end
78
79 # Returns an array of locales for which translations are available
80 # ignoring the reserved translation meta data key :i18n.
81 def available_locales
82 raise NotImplementedError
83 end
84
85 def reload!
86 @skip_syntax_deprecation = false
87 end
88
89 protected
90
91 # The method which actually looks up for the translation in the store.
92 def lookup(locale, key, scope = [], options = {})
93 raise NotImplementedError
94 end
95
96 # Evaluates defaults.
97 # If given subject is an Array, it walks the array and returns the
98 # first translation that can be resolved. Otherwise it tries to resolve
99 # the translation directly.
100 def default(locale, object, subject, options = {})
101 options = options.dup.reject { |key, value| key == :default }
102 case subject
103 when Array
104 subject.each do |item|
105 result = resolve(locale, object, item, options) and return result
106 end and nil
107 else
108 resolve(locale, object, subject, options)
109 end
110 end
111
112 # Resolves a translation.
113 # If the given subject is a Symbol, it will be translated with the
114 # given options. If it is a Proc then it will be evaluated. All other
115 # subjects will be returned directly.
116 def resolve(locale, object, subject, options = nil)
117 return subject if options[:resolve] == false
118 case subject
119 when Symbol
120 I18n.translate(subject, (options || {}).merge(:locale => locale, :raise => true))
121 when Proc
122 date_or_time = options.delete(:object) || object
123 resolve(locale, object, subject.call(date_or_time, options), options = {})
124 else
125 subject
126 end
127 rescue MissingTranslationData
128 nil
129 end
130
131 # Picks a translation from an array according to English pluralization
132 # rules. It will pick the first translation if count is not equal to 1
133 # and the second translation if it is equal to 1. Other backends can
134 # implement more flexible or complex pluralization rules.
135 def pluralize(locale, entry, count)
136 return entry unless entry.is_a?(Hash) && count
137
138 key = :zero if count == 0 && entry.has_key?(:zero)
139 key ||= count == 1 ? :one : :other
140 raise InvalidPluralizationData.new(entry, count) unless entry.has_key?(key)
141 entry[key]
142 end
143
144 # Interpolates values into a given string.
145 #
146 # interpolate "file %{file} opened by %%{user}", :file => 'test.txt', :user => 'Mr. X'
147 # # => "file test.txt opened by %{user}"
148 #
149 # Note that you have to double escape the <tt>\\</tt> when you want to escape
150 # the <tt>{{...}}</tt> key in a string (once for the string and once for the
151 # interpolation).
152 def interpolate(locale, string, values = {})
153 return string unless string.is_a?(::String) && !values.empty?
154 original_values = values.dup
155
156 preserve_encoding(string) do
157 string = string.gsub(DEPRECATED_INTERPOLATION_SYNTAX_PATTERN) do
158 escaped, key = $1, $2.to_sym
159 if escaped
160 "{{#{key}}}"
161 else
162 warn_syntax_deprecation!
163 "%{#{key}}"
164 end
165 end
166
167 keys = string.scan(INTERPOLATION_SYNTAX_PATTERN).flatten
168 return string if keys.empty?
169
170 values.each do |key, value|
171 if keys.include?(key.to_s)
172 value = value.call(values) if interpolate_lambda?(value, string, key)
173 value = value.to_s unless value.is_a?(::String)
174 values[key] = value
175 else
176 values.delete(key)
177 end
178 end
179
180 string % values
181 end
182 rescue KeyError => e
183 if string =~ RESERVED_KEYS_PATTERN
184 raise ReservedInterpolationKey.new($1.to_sym, string)
185 else
186 raise MissingInterpolationArgument.new(original_values, string)
187 end
188 end
189
190 def preserve_encoding(string)
191 if string.respond_to?(:encoding)
192 encoding = string.encoding
193 result = yield
194 result.force_encoding(encoding) if result.respond_to?(:force_encoding)
195 result
196 else
197 yield
198 end
199 end
200
201 # returns true when the given value responds to :call and the key is
202 # an interpolation placeholder in the given string
203 def interpolate_lambda?(object, string, key)
204 object.respond_to?(:call) && string =~ /%\{#{key}\}|%\<#{key}>.*?\d*\.?\d*[bBdiouxXeEfgGcps]\}/
205 end
206
207 # Loads a single translations file by delegating to #load_rb or
208 # #load_yml depending on the file extension and directly merges the
209 # data to the existing translations. Raises I18n::UnknownFileType
210 # for all other file extensions.
211 def load_file(filename)
212 type = File.extname(filename).tr('.', '').downcase
213 raise UnknownFileType.new(type, filename) unless respond_to?(:"load_#{type}")
214 data = send(:"load_#{type}", filename) # TODO raise a meaningful exception if this does not yield a Hash
215 data.each { |locale, d| store_translations(locale, d) }
216 end
217
218 # Loads a plain Ruby translations file. eval'ing the file must yield
219 # a Hash containing translation data with locales as toplevel keys.
220 def load_rb(filename)
221 eval(IO.read(filename), binding, filename)
222 end
223
224 # Loads a YAML translations file. The data must have locales as
225 # toplevel keys.
226 def load_yml(filename)
227 YAML::load(IO.read(filename))
228 end
229
230 def warn_syntax_deprecation! #:nodoc:
231 return if @skip_syntax_deprecation
232 warn "The {{key}} interpolation syntax in I18n messages is deprecated. Please use %{key} instead.\n#{caller.join("\n")}"
233 @skip_syntax_deprecation = true
234 end
235 end
236 end
237 end