async-caldav 1.0.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 +7 -0
- data/lib/async/caldav/client/addressbook.rb +117 -0
- data/lib/async/caldav/client/calendar.rb +152 -0
- data/lib/async/caldav/client.rb +580 -0
- data/lib/async/caldav/forward_auth.rb +94 -0
- data/lib/async/caldav/handlers/delete.rb +60 -0
- data/lib/async/caldav/handlers/get.rb +87 -0
- data/lib/async/caldav/handlers/head.rb +36 -0
- data/lib/async/caldav/handlers/mkcol.rb +95 -0
- data/lib/async/caldav/handlers/move.rb +126 -0
- data/lib/async/caldav/handlers/options.rb +34 -0
- data/lib/async/caldav/handlers/propfind.rb +201 -0
- data/lib/async/caldav/handlers/proppatch.rb +121 -0
- data/lib/async/caldav/handlers/put.rb +163 -0
- data/lib/async/caldav/handlers/report.rb +257 -0
- data/lib/async/caldav/server.rb +1152 -0
- data/lib/async/caldav/storage/filesystem.rb +375 -0
- data/lib/async/caldav/storage/mock.rb +402 -0
- data/lib/async/caldav/version.rb +7 -0
- data/lib/async/caldav.rb +24 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a2d260821de7e733cab42004a0f9a719fdbca8acf1071d193c63a3a8d10b90c8
|
|
4
|
+
data.tar.gz: 3218a7bd8fe1631232b2e4b4e07f279781ea643531dae9486db1ee872631c8bd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d2c5cd6e1daaba511dd1932cc9c269317bc2e51cd9d539f144df1f5c8f8b698bfc329fdb5115f09a58ed5f3f1fb85cad2af2f5c99671429d767dc3ac2045d251
|
|
7
|
+
data.tar.gz: 0abf3e7374520f9ee561653157dd8f39f55bd23f6f54ba802aebb44deb9f21bce4638382a90d7470d3911d0f33b502247be18a1dcd41eb73ffbe01b0788a54ee
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Caldav
|
|
8
|
+
class Client
|
|
9
|
+
class Addressbook
|
|
10
|
+
attr_reader :path, :displayname
|
|
11
|
+
|
|
12
|
+
def initialize(client, path, props = {})
|
|
13
|
+
@client = client
|
|
14
|
+
@path = path
|
|
15
|
+
@displayname = props[:displayname]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def contacts(filter: nil)
|
|
19
|
+
body = if filter
|
|
20
|
+
<<~XML
|
|
21
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
22
|
+
<cr:addressbook-query xmlns:d="DAV:" xmlns:cr="urn:ietf:params:xml:ns:carddav">
|
|
23
|
+
<d:prop><d:getetag/><cr:address-data/></d:prop>
|
|
24
|
+
#{filter}
|
|
25
|
+
</cr:addressbook-query>
|
|
26
|
+
XML
|
|
27
|
+
else
|
|
28
|
+
<<~XML
|
|
29
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<cr:addressbook-query xmlns:d="DAV:" xmlns:cr="urn:ietf:params:xml:ns:carddav">
|
|
31
|
+
<d:prop><d:getetag/><cr:address-data/></d:prop>
|
|
32
|
+
</cr:addressbook-query>
|
|
33
|
+
XML
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
status, _, resp_body = @client.request('REPORT', @path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
37
|
+
raise Error, "REPORT failed: #{status}" unless status == 207
|
|
38
|
+
|
|
39
|
+
@client.parse_multistatus_items(resp_body, data_tag: 'address-data')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def put_contact(filename, body, if_match: nil, if_none_match: nil)
|
|
43
|
+
headers = { 'Content-Type' => 'text/vcard' }
|
|
44
|
+
headers['If-Match'] = if_match if if_match
|
|
45
|
+
headers['If-None-Match'] = if_none_match if if_none_match
|
|
46
|
+
|
|
47
|
+
path = "#{@path}#{filename}"
|
|
48
|
+
status, resp_headers, = @client.request('PUT', path, body: body, headers: headers)
|
|
49
|
+
|
|
50
|
+
case status
|
|
51
|
+
when 201, 204
|
|
52
|
+
{ path: path, etag: resp_headers['etag'], status: status }
|
|
53
|
+
when 412
|
|
54
|
+
raise PreconditionFailed, "Precondition failed for #{path}"
|
|
55
|
+
when 409
|
|
56
|
+
raise Conflict, "UID conflict for #{path}"
|
|
57
|
+
else
|
|
58
|
+
raise Error, "PUT failed: #{status}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get_contact(filename)
|
|
63
|
+
path = "#{@path}#{filename}"
|
|
64
|
+
status, headers, body = @client.request('GET', path)
|
|
65
|
+
|
|
66
|
+
case status
|
|
67
|
+
when 200
|
|
68
|
+
{ path: path, body: body, etag: headers['etag'], content_type: headers['content-type'] }
|
|
69
|
+
when 404
|
|
70
|
+
raise NotFound, "Not found: #{path}"
|
|
71
|
+
else
|
|
72
|
+
raise Error, "GET failed: #{status}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_contact(filename)
|
|
77
|
+
path = "#{@path}#{filename}"
|
|
78
|
+
status, = @client.request('DELETE', path)
|
|
79
|
+
|
|
80
|
+
case status
|
|
81
|
+
when 204 then true
|
|
82
|
+
when 404 then raise NotFound, "Not found: #{path}"
|
|
83
|
+
else raise Error, "DELETE failed: #{status}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def delete
|
|
88
|
+
status, = @client.request('DELETE', @path)
|
|
89
|
+
|
|
90
|
+
case status
|
|
91
|
+
when 204 then true
|
|
92
|
+
when 404 then raise NotFound, "Not found: #{@path}"
|
|
93
|
+
else raise Error, "DELETE failed: #{status}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def proppatch(displayname: nil)
|
|
98
|
+
props = []
|
|
99
|
+
props << "<d:displayname>#{Protocol::Caldav::Xml.escape(displayname)}</d:displayname>" if displayname
|
|
100
|
+
|
|
101
|
+
body = <<~XML
|
|
102
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
103
|
+
<d:propertyupdate xmlns:d="DAV:">
|
|
104
|
+
<d:set><d:prop>#{props.join}</d:prop></d:set>
|
|
105
|
+
</d:propertyupdate>
|
|
106
|
+
XML
|
|
107
|
+
|
|
108
|
+
status, = @client.request('PROPPATCH', @path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
109
|
+
raise Error, "PROPPATCH failed: #{status}" unless status == 207
|
|
110
|
+
|
|
111
|
+
@displayname = displayname if displayname
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Caldav
|
|
8
|
+
class Client
|
|
9
|
+
class Calendar
|
|
10
|
+
attr_reader :path, :displayname, :description, :color, :ctag
|
|
11
|
+
|
|
12
|
+
def initialize(client, path, props = {})
|
|
13
|
+
@client = client
|
|
14
|
+
@path = path
|
|
15
|
+
@displayname = props[:displayname]
|
|
16
|
+
@description = props[:description]
|
|
17
|
+
@color = props[:color]
|
|
18
|
+
@ctag = props[:ctag]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def events(filter: nil)
|
|
22
|
+
body = if filter
|
|
23
|
+
<<~XML
|
|
24
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
26
|
+
<d:prop><d:getetag/><c:calendar-data/></d:prop>
|
|
27
|
+
#{filter}
|
|
28
|
+
</c:calendar-query>
|
|
29
|
+
XML
|
|
30
|
+
else
|
|
31
|
+
<<~XML
|
|
32
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
34
|
+
<d:prop><d:getetag/><c:calendar-data/></d:prop>
|
|
35
|
+
</c:calendar-query>
|
|
36
|
+
XML
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
status, _, resp_body = @client.request('REPORT', @path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
40
|
+
raise Error, "REPORT failed: #{status}" unless status == 207
|
|
41
|
+
|
|
42
|
+
@client.parse_multistatus_items(resp_body, data_tag: 'calendar-data')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def put_event(filename, body, if_match: nil, if_none_match: nil)
|
|
46
|
+
headers = { 'Content-Type' => 'text/calendar' }
|
|
47
|
+
headers['If-Match'] = if_match if if_match
|
|
48
|
+
headers['If-None-Match'] = if_none_match if if_none_match
|
|
49
|
+
|
|
50
|
+
path = "#{@path}#{filename}"
|
|
51
|
+
status, resp_headers, = @client.request('PUT', path, body: body, headers: headers)
|
|
52
|
+
|
|
53
|
+
case status
|
|
54
|
+
when 201, 204
|
|
55
|
+
{ path: path, etag: resp_headers['etag'], status: status }
|
|
56
|
+
when 412
|
|
57
|
+
raise PreconditionFailed, "Precondition failed for #{path}"
|
|
58
|
+
when 409
|
|
59
|
+
raise Conflict, "UID conflict for #{path}"
|
|
60
|
+
else
|
|
61
|
+
raise Error, "PUT failed: #{status}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_event(filename)
|
|
66
|
+
path = "#{@path}#{filename}"
|
|
67
|
+
status, headers, body = @client.request('GET', path)
|
|
68
|
+
|
|
69
|
+
case status
|
|
70
|
+
when 200
|
|
71
|
+
{ path: path, body: body, etag: headers['etag'], content_type: headers['content-type'] }
|
|
72
|
+
when 404
|
|
73
|
+
raise NotFound, "Not found: #{path}"
|
|
74
|
+
else
|
|
75
|
+
raise Error, "GET failed: #{status}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def delete_event(filename)
|
|
80
|
+
path = "#{@path}#{filename}"
|
|
81
|
+
status, = @client.request('DELETE', path)
|
|
82
|
+
|
|
83
|
+
case status
|
|
84
|
+
when 204 then true
|
|
85
|
+
when 404 then raise NotFound, "Not found: #{path}"
|
|
86
|
+
else raise Error, "DELETE failed: #{status}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def delete
|
|
91
|
+
status, = @client.request('DELETE', @path)
|
|
92
|
+
|
|
93
|
+
case status
|
|
94
|
+
when 204 then true
|
|
95
|
+
when 404 then raise NotFound, "Not found: #{@path}"
|
|
96
|
+
else raise Error, "DELETE failed: #{status}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def propfind
|
|
101
|
+
status, _, body = @client.request('PROPFIND', @path, headers: { 'Depth' => '0' })
|
|
102
|
+
raise Error, "PROPFIND failed: #{status}" unless status == 207
|
|
103
|
+
@client.parse_collection_props(body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def proppatch(displayname: nil, description: nil, color: nil)
|
|
107
|
+
props = []
|
|
108
|
+
props << "<d:displayname>#{Protocol::Caldav::Xml.escape(displayname)}</d:displayname>" if displayname
|
|
109
|
+
props << "<c:calendar-description>#{Protocol::Caldav::Xml.escape(description)}</c:calendar-description>" if description
|
|
110
|
+
props << "<x:calendar-color>#{Protocol::Caldav::Xml.escape(color)}</x:calendar-color>" if color
|
|
111
|
+
|
|
112
|
+
body = <<~XML
|
|
113
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
114
|
+
<d:propertyupdate xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/">
|
|
115
|
+
<d:set><d:prop>#{props.join}</d:prop></d:set>
|
|
116
|
+
</d:propertyupdate>
|
|
117
|
+
XML
|
|
118
|
+
|
|
119
|
+
status, = @client.request('PROPPATCH', @path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
120
|
+
raise Error, "PROPPATCH failed: #{status}" unless status == 207
|
|
121
|
+
|
|
122
|
+
@displayname = displayname if displayname
|
|
123
|
+
@description = description if description
|
|
124
|
+
@color = color if color
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sync(token: nil)
|
|
129
|
+
token_xml = token ? "<d:sync-token>#{token}</d:sync-token>" : "<d:sync-token/>"
|
|
130
|
+
body = <<~XML
|
|
131
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
132
|
+
<d:sync-collection xmlns:d="DAV:">
|
|
133
|
+
<d:prop><d:getetag/></d:prop>
|
|
134
|
+
#{token_xml}
|
|
135
|
+
</d:sync-collection>
|
|
136
|
+
XML
|
|
137
|
+
|
|
138
|
+
status, _, resp_body = @client.request('REPORT', @path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
139
|
+
|
|
140
|
+
if status == 403
|
|
141
|
+
raise InvalidSyncToken, "Invalid sync token"
|
|
142
|
+
end
|
|
143
|
+
raise Error, "REPORT failed: #{status}" unless status == 207
|
|
144
|
+
|
|
145
|
+
new_token = resp_body.match(/<[^>]*sync-token[^>]*>([^<]+)</)[1] rescue nil
|
|
146
|
+
items = @client.parse_sync_items(resp_body)
|
|
147
|
+
[items, new_token]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|