require 'strscan' module I18n module Backend module Simple @@translations = {} class << self # Allow client libraries to pass a block that populates the translation # storage. Decoupled for backends like a db backend that persist their # translations, so the backend can decide whether/when to yield or not. def populate(&block) yield end # Accepts a list of paths to translation files. Loads translations from # plain Ruby (*.rb) or YAML files (*.yml). See #load_rb and #load_yml # for details. def load_translations(*filenames) filenames.each {|filename| load_file filename } end # Stores translations for the given locale in memory. # This uses a deep merge for the translations hash, so existing # translations will be overwritten by new ones only at the deepest # level of the hash. def store_translations(locale, data) merge_translations(locale, data) end def translate(locale, key, options = {}) raise InvalidLocale.new(locale) if locale.nil? return key.map{|k| translate locale, k, options } if key.is_a? Array reserved = :scope, :default count, scope, default = options.values_at(:count, *reserved) options.delete(:default) values = options.reject{|name, value| reserved.include? name } entry = lookup(locale, key, scope) || default(locale, default, options) || raise(I18n::MissingTranslationData.new(locale, key, options)) entry = pluralize locale, entry, count entry = interpolate locale, entry, values entry end # Acts the same as +strftime+, but returns a localized version of the # formatted date string. Takes a key from the date/time formats # translations as a format argument (e.g., :short in :'date.formats'). def localize(locale, object, format = :default) raise ArgumentError, "Object must be a Date, DateTime or Time object. #{object.inspect} given." unless object.respond_to?(:strftime) type = object.respond_to?(:sec) ? 'time' : 'date' formats = translate(locale, :"#{type}.formats") format = formats[format.to_sym] if formats && formats[format.to_sym] # TODO raise exception unless format found? format = format.to_s.dup format.gsub!(/%a/, translate(locale, :"date.abbr_day_names")[object.wday]) format.gsub!(/%A/, translate(locale, :"date.day_names")[object.wday]) format.gsub!(/%b/, translate(locale, :"date.abbr_month_names")[object.mon]) format.gsub!(/%B/, translate(locale, :"date.month_names")[object.mon]) format.gsub!(/%p/, translate(locale, :"time.#{object.hour < 12 ? :am : :pm}")) if object.respond_to? :hour object.strftime(format) end protected # Looks up a translation from the translations hash. Returns nil if # eiher key is nil, or locale, scope or key do not exist as a key in the # nested translations hash. Splits keys or scopes containing dots # into multiple keys, i.e. currency.format is regarded the same as # %w(currency format). def lookup(locale, key, scope = []) return unless key keys = I18n.send :normalize_translation_keys, locale, key, scope keys.inject(@@translations){|result, k| result[k.to_sym] or return nil } end # Evaluates a default translation. # If the given default is a String it is used literally. If it is a Symbol # it will be translated with the given options. If it is an Array the first # translation yielded will be returned. # # I.e., default(locale, [:foo, 'default']) will return +default+ if # translate(locale, :foo) does not yield a result. def default(locale, default, options = {}) case default when String then default when Symbol then translate locale, default, options when Array then default.each do |obj| result = default(locale, obj, options.dup) and return result end and nil end rescue MissingTranslationData nil end # Picks a translation from an array according to English pluralization # rules. It will pick the first translation if count is not equal to 1 # and the second translation if it is equal to 1. Other backends can # implement more flexible or complex pluralization rules. def pluralize(locale, entry, count) return entry unless entry.is_a?(Hash) and count # raise InvalidPluralizationData.new(entry, count) unless entry.is_a?(Hash) key = :zero if count == 0 && entry.has_key?(:zero) key ||= count == 1 ? :one : :many raise InvalidPluralizationData.new(entry, count) unless entry.has_key?(key) entry[key] end # Interpolates values into a given string. # # interpolate "file {{file}} opened by \\{{user}}", :file => 'test.txt', :user => 'Mr. X' # # => "file test.txt opened by {{user}}" # # Note that you have to double escape the \\ when you want to escape # the {{...}} key in a string (once for the string and once for the # interpolation). def interpolate(locale, string, values = {}) return string if !string.is_a?(String) string = string.gsub(/%d/, '{{count}}').gsub(/%s/, '{{value}}') if string.respond_to?(:force_encoding) original_encoding = string.encoding string.force_encoding(Encoding::BINARY) end s = StringScanner.new(string) while s.skip_until(/\{\{/) s.string[s.pos - 3, 1] = '' and next if s.pre_match[-1, 1] == '\\' start_pos = s.pos - 2 key = s.scan_until(/\}\}/)[0..-3] end_pos = s.pos - 1 raise ReservedInterpolationKey.new(key, string) if %w(scope default).include?(key) raise MissingInterpolationArgument.new(key, string) unless values.has_key? key.to_sym s.string[start_pos..end_pos] = values[key.to_sym].to_s s.unscan end result = s.string result.force_encoding(original_encoding) if original_encoding result end # Loads a single translations file by delegating to #load_rb or # #load_yml depending on the file extension and directly merges the # data to the existing translations. Raises I18n::UnknownFileType # for all other file extensions. def load_file(filename) type = File.extname(filename).tr('.', '').downcase raise UnknownFileType.new(type, filename) unless respond_to? :"load_#{type}" data = send :"load_#{type}", filename # TODO raise a meaningful exception if this does not yield a Hash data.each do |locale, d| merge_translations locale, d end end # Loads a plain Ruby translations file. eval'ing the file must yield # a Hash containing translation data with locales as toplevel keys. def load_rb(filename) eval IO.read(filename), binding, filename end # Loads a YAML translations file. The data must have locales as # toplevel keys. def load_yml(filename) YAML::load IO.read(filename) end # Deep merges the given translations hash with the existing translations # for the given locale def merge_translations(locale, data) locale = locale.to_sym @@translations[locale] ||= {} data = deep_symbolize_keys data # deep_merge by Stefan Rusterholz, see http://www.ruby-forum.com/topic/142809 merger = proc{|key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 } @@translations[locale].merge! data, &merger end # Return a new hash with all keys and nested keys converted to symbols. def deep_symbolize_keys(hash) hash.inject({}){|result, (key, value)| value = deep_symbolize_keys(value) if value.is_a? Hash result[(key.to_sym rescue key) || key] = value result } end end end end end