From 3d7d840f8e6dac043c0733d29f727a4d7835e9b5 Mon Sep 17 00:00:00 2001 From: Tom Preston-Werner Date: Mon, 18 Feb 2008 00:56:24 -0800 Subject: [PATCH] seasons and weekday/weekend support (Edwin Chen) --- lib/chronic.rb | 1 + lib/chronic/repeater.rb | 14 ++ lib/chronic/repeaters/repeater_season.rb | 126 +++++++++++++++++- lib/chronic/repeaters/repeater_season_name.rb | 45 +++++-- lib/chronic/repeaters/repeater_weekday.rb | 72 ++++++++++ lib/chronic/repeaters/repeater_year.rb | 1 + test/test_RepeaterWeekday.rb | 56 ++++++++ test/test_parsing.rb | 22 +++ 8 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 lib/chronic/repeaters/repeater_weekday.rb create mode 100644 test/test_RepeaterWeekday.rb diff --git a/lib/chronic.rb b/lib/chronic.rb index ac4a1e1..fd58292 100644 --- a/lib/chronic.rb +++ b/lib/chronic.rb @@ -23,6 +23,7 @@ require 'chronic/repeaters/repeater_fortnight' require 'chronic/repeaters/repeater_week' require 'chronic/repeaters/repeater_weekend' +require 'chronic/repeaters/repeater_weekday' require 'chronic/repeaters/repeater_day' require 'chronic/repeaters/repeater_day_name' require 'chronic/repeaters/repeater_day_portion' diff --git a/lib/chronic/repeater.rb b/lib/chronic/repeater.rb index 9f80daf..dad79fe 100644 --- a/lib/chronic/repeater.rb +++ b/lib/chronic/repeater.rb @@ -2,6 +2,7 @@ class Chronic::Repeater < Chronic::Tag #:nodoc: def self.scan(tokens, options) # for each token tokens.each_index do |i| + if t = self.scan_for_season_names(tokens[i]) then tokens[i].tag(t); next end if t = self.scan_for_month_names(tokens[i]) then tokens[i].tag(t); next end if t = self.scan_for_day_names(tokens[i]) then tokens[i].tag(t); next end if t = self.scan_for_day_portions(tokens[i]) then tokens[i].tag(t); next end @@ -11,6 +12,18 @@ def self.scan(tokens, options) tokens end + def self.scan_for_season_names(token) + scanner = {/^springs?$/ => :spring, + /^summers?$/ => :summer, + /^(autumn)|(fall)s?$/ => :autumn, + /^winters?$/ => :winter} + scanner.keys.each do |scanner_item| + return Chronic::RepeaterSeasonName.new(scanner[scanner_item]) if scanner_item =~ token.word + end + + return nil + end + def self.scan_for_month_names(token) scanner = {/^jan\.?(uary)?$/ => :january, /^feb\.?(ruary)?$/ => :february, @@ -74,6 +87,7 @@ def self.scan_for_units(token) /^fortnights?$/ => :fortnight, /^weeks?$/ => :week, /^weekends?$/ => :weekend, + /^(week|business)days?$/ => :weekday, /^days?$/ => :day, /^hours?$/ => :hour, /^minutes?$/ => :minute, diff --git a/lib/chronic/repeaters/repeater_season.rb b/lib/chronic/repeaters/repeater_season.rb index a255865..6fa23f0 100644 --- a/lib/chronic/repeaters/repeater_season.rb +++ b/lib/chronic/repeaters/repeater_season.rb @@ -1,16 +1,99 @@ +class Time + def to_minidate + MiniDate.new(self.month, self.day) + end +end + +class Season + attr_reader :start, :end + + def initialize(myStart, myEnd) + @start = myStart + @end = myEnd + end + + def self.find_next_season(season, pointer) + lookup = {:spring => 0, :summer => 1, :autumn => 2, :winter => 3} + next_season_num = (lookup[season]+1*pointer) % 4 + lookup.invert[next_season_num] + end + + def self.season_after(season); find_next_season(season, +1); end + def self.season_before(season); find_next_season(season, -1); end +end + +class MiniDate + attr_accessor :month, :day + + def initialize(month, day) + @month = month + @day = day + end + + def is_between?(md_start, md_end) + return true if (@month == md_start.month and @day >= md_start.day) || + (@month == md_end.month and @day <= md_end.day) + + i = md_start.month + 1 + until i == md_end.month + return true if @month == i + i = (i+1) % 12 + end + + return false + end + + def equals?(other) + @month == other.month and day == other.day + end +end + class Chronic::RepeaterSeason < Chronic::Repeater #:nodoc: + YEAR_SEASONS = 4 SEASON_SECONDS = 7_862_400 # 91 * 24 * 60 * 60 + SEASONS = { :spring => Season.new( MiniDate.new(3,20),MiniDate.new(6,20) ), + :summer => Season.new( MiniDate.new(6,21),MiniDate.new(9,22) ), + :autumn => Season.new( MiniDate.new(9,23),MiniDate.new(12,21) ), + :winter => Season.new( MiniDate.new(12,22),MiniDate.new(3,19) ) } def next(pointer) super - raise 'Not implemented' + direction = pointer == :future ? 1 : -1 + next_season = Season.find_next_season(find_current_season(@now.to_minidate), direction) + + find_next_season_span(direction, next_season) end def this(pointer = :future) super - raise 'Not implemented' + direction = pointer == :future ? 1 : -1 + + today = Time.construct(@now.year, @now.month, @now.day) + this_ssn = find_current_season(@now.to_minidate) + case pointer + when :past + this_ssn_start = today + direction * num_seconds_til_start(this_ssn, direction) + this_ssn_end = today + when :future + this_ssn_start = today + Chronic::RepeaterDay::DAY_SECONDS + this_ssn_end = today + direction * num_seconds_til_end(this_ssn, direction) + when :none + this_ssn_start = today + direction * num_seconds_til_start(this_ssn, direction) + this_ssn_end = today + direction * num_seconds_til_end(this_ssn, direction) + end + + Chronic::Span.new(this_ssn_start, this_ssn_end) + end + + def offset(span, amount, pointer) + Chronic::Span.new(offset_by(span.begin, amount, pointer), offset_by(span.end, amount, pointer)) + end + + def offset_by(time, amount, pointer) + direction = pointer == :future ? 1 : -1 + time + amount * direction * SEASON_SECONDS end def width @@ -20,4 +103,43 @@ def width def to_s super << '-season' end + + private + + def find_next_season_span(direction, next_season) + if !@next_season_start or !@next_season_end + @next_season_start = Time.construct(@now.year, @now.month, @now.day) + @next_season_end = Time.construct(@now.year, @now.month, @now.day) + end + + @next_season_start += direction * num_seconds_til_start(next_season, direction) + @next_season_end += direction * num_seconds_til_end(next_season, direction) + + Chronic::Span.new(@next_season_start, @next_season_end) + end + + def find_current_season(md) + [:spring, :summer, :autumn, :winter].each do |season| + return season if md.is_between?(SEASONS[season].start, SEASONS[season].end) + end + end + + def num_seconds_til(goal, direction) + start = Time.construct(@now.year, @now.month, @now.day) + seconds = 0 + + until (start + direction * seconds).to_minidate.equals?(goal) + seconds += Chronic::RepeaterDay::DAY_SECONDS + end + + seconds + end + + def num_seconds_til_start(season_symbol, direction) + num_seconds_til(SEASONS[season_symbol].start, direction) + end + + def num_seconds_til_end(season_symbol, direction) + num_seconds_til(SEASONS[season_symbol].end, direction) + end end \ No newline at end of file diff --git a/lib/chronic/repeaters/repeater_season_name.rb b/lib/chronic/repeaters/repeater_season_name.rb index adfd1f2..74ce838 100644 --- a/lib/chronic/repeaters/repeater_season_name.rb +++ b/lib/chronic/repeaters/repeater_season_name.rb @@ -1,24 +1,45 @@ +require 'chronic/repeaters/repeater_season.rb' + class Chronic::RepeaterSeasonName < Chronic::RepeaterSeason #:nodoc: - @summer = ['jul 21', 'sep 22'] - @autumn = ['sep 23', 'dec 21'] - @winter = ['dec 22', 'mar 19'] - @spring = ['mar 20', 'jul 20'] + SEASON_SECONDS = 7_862_400 # 91 * 24 * 60 * 60 + DAY_SECONDS = 86_400 # (24 * 60 * 60) def next(pointer) - super - raise 'Not implemented' + direction = pointer == :future ? 1 : -1 + find_next_season_span(direction, @type) end def this(pointer = :future) - super - raise 'Not implemented' + # super + + direction = pointer == :future ? 1 : -1 + + today = Time.construct(@now.year, @now.month, @now.day) + goal_ssn_start = today + direction * num_seconds_til_start(@type, direction) + goal_ssn_end = today + direction * num_seconds_til_end(@type, direction) + curr_ssn = find_current_season(@now.to_minidate) + case pointer + when :past + this_ssn_start = goal_ssn_start + this_ssn_end = (curr_ssn == @type) ? today : goal_ssn_end + when :future + this_ssn_start = (curr_ssn == @type) ? today + Chronic::RepeaterDay::DAY_SECONDS : goal_ssn_start + this_ssn_end = goal_ssn_end + when :none + this_ssn_start = goal_ssn_start + this_ssn_end = goal_ssn_end + end + + Chronic::Span.new(this_ssn_start, this_ssn_end) end - def width - (91 * 24 * 60 * 60) + def offset(span, amount, pointer) + Chronic::Span.new(offset_by(span.begin, amount, pointer), offset_by(span.end, amount, pointer)) end - def to_s - super << '-season-' << @type.to_s + def offset_by(time, amount, pointer) + direction = pointer == :future ? 1 : -1 + time + amount * direction * Chronic::RepeaterYear::YEAR_SECONDS end + end \ No newline at end of file diff --git a/lib/chronic/repeaters/repeater_weekday.rb b/lib/chronic/repeaters/repeater_weekday.rb new file mode 100644 index 0000000..8a9eb3a --- /dev/null +++ b/lib/chronic/repeaters/repeater_weekday.rb @@ -0,0 +1,72 @@ +class Chronic::RepeaterWeekday < Chronic::Repeater #:nodoc: + WEEK_WEEKDAYS = 5 + DAY_SECONDS = 86400 # (24 * 60 * 60) + + def next(pointer) + super + + direction = pointer == :future ? 1 : -1 + + if !@current_weekday_start + @current_weekday_start = Time.construct(@now.year, @now.month, @now.day) + @current_weekday_start += direction * DAY_SECONDS + + until is_weekday?(@current_weekday_start.wday) + @current_weekday_start += direction * DAY_SECONDS + end + else + loop do + @current_weekday_start += direction * DAY_SECONDS + break if is_weekday?(@current_weekday_start.wday) + end + end + + Chronic::Span.new(@current_weekday_start, @current_weekday_start + DAY_SECONDS) + end + + def this(pointer = :future) + super + + case pointer + when :past + self.next(:past) + when :future, :none + self.next(:future) + end + end + + def offset(span, amount, pointer) + direction = pointer == :future ? 1 : -1 + + num_weekdays_passed = 0; offset = 0 + until num_weekdays_passed == amount + offset += direction * DAY_SECONDS + num_weekdays_passed += 1 if is_weekday?((span.begin+offset).wday) + end + + span + offset + end + + def width + DAY_SECONDS + end + + def to_s + super << '-weekday' + end + + private + + def is_weekend?(day) + day == symbol_to_number(:saturday) || day == symbol_to_number(:sunday) + end + + def is_weekday?(day) + !is_weekend?(day) + end + + def symbol_to_number(sym) + lookup = {:sunday => 0, :monday => 1, :tuesday => 2, :wednesday => 3, :thursday => 4, :friday => 5, :saturday => 6} + lookup[sym] || raise("Invalid symbol specified") + end +end \ No newline at end of file diff --git a/lib/chronic/repeaters/repeater_year.rb b/lib/chronic/repeaters/repeater_year.rb index 426371f..9c2411d 100644 --- a/lib/chronic/repeaters/repeater_year.rb +++ b/lib/chronic/repeaters/repeater_year.rb @@ -1,4 +1,5 @@ class Chronic::RepeaterYear < Chronic::Repeater #:nodoc: + YEAR_SECONDS = 31536000 # 365 * 24 * 60 * 60 def next(pointer) super diff --git a/test/test_RepeaterWeekday.rb b/test/test_RepeaterWeekday.rb new file mode 100644 index 0000000..e38304c --- /dev/null +++ b/test/test_RepeaterWeekday.rb @@ -0,0 +1,56 @@ +require 'lib/chronic' +require 'test/unit' + +class TestRepeaterWeekday < Test::Unit::TestCase + + def setup + @now = Time.local(2007, 6, 11, 14, 0, 0, 0) # Mon + end + + def test_next_future + weekdays = Chronic::RepeaterWeekday.new(:weekday) + weekdays.start = @now + + next1_weekday = weekdays.next(:future) # Tues + assert_equal Time.local(2007, 6, 12), next1_weekday.begin + assert_equal Time.local(2007, 6, 13), next1_weekday.end + + next2_weekday = weekdays.next(:future) # Wed + assert_equal Time.local(2007, 6, 13), next2_weekday.begin + assert_equal Time.local(2007, 6, 14), next2_weekday.end + + next3_weekday = weekdays.next(:future) # Thurs + assert_equal Time.local(2007, 6, 14), next3_weekday.begin + assert_equal Time.local(2007, 6, 15), next3_weekday.end + + next4_weekday = weekdays.next(:future) # Fri + assert_equal Time.local(2007, 6, 15), next4_weekday.begin + assert_equal Time.local(2007, 6, 16), next4_weekday.end + + next5_weekday = weekdays.next(:future) # Mon + assert_equal Time.local(2007, 6, 18), next5_weekday.begin + assert_equal Time.local(2007, 6, 19), next5_weekday.end + end + + def test_next_past + weekdays = Chronic::RepeaterWeekday.new(:weekday) + weekdays.start = @now + + last1_weekday = weekdays.next(:past) # Fri + assert_equal Time.local(2007, 6, 8), last1_weekday.begin + assert_equal Time.local(2007, 6, 9), last1_weekday.end + + last2_weekday = weekdays.next(:past) # Thurs + assert_equal Time.local(2007, 6, 7), last2_weekday.begin + assert_equal Time.local(2007, 6, 8), last2_weekday.end + end + + def test_offset + span = Chronic::Span.new(@now, @now + 1) + + offset_span = Chronic::RepeaterWeekday.new(:weekday).offset(span, 5, :future) + + assert_equal Time.local(2007, 6, 18, 14), offset_span.begin + assert_equal Time.local(2007, 6, 18, 14, 0, 1), offset_span.end + end +end \ No newline at end of file diff --git a/test/test_parsing.rb b/test/test_parsing.rb index a007265..d3ff243 100644 --- a/test/test_parsing.rb +++ b/test/test_parsing.rb @@ -638,6 +638,28 @@ def test_argument_validation end end + def test_seasons + t = parse_now("this spring", :guess => false) + assert_equal Time.local(2007, 3, 20), t.begin + assert_equal Time.local(2007, 6, 20), t.end + + t = parse_now("this winter", :guess => false) + assert_equal Time.local(2006, 12, 22, 23), t.begin + assert_equal Time.local(2007, 3, 19), t.end + + t = parse_now("last spring", :guess => false) + assert_equal Time.local(2006, 3, 20, 23), t.begin + assert_equal Time.local(2006, 6, 20), t.end + + t = parse_now("last winter", :guess => false) + assert_equal Time.local(2005, 12, 22, 23), t.begin + assert_equal Time.local(2006, 3, 19, 23), t.end + + t = parse_now("next spring", :guess => false) + assert_equal Time.local(2007, 3, 20), t.begin + assert_equal Time.local(2007, 6, 20), t.end + end + # regression # def test_partial