diff --git a/lib/ice_cube.rb b/lib/ice_cube.rb index 3c283f53..a6d5078d 100644 --- a/lib/ice_cube.rb +++ b/lib/ice_cube.rb @@ -49,6 +49,10 @@ module Validations autoload :YearlyInterval, "ice_cube/validations/yearly_interval" autoload :HourlyInterval, "ice_cube/validations/hourly_interval" + autoload :WeeklyBySetPos, 'ice_cube/validations/weekly_by_set_pos' + autoload :MonthlyBySetPos, 'ice_cube/validations/monthly_by_set_pos' + autoload :YearlyBySetPos, 'ice_cube/validations/yearly_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 429bd84c..04ae679f 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -2,7 +2,18 @@ module IceCube class IcalParser def self.schedule_from_ical(ical_string, options = {}) data = {} + + # First join lines that are wrapped + lines = [] ical_string.each_line do |line| + if lines[-1] && line =~ /\A[ \t].+/ + lines[-1] = lines[-1].strip + line.sub(/\A[ \t]+/, "") + else + lines << line + end + end + + lines.each do |line| (property, value) = line.split(":") (property, _tzid) = property.split(";") case property @@ -75,7 +86,7 @@ def self.rule_from_ical(ical) when "BYYEARDAY" validations[:day_of_year] = value.split(",").map(&:to_i) when "BYSETPOS" - # noop + params[:validations][:by_set_pos] = value.split(',').collect(&:to_i) else validations[name] = nil # invalid type end diff --git a/lib/ice_cube/rules/monthly_rule.rb b/lib/ice_cube/rules/monthly_rule.rb index 6aadf5e7..9decdbca 100644 --- a/lib/ice_cube/rules/monthly_rule.rb +++ b/lib/ice_cube/rules/monthly_rule.rb @@ -10,6 +10,7 @@ class MonthlyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::MonthlyInterval + include Validations::MonthlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/rules/weekly_rule.rb b/lib/ice_cube/rules/weekly_rule.rb index e24f77f9..12e8b485 100644 --- a/lib/ice_cube/rules/weekly_rule.rb +++ b/lib/ice_cube/rules/weekly_rule.rb @@ -10,6 +10,7 @@ class WeeklyRule < ValidatedRule # include Validations::DayOfYear # n/a include Validations::WeeklyInterval + include Validations::WeeklyBySetPos attr_reader :week_start diff --git a/lib/ice_cube/rules/yearly_rule.rb b/lib/ice_cube/rules/yearly_rule.rb index d92148c1..0ebb3057 100644 --- a/lib/ice_cube/rules/yearly_rule.rb +++ b/lib/ice_cube/rules/yearly_rule.rb @@ -10,6 +10,7 @@ class YearlyRule < ValidatedRule include Validations::DayOfYear include Validations::YearlyInterval + include Validations::YearlyBySetPos def initialize(interval = 1) super diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index 001d927d..d632f0a1 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -1,5 +1,5 @@ -require "date" -require "time" +require 'date' +require 'time' module IceCube module TimeUtil diff --git a/lib/ice_cube/validated_rule.rb b/lib/ice_cube/validated_rule.rb index 068356ea..faa5ee95 100644 --- a/lib/ice_cube/validated_rule.rb +++ b/lib/ice_cube/validated_rule.rb @@ -18,7 +18,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/monthly_by_set_pos.rb b/lib/ice_cube/validations/monthly_by_set_pos.rb new file mode 100644 index 00000000..40d93926 --- /dev/null +++ b/lib/ice_cube/validations/monthly_by_set_pos.rb @@ -0,0 +1,77 @@ +module IceCube + + module Validations::MonthlyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_month = TimeUtil.build_in_zone([step_time.year, step_time.month, 1, 0, 0, 0], step_time) + eom_date = Date.new(step_time.year, step_time.month, -1) + end_of_month = TimeUtil.build_in_zone([eom_date.year, eom_date.month, eom_date.day, 23, 59, 59], step_time) + + # Needs to start on the first day of the month + new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_month.year, start_of_month.month, start_of_month.day, step_time.hour, step_time.min, step_time.sec], start_of_month)) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_month, end_of_month) + index = occurrences.index(step_time) + if index == nil + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder['BYSETPOS'] << by_set_pos + end + + nil + end + + end + +end diff --git a/lib/ice_cube/validations/weekly_by_set_pos.rb b/lib/ice_cube/validations/weekly_by_set_pos.rb new file mode 100644 index 00000000..f7c9f791 --- /dev/null +++ b/lib/ice_cube/validations/weekly_by_set_pos.rb @@ -0,0 +1,91 @@ +module IceCube + module Validations::WeeklyBySetPos + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + # Use vanilla Ruby Date objects so we can add and subtract dates across DST changes + step_time_date = step_time.to_date + start_day_of_week = TimeUtil.sym_to_wday(rule.week_start) + step_time_day_of_week = step_time_date.wday + days_delta = step_time_day_of_week - start_day_of_week + days_to_start = days_delta >= 0 ? days_delta : 7 + days_delta + start_of_week_date = step_time_date - days_to_start + end_of_week_date = start_of_week_date + 6 + start_of_week = IceCube::TimeUtil.build_in_zone( + [start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, 0, 0, 0], step_time + ) + end_of_week = IceCube::TimeUtil.build_in_zone( + [end_of_week_date.year, end_of_week_date.month, end_of_week_date.day, 23, 59, 59], step_time + ) + + # Needs to start on the first day of the week at the step_time's hour, min, sec + start_of_week_adjusted = IceCube::TimeUtil.build_in_zone( + [ + start_of_week_date.year, start_of_week_date.month, start_of_week_date.day, + step_time.hour, step_time.min, step_time.sec + ], step_time + ) + new_schedule = IceCube::Schedule.new(start_of_week_adjusted) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_week, end_of_week) + index = occurrences.index(step_time) + if index.nil? + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder['BYSETPOS'] << by_set_pos + end + + nil + end + end +end diff --git a/lib/ice_cube/validations/yearly_by_set_pos.rb b/lib/ice_cube/validations/yearly_by_set_pos.rb new file mode 100644 index 00000000..f5cc9a19 --- /dev/null +++ b/lib/ice_cube/validations/yearly_by_set_pos.rb @@ -0,0 +1,77 @@ +module IceCube + + module Validations::YearlyBySetPos + + def by_set_pos(*by_set_pos) + by_set_pos.flatten! + by_set_pos.each do |set_pos| + unless (-366..366).include?(set_pos) && set_pos != 0 + raise ArgumentError, "Expecting number in [-366, -1] or [1, 366], got #{set_pos} (#{by_set_pos})" + end + end + + @by_set_pos = by_set_pos + replace_validations_for(:by_set_pos, [Validation.new(by_set_pos, self)]) + self + end + + class Validation + + attr_reader :rule, :by_set_pos + + def initialize(by_set_pos, rule) + + @by_set_pos = by_set_pos + @rule = rule + end + + def type + :day + end + + def dst_adjust? + true + end + + def validate(step_time, start_time) + start_of_year = TimeUtil.build_in_zone([step_time.year, 1, 1, 0, 0, 0], step_time) + end_of_year = TimeUtil.build_in_zone([step_time.year, 12, 31, 23, 59, 59], step_time) + + # Needs to start on the first day of the year + new_schedule = IceCube::Schedule.new(IceCube::TimeUtil.build_in_zone([start_of_year.year, start_of_year.month, start_of_year.day, step_time.hour, step_time.min, step_time.sec], start_of_year)) do |s| + s.add_recurrence_rule(IceCube::Rule.from_hash(rule.to_hash.except(:by_set_pos, :count, :until))) + end + + occurrences = new_schedule.occurrences_between(start_of_year, end_of_year) + + index = occurrences.index(step_time) + if index == nil + 1 + else + positive_set_pos = index + 1 + negative_set_pos = index - occurrences.length + + if @by_set_pos.include?(positive_set_pos) || @by_set_pos.include?(negative_set_pos) + 0 + else + 1 + end + end + end + + def build_s(builder) + builder.piece(:by_set_pos) << by_set_pos + end + + def build_hash(builder) + builder[:by_set_pos] = by_set_pos + end + + def build_ical(builder) + builder['BYSETPOS'] << by_set_pos + end + + nil + 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..245790d4 --- /dev/null +++ b/spec/examples/by_set_pos_spec.rb @@ -0,0 +1,103 @@ +require File.dirname(__FILE__) + '/../spec_helper' + +module IceCube + describe WeeklyRule, 'BYSETPOS' do + it 'should behave correctly' do + # Weekly on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") + schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 01, 01), Time.new(2024, 01, 01))). + to eq([ + Time.new(2023,1,2,12,0,0), + Time.new(2023,1,9,12,0,0), + Time.new(2023,1,16,12,0,0), + Time.new(2023,1,23,12,0,0) + ]) + end + + it 'should work with intervals' do + # Every 2 weeks on Monday, Wednesday, and Friday with the week starting on Wednesday, the last day of the set + schedule = IceCube::Schedule.from_ical("RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;WKST=WE;BYDAY=MO,WE,FR;BYSETPOS=-1") + schedule.start_time = Time.new(2022, 12, 27, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2022, 01, 01), Time.new(2024, 01, 01))). + to eq([ + Time.new(2023,1,9,12,0,0), + Time.new(2023,1,23,12,0,0), + Time.new(2023,2,6,12,0,0), + Time.new(2023,2,20,12,0,0) + ]) + end + end + + 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) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + 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) + ]) + end + + it 'should work with intervals' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=WE;BYSETPOS=4;INTERVAL=2" + schedule.start_time = Time.new(2015, 5, 28, 12, 0, 0) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015,7,22,12,0,0), + Time.new(2015,9,23,12,0,0), + Time.new(2015,11,25,12,0,0), + Time.new(2016,1,27,12,0,0), + ]) + 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) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2017, 01, 01))). + to eq([ + Time.new(2015, 7, 31), + Time.new(2016, 7, 31) + ]) + end + + it 'should work with intervals' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;INTERVAL=2" + schedule.start_time = Time.new(1966,7,5) + expect(schedule.occurrences_between(Time.new(2015, 01, 01), Time.new(2023, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31), + Time.new(2022, 7, 31), + ]) + end + + it 'should work with counts' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3" + schedule.start_time = Time.new(2016,1,1) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2050, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2017, 7, 31), + Time.new(2018, 7, 31), + ]) + end + + it 'should work with counts and intervals' do + schedule = IceCube::Schedule.from_ical "RRULE:FREQ=YEARLY;BYMONTH=7;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1;COUNT=3;INTERVAL=2" + schedule.start_time = Time.new(2016,1,1) + expect(schedule.occurrences_between(Time.new(2016, 01, 01), Time.new(2050, 01, 01))). + to eq([ + Time.new(2016, 7, 31), + Time.new(2018, 7, 31), + Time.new(2020, 7, 31), + ]) + end + end +end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 2ab66c3c..e3d50df9 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -84,7 +84,26 @@ module IceCube expect(rule).to eq(IceCube::Rule.weekly(2, :monday)) end - it "should return no occurrences after daily interval with count is over" do + 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 raise when by_set_pos is out of range (BYSETPOS)' do + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=-367") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=367") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + + expect { + IceCube::Rule.from_ical("FREQ=MONTHLY;BYDAY=MO,WE;BYSETPOS=0") + }.to raise_error(/Expecting number in \[-366, -1\] or \[1, 366\]/) + 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")) expect(schedule.occurrences_between(Time.now + (IceCube::ONE_DAY * 7), Time.now + (IceCube::ONE_DAY * 14)).count).to eq(0) @@ -429,5 +448,13 @@ def sorted_ical(ical) it_behaves_like "an invalid ical string" end end + + describe "ical data with wrapping" do + it "matches simple daily" do + ical_string = "DTSTART:20130314T201500Z\nDTEND:20130314T201545Z\nRRULE:FREQ=WEEKLY;BYDAY=TH;UNT\n IL=20130531T100000Z\nDESCRIPTION:This is a test event\nSUMMARY:Test Event\n" + schedule = IceCube::Schedule.from_ical(ical_string) + expect(schedule.to_ical.split(/\n/).select {|x| x =~ /RRULE/}.first).to eq("RRULE:FREQ=WEEKLY;UNTIL=20130531T100000Z;BYDAY=TH") + end + end end end diff --git a/spec/examples/to_yaml_spec.rb b/spec/examples/to_yaml_spec.rb index e7c62c59..7166110d 100644 --- a/spec/examples/to_yaml_spec.rb +++ b/spec/examples/to_yaml_spec.rb @@ -78,7 +78,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .day_of_year" do - schedule1 = Schedule.new(Time.now) + schedule1 = Schedule.new(Time.zone.now) schedule1.add_recurrence_rule Rule.yearly.day_of_year(100, 200) yaml_string = schedule1.to_yaml @@ -112,7 +112,7 @@ module IceCube end it "should be able to make a round-trip to YAML with .month_of_year" do - schedule = Schedule.new(Time.now) + schedule = Schedule.new(Time.zone.now) schedule.add_recurrence_rule Rule.yearly.month_of_year(:april, :may) yaml_string = schedule.to_yaml