calendav 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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