Class: Async::Caldav::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/async/caldav/client.rb,
lib/async/caldav/client/calendar.rb,
lib/async/caldav/client/addressbook.rb

Defined Under Namespace

Classes: Addressbook, Calendar, Conflict, Error, InvalidSyncToken, NotFound, PreconditionFailed, Unauthorized

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url, user:, password: nil, headers: {}) ⇒ Client

Returns a new instance of Client.



25
26
27
28
29
30
31
# File 'lib/async/caldav/client.rb', line 25

def initialize(base_url, user:, password: nil, headers: {})
  @uri = URI.parse(base_url)
  @user = user
  @password = password
  @extra_headers = headers
  @http = nil
end

Instance Attribute Details

#userObject (readonly)

Returns the value of attribute user.



23
24
25
# File 'lib/async/caldav/client.rb', line 23

def user
  @user
end

Class Method Details

.open(base_url, **opts) ⇒ Object



33
34
35
36
37
38
39
40
41
42
# File 'lib/async/caldav/client.rb', line 33

def self.open(base_url, **opts)
  client = new(base_url, **opts)
  return client unless block_given?

  begin
    yield client
  ensure
    client.close
  end
end

Instance Method Details

#addressbook(name) ⇒ Object



77
78
79
# File 'lib/async/caldav/client.rb', line 77

def addressbook(name)
  Addressbook.new(self, "/addressbooks/#{@user}/#{name}/")
end

#addressbooksObject

Raises:



65
66
67
68
69
70
71
# File 'lib/async/caldav/client.rb', line 65

def addressbooks
  path = "/addressbooks/#{@user}/"
  status, _, body = request('PROPFIND', path, headers: { 'Depth' => '1' })
  raise Error, "PROPFIND failed: #{status}" unless status == 207

  parse_collections(body, path, type: :addressbook)
end

#calendar(name) ⇒ Object



73
74
75
# File 'lib/async/caldav/client.rb', line 73

def calendar(name)
  Calendar.new(self, "/calendars/#{@user}/#{name}/")
end

#calendarsObject

Raises:



57
58
59
60
61
62
63
# File 'lib/async/caldav/client.rb', line 57

def calendars
  path = "/calendars/#{@user}/"
  status, _, body = request('PROPFIND', path, headers: { 'Depth' => '1' })
  raise Error, "PROPFIND failed: #{status}" unless status == 207

  parse_collections(body, path, type: :calendar)
end

#closeObject



44
45
46
47
# File 'lib/async/caldav/client.rb', line 44

def close
  @http&.finish if @http&.started?
  @http = nil
end

#create_addressbook(name, displayname: nil) ⇒ Object

Raises:



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/async/caldav/client.rb', line 102

def create_addressbook(name, displayname: nil)
  path = "/addressbooks/#{@user}/#{name}/"
  body = <<~XML
    <?xml version="1.0" encoding="UTF-8"?>
    <d:mkcol xmlns:d="DAV:" xmlns:cr="urn:ietf:params:xml:ns:carddav">
      <d:set><d:prop>
        <d:resourcetype><d:collection/><cr:addressbook/></d:resourcetype>
        <d:displayname>#{Protocol::Caldav::Xml.escape(displayname || name)}</d:displayname>
      </d:prop></d:set>
    </d:mkcol>
  XML

  status, = request('MKCOL', path, body: body, headers: { 'Content-Type' => 'text/xml' })
  raise Error, "MKCOL failed: #{status}" unless status == 201

  Addressbook.new(self, path, displayname: displayname || name)
end

#create_calendar(name, displayname: nil, description: nil, color: nil) ⇒ Object

— Create —

Raises:



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/async/caldav/client.rb', line 83

def create_calendar(name, displayname: nil, description: nil, color: nil)
  path = "/calendars/#{@user}/#{name}/"
  props = []
  props << "<d:displayname>#{Protocol::Caldav::Xml.escape(displayname || name)}</d:displayname>" if displayname || name
  props << "<c:calendar-description>#{Protocol::Caldav::Xml.escape(description)}</c:calendar-description>" if description

  body = <<~XML
    <?xml version="1.0" encoding="UTF-8"?>
    <c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
      <d:set><d:prop>#{props.join}</d:prop></d:set>
    </c:mkcalendar>
  XML

  status, = request('MKCALENDAR', path, body: body, headers: { 'Content-Type' => 'text/xml' })
  raise Error, "MKCALENDAR failed: #{status}" unless status == 201

  Calendar.new(self, path, displayname: displayname || name, description: description, color: color)
end

#parse_collection_props(xml) ⇒ Object



176
177
178
179
180
181
182
183
184
# File 'lib/async/caldav/client.rb', line 176

def parse_collection_props(xml)
  props = {}
  props[:displayname] = Protocol::Caldav::Xml.extract_value(xml, 'displayname')
  props[:description] = Protocol::Caldav::Xml.extract_value(xml, 'calendar-description')
  props[:color] = Protocol::Caldav::Xml.extract_value(xml, 'calendar-color')
  ctag = Protocol::Caldav::Xml.extract_value(xml, 'getctag')
  props[:ctag] = ctag if ctag
  props
end

#parse_multistatus_items(xml, data_tag:) ⇒ Object

— Response parsing (used by Calendar/Addressbook) —



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/async/caldav/client.rb', line 138

def parse_multistatus_items(xml, data_tag:)
  items = []
  xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
    resp = match[0]
    href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
    next unless href

    etag = resp.match(/<[^>]*getetag[^>]*>([^<]+)</)[1]&.strip rescue nil
    data = resp.match(/<[^>]*#{data_tag}[^>]*>(.*?)<\/[^>]*#{data_tag}>/m)
    body = data ? data[1].strip : nil

    # Unescape XML entities in the body
    if body
      body = body.gsub('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>').gsub('&quot;', '"')
    end

    items << { path: href, body: body, etag: etag }
  end
  items
end

#parse_sync_items(xml) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/async/caldav/client.rb', line 159

def parse_sync_items(xml)
  items = []
  xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
    resp = match[0]
    href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
    next unless href

    if resp.include?('404')
      items << { path: href, status: 404 }
    else
      etag = resp.match(/<[^>]*getetag[^>]*>([^<]+)</)[1]&.strip rescue nil
      items << { path: href, status: 200, etag: etag }
    end
  end
  items
end

#principalObject

— Discovery —



51
52
53
54
55
# File 'lib/async/caldav/client.rb', line 51

def principal
  _, _, body = request('PROPFIND', '/', headers: { 'Depth' => '0' })
  match = body.match(/<[^>]*current-user-principal[^>]*>\s*<[^>]*href[^>]*>([^<]+)</)
  match ? match[1].strip : "/#{@user}/"
end

#request(method, path, body: nil, headers: {}) ⇒ Object

— HTTP transport —

Raises:



122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/async/caldav/client.rb', line 122

def request(method, path, body: nil, headers: {})
  http = connect
  req = build_request(method, path, body, headers)
  response = http.request(req)

  status = response.code.to_i
  resp_headers = {}
  response.each_header { |k, v| resp_headers[k] = v }

  raise Unauthorized, "401 Unauthorized" if status == 401

  [status, resp_headers, response.body || '']
end