calendav 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -2
- data/README.md +183 -39
- data/lib/calendav/calendar.rb +18 -42
- data/lib/calendav/client.rb +3 -5
- data/lib/calendav/clients/calendars_client.rb +60 -18
- data/lib/calendav/clients/events_client.rb +32 -13
- data/lib/calendav/contextual_url.rb +6 -3
- data/lib/calendav/endpoint.rb +43 -13
- data/lib/calendav/errors.rb +28 -0
- data/lib/calendav/event.rb +13 -35
- data/lib/calendav/multi_response.rb +27 -0
- data/lib/calendav/namespaces.rb +10 -0
- data/lib/calendav/parsers/calendar_xml.rb +58 -0
- data/lib/calendav/parsers/event_xml.rb +37 -0
- data/lib/calendav/parsers/response_xml.rb +48 -0
- data/lib/calendav/parsers/sync_xml.rb +57 -0
- data/lib/calendav/requests/calendar_home_set.rb +2 -2
- data/lib/calendav/requests/current_user_principal.rb +2 -2
- data/lib/calendav/requests/list_calendars.rb +32 -7
- data/lib/calendav/requests/list_events.rb +22 -10
- data/lib/calendav/requests/make_calendar.rb +8 -2
- data/lib/calendav/requests/sync_collection.rb +36 -0
- data/lib/calendav/requests/update_calendar.rb +63 -0
- data/lib/calendav/sync_collection.rb +13 -0
- data/spec/acceptance/apple_spec.rb +217 -0
- data/spec/acceptance/google_spec.rb +167 -26
- data/spec/spec_helper.rb +2 -0
- data/spec/support/encoded_matchers.rb +29 -0
- metadata +34 -7
- data/lib/calendav/xml_processor.rb +0 -42
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "../errors"
|
3
4
|
require_relative "../event"
|
4
5
|
require_relative "../requests/list_events"
|
5
6
|
|
@@ -13,23 +14,45 @@ module Calendav
|
|
13
14
|
end
|
14
15
|
|
15
16
|
def create(calendar_url, event_identifier, ics)
|
16
|
-
|
17
|
-
result = endpoint.put(ics, url:
|
17
|
+
event_url = merged_url(calendar_url, event_identifier)
|
18
|
+
result = endpoint.put(ics, url: event_url, content_type: :ics)
|
18
19
|
|
19
|
-
result.headers["Location"]
|
20
|
+
result.headers["Location"] || event_url
|
20
21
|
end
|
21
22
|
|
22
|
-
def delete(event_url)
|
23
|
-
endpoint.delete(url: event_url)
|
23
|
+
def delete(event_url, etag: nil)
|
24
|
+
endpoint.delete(url: event_url, etag: etag)
|
25
|
+
rescue PreconditionError
|
26
|
+
false
|
24
27
|
end
|
25
28
|
|
26
|
-
def
|
29
|
+
def find(event_url)
|
30
|
+
response = endpoint.get(url: event_url)
|
31
|
+
|
32
|
+
Event.new(
|
33
|
+
event_url,
|
34
|
+
calendar_data: response.body.to_s,
|
35
|
+
etag: response.headers["ETag"]
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def list(calendar_url, from: nil, to: nil)
|
27
40
|
request = Requests::ListEvents.call(from: from, to: to)
|
28
41
|
|
29
42
|
endpoint
|
30
43
|
.report(request.to_xml, url: calendar_url, depth: 1)
|
31
|
-
.xpath(".//
|
32
|
-
.collect { |node| Event.from_xml(node) }
|
44
|
+
.reject { |node| node.xpath(".//caldav:calendar-data").text.empty? }
|
45
|
+
.collect { |node| Event.from_xml(calendar_url, node) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def update(event_url, ics, etag: nil)
|
49
|
+
endpoint.put(
|
50
|
+
ics, url: event_url, content_type: :ics, etag: etag
|
51
|
+
)
|
52
|
+
|
53
|
+
true
|
54
|
+
rescue PreconditionError
|
55
|
+
false
|
33
56
|
end
|
34
57
|
|
35
58
|
private
|
@@ -37,11 +60,7 @@ module Calendav
|
|
37
60
|
attr_reader :client, :endpoint, :credentials
|
38
61
|
|
39
62
|
def merged_url(calendar_url, event_identifier)
|
40
|
-
|
41
|
-
"#{calendar_url}#{event_identifier}"
|
42
|
-
else
|
43
|
-
"#{calendar_url}/#{event_identifier}"
|
44
|
-
end
|
63
|
+
"#{calendar_url.delete_suffix('/')}/#{event_identifier}"
|
45
64
|
end
|
46
65
|
end
|
47
66
|
end
|
@@ -1,12 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "uri"
|
4
|
+
|
3
5
|
module Calendav
|
4
6
|
class ContextualURL
|
5
|
-
def self.call(
|
6
|
-
|
7
|
+
def self.call(host, url_or_path)
|
8
|
+
host = URI(host)
|
9
|
+
return host.to_s if url_or_path.nil? || url_or_path.empty?
|
7
10
|
|
8
11
|
if url_or_path.start_with?("/")
|
9
|
-
|
12
|
+
host.dup.tap { |new_url| new_url.path = url_or_path }.to_s
|
10
13
|
else
|
11
14
|
URI(url_or_path).to_s
|
12
15
|
end
|
data/lib/calendav/endpoint.rb
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "./contextual_url"
|
4
|
-
require_relative "./
|
4
|
+
require_relative "./errors"
|
5
|
+
require_relative "./parsers/response_xml"
|
5
6
|
|
6
7
|
module Calendav
|
7
8
|
class Endpoint
|
@@ -10,14 +11,18 @@ module Calendav
|
|
10
11
|
xml: "application/xml; charset=utf-8"
|
11
12
|
}.freeze
|
12
13
|
|
13
|
-
RequestFailedError = Class.new(StandardError)
|
14
|
-
|
15
14
|
def initialize(credentials)
|
16
15
|
@credentials = credentials
|
17
16
|
end
|
18
17
|
|
19
|
-
def delete(url:)
|
20
|
-
request(:delete, url: url)
|
18
|
+
def delete(url:, etag: nil)
|
19
|
+
request(:delete, url: url, http: with_headers(etag: etag))
|
20
|
+
.status
|
21
|
+
.success?
|
22
|
+
end
|
23
|
+
|
24
|
+
def get(url:)
|
25
|
+
request(:get, url: url)
|
21
26
|
end
|
22
27
|
|
23
28
|
def mkcalendar(body, url:)
|
@@ -29,6 +34,10 @@ module Calendav
|
|
29
34
|
)
|
30
35
|
end
|
31
36
|
|
37
|
+
def options(url:)
|
38
|
+
request(:options, url: url)
|
39
|
+
end
|
40
|
+
|
32
41
|
def propfind(body, url: nil, depth: 0)
|
33
42
|
request(
|
34
43
|
:propfind,
|
@@ -38,12 +47,21 @@ module Calendav
|
|
38
47
|
)
|
39
48
|
end
|
40
49
|
|
41
|
-
def
|
50
|
+
def proppatch(body, url: nil)
|
51
|
+
request(
|
52
|
+
:proppatch,
|
53
|
+
body,
|
54
|
+
url: url,
|
55
|
+
http: with_headers(content_type: :xml)
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
59
|
+
def put(body, url:, content_type: nil, etag: nil)
|
42
60
|
request(
|
43
61
|
:put,
|
44
62
|
body,
|
45
63
|
url: url,
|
46
|
-
http: with_headers(content_type: content_type)
|
64
|
+
http: with_headers(content_type: content_type, etag: etag)
|
47
65
|
)
|
48
66
|
end
|
49
67
|
|
@@ -72,10 +90,11 @@ module Calendav
|
|
72
90
|
end
|
73
91
|
end
|
74
92
|
|
75
|
-
def with_headers(content_type: nil, depth: nil)
|
93
|
+
def with_headers(content_type: nil, depth: nil, etag: nil)
|
76
94
|
http = authenticated
|
77
95
|
|
78
96
|
http = http.headers(depth: depth) if depth
|
97
|
+
http = http.headers("If-Match" => etag) if etag
|
79
98
|
if content_type
|
80
99
|
http = http.headers("Content-Type" => CONTENT_TYPES[content_type])
|
81
100
|
end
|
@@ -85,15 +104,26 @@ module Calendav
|
|
85
104
|
|
86
105
|
def request(verb, body = nil, url:, http: with_headers)
|
87
106
|
response = http.request(
|
88
|
-
verb, ContextualURL.call(credentials, url), body: body
|
107
|
+
verb, ContextualURL.call(credentials.host, url), body: body
|
89
108
|
)
|
90
|
-
|
91
|
-
|
109
|
+
|
110
|
+
if response.status.success?
|
111
|
+
parse(response)
|
112
|
+
elsif response.status.code == 412
|
113
|
+
raise PreconditionError, response
|
114
|
+
else
|
115
|
+
raise RequestError, response
|
92
116
|
end
|
117
|
+
end
|
93
118
|
|
94
|
-
|
119
|
+
def parse(response)
|
120
|
+
return response if response.content_length&.zero? || response.body.empty?
|
95
121
|
|
96
|
-
|
122
|
+
if response.content_type.mime_type == "text/calendar"
|
123
|
+
response
|
124
|
+
else
|
125
|
+
Parsers::ResponseXML.call(response.body)
|
126
|
+
end
|
97
127
|
end
|
98
128
|
end
|
99
129
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Calendav
|
4
|
+
Error = Class.new(StandardError)
|
5
|
+
|
6
|
+
class ParsingXMLError < Error
|
7
|
+
attr_reader :xml, :original
|
8
|
+
|
9
|
+
def initialize(xml, original)
|
10
|
+
super original.message
|
11
|
+
|
12
|
+
@xml = xml
|
13
|
+
@original = original
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class RequestError < Error
|
18
|
+
attr_reader :response
|
19
|
+
|
20
|
+
def initialize(response)
|
21
|
+
super response.status.to_s
|
22
|
+
|
23
|
+
@response = response
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
PreconditionError = Class.new(RequestError)
|
28
|
+
end
|
data/lib/calendav/event.rb
CHANGED
@@ -1,56 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "./
|
3
|
+
require_relative "./contextual_url"
|
4
|
+
require_relative "./parsers/event_xml"
|
4
5
|
|
5
6
|
module Calendav
|
6
7
|
class Event
|
7
|
-
attr_reader :
|
8
|
+
attr_reader :url, :calendar_data, :etag
|
8
9
|
|
9
|
-
def self.from_xml(node)
|
10
|
+
def self.from_xml(host, node)
|
10
11
|
new(
|
11
|
-
node.xpath("./dav:href").text,
|
12
|
-
|
13
|
-
node.namespaces
|
12
|
+
ContextualURL.call(host, node.xpath("./dav:href").text),
|
13
|
+
Parsers::EventXML.call(node)
|
14
14
|
)
|
15
15
|
end
|
16
16
|
|
17
|
-
def initialize(
|
18
|
-
@
|
19
|
-
@
|
20
|
-
@
|
21
|
-
end
|
22
|
-
|
23
|
-
def calendar_data
|
24
|
-
attribute_value fragment.xpath("//caldav:calendar-data")
|
25
|
-
end
|
26
|
-
|
27
|
-
def etag
|
28
|
-
attribute_value fragment.xpath("//dav:getetag")
|
17
|
+
def initialize(url, attributes = {})
|
18
|
+
@url = url
|
19
|
+
@calendar_data = attributes[:calendar_data]
|
20
|
+
@etag = attributes[:etag]
|
29
21
|
end
|
30
22
|
|
31
23
|
def summary
|
32
24
|
inner_event.summary
|
33
25
|
end
|
34
26
|
|
35
|
-
|
36
|
-
|
37
|
-
attr_reader :attribute_nodes, :namespaces
|
38
|
-
|
39
|
-
def fragment
|
40
|
-
@fragment ||= XMLProcessor.call(
|
41
|
-
"<nodes>#{attribute_nodes}</nodes>", namespaces
|
42
|
-
)
|
27
|
+
def unloaded?
|
28
|
+
calendar_data.nil?
|
43
29
|
end
|
44
30
|
|
45
|
-
|
46
|
-
return nil if node.children.empty?
|
47
|
-
|
48
|
-
if node.children.any?(&:element?)
|
49
|
-
node.children.select(&:element?).collect(&:to_xml).join
|
50
|
-
else
|
51
|
-
node.children.text
|
52
|
-
end
|
53
|
-
end
|
31
|
+
private
|
54
32
|
|
55
33
|
def inner_calendar
|
56
34
|
Icalendar::Calendar.parse(calendar_data).first
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Calendav
|
4
|
+
class MultiResponse
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
def initialize(document)
|
8
|
+
@document = document
|
9
|
+
end
|
10
|
+
|
11
|
+
def each(...)
|
12
|
+
responses.each(...)
|
13
|
+
end
|
14
|
+
|
15
|
+
def xpath(...)
|
16
|
+
document.xpath(...)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
attr_reader :document
|
22
|
+
|
23
|
+
def responses
|
24
|
+
document.xpath("/dav:multistatus/dav:response")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Calendav
|
4
|
+
module Parsers
|
5
|
+
class CalendarXML
|
6
|
+
XPATHS = {
|
7
|
+
display_name: ".//dav:displayname",
|
8
|
+
description: ".//caldav:calendar-description",
|
9
|
+
ctag: ".//cs:getctag",
|
10
|
+
etag: ".//dav:getetag",
|
11
|
+
time_zone: ".//caldav:calendar-timezone",
|
12
|
+
color: ".//apple:calendar-color",
|
13
|
+
sync_token: ".//dav:sync-token"
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def self.call(...)
|
17
|
+
new(...).call
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(element)
|
21
|
+
@element = element
|
22
|
+
end
|
23
|
+
|
24
|
+
def call
|
25
|
+
XPATHS
|
26
|
+
.transform_values { |xpath| value(xpath) }
|
27
|
+
.merge(components: components, reports: reports)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :element
|
33
|
+
|
34
|
+
def components
|
35
|
+
element
|
36
|
+
.xpath(".//caldav:supported-calendar-component-set/caldav:comp")
|
37
|
+
.collect { |node| node["name"] }
|
38
|
+
end
|
39
|
+
|
40
|
+
def reports
|
41
|
+
element
|
42
|
+
.xpath("//dav:supported-report-set/dav:supported-report/dav:report/*")
|
43
|
+
.collect(&:name)
|
44
|
+
end
|
45
|
+
|
46
|
+
def value(xpath)
|
47
|
+
node = element.xpath(xpath)
|
48
|
+
return nil if node.children.empty?
|
49
|
+
|
50
|
+
if node.children.any?(&:element?)
|
51
|
+
node.children.select(&:element?).collect(&:to_xml).join
|
52
|
+
else
|
53
|
+
node.children.text
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Calendav
|
4
|
+
module Parsers
|
5
|
+
class EventXML
|
6
|
+
def self.call(...)
|
7
|
+
new(...).call
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(element)
|
11
|
+
@element = element
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
{
|
16
|
+
calendar_data: value(".//caldav:calendar-data"),
|
17
|
+
etag: value(".//dav:getetag")
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
attr_reader :element
|
24
|
+
|
25
|
+
def value(xpath)
|
26
|
+
node = element.xpath(xpath)
|
27
|
+
return nil if node.children.empty?
|
28
|
+
|
29
|
+
if node.children.any?(&:element?)
|
30
|
+
node.children.select(&:element?).collect(&:to_xml).join
|
31
|
+
else
|
32
|
+
node.children.text
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../errors"
|
4
|
+
require_relative "../multi_response"
|
5
|
+
require_relative "../namespaces"
|
6
|
+
|
7
|
+
module Calendav
|
8
|
+
module Parsers
|
9
|
+
class ResponseXML
|
10
|
+
def self.call(...)
|
11
|
+
new(...).call
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(raw, namespaces = NAMESPACES)
|
15
|
+
@raw = raw
|
16
|
+
@namespaces = namespaces
|
17
|
+
end
|
18
|
+
|
19
|
+
def call
|
20
|
+
return document if document.xpath("/dav:multistatus").empty?
|
21
|
+
|
22
|
+
MultiResponse.new(document)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :raw, :namespaces
|
28
|
+
|
29
|
+
def document
|
30
|
+
@document ||= begin
|
31
|
+
initial = parse(raw)
|
32
|
+
|
33
|
+
namespaces.each do |key, value|
|
34
|
+
initial.root[key] = value unless initial.namespaces[key]
|
35
|
+
end
|
36
|
+
|
37
|
+
parse(initial.to_xml)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def parse(string)
|
42
|
+
Nokogiri::XML(string) { |config| config.strict.noblanks }
|
43
|
+
rescue Nokogiri::XML::SyntaxError => error
|
44
|
+
raise ParsingXMLError.new(string, error)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|