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
119
# File 'lib/async/caldav/client.rb', line 102

def create_addressbook(name, displayname: nil)
  path = "/addressbooks/#{@user}/#{name}/"
  x = Builder::XmlMarkup.new
  x.instruct! :xml, version: "1.0", encoding: "UTF-8"
  x.tag!("d:mkcol", "xmlns:d" => "DAV:", "xmlns:cr" => "urn:ietf:params:xml:ns:carddav") do
    x.tag!("d:set") do
      x.tag!("d:prop") do
        x.tag!("d:resourcetype") { x.tag!("d:collection"); x.tag!("cr:addressbook") }
        x.tag!("d:displayname", displayname || name)
      end
    end
  end

  status, = request('MKCOL', path, body: x.target!, 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}/"
  x = Builder::XmlMarkup.new
  x.instruct! :xml, version: "1.0", encoding: "UTF-8"
  x.tag!("c:mkcalendar", "xmlns:d" => "DAV:", "xmlns:c" => "urn:ietf:params:xml:ns:caldav") do
    x.tag!("d:set") do
      x.tag!("d:prop") do
        x.tag!("d:displayname", displayname || name) if displayname || name
        x.tag!("c:calendar-description", description) if description
      end
    end
  end

  status, = request('MKCALENDAR', path, body: x.target!, 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



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

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) —



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

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



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

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:



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

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