calendav 0.0.1 → 0.1.0

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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "../sync_collection"
6
+
7
+ module Calendav
8
+ module Parsers
9
+ class SyncXML
10
+ def self.call(...)
11
+ new(...).call
12
+ end
13
+
14
+ def initialize(calendar_url, multi_response)
15
+ @calendar_url = calendar_url
16
+ @multi_response = multi_response
17
+ end
18
+
19
+ def call
20
+ SyncCollection.new(events, deleted_urls, token)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :calendar_url, :multi_response
26
+
27
+ def deleted_urls
28
+ individual_responses
29
+ .select { |node| node.xpath("./dav:status").text["404 Not Found"] }
30
+ .collect { |node| response_url(node) }
31
+ end
32
+
33
+ def events
34
+ individual_responses
35
+ .reject { |node| node.xpath("./dav:status").text["404 Not Found"] }
36
+ .collect { |node| Event.from_xml(calendar_url, node) }
37
+ end
38
+
39
+ def calendar_path
40
+ @calendar_path ||= URI(calendar_url).path
41
+ end
42
+
43
+ def individual_responses
44
+ multi_response
45
+ .reject { |node| node.xpath("./dav:href").text == calendar_path }
46
+ end
47
+
48
+ def token
49
+ multi_response.xpath("/dav:multistatus/dav:sync-token").text
50
+ end
51
+
52
+ def response_url(response)
53
+ ContextualURL.call(calendar_url, response.xpath("./dav:href").text)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "nokogiri"
4
4
 
5
- require_relative "../xml_processor"
5
+ require_relative "../namespaces"
6
6
 
7
7
  module Calendav
8
8
  module Requests
@@ -13,7 +13,7 @@ module Calendav
13
13
 
14
14
  def call
15
15
  Nokogiri::XML::Builder.new do |xml|
16
- xml["dav"].propfind(XMLProcessor::NAMESPACES) do
16
+ xml["dav"].propfind(NAMESPACES) do
17
17
  xml["dav"].prop do
18
18
  xml["caldav"].public_send(:"calendar-home-set")
19
19
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "nokogiri"
4
4
 
5
- require_relative "../xml_processor"
5
+ require_relative "../namespaces"
6
6
 
7
7
  module Calendav
8
8
  module Requests
@@ -13,7 +13,7 @@ module Calendav
13
13
 
14
14
  def call
15
15
  Nokogiri::XML::Builder.new do |xml|
16
- xml["dav"].propfind(XMLProcessor::NAMESPACES) do
16
+ xml["dav"].propfind(NAMESPACES) do
17
17
  xml["dav"].prop do
18
18
  xml["dav"].public_send(:"current-user-principal")
19
19
  end
@@ -2,28 +2,53 @@
2
2
 
3
3
  require "nokogiri"
4
4
 
5
- require_relative "../xml_processor"
5
+ require_relative "../namespaces"
6
6
 
7
7
  module Calendav
8
8
  module Requests
9
9
  class ListCalendars
10
+ PROPERTIES = [
11
+ { key: :display_name, namespace: "dav", name: "displayname" },
12
+ { key: :resource_type, namespace: "dav", name: "resourcetype" },
13
+ { key: :etag, namespace: "dav", name: "getetag" },
14
+ { key: :ctag, namespace: "cs", name: "getctag" },
15
+ { key: :color, namespace: "apple", name: "calendar-color" },
16
+ { key: :sync_token, namespace: "dav", name: "sync-token" },
17
+ { key: :reports, namespace: "dav", name: "supported-report-set" },
18
+ {
19
+ key: :components,
20
+ namespace: "caldav",
21
+ name: "supported-calendar-component-set"
22
+ }
23
+ ].freeze
24
+
10
25
  def self.call(...)
11
26
  new(...).call
12
27
  end
13
28
 
29
+ def initialize(attributes)
30
+ @attributes = attributes
31
+ end
32
+
14
33
  def call
15
34
  Nokogiri::XML::Builder.new do |xml|
16
- xml["dav"].propfind(XMLProcessor::NAMESPACES) do
35
+ xml["dav"].propfind(NAMESPACES) do
17
36
  xml["dav"].prop do
18
- xml["dav"].displayname
19
- xml["dav"].resourcetype
20
- xml["dav"].getetag
21
- xml["cs"].getctag
22
- xml["apple"].public_send(:"calendar-color")
37
+ properties.each do |hash|
38
+ xml[hash[:namespace]].public_send(hash[:name].to_sym)
39
+ end
23
40
  end
24
41
  end
25
42
  end
26
43
  end
44
+
45
+ private
46
+
47
+ attr_reader :attributes
48
+
49
+ def properties
50
+ PROPERTIES.select { |hash| attributes.include?(hash[:key]) }
51
+ end
27
52
  end
28
53
  end
29
54
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "nokogiri"
4
4
 
5
- require_relative "../xml_processor"
5
+ require_relative "../namespaces"
6
6
 
7
7
  module Calendav
8
8
  module Requests
@@ -18,9 +18,7 @@ module Calendav
18
18
 
19
19
  def call
20
20
  Nokogiri::XML::Builder.new do |xml|
21
- xml["caldav"].public_send(
22
- "calendar-query", XMLProcessor::NAMESPACES
23
- ) do
21
+ xml["caldav"].public_send("calendar-query", NAMESPACES) do
24
22
  xml["dav"].prop do
25
23
  xml["dav"].getetag
26
24
  xml["caldav"].public_send(:"calendar-data")
@@ -28,11 +26,11 @@ module Calendav
28
26
  xml["caldav"].filter do
29
27
  xml["caldav"].public_send(:"comp-filter", name: "VCALENDAR") do
30
28
  xml["caldav"].public_send(:"comp-filter", name: "VEVENT") do
31
- xml["caldav"].public_send(
32
- :"time-range",
33
- start: from.utc.iso8601.delete(":-"),
34
- end: to.utc.iso8601.delete(":-")
35
- )
29
+ if range?
30
+ xml["caldav"].public_send(
31
+ :"time-range", start: from, end: to
32
+ )
33
+ end
36
34
  end
37
35
  end
38
36
  end
@@ -42,7 +40,21 @@ module Calendav
42
40
 
43
41
  private
44
42
 
45
- attr_reader :from, :to
43
+ def from
44
+ return nil if @from.nil?
45
+
46
+ @from.utc.iso8601.delete(":-")
47
+ end
48
+
49
+ def to
50
+ return nil if @to.nil?
51
+
52
+ @to.utc.iso8601.delete(":-")
53
+ end
54
+
55
+ def range?
56
+ to || from
57
+ end
46
58
  end
47
59
  end
48
60
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "nokogiri"
4
4
 
5
- require_relative "../xml_processor"
5
+ require_relative "../namespaces"
6
6
 
7
7
  module Calendav
8
8
  module Requests
@@ -17,7 +17,7 @@ module Calendav
17
17
 
18
18
  def call
19
19
  Nokogiri::XML::Builder.new do |xml|
20
- xml["caldav"].propfind(XMLProcessor::NAMESPACES) do
20
+ xml["caldav"].mkcalendar(NAMESPACES) do
21
21
  xml["dav"].set do
22
22
  xml["dav"].prop do
23
23
  xml["dav"].displayname display_name
@@ -32,6 +32,8 @@ module Calendav
32
32
  xml["caldav"].public_send(:"calendar-timezone", time_zone)
33
33
  end
34
34
 
35
+ xml["apple"].public_send(:"calendar-color", color) if color
36
+
35
37
  xml["caldav"].public_send(
36
38
  :"supported-calendar-component-set"
37
39
  ) do
@@ -47,6 +49,10 @@ module Calendav
47
49
 
48
50
  attr_reader :attributes
49
51
 
52
+ def color
53
+ attributes[:color]
54
+ end
55
+
50
56
  def display_name
51
57
  attributes[:display_name]
52
58
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ require_relative "../namespaces"
6
+
7
+ module Calendav
8
+ module Requests
9
+ class SyncCollection
10
+ def self.call(...)
11
+ new(...).call
12
+ end
13
+
14
+ def initialize(token)
15
+ @token = token
16
+ end
17
+
18
+ def call
19
+ Nokogiri::XML::Builder.new do |xml|
20
+ xml["dav"].public_send(:"sync-collection", NAMESPACES) do
21
+ xml["dav"].public_send(:"sync-token", token)
22
+ xml["dav"].public_send(:"sync-level", 1)
23
+ xml["dav"].prop do
24
+ xml["dav"].getetag
25
+ xml["caldav"].public_send(:"calendar-data")
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :token
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ require_relative "../namespaces"
6
+
7
+ module Calendav
8
+ module Requests
9
+ class UpdateCalendar
10
+ def self.call(...)
11
+ new(...).call
12
+ end
13
+
14
+ def initialize(attributes)
15
+ @attributes = attributes
16
+ end
17
+
18
+ def call
19
+ Nokogiri::XML::Builder.new do |xml|
20
+ xml["dav"].propertyupdate(NAMESPACES) do
21
+ xml["dav"].set do
22
+ xml["dav"].prop do
23
+ xml["dav"].displayname display_name if display_name
24
+
25
+ if description
26
+ xml["caldav"].public_send(
27
+ :"calendar-description", description
28
+ )
29
+ end
30
+
31
+ if time_zone
32
+ xml["caldav"].public_send(:"calendar-timezone", time_zone)
33
+ end
34
+
35
+ xml["apple"].public_send(:"calendar-color", color) if color
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :attributes
45
+
46
+ def color
47
+ attributes[:color]
48
+ end
49
+
50
+ def display_name
51
+ attributes[:display_name]
52
+ end
53
+
54
+ def description
55
+ attributes[:description]
56
+ end
57
+
58
+ def time_zone
59
+ attributes[:time_zone]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ class SyncCollection
5
+ attr_reader :changes, :deletions, :sync_token
6
+
7
+ def initialize(changes, deletions, sync_token)
8
+ @changes = changes
9
+ @deletions = deletions
10
+ @sync_token = sync_token
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "icalendar"
4
+ require "icalendar/tzinfo"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ RSpec.describe "Apple" do
9
+ let(:provider) { :apple }
10
+ let(:username) { ENV.fetch("APPLE_USERNAME") }
11
+ let(:password) { ENV.fetch("APPLE_PASSWORD") }
12
+ let(:credentials) { Calendav.credentials(provider, username, password) }
13
+
14
+ subject { Calendav.client(credentials) }
15
+
16
+ it "determines the user's principal URL" do
17
+ expect(subject.principal_url)
18
+ .to eq_encoded_url("https://caldav.icloud.com/20264203208/principal/")
19
+ end
20
+
21
+ it "determines the user's calendar URL" do
22
+ expect(subject.calendars.home_url)
23
+ .to eq_encoded_url("https://p49-caldav.icloud.com/20264203208/calendars/")
24
+ end
25
+
26
+ it "supports calendar creation" do
27
+ expect(subject.calendars.create?).to eq(true)
28
+ end
29
+
30
+ it "can create, find, update and delete calendars" do
31
+ identifier = SecureRandom.uuid
32
+ time_zone = TZInfo::Timezone.get "UTC"
33
+ ical_time_zone = time_zone.ical_timezone Time.now.utc
34
+
35
+ url = subject.calendars.create(
36
+ identifier,
37
+ display_name: "Calendav Test",
38
+ description: "For test purposes only",
39
+ color: "#00FF00",
40
+ time_zone: ical_time_zone.to_ical
41
+ )
42
+ expect(url).to include(URI.decode_www_form_component(identifier))
43
+ expect(url).to start_with("https://")
44
+
45
+ calendars = subject.calendars.list
46
+ expect(calendars.collect(&:display_name)).to include("Calendav Test")
47
+
48
+ expect(
49
+ subject.calendars.update(url, display_name: "Calendav Update")
50
+ ).to eq(true)
51
+
52
+ calendars = subject.calendars.list
53
+ expect(calendars.collect(&:display_name)).to include("Calendav Update")
54
+
55
+ subject.calendars.delete(url)
56
+ end
57
+
58
+ context "with a calendar" do
59
+ let(:calendar_url) do
60
+ subject.calendars.create(SecureRandom.uuid, display_name: "Calendav Test")
61
+ end
62
+ let(:calendar) { subject.calendars.find(calendar_url) }
63
+ let(:identifier) { "#{SecureRandom.uuid}.ics" }
64
+ let(:start) { Time.new 2021, 6, 1, 10, 30 }
65
+ let(:finish) { Time.new 2021, 6, 1, 12, 30 }
66
+
67
+ after :each do
68
+ subject.calendars.delete(calendar_url)
69
+ end
70
+
71
+ it "supports events" do
72
+ expect(calendar.components).to include("VEVENT")
73
+ end
74
+
75
+ it "supports WebDAV-Sync" do
76
+ expect(calendar.reports).to include("sync-collection")
77
+ end
78
+
79
+ it "can create, find, update and delete events" do
80
+ ics = Icalendar::Calendar.new
81
+ ics.event do |event|
82
+ event.dtstart = start.utc
83
+ event.dtend = finish.utc
84
+ event.summary = "Brunch"
85
+ end
86
+ ics.publish
87
+
88
+ # Create an event
89
+ event_url = subject.events.create(calendar.url, identifier, ics.to_ical)
90
+ expect(event_url).to include(URI.decode_www_form_component(calendar.url))
91
+
92
+ # Search for the event
93
+ events = subject.events.list(
94
+ calendar.url, from: Time.new(2021, 6, 1), to: Time.new(2021, 6, 2)
95
+ )
96
+ expect(events.length).to eq(1)
97
+ expect(events.first.summary).to eq("Brunch")
98
+ expect(events.first.url).to eq_encoded_url(event_url)
99
+
100
+ # Update the event
101
+ ics.events.first.dtstart = Time.new(2021, 7, 1, 10, 30).utc
102
+ ics.events.first.dtend = Time.new(2021, 7, 1, 12, 30).utc
103
+ subject.events.update(event_url, ics.to_ical)
104
+
105
+ # Search again
106
+ events = subject.events.list(
107
+ calendar.url, from: Time.new(2021, 7, 1), to: Time.new(2021, 7, 2)
108
+ )
109
+ expect(events.length).to eq(1)
110
+ expect(events.first.summary).to eq("Brunch")
111
+ expect(events.first.url).to eq_encoded_url(event_url)
112
+
113
+ # Create another event
114
+ ics = Icalendar::Calendar.new
115
+ ics.event do |event|
116
+ event.dtstart = start.utc
117
+ event.dtend = finish.utc
118
+ event.summary = "Brunch"
119
+ end
120
+ ics.publish
121
+
122
+ another_url = subject.events.create(
123
+ calendar.url, "#{SecureRandom.uuid}.ics", ics.to_ical
124
+ )
125
+
126
+ # Search for all events
127
+ events = subject.events.list(calendar.url)
128
+ expect(events.length).to eq(2)
129
+
130
+ # Delete the events
131
+ expect(subject.events.delete(event_url)).to eq(true)
132
+ expect(subject.events.delete(another_url)).to eq(true)
133
+ end
134
+
135
+ it "respects etag conditions with updates and deletions" do
136
+ ics = Icalendar::Calendar.new
137
+ ics.event do |event|
138
+ event.dtstart = start.utc
139
+ event.dtend = finish.utc
140
+ event.summary = "Brunch"
141
+ end
142
+ ics.publish
143
+
144
+ event_url = subject.events.create(calendar.url, identifier, ics.to_ical)
145
+ event = subject.events.find(event_url)
146
+
147
+ ics.events.first.summary = "Coffee"
148
+ expect(
149
+ subject.events.update(event_url, ics.to_ical, etag: event.etag)
150
+ ).to eq(true)
151
+
152
+ expect(subject.events.find(event_url).summary).to eq("Coffee")
153
+
154
+ # Updating with the old etag should fail
155
+ ics.events.first.summary = "Brunch"
156
+ expect(
157
+ subject.events.update(event_url, ics.to_ical, etag: event.etag)
158
+ ).to eq(false)
159
+
160
+ expect(subject.events.find(event_url).summary).to eq("Coffee")
161
+
162
+ expect(subject.events.delete(event_url, etag: event.etag)).to eq(false)
163
+ expect(subject.events.delete(event_url)).to eq(true)
164
+ end
165
+
166
+ it "handles synchronisation requests" do
167
+ first = Icalendar::Calendar.new
168
+ first.event do |event|
169
+ event.dtstart = start.utc
170
+ event.dtend = finish.utc
171
+ event.summary = "Brunch"
172
+ end
173
+ first.publish
174
+
175
+ first_url = subject.events.create(calendar.url, identifier, first.to_ical)
176
+ token = subject.calendars.find(calendar.url, sync: true).sync_token
177
+
178
+ events = subject.events.list(calendar.url)
179
+ expect(events.length).to eq(1)
180
+
181
+ second = Icalendar::Calendar.new
182
+ second.event do |event|
183
+ event.dtstart = start.utc
184
+ event.dtend = finish.utc
185
+ event.summary = "Brunch Again"
186
+ end
187
+ second.publish
188
+ second_url = subject.events.create(
189
+ calendar.url, "#{SecureRandom.uuid}.ics", second.to_ical
190
+ )
191
+
192
+ first.events.first.summary = "Coffee"
193
+ subject.events.update(first_url, first.to_ical)
194
+
195
+ collection = subject.calendars.sync(calendar.url, token)
196
+ expect(collection.changes.collect(&:url))
197
+ .to match_encoded_urls([first_url, second_url])
198
+
199
+ expect(collection.deletions).to be_empty
200
+
201
+ first.events.first.summary = "Brunch"
202
+ subject.events.update(first_url, first.to_ical)
203
+
204
+ subject.events.delete(second_url)
205
+
206
+ collection = subject.calendars.sync(calendar.url, collection.sync_token)
207
+ urls = collection.changes.collect(&:url)
208
+ expect(urls.length).to eq(1)
209
+ expect(urls[0]).to eq_encoded_url(first_url)
210
+
211
+ expect(collection.deletions.length).to eq(1)
212
+ expect(collection.deletions.first).to eq_encoded_url(second_url)
213
+
214
+ subject.events.delete(first_url)
215
+ end
216
+ end
217
+ end