diff --git a/.travis.yml b/.travis.yml index 45d99a55..be216d88 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,10 @@ sudo: false language: ruby before_install: - - gem install bundler + - if [[ $TRAVIS_RUBY_VERSION =~ ^(1|2\.[012]|jruby-1)\. ]]; then gem install rubygems-update --version '~> 2.7' --no-document && update_rubygems; elif [[ ! $TRAVIS_RUBY_VERSION =~ ^truffleruby- ]]; then gem update --system; fi + - gem --version + - if [[ $TRAVIS_RUBY_VERSION =~ ^(1|2\.[012]|jruby-1)\. ]]; then gem install bundler --version '~> 1.17'; else gem install bundler; fi + - bundle --version notifications: email: - john.crepezzi@gmail.com diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index 4a38232e..e690c15f 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -50,6 +50,8 @@ module Validations autoload :YearlyInterval, 'ice_cube/validations/yearly_interval' autoload :HourlyInterval, 'ice_cube/validations/hourly_interval' + autoload :BySetPos, 'ice_cube/validations/by_set_pos' + autoload :HourOfDay, 'ice_cube/validations/hour_of_day' autoload :MonthOfYear, 'ice_cube/validations/month_of_year' autoload :MinuteOfHour, 'ice_cube/validations/minute_of_hour' diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index c6b91a1a..bac7e121 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -4,7 +4,7 @@ def self.schedule_from_ical(ical_string, options = {}) data = {} ical_string.each_line do |line| (property, value) = line.split(':') - (property, tzid) = property.split(';') + (property, _tzid) = property.split(';') case property when 'DTSTART' data[:start_time] = TimeUtil.deserialize_time(value) @@ -75,6 +75,7 @@ def self.rule_from_ical(ical) when 'BYYEARDAY' validations[:day_of_year] = value.split(',').map(&:to_i) when 'BYSETPOS' + params[:validations][:by_set_pos] = value.split(',').collect(&:to_i) else validations[name] = nil # invalid type end diff --git a/lib/ice_cube/rule.rb b/lib/ice_cube/rule.rb index c8cd992c..944c6f37 100644 --- a/lib/ice_cube/rule.rb +++ b/lib/ice_cube/rule.rb @@ -58,6 +58,10 @@ def on?(time, schedule) next_time(time, schedule, time).to_i == time.to_i end + def interval_type + self.class.interval_type + end + class << self # Convert from a hash and create a rule @@ -90,6 +94,10 @@ def from_hash(original_hash) rule end + def interval_type + @_interval_type ||= self.name.split('::').last.sub(/Rule$/, '').downcase + end + private def apply_validation(rule, name, args) diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index 69830fab..f307a92b 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -8,6 +8,7 @@ class ValidatedRule < Rule include Validations::Count include Validations::Until + include Validations::BySetPos # Validations ordered for efficiency in sequence of: # * descending intervals @@ -20,7 +21,8 @@ class ValidatedRule < Rule :base_sec, :base_min, :base_day, :base_hour, :base_month, :base_wday, :day_of_year, :second_of_minute, :minute_of_hour, :day_of_month, :hour_of_day, :month_of_year, :day_of_week, - :interval + :interval, + :by_set_pos ] attr_reader :validations diff --git a/lib/ice_cube/validations/by_set_pos.rb b/lib/ice_cube/validations/by_set_pos.rb new file mode 100644 index 00000000..50dd1d4b --- /dev/null +++ b/lib/ice_cube/validations/by_set_pos.rb @@ -0,0 +1,138 @@ +require "active_support/core_ext/date/calculations" +require "active_support/core_ext/date_time/calculations" +require "active_support/core_ext/time/calculations" + +require "active_support/core_ext/hash/except" + +module IceCube + + module Validations::BySetPos + + def by_set_pos(*bysplist) + bysplist.flatten.each do |set_pos_day| + unless set_pos_day.is_a?(Integer) && (-366..366).include?(set_pos_day) && set_pos_day != 0 + raise ArgumentError, "expecting Integer value in [-366, -1] or [1, 366] for setposday, got #{set_pos_day} (#{bysplist})" + end + + validations_for(:by_set_pos) << Validation.new(set_pos_day, self) + end + + self + end + + class Validation + + attr_reader :source_rule, :set_pos_day + + def initialize(set_pos_day, source_rule) + @set_pos_day = set_pos_day + @source_rule = source_rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, _start_time) + @step_time = step_time + + if step_time == occurrences_this_period[zero_indexed_position] + 0 + else + 1 + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << set_pos_day + end + + def build_hash(builder) + builder.validations_array(:by_set_pos) << set_pos_day + end + + def build_ical(builder) + builder['BYSETPOS'] << set_pos_day + end + + private + + attr_reader :step_time + + def zero_indexed_position + if set_pos_day > 0 + set_pos_day - 1 + else + set_pos_day + end + end + + def occurrences_this_period + schedule_for_rule.occurrences_between( + beginning_of_period, + end_of_period + ) + end + + def interval_type + if source_rule.interval_type == 'daily' + 'day' + else + source_rule.interval_type.sub(/ly$/, '') + end + end + + def beginning_of_period + if interval_type == 'second' + fail 'boo' + else + step_time.public_send("beginning_of_#{interval_type}") + end + end + + def end_of_period + if interval_type == 'second' + fail 'boo' + else + step_time.public_send("end_of_#{interval_type}") + end + end + + def last_period + case interval_type + when 'second' + fail 'boo' + when 'minute' + step_time - ONE_MINUTE + when 'hour' + step_time - ONE_HOUR + when 'day' + step_time.yesterday + when 'week', 'month', 'year' + step_time.public_send("last_#{interval_type}") + end + end + + def schedule_for_rule + IceCube::Schedule.new(last_period) do |s| + s.add_recurrence_rule Rule.from_hash(rule_hash_for_all_occurrences) + end + end + + def rule_hash_for_all_occurrences + source_rule.to_hash.except(:count, :until).tap do |hash| + if hash[:validations] + hash[:validations].delete(:by_set_pos) + end + end + end + + end + + end + +end diff --git a/spec/examples/by_set_pos_spec.rb b/spec/examples/by_set_pos_spec.rb new file mode 100644 index 00000000..f8bfc931 --- /dev/null +++ b/spec/examples/by_set_pos_spec.rb @@ -0,0 +1,31 @@ +require File.dirname(__FILE__) + '/../spec_helper' + +module IceCube + + describe MonthlyRule, 'BYSETPOS' do + it 'should behave correctly' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expectations = [ + Time.new(2015, 6, 24, 12, 0, 0), + Time.new(2015, 7, 22, 12, 0, 0), + Time.new(2015, 8, 26, 12, 0, 0), + Time.new(2015, 9, 23, 12, 0, 0) + ] + expect(schedule.occurrences(Time.new(2017, 01, 01))).to eq(expectations) + end + + end + + describe YearlyRule, 'BYSETPOS' do + it 'should behave correctly' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1" + schedule.start_time = Time.new(1966, 7, 5) + expectations = [ + Time.new(2015, 7, 31), + Time.new(2016, 7, 31) + ] + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))).to eq(expectations) + end + end +end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 12cf8bf8..c76fb5b8 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -86,6 +86,11 @@ module IceCube expect(rule).to eq(IceCube::Rule.weekly(2, :monday)) end + it 'should be able to parse by_set_pos start (BYSETPOS)' do + rule = IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-1,1") + expect(rule).to eq(IceCube::Rule.monthly.day(:monday, :wednesday).by_set_pos([-1, 1])) + end + it 'should return no occurrences after daily interval with count is over' do schedule = IceCube::Schedule.new(Time.now) schedule.add_recurrence_rule(IceCube::Rule.from_ical("FREQ=DAILY;COUNT=5")) @@ -185,6 +190,16 @@ def sorted_ical(ical) expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) end + it 'handles bysetpos' do + start_time = Time.now + + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule(IceCube::Rule.weekly.day(:monday).by_set_pos(1, -1)) + + ical = schedule.to_ical + expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) + end + end describe 'weekly frequency' do @@ -237,6 +252,16 @@ def sorted_ical(ical) ical = schedule.to_ical expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) end + + it 'handles bysetpos' do + start_time = Time.now + + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule(IceCube::Rule.weekly.day(:monday).by_set_pos(1, -1)) + + ical = schedule.to_ical + expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) + end end describe 'monthly frequency' do @@ -279,6 +304,16 @@ def sorted_ical(ical) ical = schedule.to_ical expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) end + + it 'handles bysetpos' do + start_time = Time.now + + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule(IceCube::Rule.monthly.day(:monday).by_set_pos(1, -1)) + + ical = schedule.to_ical + expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) + end end describe 'yearly frequency' do @@ -332,6 +367,16 @@ def sorted_ical(ical) expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) end + it 'handles bysetpos' do + start_time = Time.now + + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule(IceCube::Rule.yearly.month_of_year(:february).day(:monday).by_set_pos(1, -1)) + + ical = schedule.to_ical + expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical)) + end + it 'handles specific months' do start_time = Time.now diff --git a/spec/examples/rfc_spec.rb b/spec/examples/rfc_spec.rb index b7f4cfdc..4c6e1ebb 100644 --- a/spec/examples/rfc_spec.rb +++ b/spec/examples/rfc_spec.rb @@ -325,6 +325,24 @@ expect(dates).to eq(expecation) end + it 'should ~ third instance into the month of one of Tuesday, Wednesday, or Thursday, for the next 3 months' do + start_time = Time.utc(1997, 9, 4, 9, 0, 0) + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule IceCube::Rule.monthly.count(3).day(:tuesday , :wednesday , :thursday).by_set_pos(3) + dates = schedule.all_occurrences + expectation = [Time.utc(1997, 9, 4, 9), Time.utc(1997, 10, 7, 9), Time.utc(1997, 11, 6, 9)] + expect(dates).to eq(expectation) + end + + it 'should ~ second-to-last weekday of the month' do + start_time = Time.utc(1997, 9, 29, 9, 0, 0) + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule IceCube::Rule.monthly.day(:monday, :tuesday , :wednesday , :thursday, :friday).by_set_pos(-2) + next_dates = schedule.occurrences(Time.utc(1997, 12, 31)) + expectation = [Time.utc(1997, 9, 29, 9), Time.utc(1997, 10, 30, 9), Time.utc(1997, 11, 27, 9), Time.utc(1997, 12, 30, 9)] + expect(next_dates).to eq(expectation) + end + end def test_expectations(schedule, dates_array) diff --git a/spec/examples/yearly_rule_spec.rb b/spec/examples/yearly_rule_spec.rb index fe725e65..e4375871 100644 --- a/spec/examples/yearly_rule_spec.rb +++ b/spec/examples/yearly_rule_spec.rb @@ -77,6 +77,14 @@ expect(schedule.occurrences(Time.utc(2010, 12, 31))).to eq days_of_year end + it 'should produce the correct days for @interval = 1 when you specify week days' do + start_time = Time.utc(2010, 1, 1) + schedule = IceCube::Schedule.new(start_time) + schedule.add_recurrence_rule IceCube::Rule.yearly.day(:monday) + + expect(schedule.occurrences(Time.utc(2010, 12, 31)).count).to eq 52 + end + it 'should produce the correct days for @interval = 1 when you specify negative days' do schedule = IceCube::Schedule.new(Time.utc(2010, 1, 1)) schedule.add_recurrence_rule IceCube::Rule.yearly.day_of_year(100, -1)