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.
@@ -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
- url = merged_url(calendar_url, event_identifier)
17
- result = endpoint.put(ics, url: url, content_type: :ics)
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 list(calendar_url, from:, to:)
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(".//dav:response")
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
- if calendar_url.end_with?("/")
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(credentials, url_or_path)
6
- return credentials.host.to_s if url_or_path.nil? || url_or_path.empty?
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
- credentials.host.dup.tap { |new_url| new_url.path = url_or_path }.to_s
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
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "./contextual_url"
4
- require_relative "./xml_processor"
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).status.success?
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 put(body, url:, content_type: nil)
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
- unless response.status.success?
91
- raise RequestFailedError, response.status.to_s
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
- return response if response.content_length&.zero?
119
+ def parse(response)
120
+ return response if response.content_length&.zero? || response.body.empty?
95
121
 
96
- XMLProcessor.call(response.body)
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
@@ -1,56 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./xml_processor"
3
+ require_relative "./contextual_url"
4
+ require_relative "./parsers/event_xml"
4
5
 
5
6
  module Calendav
6
7
  class Event
7
- attr_reader :path
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
- node.xpath(".//dav:prop/*").to_xml,
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(path, attribute_nodes, namespaces = {})
18
- @path = path
19
- @attribute_nodes = attribute_nodes
20
- @namespaces = namespaces
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
- private
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
- def attribute_value(node)
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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ NAMESPACES = {
5
+ "xmlns:dav" => "DAV:",
6
+ "xmlns:caldav" => "urn:ietf:params:xml:ns:caldav",
7
+ "xmlns:cs" => "http://calendarserver.org/ns/",
8
+ "xmlns:apple" => "http://apple.com/ns/ical/"
9
+ }.freeze
10
+ 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