icalendar-recurrence 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. data/.gitignore +17 -0
  2. data/.rspec +3 -0
  3. data/.travis.yml +6 -0
  4. data/Gemfile +3 -0
  5. data/Guardfile +5 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +86 -0
  8. data/Rakefile +6 -0
  9. data/icalendar-recurrence.gemspec +31 -0
  10. data/lib/icalendar-recurrence.rb +1 -0
  11. data/lib/icalendar/recurrence.rb +12 -0
  12. data/lib/icalendar/recurrence/event_extensions.rb +36 -0
  13. data/lib/icalendar/recurrence/schedule.rb +148 -0
  14. data/lib/icalendar/recurrence/time_util.rb +94 -0
  15. data/lib/icalendar/recurrence/version.rb +5 -0
  16. data/lib/icalendar/recurrence/weekday_extensions.rb +13 -0
  17. data/spec/lib/recurrence_spec.rb +154 -0
  18. data/spec/lib/schedule_spec.rb +52 -0
  19. data/spec/lib/time_util_spec.rb +137 -0
  20. data/spec/spec_helper.rb +14 -0
  21. data/spec/support/fixtures/daily_event.ics +30 -0
  22. data/spec/support/fixtures/embedded_timezone_event.ics +36 -0
  23. data/spec/support/fixtures/every_monday_event.ics +28 -0
  24. data/spec/support/fixtures/every_other_day_event.ics +29 -0
  25. data/spec/support/fixtures/every_weekday_daily_event.ics +26 -0
  26. data/spec/support/fixtures/everyday_for_four_days_event.ics +23 -0
  27. data/spec/support/fixtures/first_of_every_year_event.ics +31 -0
  28. data/spec/support/fixtures/first_saturday_of_month_event.ics +49 -0
  29. data/spec/support/fixtures/first_sunday_of_january_yearly_event.ics +26 -0
  30. data/spec/support/fixtures/monday_until_friday_event.ics +23 -0
  31. data/spec/support/fixtures/multi_day_weekly_event.ics +45 -0
  32. data/spec/support/fixtures/on_third_every_two_months_event.ics +28 -0
  33. data/spec/support/fixtures/one_day_a_month_for_three_months_event.ics +29 -0
  34. data/spec/support/fixtures/utc_event.ics +28 -0
  35. data/spec/support/helpers.rb +14 -0
  36. metadata +266 -0
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --format progress
3
+ <%= "--format Nc" if RUBY_PLATFORM.match(/darwin/) %>
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.0
4
+ - 2.0.0
5
+ - 1.9.3
6
+ script: bundle exec rspec
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,5 @@
1
+ guard :rspec, cmd: "bundle exec rspec" do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
4
+ watch('spec/spec_helper.rb') { "spec" }
5
+ end
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Jordan Raine
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,86 @@
1
+ # iCalendar Recurrence [![Build Status](https://travis-ci.org/icalendar/icalendar-recurrence.svg?branch=master)](https://travis-ci.org/icalendar/icalendar-recurrence) [![Code Climate](https://codeclimate.com/github/icalendar/icalendar-recurrence.png)](https://codeclimate.com/github/icalendar/icalendar-recurrence)
2
+
3
+ Adds event recurrence to the [icalendar gem](https://github.com/icalendar/icalendar). This is helpful in cases where you'd like to parse an ICS and generate a series of event occurrences.
4
+
5
+ ## Install
6
+
7
+ _Note: This only works against the 2.0beta release of the icalendar gem._
8
+
9
+ **Until icalendar 2.0beta is released, use git repos in your Gemfile:**
10
+
11
+
12
+ ```ruby
13
+ gem "icalendar", git: "https://github.com/icalendar/icalendar", branch: "2.0beta"
14
+ gem "icalendar-recurrence", git: "https://github.com/icalendar/icalendar-recurrence"
15
+ ```
16
+
17
+ and run `bundle install` from your shell.
18
+
19
+ ## Usage
20
+
21
+ ### Show occurrences of event between dates
22
+
23
+ ```ruby
24
+ require 'date' # for parse method
25
+ require 'icalendar/recurrence'
26
+
27
+ calendars = Icalendar.parse(File.read(path_to_ics)) # parse an ICS file
28
+ event = Array(calendars).first.events.first # retrieve the first event
29
+ event.occurrences_between(Date.parse("2014-01-01"), Date.parse("2014-02-01")) # get all occurrence for one month
30
+ ```
31
+
32
+ ### Working with occurrences
33
+
34
+ An event occurrence is a simple struct object with `start_time` and `end_time` methods.
35
+
36
+ ```ruby
37
+ occurrence.start_time # => 2014-02-01 00:00:00 -0800
38
+ occurrence.end_time # => 2014-02-02 00:00:00 -0800
39
+ ```
40
+
41
+ ### Daily event with excluded date (inline ICS example)
42
+
43
+ ```ruby
44
+ require 'date' # for parse method
45
+ require 'icalendar/recurrence'
46
+
47
+ ics_string = <<-EOF
48
+ BEGIN:VCALENDAR
49
+ X-WR-CALNAME:Test Public
50
+ X-WR-CALID:f512e378-050c-4366-809a-ef471ce45b09:101165
51
+ PRODID:Zimbra-Calendar-Provider
52
+ VERSION:2.0
53
+ METHOD:PUBLISH
54
+ BEGIN:VEVENT
55
+ UID:efcb99ae-d540-419c-91fa-42cc2bd9d302
56
+ RRULE:FREQ=DAILY;INTERVAL=1
57
+ SUMMARY:Every day, except the 28th
58
+ DTSTART;VALUE=DATE:20140101
59
+ DTEND;VALUE=DATE:20140102
60
+ STATUS:CONFIRMED
61
+ CLASS:PUBLIC
62
+ X-MICROSOFT-CDO-ALLDAYEVENT:TRUE
63
+ TRANSP:TRANSPARENT
64
+ LAST-MODIFIED:20140113T200625Z
65
+ DTSTAMP:20140113T200625Z
66
+ SEQUENCE:0
67
+ EXDATE;VALUE=DATE:20140128
68
+ END:VEVENT
69
+ END:VCALENDAR
70
+ EOF
71
+
72
+ # An event that occurs every day, starting January 1, 2014 with one excluded
73
+ # date. January 28, 2014 will not appear in the occurrences.
74
+ calendars = Icalendar.parse(ics_string)
75
+ every_day_except_jan_28 = Array(calendars).first.events.first
76
+ puts "Every day except January 28, 2014, occurrences from 2014-01-01 to 2014-02-01:"
77
+ puts every_day_except_jan_28.occurrences_between(Date.parse("2014-01-01"), Date.parse("2014-02-01"))
78
+ ```
79
+
80
+ ## Contributing
81
+
82
+ 1. Fork it ( http://github.com/<my-github-username>/icalendar-recurrence/fork )
83
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
84
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
85
+ 4. Push to the branch (`git push origin my-new-feature`)
86
+ 5. Create new Pull Request
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: [:spec, :build]
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'icalendar/recurrence/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "icalendar-recurrence"
8
+ spec.version = Icalendar::Recurrence::VERSION
9
+ spec.authors = ["Jordan Raine"]
10
+ spec.email = ["jnraine@gmail.com"]
11
+ spec.summary = %q{Provides recurrence to icalendar gem.}
12
+ spec.homepage = "https://github.com/icalendar/icalendar-recurrence"
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_runtime_dependency 'icalendar', '~> 2.0.0.beta.1'
21
+ spec.add_runtime_dependency 'ice_cube', '~> 0.11.1'
22
+
23
+ spec.add_development_dependency 'rake', '~> 10.2.1'
24
+ spec.add_development_dependency 'rspec', '~> 2.14.1'
25
+ spec.add_development_dependency 'bundler', '~> 1.3'
26
+ spec.add_development_dependency 'tzinfo', '~> 0.3'
27
+ spec.add_development_dependency 'timecop', '~> 0.6.3'
28
+ spec.add_development_dependency 'guard-rspec', '~> 4.2.8'
29
+ spec.add_development_dependency 'activesupport', '~> 4.0.4'
30
+ spec.add_development_dependency 'rspec-nc'
31
+ end
@@ -0,0 +1 @@
1
+ require_relative './icalendar/recurrence'
@@ -0,0 +1,12 @@
1
+ require "icalendar"
2
+ require "icalendar/recurrence/version"
3
+ require "icalendar/recurrence/event_extensions"
4
+ require "icalendar/recurrence/weekday_extensions"
5
+ require "icalendar/recurrence/schedule"
6
+ require "icalendar/recurrence/time_util"
7
+
8
+ module Icalendar
9
+ module Recurrence
10
+ # Your code goes here...
11
+ end
12
+ end
@@ -0,0 +1,36 @@
1
+ module Icalendar
2
+ module Recurrence
3
+ module EventExtensions
4
+ def start
5
+ dtstart
6
+ end
7
+
8
+ def start_time
9
+ TimeUtil.to_time(start)
10
+ end
11
+
12
+ def end
13
+ dtend
14
+ end
15
+
16
+ def occurrences_between(begin_time, closing_time)
17
+ schedule.occurrences_between(begin_time, closing_time)
18
+ end
19
+
20
+ def schedule
21
+ @schedule ||= Schedule.new(self)
22
+ end
23
+
24
+ def tzid
25
+ ugly_tzid = dtstart.ical_params.fetch("tzid", nil)
26
+ return nil if ugly_tzid.nil?
27
+
28
+ Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, "")
29
+ end
30
+ end
31
+ end
32
+
33
+ class Event
34
+ include Icalendar::Recurrence::EventExtensions
35
+ end
36
+ end
@@ -0,0 +1,148 @@
1
+ require 'ice_cube'
2
+
3
+ module Icalendar
4
+ module Recurrence
5
+ class Occurrence < Struct.new(:start_time, :end_time)
6
+ end
7
+
8
+ class Schedule
9
+ attr_reader :event
10
+
11
+ def initialize(event)
12
+ @event = event
13
+ end
14
+
15
+ def timezone
16
+ event.tzid
17
+ end
18
+
19
+ def rrules
20
+ event.rrule
21
+ end
22
+
23
+ def start_time
24
+ TimeUtil.to_time(event.start)
25
+ end
26
+
27
+ def end_time
28
+ TimeUtil.to_time(event.end)
29
+ end
30
+
31
+ def occurrences_between(begin_time, closing_time)
32
+ ice_cube_occurrences = ice_cube_schedule.occurrences_between(TimeUtil.to_time(begin_time), TimeUtil.to_time(closing_time))
33
+
34
+ ice_cube_occurrences.map do |occurrence|
35
+ convert_ice_cube_occurrence(occurrence)
36
+ end
37
+ end
38
+
39
+ def convert_ice_cube_occurrence(ice_cube_occurrence)
40
+ if timezone
41
+ begin
42
+ tz = TZInfo::Timezone.get(timezone)
43
+ start_time = tz.local_to_utc(ice_cube_occurrence.start_time)
44
+ end_time = tz.local_to_utc(ice_cube_occurrence.end_time)
45
+ rescue TZInfo::InvalidTimezoneIdentifier => e
46
+ warn "Unknown TZID specified in ical event (#{timezone.inspect}), ignoring (will likely cause event to be at wrong time!)"
47
+ end
48
+ end
49
+
50
+ start_time ||= ice_cube_occurrence.start_time
51
+ end_time ||= ice_cube_occurrence.end_time
52
+
53
+ Icalendar::Recurrence::Occurrence.new(start_time, end_time)
54
+ end
55
+
56
+ def ice_cube_schedule
57
+ schedule = IceCube::Schedule.new
58
+ schedule.start_time = start_time
59
+ schedule.end_time = end_time
60
+
61
+ rrules.each do |rrule|
62
+ ice_cube_recurrence_rule = convert_rrule_to_ice_cube_recurrence_rule(rrule)
63
+ schedule.add_recurrence_rule(ice_cube_recurrence_rule)
64
+ end
65
+
66
+ event.exdate.each do |exception_date|
67
+ exception_date = Time.parse(exception_date) if exception_date.is_a?(String)
68
+ schedule.add_exception_time(TimeUtil.to_time(exception_date))
69
+ end
70
+
71
+ schedule
72
+ end
73
+
74
+ def transform_byday_to_hash(byday_entries)
75
+ hashable_array = Array(byday_entries).map {|byday| convert_byday_to_ice_cube_day_of_week_hash(byday) }.flatten(1)
76
+ hash = Hash[*hashable_array]
77
+
78
+ if hash.values.include?([0]) # byday interval not specified (e.g., BYDAY=SA not BYDAY=1SA)
79
+ hash.keys
80
+ else
81
+ hash
82
+ end
83
+ end
84
+
85
+ # private
86
+
87
+
88
+ def convert_rrule_to_ice_cube_recurrence_rule(rrule)
89
+ ice_cube_recurrence_rule = base_ice_cube_recurrence_rule(rrule.frequency, rrule.interval)
90
+
91
+ ice_cube_recurrence_rule.tap do |r|
92
+ days = transform_byday_to_hash(rrule.by_day)
93
+
94
+ r.month_of_year(rrule.by_month) unless rrule.by_month.nil?
95
+ r.day_of_month(rrule.by_month_day.map(&:to_i)) unless rrule.by_month_day.nil?
96
+ r.day_of_week(days) if days.is_a?(Hash) and !days.empty?
97
+ r.day(days) if days.is_a?(Array) and !days.empty?
98
+ r.until(TimeUtil.to_time(rrule.until)) if rrule.until
99
+ r.count(rrule.count)
100
+ end
101
+
102
+ ice_cube_recurrence_rule
103
+ end
104
+
105
+ def base_ice_cube_recurrence_rule(frequency, interval)
106
+ if frequency == "DAILY"
107
+ IceCube::DailyRule.new(interval)
108
+ elsif frequency == "WEEKLY"
109
+ IceCube::WeeklyRule.new(interval)
110
+ elsif frequency == "MONTHLY"
111
+ IceCube::MonthlyRule.new(interval)
112
+ elsif frequency == "YEARLY"
113
+ IceCube::YearlyRule.new(interval)
114
+ else
115
+ raise "Unknown frequency: #{rrule.frequency}"
116
+ end
117
+ end
118
+
119
+ def convert_byday_to_ice_cube_day_of_week_hash(ical_byday)
120
+ data = parse_ical_byday(ical_byday)
121
+ day_code = data.fetch(:day_code)
122
+ position = data.fetch(:position)
123
+
124
+ day_symbol = case day_code.to_s
125
+ when "SU" then :sunday
126
+ when "MO" then :monday
127
+ when "TU" then :tuesday
128
+ when "WE" then :wednesday
129
+ when "TH" then :thursday
130
+ when "FR" then :friday
131
+ when "SA" then :saturday
132
+ else
133
+ raise ArgumentError.new "Unexpected ical_day: #{ical_day.inspect}"
134
+ end
135
+
136
+ [day_symbol, Array(position)]
137
+ end
138
+
139
+ # Parses ICAL BYDAY value to day and position array
140
+ # 1SA => {day_code: "SA", position: 1}
141
+ # MO => {day_code: "MO", position: nil
142
+ def parse_ical_byday(ical_byday)
143
+ match = ical_byday.match(/(\d*)([A-Z]{2})/)
144
+ {day_code: match[2], position: match[1].to_i}
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,94 @@
1
+ require 'tzinfo'
2
+
3
+ module Icalendar
4
+ module Recurrence
5
+ module TimeUtil
6
+ def datetime_to_time(datetime)
7
+ raise ArgumentError, "Unsupported DateTime object passed (must be Icalendar::Values::DateTime#{datetime.class} passed instead)" unless supported_datetime_object?(datetime)
8
+ offset = timezone_offset(datetime.ical_params["tzid"], moment: datetime.to_date)
9
+ offset ||= datetime.strftime("%:z")
10
+
11
+ Time.new(datetime.year, datetime.month, datetime.mday, datetime.hour, datetime.min, datetime.sec, offset)
12
+ end
13
+
14
+ def date_to_time(date)
15
+ raise ArgumentError, "Must pass a Date object (#{date.class} passed instead)" unless supported_date_object?(date)
16
+ Time.new(date.year, date.month, date.mday)
17
+ end
18
+
19
+ def to_time(time_object)
20
+ if supported_time_object?(time_object)
21
+ time_object
22
+ elsif supported_datetime_object?(time_object)
23
+ datetime_to_time(time_object)
24
+ elsif supported_date_object?(time_object)
25
+ date_to_time(time_object)
26
+ elsif time_object.is_a?(String)
27
+ Time.parse(time_object)
28
+ else
29
+ raise ArgumentError, "Unsupported time object passed: #{time_object.inspect}"
30
+ end
31
+ end
32
+
33
+ # Calculates offset for given timezone ID (tzid). Optional, specify a
34
+ # moment in time to calulcate this offset. If no moment is specified,
35
+ # use the current time.
36
+ #
37
+ # # If done before daylight savings:
38
+ # TimeUtil.timezone_offset("America/Los_Angeles") => -08:00
39
+ # # Or after:
40
+ # TimeUtil.timezone_offset("America/Los_Angeles", moment: Time.parse("2014-04-01")) => -07:00
41
+ #
42
+ def timezone_offset(tzid, options = {})
43
+ tzid = Array(tzid).first
44
+ options = {moment: Time.now}.merge(options)
45
+ moment = options.fetch(:moment)
46
+ utc_moment = to_time(moment.clone).utc
47
+ tzid = tzid.to_s.gsub(/^(["'])|(["'])$/, "")
48
+ utc_offset = TZInfo::Timezone.get(tzid).period_for_utc(utc_moment).utc_total_offset # this seems to work, but I feel like there is a lurking bug
49
+ hour_offset = utc_offset/60/60
50
+ hour_offset = "+#{hour_offset}" if hour_offset >= 0
51
+ match = hour_offset.to_s.match(/(\+|-)(\d+)/)
52
+ "#{match[1]}#{match[2].rjust(2, "0")}:00"
53
+ rescue TZInfo::InvalidTimezoneIdentifier => e
54
+ nil
55
+ end
56
+
57
+ # See #timezone_offset_at_moment
58
+ def timezone_to_hour_minute_utc_offset(tzid, moment = Time.now)
59
+ timezone_offset(tzid, moment: moment)
60
+ end
61
+
62
+ def supported_date_object?(time_object)
63
+ time_object.is_a?(Date) or time_object.is_a?(Icalendar::Values::Date)
64
+ end
65
+
66
+ def supported_datetime_object?(time_object)
67
+ time_object.is_a?(Icalendar::Values::DateTime)
68
+ end
69
+
70
+ def supported_time_object?(time_object)
71
+ time_object.is_a?(Time)
72
+ end
73
+
74
+ # Replaces the existing offset with one associated with given TZID. Does
75
+ # not change hour of day, only the offset. For example, if given a UTC
76
+ # time of 8am, the returned time object will still be 8am but in another
77
+ # timezone. See test for working examples.
78
+ def force_zone(time, tzid)
79
+ offset = timezone_offset(tzid, moment: time)
80
+ raise ArgumentError.new("Unknown TZID: #{tzid}") if offset.nil?
81
+ Time.new(time.year, time.month, time.mday, time.hour, time.min, time.sec, offset)
82
+ end
83
+
84
+ extend self
85
+ end
86
+ end
87
+ end
88
+
89
+ # Should we move this to a module and extend time?
90
+ class Time
91
+ def force_zone(tzid)
92
+ TimeUtil.force_zone(self, tzid)
93
+ end
94
+ end