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.
- 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
|