In an attempt to push yet more behaviour from my Rails controllers into model classes, I was extracting some code into a yet-to-be-published plugin that allows date and time columns to be set using more human-readable values. For examples:
>> task.completed_at = **"now"**>> task.completed_at=> Wed Apr 09 14:39:12 +1000 2008
Although my actual requirement was to support "now" and "today" I figured it would be rather cool if I could support anything that Chronic does. (If you haven’t used Chronic before, it’s a natural language date/time parser written in pure Ruby that understands a vast array of expressions including ranges.)
Naturally (no pun intended), Chronic also supports explicit dates such as “1/2/08”. As part of my testing however, I discovered that the date parsing is decidedly US-centric presuming, of course, that dates are specified as “month-day-year”. So for anyone living in say, Australia, it can be pretty frustrating to have Chronic.parse("1/2/08") return "Wed Jan 02 12:00:00 +1100 2008" rather than the expected "Fri Feb 01 12:00:00 +1100 2008".
The good news is, there is a solution. The bad news is, the solution is far from elegant. But first some context.
Chronic is actually written very nicely and the code is fairly easy to follow. In essence it works as follows: the input string is tokenized; each token is inspected to see if it’s a keyword such as a month name, day name, or a number, etc.; and finally tries to matches the sequence of tokens against a pattern such as “a day number followed by a month name and then a year.” The problem arises because the pattern for matching “month-day-year” comes before the one for “day-month-year” meaning that unless the first number is greater than 12, Chronic will always consider it to be a month.
The less than elegant solution is almost trivial and involves switching the order in which the patterns are matched. Doing so returns the desired result:
>> Chronic.parse("1/2/08")=> "Fri Feb 01 12:00:00 +1100 2008"
Which is all very well and good but now we have the reverse problem. What would be better is if we had a more general solution, one that allows us to specify the desired precedence when parsing:
>> Chronic.parse("1/2/08") # Default to U.S. date formats=> Wed Jan 02 12:00:00 +1100 2008>> Chronic.parse("1/2/08", **:explicit_date_format => :non_us**) # Prefer Non-U.S. formats=> Fri Feb 01 12:00:00 +1100 2008>> Chronic.parse("1/2/08", **:explicit_date_format => :us**) # Prefer U.S. formats=> Wed Jan 02 12:00:00 +1100 2008
Among other things, this allows us to have users in Australia enter dates with one format and users in the U.S. another.
For anyone interested, I’ve pasted a diff for each approach below.
A less than elegant solution:
--- a/chronic-0.2.3/lib/chronic/handlers.rb+++ b/chronic-0.2.3/lib/chronic/handlers.rb@@ -13,8 +13,8 @@ module ChronicHandler.new([:repeater_month_name, :ordinal_day, :separator_at?, 'time?'], :handle_rmn_od),Handler.new([:repeater_month_name, :scalar_year], :handle_rmn_sy),Handler.new([:scalar_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_sd_rmn_sy),- Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_day, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sm_sd_sy),Handler.new([:scalar_day, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sd_sm_sy),+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_day, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sm_sd_sy),Handler.new([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :separator_at?, 'time?'], :handle_sy_sm_sd),Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_year], :handle_sm_sy)],
A more general solution:
--- a/chronic-0.2.3/lib/chronic/chronic.rb+++ b/chronic-0.2.3/lib/chronic/chronic.rb@@ -43,7 +43,8 @@ module Chronicdefault_options = {:context => :future,- :ambiguous_time_range => 6}+ :ambiguous_time_range => 6,+ :explicit_date_format => :us}options = default_options.merge specified_options# ensure the specified options are valid@@ -51,6 +52,7 @@ module Chronicdefault_options.keys.include?(key) || raise(InvalidArgumentException, "#{key} is not a valid option key.")end[:past, :future, :none].include?(options[:context]) || raise(InvalidArgumentException, "Invalid value ':#{options[:context]}' for :context specified. Valid values are :past and :future.")+ [:us, :non_us].include?(options[:explicit_date_format]) || raise(InvalidArgumentException, "Invalid value ':#{options[:explicit_date_format]}' for :explicit_date_format specified. Valid values are :us and :non_us.")# store now for later =)@now = options[:now] -- - a/chronic-0.2.3/lib/chronic/handlers.rb+++ b/chronic-0.2.3/lib/chronic/handlers.rb@@ -3,41 +3,50 @@ module Chronicclass << selfdef definitions #:nodoc:- @definitions ||=- {:time => [Handler.new([:repeater_time, :repeater_day_portion?], nil)],+ if @definitions.nil?+ us_date = [Handler.new([:repeater_day_name, :repeater_month_name, :scalar_day, :repeater_time, :time_zone, :scalar_year], :handle_rdn_rmn_sd_t_tz_sy),+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year], :handle_rmn_sd_sy),+ Handler.new([:repeater_month_name, :scalar_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_sd_sy),+ Handler.new([:repeater_month_name, :scalar_day, :separator_at?, 'time?'], :handle_rmn_sd),+ Handler.new([:repeater_month_name, :ordinal_day, :separator_at?, 'time?'], :handle_rmn_od),+ Handler.new([:repeater_month_name, :scalar_year], :handle_rmn_sy),+ Handler.new([:scalar_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_sd_rmn_sy),+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_day, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sm_sd_sy),+ Handler.new([:scalar_day, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sd_sm_sy),+ Handler.new([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :separator_at?, 'time?'], :handle_sy_sm_sd),+ Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_year], :handle_sm_sy)]++ non_us_date = us_date.dup+ non_us_date[7] = us_date[8]+ non_us_date[8] = us_date[7]++ @definitions =+ {:time => [Handler.new([:repeater_time, :repeater_day_portion?], nil)],- :date => [Handler.new([:repeater_day_name, :repeater_month_name, :scalar_day, :repeater_time, :time_zone, :scalar_year], :handle_rdn_rmn_sd_t_tz_sy),- Handler.new([:repeater_month_name, :scalar_day, :scalar_year], :handle_rmn_sd_sy),- Handler.new([:repeater_month_name, :scalar_day, :scalar_year, :separator_at?, 'time?'], :handle_rmn_sd_sy),- Handler.new([:repeater_month_name, :scalar_day, :separator_at?, 'time?'], :handle_rmn_sd),- Handler.new([:repeater_month_name, :ordinal_day, :separator_at?, 'time?'], :handle_rmn_od),- Handler.new([:repeater_month_name, :scalar_year], :handle_rmn_sy),- Handler.new([:scalar_day, :repeater_month_name, :scalar_year, :separator_at?, 'time?'], :handle_sd_rmn_sy),- Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_day, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sm_sd_sy),- Handler.new([:scalar_day, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_year, :separator_at?, 'time?'], :handle_sd_sm_sy),- Handler.new([:scalar_year, :separator_slash_or_dash, :scalar_month, :separator_slash_or_dash, :scalar_day, :separator_at?, 'time?'], :handle_sy_sm_sd),- Handler.new([:scalar_month, :separator_slash_or_dash, :scalar_year], :handle_sm_sy)],+ :date => {:us => us_date, :non_us => non_us_date},- # tonight at 7pm- :anchor => [Handler.new([:grabber?, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),- Handler.new([:grabber?, :repeater, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),- Handler.new([:repeater, :grabber, :repeater], :handle_r_g_r)],+ # tonight at 7pm+ :anchor => [Handler.new([:grabber?, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),+ Handler.new([:grabber?, :repeater, :repeater, :separator_at?, :repeater?, :repeater?], :handle_r),+ Handler.new([:repeater, :grabber, :repeater], :handle_r_g_r)],- # 3 weeks from now, in 2 months- :arrow => [Handler.new([:scalar, :repeater, :pointer], :handle_s_r_p),- Handler.new([:pointer, :scalar, :repeater], :handle_p_s_r),- Handler.new([:scalar, :repeater, :pointer, 'anchor'], :handle_s_r_p_a)],+ # 3 weeks from now, in 2 months+ :arrow => [Handler.new([:scalar, :repeater, :pointer], :handle_s_r_p),+ Handler.new([:pointer, :scalar, :repeater], :handle_p_s_r),+ Handler.new([:scalar, :repeater, :pointer, 'anchor'], :handle_s_r_p_a)],- # 3rd week in march- :narrow => [Handler.new([:ordinal, :repeater, :separator_in, :repeater], :handle_o_r_s_r),- Handler.new([:ordinal, :repeater, :grabber, :repeater], :handle_o_r_g_r)]- }+ # 3rd week in march+ :narrow => [Handler.new([:ordinal, :repeater, :separator_in, :repeater], :handle_o_r_s_r),+ Handler.new([:ordinal, :repeater, :grabber, :repeater], :handle_o_r_g_r)]+ }+ end+ @definitionsenddef tokens_to_span(tokens, options) #:nodoc:# maybe it's a specific date- self.definitions[:date].each do |handler|+ self.definitions[:date][options[:explicit_date_format]].each do |handler|if handler.match(tokens, self.definitions)puts "-date" if Chronic.debuggood_tokens = tokens.select { |o| !o.get_tag Separator }