livecal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d86406289be6c6c425aee8a69755f1df4624a6e7077652202ee90c6a42ac4a07
4
+ data.tar.gz: 6f37956449a13bbeb918f914bfa4ba2937f47b999485e32d08f8a274ae42413c
5
+ SHA512:
6
+ metadata.gz: 4dfaf07671741d0f2b46e757aeba5c0c4d3b01c2f1e6b6d46bab68a48bf86bf69525433c2feecd97e2b383878004e3de45bcadc1505b0540641e38487c4dc026
7
+ data.tar.gz: 8af62306c9741adae686b67b31ec6618c2162459fcdf28aaaf095ff40c6b3017acfb3830e65e07dd8f0bcf2e478ad6973b2ae3dacf89c347617814928a76551e
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE.md ADDED
@@ -0,0 +1,33 @@
1
+ Livecal Copyright (c) 2021 Pat Allan
2
+
3
+ Hippocratic License Version Number: 2.1.
4
+
5
+ Purpose. The purpose of this License is for the Licensor named above to permit the Licensee (as defined below) broad permission, if consistent with Human Rights Laws and Human Rights Principles (as each is defined below), to use and work with the Software (as defined below) within the full scope of Licensor’s copyright and patent rights, if any, in the Software, while ensuring attribution and protecting the Licensor from liability.
6
+
7
+ Permission and Conditions. The Licensor grants permission by this license (“License”), free of charge, to the extent of Licensor’s rights under applicable copyright and patent law, to any person or entity (the “Licensee”) obtaining a copy of this software and associated documentation files (the “Software”), to do everything with the Software that would otherwise infringe (i) the Licensor’s copyright in the Software or (ii) any patent claims to the Software that the Licensor can license or becomes able to license, subject to all of the following terms and conditions:
8
+
9
+ * Acceptance. This License is automatically offered to every person and entity subject to its terms and conditions. Licensee accepts this License and agrees to its terms and conditions by taking any action with the Software that, absent this License, would infringe any intellectual property right held by Licensor.
10
+
11
+ * Notice. Licensee must ensure that everyone who gets a copy of any part of this Software from Licensee, with or without changes, also receives the License and the above copyright notice (and if included by the Licensor, patent, trademark and attribution notice). Licensee must cause any modified versions of the Software to carry prominent notices stating that Licensee changed the Software. For clarity, although Licensee is free to create modifications of the Software and distribute only the modified portion created by Licensee with additional or different terms, the portion of the Software not modified must be distributed pursuant to this License. If anyone notifies Licensee in writing that Licensee has not complied with this Notice section, Licensee can keep this License by taking all practical steps to comply within 30 days after the notice. If Licensee does not do so, Licensee’s License (and all rights licensed hereunder) shall end immediately.
12
+
13
+ * Compliance with Human Rights Principles and Human Rights Laws.
14
+
15
+ 1. Human Rights Principles.
16
+
17
+ (a) Licensee is advised to consult the articles of the United Nations Universal Declaration of Human Rights and the United Nations Global Compact that define recognized principles of international human rights (the “Human Rights Principles”). Licensee shall use the Software in a manner consistent with Human Rights Principles.
18
+
19
+ (b) Unless the Licensor and Licensee agree otherwise, any dispute, controversy, or claim arising out of or relating to (i) Section 1(a) regarding Human Rights Principles, including the breach of Section 1(a), termination of this License for breach of the Human Rights Principles, or invalidity of Section 1(a) or (ii) a determination of whether any Law is consistent or in conflict with Human Rights Principles pursuant to Section 2, below, shall be settled by arbitration in accordance with the Hague Rules on Business and Human Rights Arbitration (the “Rules”); provided, however, that Licensee may elect not to participate in such arbitration, in which event this License (and all rights licensed hereunder) shall end immediately. The number of arbitrators shall be one unless the Rules require otherwise.
20
+
21
+ Unless both the Licensor and Licensee agree to the contrary: (1) All documents and information concerning the arbitration shall be public and may be disclosed by any party; (2) The repository referred to under Article 43 of the Rules shall make available to the public in a timely manner all documents concerning the arbitration which are communicated to it, including all submissions of the parties, all evidence admitted into the record of the proceedings, all transcripts or other recordings of hearings and all orders, decisions and awards of the arbitral tribunal, subject only to the arbitral tribunal's powers to take such measures as may be necessary to safeguard the integrity of the arbitral process pursuant to Articles 18, 33, 41 and 42 of the Rules; and (3) Article 26(6) of the Rules shall not apply.
22
+
23
+ 2. Human Rights Laws. The Software shall not be used by any person or entity for any systems, activities, or other uses that violate any Human Rights Laws. “Human Rights Laws” means any applicable laws, regulations, or rules (collectively, “Laws”) that protect human, civil, labor, privacy, political, environmental, security, economic, due process, or similar rights; provided, however, that such Laws are consistent and not in conflict with Human Rights Principles (a dispute over the consistency or a conflict between Laws and Human Rights Principles shall be determined by arbitration as stated above). Where the Human Rights Laws of more than one jurisdiction are applicable or in conflict with respect to the use of the Software, the Human Rights Laws that are most protective of the individuals or groups harmed shall apply.
24
+
25
+ 3. Indemnity. Licensee shall hold harmless and indemnify Licensor (and any other contributor) against all losses, damages, liabilities, deficiencies, claims, actions, judgments, settlements, interest, awards, penalties, fines, costs, or expenses of whatever kind, including Licensor’s reasonable attorneys’ fees, arising out of or relating to Licensee’s use of the Software in violation of Human Rights Laws or Human Rights Principles.
26
+
27
+ * Failure to Comply. Any failure of Licensee to act according to the terms and conditions of this License is both a breach of the License and an infringement of the intellectual property rights of the Licensor (subject to exceptions under Laws, e.g., fair use). In the event of a breach or infringement, the terms and conditions of this License may be enforced by Licensor under the Laws of any jurisdiction to which Licensee is subject. Licensee also agrees that the Licensor may enforce the terms and conditions of this License against Licensee through specific performance (or similar remedy under Laws) to the extent permitted by Laws. For clarity, except in the event of a breach of this License, infringement, or as otherwise stated in this License, Licensor may not terminate this License with Licensee.
28
+
29
+ * Enforceability and Interpretation. If any term or provision of this License is determined to be invalid, illegal, or unenforceable by a court of competent jurisdiction, then such invalidity, illegality, or unenforceability shall not affect any other term or provision of this License or invalidate or render unenforceable such term or provision in any other jurisdiction; provided, however, subject to a court modification pursuant to the immediately following sentence, if any term or provision of this License pertaining to Human Rights Laws or Human Rights Principles is deemed invalid, illegal, or unenforceable against Licensee by a court of competent jurisdiction, all rights in the Software granted to Licensee shall be deemed null and void as between Licensor and Licensee. Upon a determination that any term or provision is invalid, illegal, or unenforceable, to the extent permitted by Laws, the court may modify this License to affect the original purpose that the Software be used in compliance with Human Rights Principles and Human Rights Laws as closely as possible. The language in this License shall be interpreted as to its fair meaning and not strictly for or against any party.
30
+
31
+ * Disclaimer. TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES “AS IS,” WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR AND ANY OTHER CONTRIBUTOR SHALL NOT BE LIABLE TO ANYONE FOR ANY DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THIS LICENSE, UNDER ANY KIND OF LEGAL CLAIM.
32
+
33
+ This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) and is offered for use by licensors and licensees at their own risk, on an “AS IS” basis, and with no warranties express or implied, to the maximum extent permitted by Laws.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # Livecal
2
+
3
+ Livecal processes ical files and translates them into calendars with events listed as they're actually scheduled (i.e. recurring events are provided for each instance that they occur, not just the single piece of information that they're stored as in an ical file).
4
+
5
+ ## Usage
6
+
7
+ ```ruby
8
+ # You must provide from/to (otherwise endless recurring events will continue
9
+ # forever). The example here is the next 24 hours, but you can use whatever
10
+ # frame of time you like.
11
+ )
12
+ calendars = Livecal.from_string(
13
+ contents_of_an_ical_file,
14
+ from: Time.now,
15
+ to: (Time.now + 86_400)
16
+ )
17
+
18
+ calendars.each do |calendar|
19
+ calendar.events.each do |event|
20
+ puts event.summary, event.starts_at, event.ends_at
21
+ end
22
+ end
23
+
24
+ # Also:
25
+ calendars = Livecal.from_url(
26
+ "http://example.com/calendar.ics",
27
+ from: Time.now,
28
+ to: (Time.now + 86_400)
29
+ )
30
+ # And:
31
+ calendars = Livecal.from_file(
32
+ "/path/to/my-calendars.ics",
33
+ from: Time.now,
34
+ to: (Time.now + 86_400)
35
+ )
36
+ ```
37
+
38
+ ## Installation
39
+
40
+ Add this line to your application's Gemfile:
41
+
42
+ ```ruby
43
+ gem "livecal"
44
+ ```
45
+
46
+ And then execute:
47
+
48
+ $ bundle install
49
+
50
+ Or install it yourself as:
51
+
52
+ $ gem install livecal
53
+
54
+ ## Development
55
+
56
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
57
+
58
+ ## Contributing
59
+
60
+ Bug reports and pull requests are welcome on GitHub at https://github.com/pat/livecal. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/pat/livecal/blob/master/CODE_OF_CONDUCT.md).
61
+
62
+ ## License
63
+
64
+ The gem is available as open source under the terms of the [Hippocratic License](https://firstdonoharm.dev).
65
+
66
+ ## Code of Conduct
67
+
68
+ Everyone interacting in the Livecal project is expected to follow the [code of conduct](https://github.com/pat/livecal/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/lib/livecal.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./livecal/calendar_source"
4
+ require_relative "./livecal/parser"
5
+
6
+ module Livecal
7
+ def self.from_url(url, from:, to:)
8
+ from_sources CalendarSource.from_url(url), from: from, to: to
9
+ end
10
+
11
+ def self.from_file(path, from:, to:)
12
+ from_sources CalendarSource.from_file(path), from: from, to: to
13
+ end
14
+
15
+ def self.from_string(contents, from:, to:)
16
+ from_sources CalendarSource.from_string(contents), from: from, to: to
17
+ end
18
+
19
+ def self.from_sources(sources, from:, to:)
20
+ sources.collect { |source| Parser.call(source, from: from, to: to) }
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Livecal
4
+ class Calendar
5
+ include Enumerable
6
+
7
+ attr_reader :events
8
+
9
+ def initialize(events)
10
+ @events = events
11
+ end
12
+
13
+ def each(&block)
14
+ events.each(&block)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "icalendar"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Livecal
8
+ class CalendarSource
9
+ def self.from_url(url)
10
+ from_string(Net::HTTP.get(URI(url)))
11
+ end
12
+
13
+ def self.from_file(path)
14
+ from_string(File.read(path))
15
+ end
16
+
17
+ def self.from_string(contents)
18
+ Icalendar::Calendar.parse(contents).collect { |source| new(source) }
19
+ end
20
+
21
+ def initialize(source)
22
+ @source = source
23
+ end
24
+
25
+ def recurring_changes
26
+ events.select { |event| event.rrule.empty? && event.recurrence_id }
27
+ end
28
+
29
+ def recurring_events
30
+ events.select { |event| event.rrule.any? }
31
+ end
32
+
33
+ def standalone_events
34
+ events.select { |event| event.rrule.empty? && event.recurrence_id.nil? }
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :source
40
+
41
+ def events
42
+ source.events
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Livecal
6
+ class Event < SimpleDelegator
7
+ def self.from_ical(source)
8
+ new(source)
9
+ end
10
+
11
+ def starts_at
12
+ dtstart
13
+ end
14
+
15
+ def ends_at
16
+ dtend
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./calendar"
4
+ require_relative "./event"
5
+ require_relative "./recurring_events"
6
+
7
+ module Livecal
8
+ class Parser
9
+ def self.call(...)
10
+ new(...).call
11
+ end
12
+
13
+ def initialize(calendar_source, from:, to:)
14
+ @calendar_source = calendar_source
15
+ @from = from
16
+ @to = to
17
+ end
18
+
19
+ def call
20
+ Calendar.new(events.collect { |event| Event.from_ical(event) })
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :calendar_source, :from, :to
26
+
27
+ def events
28
+ (
29
+ standalone_events + recurring_events + recurring_changes
30
+ ).sort_by(&:dtstart)
31
+ end
32
+
33
+ def recurring_changes
34
+ within_window(calendar_source.recurring_changes)
35
+ end
36
+
37
+ def recurring_events
38
+ calendar_source
39
+ .recurring_events
40
+ .collect { |event| instances(event) }
41
+ .flatten
42
+ end
43
+
44
+ def instances(event)
45
+ RecurringEvents.call(
46
+ event,
47
+ from: from,
48
+ to: to,
49
+ changes: recurring_changes
50
+ )
51
+ end
52
+
53
+ def standalone_events
54
+ within_window(calendar_source.standalone_events)
55
+ end
56
+
57
+ def within_window(events)
58
+ events.select { |event| event.dtstart >= from && event.dtstart <= to }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "icalendar"
4
+ require "rrule"
5
+
6
+ module Livecal
7
+ class RecurringEvents
8
+ include Enumerable
9
+
10
+ def self.call(...)
11
+ new(...).to_a
12
+ end
13
+
14
+ def initialize(source, from:, to:, changes:)
15
+ @source = source
16
+ @from = from
17
+ @to = to
18
+ @changes = changes.select do |change|
19
+ change.uid == source.uid && change.sequence >= source.sequence
20
+ end
21
+ end
22
+
23
+ def each(&block)
24
+ instances.each(&block)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :source, :from, :to, :changes
30
+
31
+ def changed?(instance)
32
+ changes.any? { |change| change.recurrence_id == instance.dtstart }
33
+ end
34
+
35
+ def duration
36
+ @duration ||= source.dtend - source.dtstart
37
+ end
38
+
39
+ def instance_with_start(time)
40
+ source.dup.tap do |instance|
41
+ instance.rrule = []
42
+ instance.dtstart = time_in_zone(time)
43
+ instance.dtend = time_in_zone(time + duration)
44
+ end
45
+ end
46
+
47
+ def instances
48
+ rrule
49
+ .between(from, to)
50
+ .collect { |start| instance_with_start(start) }
51
+ .reject { |instance| changed?(instance) }
52
+ end
53
+
54
+ def rrule
55
+ RRule::Rule.new(
56
+ source.rrule.first.value_ical,
57
+ dtstart: source.dtstart,
58
+ exdate: source.exdate,
59
+ tzid: tzid
60
+ )
61
+ end
62
+
63
+ def time_in_zone(time)
64
+ ::Icalendar::Values::DateTime.new(time, "tzid" => tzid)
65
+ end
66
+
67
+ def tzid
68
+ @tzid ||= source.dtstart.time_zone.name
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "Parsing ical files" do
4
+ let(:from) { Time.new 2021, 6, 6, 0, 0, 0, tz }
5
+ let(:to) { Time.new 2021, 6, 7, 0, 0, 0, tz }
6
+ let(:tz) { "+10:00" }
7
+
8
+ context "handles a single event" do
9
+ let(:source) do
10
+ <<~ICAL
11
+ BEGIN:VCALENDAR
12
+ PRODID:-//Google Inc//Google Calendar 70.9054//EN
13
+ VERSION:2.0
14
+ CALSCALE:GREGORIAN
15
+ METHOD:PUBLISH
16
+ X-WR-CALNAME:livecal
17
+ X-WR-TIMEZONE:Australia/Melbourne
18
+ BEGIN:VEVENT
19
+ DTSTART:20210606T020000Z
20
+ DTEND:20210606T030000Z
21
+ DTSTAMP:20210606T013959Z
22
+ UID:6vc8dul781vckb41vh5avkchi3@google.com
23
+ CREATED:20210606T013948Z
24
+ DESCRIPTION:
25
+ LAST-MODIFIED:20210606T013948Z
26
+ LOCATION:
27
+ SEQUENCE:0
28
+ STATUS:CONFIRMED
29
+ SUMMARY:Appointment
30
+ TRANSP:OPAQUE
31
+ END:VEVENT
32
+ END:VCALENDAR
33
+ ICAL
34
+ end
35
+
36
+ it "returns the event if in the requested window" do
37
+ calendars = Livecal.from_string source, from: from, to: to
38
+
39
+ expect(calendars.length).to eq(1)
40
+ expect(calendars.first.events.length).to eq(1)
41
+
42
+ event = calendars.first.events.first
43
+
44
+ expect(event.summary).to eq("Appointment")
45
+ end
46
+
47
+ it "returns no events if the event is outside the requested window" do
48
+ from = Time.new 2021, 6, 7, 0, 0, 0, tz
49
+ to = Time.new 2021, 6, 7, 23, 0, 0, tz
50
+
51
+ calendars = Livecal.from_string source, from: from, to: to
52
+
53
+ expect(calendars.length).to eq(1)
54
+ expect(calendars.first.events).to be_empty
55
+ end
56
+ end
57
+
58
+ context "with recurring events" do
59
+ let(:source) do
60
+ <<~ICAL
61
+ BEGIN:VCALENDAR
62
+ PRODID:-//Google Inc//Google Calendar 70.9054//EN
63
+ VERSION:2.0
64
+ CALSCALE:GREGORIAN
65
+ METHOD:PUBLISH
66
+ X-WR-CALNAME:livecal
67
+ X-WR-TIMEZONE:Australia/Melbourne
68
+ BEGIN:VTIMEZONE
69
+ TZID:Australia/Melbourne
70
+ X-LIC-LOCATION:Australia/Melbourne
71
+ BEGIN:STANDARD
72
+ TZOFFSETFROM:+1100
73
+ TZOFFSETTO:+1000
74
+ TZNAME:AEST
75
+ DTSTART:19700405T030000
76
+ RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU
77
+ END:STANDARD
78
+ BEGIN:DAYLIGHT
79
+ TZOFFSETFROM:+1000
80
+ TZOFFSETTO:+1100
81
+ TZNAME:AEDT
82
+ DTSTART:19701004T020000
83
+ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU
84
+ END:DAYLIGHT
85
+ END:VTIMEZONE
86
+ BEGIN:VEVENT
87
+ DTSTART;TZID=Australia/Melbourne:20210606T120000
88
+ DTEND;TZID=Australia/Melbourne:20210606T130000
89
+ RRULE:FREQ=WEEKLY;BYDAY=SU
90
+ DTSTAMP:20210606T015535Z
91
+ UID:6vc8dul781vckb41vh5avkchi3@google.com
92
+ CREATED:20210606T013948Z
93
+ DESCRIPTION:
94
+ LAST-MODIFIED:20210606T015524Z
95
+ LOCATION:
96
+ SEQUENCE:1
97
+ STATUS:CONFIRMED
98
+ SUMMARY:Appointment
99
+ TRANSP:OPAQUE
100
+ END:VEVENT
101
+ END:VCALENDAR
102
+ ICAL
103
+ end
104
+
105
+ it "returns the events in the requested window" do
106
+ from = Time.new 2021, 6, 6, 0, 0, 0, tz
107
+ to = Time.new 2021, 6, 14, 0, 0, 0, tz
108
+ calendars = Livecal.from_string source, from: from, to: to
109
+
110
+ expect(calendars.length).to eq(1)
111
+ expect(calendars.first.events.length).to eq(2)
112
+
113
+ first, second = calendars.first.events.to_a
114
+
115
+ expect(first.summary).to eq("Appointment")
116
+ expect(first.starts_at).to eq(Time.new(2021, 6, 6, 12, 0, 0, tz))
117
+ expect(first.ends_at).to eq(Time.new(2021, 6, 6, 13, 0, 0, tz))
118
+
119
+ expect(second.summary).to eq("Appointment")
120
+ expect(second.starts_at).to eq(Time.new(2021, 6, 13, 12, 0, 0, tz))
121
+ expect(second.ends_at).to eq(Time.new(2021, 6, 13, 13, 0, 0, tz))
122
+ end
123
+
124
+ it "includes events when the initial instance is outside the window" do
125
+ from = Time.new 2021, 6, 13, 0, 0, 0, tz
126
+ to = Time.new 2021, 6, 14, 0, 0, 0, tz
127
+ calendars = Livecal.from_string source, from: from, to: to
128
+
129
+ expect(calendars.length).to eq(1)
130
+ expect(calendars.first.events.length).to eq(1)
131
+
132
+ event = calendars.first.events.first
133
+
134
+ expect(event.summary).to eq("Appointment")
135
+ expect(event.starts_at).to eq(Time.new(2021, 6, 13, 12, 0, 0, tz))
136
+ expect(event.ends_at).to eq(Time.new(2021, 6, 13, 13, 0, 0, tz))
137
+ end
138
+ end
139
+
140
+ context "recurring events with exclusions" do
141
+ let(:source) do
142
+ <<~ICAL
143
+ BEGIN:VCALENDAR
144
+ PRODID:-//Google Inc//Google Calendar 70.9054//EN
145
+ VERSION:2.0
146
+ CALSCALE:GREGORIAN
147
+ METHOD:PUBLISH
148
+ X-WR-CALNAME:livecal
149
+ X-WR-TIMEZONE:Australia/Melbourne
150
+ BEGIN:VTIMEZONE
151
+ TZID:Australia/Melbourne
152
+ X-LIC-LOCATION:Australia/Melbourne
153
+ BEGIN:STANDARD
154
+ TZOFFSETFROM:+1100
155
+ TZOFFSETTO:+1000
156
+ TZNAME:AEST
157
+ DTSTART:19700405T030000
158
+ RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU
159
+ END:STANDARD
160
+ BEGIN:DAYLIGHT
161
+ TZOFFSETFROM:+1000
162
+ TZOFFSETTO:+1100
163
+ TZNAME:AEDT
164
+ DTSTART:19701004T020000
165
+ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU
166
+ END:DAYLIGHT
167
+ END:VTIMEZONE
168
+ BEGIN:VEVENT
169
+ DTSTART;TZID=Australia/Melbourne:20210606T120000
170
+ DTEND;TZID=Australia/Melbourne:20210606T130000
171
+ RRULE:FREQ=WEEKLY;BYDAY=SU
172
+ EXDATE;TZID=Australia/Melbourne:20210613T120000
173
+ DTSTAMP:20210606T031826Z
174
+ UID:6vc8dul781vckb41vh5avkchi3@google.com
175
+ CREATED:20210606T013948Z
176
+ DESCRIPTION:
177
+ LAST-MODIFIED:20210606T015524Z
178
+ LOCATION:
179
+ SEQUENCE:1
180
+ STATUS:CONFIRMED
181
+ SUMMARY:Appointment
182
+ TRANSP:OPAQUE
183
+ END:VEVENT
184
+ END:VCALENDAR
185
+ ICAL
186
+ end
187
+
188
+ it "returns the events in the requested window" do
189
+ from = Time.new 2021, 6, 6, 0, 0, 0, tz
190
+ to = Time.new 2021, 6, 21, 0, 0, 0, tz
191
+ calendars = Livecal.from_string source, from: from, to: to
192
+
193
+ expect(calendars.length).to eq(1)
194
+ expect(calendars.first.events.length).to eq(2)
195
+
196
+ first, second = calendars.first.events.to_a
197
+
198
+ expect(first.summary).to eq("Appointment")
199
+ expect(first.starts_at).to eq(Time.new(2021, 6, 6, 12, 0, 0, tz))
200
+ expect(first.ends_at).to eq(Time.new(2021, 6, 6, 13, 0, 0, tz))
201
+
202
+ expect(second.summary).to eq("Appointment")
203
+ expect(second.starts_at).to eq(Time.new(2021, 6, 20, 12, 0, 0, tz))
204
+ expect(second.ends_at).to eq(Time.new(2021, 6, 20, 13, 0, 0, tz))
205
+ end
206
+ end
207
+
208
+ context "recurring events with changes" do
209
+ let(:source) do
210
+ <<~ICAL
211
+ BEGIN:VCALENDAR
212
+ PRODID:-//Google Inc//Google Calendar 70.9054//EN
213
+ VERSION:2.0
214
+ CALSCALE:GREGORIAN
215
+ METHOD:PUBLISH
216
+ X-WR-CALNAME:livecal
217
+ X-WR-TIMEZONE:Australia/Melbourne
218
+ BEGIN:VTIMEZONE
219
+ TZID:Australia/Melbourne
220
+ X-LIC-LOCATION:Australia/Melbourne
221
+ BEGIN:STANDARD
222
+ TZOFFSETFROM:+1100
223
+ TZOFFSETTO:+1000
224
+ TZNAME:AEST
225
+ DTSTART:19700405T030000
226
+ RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU
227
+ END:STANDARD
228
+ BEGIN:DAYLIGHT
229
+ TZOFFSETFROM:+1000
230
+ TZOFFSETTO:+1100
231
+ TZNAME:AEDT
232
+ DTSTART:19701004T020000
233
+ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU
234
+ END:DAYLIGHT
235
+ END:VTIMEZONE
236
+ BEGIN:VEVENT
237
+ DTSTART;TZID=Australia/Melbourne:20210606T120000
238
+ DTEND;TZID=Australia/Melbourne:20210606T130000
239
+ RRULE:FREQ=WEEKLY;BYDAY=SU
240
+ DTSTAMP:20210606T032058Z
241
+ UID:60oght30b0avdekvlsg2bkfi7p@google.com
242
+ CREATED:20210606T032036Z
243
+ DESCRIPTION:
244
+ LAST-MODIFIED:20210606T032036Z
245
+ LOCATION:
246
+ SEQUENCE:0
247
+ STATUS:CONFIRMED
248
+ SUMMARY:Appointment
249
+ TRANSP:OPAQUE
250
+ END:VEVENT
251
+ BEGIN:VEVENT
252
+ DTSTART;TZID=Australia/Melbourne:20210614T130000
253
+ DTEND;TZID=Australia/Melbourne:20210614T140000
254
+ DTSTAMP:20210606T032058Z
255
+ UID:60oght30b0avdekvlsg2bkfi7p@google.com
256
+ RECURRENCE-ID;TZID=Australia/Melbourne:20210613T120000
257
+ CREATED:20210606T032036Z
258
+ DESCRIPTION:
259
+ LAST-MODIFIED:20210606T032050Z
260
+ LOCATION:
261
+ SEQUENCE:1
262
+ STATUS:CONFIRMED
263
+ SUMMARY:Appointment
264
+ TRANSP:OPAQUE
265
+ END:VEVENT
266
+ END:VCALENDAR
267
+ ICAL
268
+ end
269
+
270
+ it "returns the events in the requested window" do
271
+ from = Time.new 2021, 6, 6, 0, 0, 0, tz
272
+ to = Time.new 2021, 6, 21, 0, 0, 0, tz
273
+ calendars = Livecal.from_string source, from: from, to: to
274
+
275
+ expect(calendars.length).to eq(1)
276
+ expect(calendars.first.events.length).to eq(3)
277
+
278
+ first, second, third = calendars.first.events.to_a
279
+
280
+ expect(first.summary).to eq("Appointment")
281
+ expect(first.starts_at).to eq(Time.new(2021, 6, 6, 12, 0, 0, tz))
282
+ expect(first.ends_at).to eq(Time.new(2021, 6, 6, 13, 0, 0, tz))
283
+
284
+ expect(second.summary).to eq("Appointment")
285
+ expect(second.starts_at).to eq(Time.new(2021, 6, 14, 13, 0, 0, tz))
286
+ expect(second.ends_at).to eq(Time.new(2021, 6, 14, 14, 0, 0, tz))
287
+
288
+ expect(third.summary).to eq("Appointment")
289
+ expect(third.starts_at).to eq(Time.new(2021, 6, 20, 12, 0, 0, tz))
290
+ expect(third.ends_at).to eq(Time.new(2021, 6, 20, 13, 0, 0, tz))
291
+ end
292
+
293
+ it "factors in changes outside the requested window" do
294
+ from = Time.new 2021, 6, 6, 0, 0, 0, tz
295
+ to = Time.new 2021, 6, 13, 0, 0, 0, tz
296
+ calendars = Livecal.from_string source, from: from, to: to
297
+
298
+ expect(calendars.length).to eq(1)
299
+ expect(calendars.first.events.length).to eq(1)
300
+
301
+ event = calendars.first.events.first
302
+
303
+ expect(event.summary).to eq("Appointment")
304
+ expect(event.starts_at).to eq(Time.new(2021, 6, 6, 12, 0, 0, tz))
305
+ expect(event.ends_at).to eq(Time.new(2021, 6, 6, 13, 0, 0, tz))
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "livecal"
5
+
6
+ RSpec.configure do |config|
7
+ config.disable_monkey_patching!
8
+
9
+ config.expect_with :rspec do |c|
10
+ c.syntax = :expect
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: livecal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pat Allan
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-06-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: icalendar
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rrule
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description:
84
+ email:
85
+ - pat@freelancing-gods.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".rspec"
91
+ - Gemfile
92
+ - LICENSE.md
93
+ - README.md
94
+ - Rakefile
95
+ - lib/livecal.rb
96
+ - lib/livecal/calendar.rb
97
+ - lib/livecal/calendar_source.rb
98
+ - lib/livecal/event.rb
99
+ - lib/livecal/parser.rb
100
+ - lib/livecal/recurring_events.rb
101
+ - spec/integration/parsing_spec.rb
102
+ - spec/spec_helper.rb
103
+ homepage: https://github.com/pat/livecal
104
+ licenses:
105
+ - Hippocratic-2.1
106
+ metadata:
107
+ homepage_uri: https://github.com/pat/livecal
108
+ source_code_uri: https://github.com/pat/livecal
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '2.7'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.1.6
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: Parses ical/ics files into a live calendar
128
+ test_files:
129
+ - spec/spec_helper.rb
130
+ - spec/integration/parsing_spec.rb
131
+ - ".rspec"
132
+ - Gemfile
133
+ - Rakefile