Skip to content
5 changes: 4 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -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:
- [email protected]
Expand Down
2 changes: 2 additions & 0 deletions lib/ice_cube.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion lib/ice_cube/parsers/ical_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/ice_cube/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion lib/ice_cube/validated_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
138 changes: 138 additions & 0 deletions lib/ice_cube/validations/by_set_pos.rb
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self: this could be hash[:validations]&.delete(:by_set_post). Not sure about the Ruby version requirements though... 🤔

end
end
end

end

end

end
31 changes: 31 additions & 0 deletions spec/examples/by_set_pos_spec.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions spec/examples/from_ical_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions spec/examples/rfc_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions spec/examples/yearly_rule_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down