Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Full Timezone support when parsing ical #408

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ice_cube.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ Gem::Specification.new do |s|

s.add_development_dependency('rake')
s.add_development_dependency('rspec', '> 3')
s.add_development_dependency('timecop')
pacso marked this conversation as resolved.
Show resolved Hide resolved
end
4 changes: 2 additions & 2 deletions lib/ice_cube/builders/ical_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ def self.ical_utc_format(time)
def self.ical_format(time, force_utc)
time = time.dup.utc if force_utc
if time.utc?
":#{IceCube::I18n.l(time, format: '%Y%m%dT%H%M%SZ')}" # utc time
":#{IceCube::I18n.l(time.utc, format: '%Y%m%dT%H%M%SZ')}" # utc time
else
";TZID=#{IceCube::I18n.l(time, format: '%Z:%Y%m%dT%H%M%S')}" # local time specified
";TZID=#{time.time_zone.name}:#{IceCube::I18n.l(time, format: '%Y%m%dT%H%M%S')}" # local time specified
end
end

Expand Down
155 changes: 87 additions & 68 deletions lib/ice_cube/parsers/ical_parser.rb
Original file line number Diff line number Diff line change
@@ -1,83 +1,102 @@
module IceCube
class IcalParser
def self.schedule_from_ical(ical_string, options = {})
data = {}
ical_string.each_line do |line|
(property, value) = line.split(':')
(property, tzid) = property.split(';')
case property
when 'DTSTART'
data[:start_time] = Time.parse(value)
when 'DTEND'
data[:end_time] = Time.parse(value)
when 'EXDATE'
data[:extimes] ||= []
data[:extimes] += value.split(',').map{|v| Time.parse(v)}
when 'DURATION'
data[:duration] # FIXME
when 'RRULE'
data[:rrules] ||= []
data[:rrules] += [rule_from_ical(value)]
class << self
def schedule_from_ical(ical_string, options = {})
data = {}
ical_string.each_line do |line|
(property, value) = line.split(':')
(property, tzid) = property.split(';')
case property
when 'DTSTART'
data[:start_time] = parse_in_tzid(value, tzid)
when 'DTEND'
data[:end_time] = parse_in_tzid(value, tzid)
when 'RDATE'
date[:rtimes] ||= []
data[:rtimes] += value.split(',').map do |v|
parse_in_tzid(v, tzid)
end
when 'EXDATE'
data[:extimes] ||= []
data[:extimes] += value.split(',').map do |v|
parse_in_tzid(v, tzid)
end
when 'DURATION'
data[:duration] # FIXME
when 'RRULE'
data[:rrules] ||= []
data[:rrules] += [rule_from_ical(value)]
end
end
Schedule.from_hash data
end
Schedule.from_hash data
end

def self.rule_from_ical(ical)
raise ArgumentError, 'empty ical rule' if ical.nil?
def rule_from_ical(ical)
raise ArgumentError, 'empty ical rule' if ical.nil?

validations = {}
params = {validations: validations, interval: 1}
validations = {}
params = {validations: validations, interval: 1}

ical.split(';').each do |rule|
(name, value) = rule.split('=')
raise ArgumentError, "Invalid iCal rule component" if value.nil?
value.strip!
case name
when 'FREQ'
params[:rule_type] = "IceCube::#{value[0]}#{value.downcase[1..-1]}Rule"
when 'INTERVAL'
params[:interval] = value.to_i
when 'COUNT'
params[:count] = value.to_i
when 'UNTIL'
params[:until] = Time.parse(value).utc
when 'WKST'
params[:week_start] = TimeUtil.ical_day_to_symbol(value)
when 'BYSECOND'
validations[:second_of_minute] = value.split(',').map(&:to_i)
when 'BYMINUTE'
validations[:minute_of_hour] = value.split(',').map(&:to_i)
when 'BYHOUR'
validations[:hour_of_day] = value.split(',').map(&:to_i)
when 'BYDAY'
dows = {}
days = []
value.split(',').each do |expr|
day = TimeUtil.ical_day_to_symbol(expr.strip[-2..-1])
if expr.strip.length > 2 # day with occurence
occ = expr[0..-3].to_i
dows[day].nil? ? dows[day] = [occ] : dows[day].push(occ)
days.delete(TimeUtil.sym_to_wday(day))
else
days.push TimeUtil.sym_to_wday(day) if dows[day].nil?
ical.split(';').each do |rule|
(name, value) = rule.split('=')
raise ArgumentError, "Invalid iCal rule component" if value.nil?
value.strip!
case name
when 'FREQ'
params[:rule_type] = "IceCube::#{value[0]}#{value.downcase[1..-1]}Rule"
when 'INTERVAL'
params[:interval] = value.to_i
when 'COUNT'
params[:count] = value.to_i
when 'UNTIL'
params[:until] = Time.parse(value).utc
when 'WKST'
params[:week_start] = TimeUtil.ical_day_to_symbol(value)
when 'BYSECOND'
validations[:second_of_minute] = value.split(',').map(&:to_i)
when 'BYMINUTE'
validations[:minute_of_hour] = value.split(',').map(&:to_i)
when 'BYHOUR'
validations[:hour_of_day] = value.split(',').map(&:to_i)
when 'BYDAY'
dows = {}
days = []
value.split(',').each do |expr|
day = TimeUtil.ical_day_to_symbol(expr.strip[-2..-1])
if expr.strip.length > 2 # day with occurence
occ = expr[0..-3].to_i
dows[day].nil? ? dows[day] = [occ] : dows[day].push(occ)
days.delete(TimeUtil.sym_to_wday(day))
else
days.push TimeUtil.sym_to_wday(day) if dows[day].nil?
end
end
validations[:day_of_week] = dows unless dows.empty?
validations[:day] = days unless days.empty?
when 'BYMONTHDAY'
validations[:day_of_month] = value.split(',').map(&:to_i)
when 'BYMONTH'
validations[:month_of_year] = value.split(',').map(&:to_i)
when 'BYYEARDAY'
validations[:day_of_year] = value.split(',').map(&:to_i)
when 'BYSETPOS'
else
validations[name] = nil # invalid type
end
validations[:day_of_week] = dows unless dows.empty?
validations[:day] = days unless days.empty?
when 'BYMONTHDAY'
validations[:day_of_month] = value.split(',').map(&:to_i)
when 'BYMONTH'
validations[:month_of_year] = value.split(',').map(&:to_i)
when 'BYYEARDAY'
validations[:day_of_year] = value.split(',').map(&:to_i)
when 'BYSETPOS'
else
validations[name] = nil # invalid type
end

Rule.from_hash(params)
end

Rule.from_hash(params)
private

def parse_in_tzid(value, tzid)
time_parser = Time
if tzid
time_parser = ActiveSupport::TimeZone.new(tzid.split('=')[1]) || Time
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use rescue Time here if ActiveSupport is not loaded? Maybe something like IcalParser.time_parser can be determined statically for performance.

end
time_parser.parse(value)
end
end
end
end
64 changes: 55 additions & 9 deletions spec/examples/from_ical_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,34 @@ module IceCube

end

describe Schedule, 'from_ical' do
describe Schedule, 'from_ical', system_time_zone: "America/Chicago" do

ical_string = <<-ICAL.gsub(/^\s*/, '')
DTSTART:20130314T201500Z
DTEND:20130314T201545Z
RRULE:FREQ=WEEKLY;BYDAY=TH;UNTIL=20130531T100000Z
ICAL

ical_string_with_multiple_exdates = <<-ICAL.gsub(/^\s*/, '')
ical_string_with_time_zones = <<-ICAL.gsub(/^\s*/,'')
DTSTART;TZID=America/Denver:20130731T143000
DTEND:20130731T153000
RRULE:FREQ=WEEKLY
EXDATE;TZID=America/Chicago:20130823T143000
ICAL

ical_string_with_multiple_exdates_and_rdates = <<-ICAL.gsub(/^\s*/, '')
DTSTART;TZID=America/Denver:20130731T143000
DTEND;TZID=America/Denver:20130731T153000
RRULE:FREQ=WEEKLY;UNTIL=20140730T203000Z;BYDAY=MO,WE,FR
EXDATE;TZID=America/Denver:20130823T143000
EXDATE;TZID=America/Denver:20130812T143000
EXDATE;TZID=America/Denver:20130807T143000
RDATE;TZID=America/Denver:20150812T143000
RDATE;TZID=America/Denver:20150807T143000
ICAL

ical_string_with_multiple_rules = <<-ICAL.gsub(/^\s*/, '' )
DTSTART;TZID=CDT:20151005T195541
ical_string_with_multiple_rules = <<-ICAL.gsub(/^\s*/, '' )
DTSTART;TZID=America/Denver:20151005T195541
RRULE:FREQ=WEEKLY;BYDAY=MO,TU
RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=FR
ICAL
Expand All @@ -130,6 +139,43 @@ def sorted_ical(ical)
it "loads an ICAL string" do
expect(IceCube::Schedule.from_ical(ical_string)).to be_a(IceCube::Schedule)
end

describe "parsing time zones" do
it "sets the time zone of the start time" do
schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones)
expect(schedule.start_time.time_zone).to eq ActiveSupport::TimeZone.new("America/Denver")
expect(schedule.start_time.is_a?(Time)).to be true
expect(schedule.start_time.is_a?(ActiveSupport::TimeWithZone)).to be true
end

it "treats UTC as a Time rather than TimeWithZone" do
schedule = IceCube::Schedule.from_ical(ical_string)
expect(schedule.start_time.utc_offset).to eq 0
expect(schedule.start_time.is_a?(Time)).to be true
expect(schedule.start_time.is_a?(ActiveSupport::TimeWithZone)).to be false
end

it "uses the system time if a time zone is not explicity provided" do
schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones)
expect(schedule.end_time).not_to respond_to :time_zone
end

it "sets the time zone of the exception times" do
schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones)
expect(schedule.exception_times[0].time_zone).to eq ActiveSupport::TimeZone.new("America/Chicago")
end

it "adding the offset doesnt also change the time" do
schedule = IceCube::Schedule.from_ical(ical_string_with_time_zones)
expect(schedule.exception_times[0].hour).to eq 14
end

it "loads the ical DTSTART as output by IceCube to_ical method" do
now = Time.new(2016,5,9,12).in_time_zone("America/Los_Angeles")
schedule = IceCube::Schedule.from_ical(IceCube::Schedule.new(now).to_ical)
expect(schedule.start_time).to eq(now)
end
end
end

describe "daily frequency" do
Expand Down Expand Up @@ -240,7 +286,6 @@ def sorted_ical(ical)
describe 'monthly frequency' do
it 'matches simple monthly' do
start_time = Time.now

schedule = IceCube::Schedule.new(start_time)
schedule.add_recurrence_rule(IceCube::Rule.monthly)

Expand Down Expand Up @@ -352,20 +397,21 @@ def sorted_ical(ical)
end

describe "exceptions" do
it 'handles single EXDATE lines' do
it 'handles single EXDATE lines, single RDATE lines' do
start_time = Time.now

schedule = IceCube::Schedule.new(start_time)
schedule.add_recurrence_rule(IceCube::Rule.daily)
schedule.add_exception_time(Time.now + (IceCube::ONE_DAY * 2))
schedule.add_recurrence_time(Time.now + IceCube::ONE_DAY * 4)

ical = schedule.to_ical
expect(sorted_ical(IceCube::Schedule.from_ical(ical).to_ical)).to eq(sorted_ical(ical))
end

it 'handles multiple EXDATE lines' do
schedule = IceCube::Schedule.from_ical ical_string_with_multiple_exdates
it 'handles multiple EXDATE / RDATE lines' do
schedule = IceCube::Schedule.from_ical ical_string_with_multiple_exdates_and_rdates
expect(schedule.exception_times.count).to eq(3)
expect(schedule.recurrence_times.count).to eq(2)
end

it 'should raise ArgumentError when parsing an invalid rule type' do
Expand Down
15 changes: 8 additions & 7 deletions spec/examples/hourly_rule_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ module IceCube
end

it 'should not skip times in DST end hour' do
schedule = Schedule.new(t0 = Time.local(2013, 11, 3, 0, 0, 0))
tz = ActiveSupport::TimeZone["America/Vancouver"]
schedule = Schedule.new(t0 = tz.local(2013, 11, 3, 0, 0, 0))
schedule.add_recurrence_rule Rule.hourly
expect(schedule.first(4)).to eq([
Time.local(2013, 11, 3, 0, 0, 0), # -0700
Time.local(2013, 11, 3, 1, 0, 0) - ONE_HOUR, # -0700
Time.local(2013, 11, 3, 1, 0, 0), # -0800
Time.local(2013, 11, 3, 2, 0, 0), # -0800
])
expect(schedule.first(4)).to eq [
tz.local(2013, 11, 3, 0, 0, 0), # -0700
tz.local(2013, 11, 3, 1, 0, 0), # -0700
tz.local(2013, 11, 3, 2, 0, 0) - ONE_HOUR, # -0800
tz.local(2013, 11, 3, 2, 0, 0), # -0800
]
end

end
Expand Down
13 changes: 13 additions & 0 deletions spec/examples/recur_spec.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require File.dirname(__FILE__) + '/../spec_helper'
require 'timecop'

include IceCube

Expand Down Expand Up @@ -115,6 +116,18 @@
expect(schedule.next_occurrence(schedule.start_time)).to eq(schedule.start_time + 30 * ONE_MINUTE)
end

it 'should get the next occurrence across the daylight savings time boundary' do
# 2016 daylight savings time cutoff is Sunday March 13
# Time.zone = 'America/New_York'
start_time = Time.zone.local(2016, 3, 13, 0, 0, 0)
expected_next_time = Time.zone.local(2016, 3, 13, 5, 0, 0)
schedule = Schedule.new(start_time)
schedule.add_recurrence_rule(Rule.hourly(interval=4))

Timecop.freeze(start_time) do
expect(schedule.next_occurrence(schedule.start_time)).to eq expected_next_time
end
end
end

describe :next_occurrences do
Expand Down
Loading