icalendar-recurrence 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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