lib/delocalize/parsers/date_time.rb



require 'date'

# TODO:
#   * AM/PM calculation
#   * proper documentation (comments)
module Delocalize
  module Parsers
    class DateTime
      # extend/change this according to your needs by merging your custom regexps
      REGEXPS = {
        '%B' => "(#{Date::MONTHNAMES.compact.join('|')})",      # long month name
        '%b' => "(#{Date::ABBR_MONTHNAMES.compact.join('|')})", # short month name
        '%m' => "(\\d{2})",                                     # numeric month
        '%A' => "(#{Date::DAYNAMES.join('|')})",                # full day name
        '%a' => "(#{Date::ABBR_DAYNAMES.join('|')})",           # short day name
        '%Y' => "(\\d{4})",                                     # long year
        '%y' => "(\\d{2})",                                     # short year
        '%e' => "(\\s\\d|\\d{2})",                              # short day
        '%d' => "(\\d{2})",                                     # full day
        '%H' => "(\\d{2})",                                     # hour (24)
        '%M' => "(\\d{2})",                                     # minute
        '%S' => "(\\d{2})"                                      # second
      }

      attr_reader :type

      def initialize(type)
        @type = type
      end

      def parse(datetime)
        return unless datetime
        return datetime if datetime.respond_to?(:strftime) # already a Date/Time object -> no need to parse it

        translate_month_and_day_names(datetime)
        input_formats(type).each do |original_format|
          next unless datetime =~ /^#{apply_regex(original_format)}$/

          datetime = ::DateTime.strptime(datetime, original_format) rescue break
          return Date == type ?
            datetime.to_date :
            Time.zone.local(datetime.year, datetime.mon, datetime.mday, datetime.hour, datetime.min, datetime.sec)
        end
        default_parse(datetime, type)
      end

    private

      def default_parse(datetime, type)
        return if datetime.blank?

        today = Date.current
        parsed = Date._parse(datetime)
        raise ArgumentError, "invalid date: #{datetime}" if parsed.empty? # the datetime value is invalid

        # set default year, month and day if not found
        parsed.reverse_merge!(:year => today.year, :mon => today.mon, :mday => today.mday)

        if Date == type
          Date.civil(*parsed.values_at(:year, :mon, :mday))
        else
          Time.zone.local(*parsed.values_at(:year, :mon, :mday, :hour, :min, :sec))
        end
      end

      def translate_month_and_day_names(datetime)
        # Note: This should be a bulk lookup but due to a bug in i18n it doesn't work properly with fallbacks.
        # See https://github.com/svenfuchs/i18n/issues/104.
        # TODO Make it a bulk lookup again at some point in the future when the bug is fixed in i18n.
        translated = [:month_names, :abbr_month_names, :day_names, :abbr_day_names].map do |key|
          I18n.t(key, :scope => :date)
        end.flatten.compact

        original = (Date::MONTHNAMES + Date::ABBR_MONTHNAMES + Date::DAYNAMES + Date::ABBR_DAYNAMES).compact
        translated.each_with_index { |name, i| datetime.gsub!(/\b#{name}\b/, original[i]) }
      end

      def input_formats(type)
        # Date uses date formats, all others use time formats
        type = type == Date ? :date : :time
        I18n.t(:"#{type}.formats").slice(*I18n.t(:"#{type}.input.formats")).values
      end

      def apply_regex(format)
        format.gsub(/(#{REGEXPS.keys.join('|')})/) { |s| REGEXPS[$1] }
      end
    end
  end
end