caldav.rb 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25dd8ca7b31b78c727d868379e569f2a9a1715ca4f00d7378aa0fdba1bf534aa
4
+ data.tar.gz: fbd981192af07191ba341e9087966b509c04976fbb985d8152b0e1a67340198b
5
+ SHA512:
6
+ metadata.gz: 69bb382d65bf98f71c1ccc3fad7b110a034edad6906efab5530b46ff3a4bcd0e78d0852864fadfa5c116c96b89803ed46e79755ba548fb817243b74287cc3e70
7
+ data.tar.gz: e6c8231c951220f020b36d1b6330713fb31ecf5908b26c0e0e1190754f9bbd4159a70d518d39d83526e6e8f1ea33e529debaac8438c1dda6bff12456686202c1
data/CHANGELOG ADDED
@@ -0,0 +1,13 @@
1
+ # CHANGELOG
2
+
3
+ ## 20260620
4
+
5
+ 0.0.0: Initial release.
6
+
7
+ 1. + CalDAV connection class (< WebDAV) providing the four CalDAV-specific protocol verbs: mkcalendar, calendar_query, calendar_multiget, freebusy_query.
8
+ 2. + Net::HTTP::Mkcalendar request class. Not provided by Ruby's standard library. Mirrors webdav's Net::HTTP::Report. The REPORT method (used by calendar_query, calendar_multiget, freebusy_query) is already provided by webdav 0.2.0 and is reused.
9
+ 3. + CalDAV::MultiStatus (< WebDAV::MultiStatus): type-preserving subclass. Overrides the private WebDAV::MultiStatus#parse_response to wrap each resource in a CalDAV::Resource. Interim coupling to a private webdav method; dissolves when webdav objectifies resources at the factoring stage.
10
+ 4. + CalDAV::Resource: wraps a webdav resource hash and adds CalDAV-namespace navigation accessors: calendar_data, calendar_description, supported_calendar_component_set, calendar_timezone, is_calendar?. Strictly navigation, not parsing — accessors return raw iCalendar strings/values; iCalendar parsing into domain objects is Layer 2.
11
+ 5. + Discovery helpers: CalDAV#current_user_principal, #calendar_home_set, #calendars. Return paths/strings. The object-oriented discovery API is Layer 2 (future release).
12
+ 6. + Layer 1 (Protocol) of the planned three-layer ecosystem. Layer 2 (Objects: Event/Calendar/Principal value objects with iCalendar parsing) and Layer 3 (Queryable: Namo-backed Calendar) are future releases — reserved require paths are caldav.rb/objects and caldav.rb/queryable respectively.
13
+ 7. + Read-only compatibility. Writes (requiring If-Match conditional headers, which webdav's #put does not yet expose), OAuth, sync-collection, and server-quirk handling are deferred.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thoran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # caldav.rb
2
+
3
+ A CalDAV client library for Ruby, built on [webdav](https://github.com/thoran/webdav) and implementing RFC 4791. This release ships **Layer 1 (Protocol)** of a planned three-layer ecosystem.
4
+
5
+
6
+ ## Installation
7
+
8
+ ```
9
+ gem install caldav.rb
10
+ ```
11
+
12
+ Or in your Gemfile:
13
+
14
+ ```ruby
15
+ gem 'caldav.rb'
16
+ ```
17
+
18
+
19
+ ## Concepts
20
+
21
+ caldav.rb is designed in three layers, each a strict superset of the one below. This release ships Layer 1 only; the higher layers are future releases with reserved require paths.
22
+
23
+ - **Layer 1 — Protocol** (this release). The CalDAV verbs, multistatus responses, and namespace-aware navigation accessors. The equivalent of a raw protocol library: it returns CalDAV-typed responses but does not parse iCalendar payloads or construct domain objects. Most users will want Layer 2 once it ships; until then, the protocol layer is usable directly by reading `resource.calendar_data` and parsing it with the `icalendar` gem.
24
+ - **Layer 2 — Objects** (future, `require 'caldav.rb/objects'`). `CalDAV::Event`, `CalDAV::Calendar`, and `CalDAV::Principal` value objects with iCalendar parsing and convenience predicates.
25
+ - **Layer 3 — Queryable** (future, `require 'caldav.rb/queryable'`). A [Namo](https://github.com/thoran/namo)-backed `CalDAV::Query::Calendar` exposing events as queryable rows with derived columns.
26
+
27
+
28
+ ## Usage
29
+
30
+ ```ruby
31
+ require 'caldav'
32
+
33
+ caldav = CalDAV.new('https://caldav.example.com/dav/', username: 'user', password: 'pass')
34
+ ```
35
+
36
+ ### Discovery
37
+
38
+ ```ruby
39
+ principal = caldav.current_user_principal
40
+ home = caldav.calendar_home_set(principal)
41
+ caldav.calendars(home).each{|href| puts href}
42
+ ```
43
+
44
+ Each discovery helper defaults its argument to the result of the previous step, so `caldav.calendars` alone walks principal → home-set → calendars.
45
+
46
+ Discovery begins by PROPFINDing the **path of the base URL**. Many servers do not expose the current-user-principal at `/`, so point the base URL at the server's CalDAV context — e.g. `https://caldav.example.com/dav/` rather than `https://caldav.example.com/`. (RFC 6764 `.well-known/caldav` redirects are not auto-followed for PROPFIND in this release.) You can also override the starting point per call: `caldav.current_user_principal('/dav/')`.
47
+
48
+ ### Querying a calendar
49
+
50
+ ```ruby
51
+ result = caldav.calendar_query('/calendars/user/work/', body: query_xml)
52
+ result.resources.each do |resource|
53
+ puts resource.href
54
+ puts resource.calendar_data # the raw iCalendar string
55
+ end
56
+ ```
57
+
58
+ ### Fetching specific events
59
+
60
+ ```ruby
61
+ result = caldav.calendar_multiget('/calendars/user/work/', body: multiget_xml)
62
+ ```
63
+
64
+ ### Free/busy
65
+
66
+ ```ruby
67
+ response = caldav.freebusy_query('/calendars/user/work/', body: freebusy_xml)
68
+ puts response.body # VFREEBUSY iCalendar data — not a multistatus
69
+ ```
70
+
71
+ ### Creating a calendar
72
+
73
+ ```ruby
74
+ caldav.mkcalendar('/calendars/user/new/', body: mkcalendar_xml)
75
+ ```
76
+
77
+
78
+ ## Methods
79
+
80
+ ### Protocol verbs (RFC 4791)
81
+
82
+ - `mkcalendar(path, body:)` — §5.3.1. Create a calendar collection. Returns a `WebDAV::Response`.
83
+ - `calendar_query(path, body:, depth:)` — §7.8. REPORT with a `<c:calendar-query>` body. Returns a `CalDAV::MultiStatus`.
84
+ - `calendar_multiget(path, body:, depth:)` — §7.9. REPORT with a `<c:calendar-multiget>` body. Returns a `CalDAV::MultiStatus`.
85
+ - `freebusy_query(path, body:, depth:)` — §7.10. REPORT with a `<c:free-busy-query>` body. Returns a raw `WebDAV::Response` carrying VFREEBUSY iCalendar data — **not** a multistatus. This asymmetry is inherent to the CalDAV spec.
86
+
87
+ ### Discovery
88
+
89
+ - `current_user_principal(path = base URL path)` — returns the principal URL string. PROPFINDs the base URL's path by default; pass a path to start discovery elsewhere.
90
+ - `calendar_home_set(principal = current_user_principal)` — returns the calendar-home-set URL string.
91
+ - `calendars(home = calendar_home_set)` — returns an array of calendar collection URL strings.
92
+
93
+
94
+ ## Responses
95
+
96
+ The REPORT verbs return `CalDAV::MultiStatus`, a type-preserving subclass of `WebDAV::MultiStatus` whose `resources` are `CalDAV::Resource` objects. Each `CalDAV::Resource` adds CalDAV-namespace navigation accessors over the underlying webdav resource:
97
+
98
+ - `href` — the resource URL
99
+ - `calendar_data` — the iCalendar string from `<c:calendar-data>`
100
+ - `calendar_description` — `<c:calendar-description>`
101
+ - `supported_calendar_component_set` — `<c:supported-calendar-component-set>`
102
+ - `calendar_timezone` — the VTIMEZONE string from `<c:calendar-timezone>`
103
+ - `is_calendar?` — true when `<d:resourcetype>` includes `<c:calendar/>`
104
+
105
+ These are strictly navigation: they return raw strings and values, never parsed iCalendar objects. Parsing is Layer 2's job.
106
+
107
+
108
+ ## Limitations
109
+
110
+ This is a Layer 1 protocol release. Known boundaries:
111
+
112
+ - **Read-only.** Conditional writes need `If-Match`, which webdav's `put` does not yet expose; writes arrive with Layer 2.
113
+ - **No iCalendar parsing.** Use the `icalendar` gem on `resource.calendar_data` if you need parsed events now.
114
+ - **Basic auth only.** No OAuth.
115
+ - **Tested against one real CalDAV server.** Other servers should work but are unverified.
116
+ - **No sync-collection.** Incremental sync is deferred.
117
+
118
+
119
+ ## Dependencies
120
+
121
+ - [webdav](https://github.com/thoran/webdav)
122
+
123
+
124
+ ## Testing
125
+
126
+ ```
127
+ rake
128
+ ```
129
+
130
+ Unit tests stub at the request boundary and need no network. A separate set of
131
+ integration tests (`test/integration_test.rb`) run against a real CalDAV server
132
+ via [VCR](https://github.com/vcr/vcr): they record real interactions into
133
+ host- and credential-scrubbed cassettes under `test/cassettes/` on first run, then
134
+ replay offline. Without a cassette and without credentials they skip, so the
135
+ default suite stays green.
136
+
137
+ To record against a live account, supply the server and credentials through the
138
+ environment and run `rake`:
139
+
140
+ ```
141
+ CALDAV_URL='https://your-caldav-host/' \
142
+ CALDAV_USERNAME='you@example.com' \
143
+ CALDAV_PASSWORD='app-password' \
144
+ rake
145
+ ```
146
+
147
+ See [test/cassettes/README.md](test/cassettes/README.md) for details.
148
+
149
+
150
+ ## Contributing
151
+
152
+ 1. Fork it [https://github.com/thoran/caldav.rb/fork](https://github.com/thoran/caldav.rb/fork)
153
+ 2. Create your feature branch (git checkout -b my-new-feature)
154
+ 3. Commit your changes (git commit -am 'Add some feature')
155
+ 4. Push to the branch (git push origin my-new-feature)
156
+ 5. Create a new pull request
157
+
158
+
159
+ ## Licence
160
+
161
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # Rakefile
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.test_files = FileList['test/**/*_test.rb']
7
+ end
8
+
9
+ task default: :test
data/caldav.rb.gemspec ADDED
@@ -0,0 +1,50 @@
1
+ require_relative './lib/CalDAV/VERSION'
2
+
3
+ class Gem::Specification
4
+ def dependencies=(gems)
5
+ gems.each{|gem| add_dependency(*gem)}
6
+ end
7
+
8
+ def development_dependencies=(gems)
9
+ gems.each{|gem| add_development_dependency(*gem)}
10
+ end
11
+ end
12
+
13
+ Gem::Specification.new do |spec|
14
+ spec.name = 'caldav.rb'
15
+ spec.version = CalDAV::VERSION
16
+
17
+ spec.summary = "A Ruby CalDAV client library."
18
+ spec.description = "A Ruby CalDAV client library, built on webdav (RFC 4791)."
19
+
20
+ spec.author = 'thoran'
21
+ spec.email = 'code@thoran.com'
22
+ spec.homepage = 'https://github.com/thoran/caldav'
23
+ spec.license = 'MIT'
24
+
25
+ spec.required_ruby_version = '>= 3.2'
26
+ spec.require_paths = ['lib']
27
+
28
+ spec.files = [
29
+ 'caldav.rb.gemspec',
30
+ 'CHANGELOG',
31
+ 'Gemfile',
32
+ 'LICENSE',
33
+ 'Rakefile',
34
+ 'README.md',
35
+ Dir['lib/**/*.rb'],
36
+ Dir['test/**/*.rb']
37
+ ].flatten
38
+
39
+ spec.dependencies = [
40
+ ['webdav', '~> 0.2']
41
+ ]
42
+
43
+ spec.development_dependencies = [
44
+ ['minitest', '~> 6.0'],
45
+ ['minitest-mock'],
46
+ ['rake'],
47
+ ['vcr', '~> 6.0'],
48
+ ['webmock', '~> 3.0']
49
+ ]
50
+ end
@@ -0,0 +1,33 @@
1
+ # CalDAV/MultiStatus.rb
2
+ # CalDAV::MultiStatus
3
+
4
+ require 'webdav'
5
+
6
+ require_relative './Resource'
7
+
8
+ class CalDAV < WebDAV
9
+ class MultiStatus < WebDAV::MultiStatus
10
+
11
+ private
12
+
13
+ def parse_response(response_element)
14
+ CalDAV::Resource.new(super)
15
+ end
16
+
17
+ # Interim: webdav 0.2.0's parse_properties does `text || to_s`, so a nested
18
+ # property (resourcetype, current-user-principal, calendar-home-set) whose
19
+ # server pretty-prints the XML yields the inter-tag whitespace instead of the
20
+ # markup, dropping the nested elements. Treat blank text as absent so such
21
+ # properties fall through to their serialised form, as they do for compact
22
+ # servers. Dissolves when webdav is reworked.
23
+ def parse_properties(prop_element)
24
+ return {} unless prop_element
25
+ prop_element.elements.to_a.each_with_object({}) do |property_element, result|
26
+ result[property_element.namespace] ||= {}
27
+ text = property_element.text
28
+ value = text && !text.strip.empty? ? text : property_element.to_s
29
+ result[property_element.namespace][property_element.name] = value
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,68 @@
1
+ # CalDAV/Resource.rb
2
+ # CalDAV::Resource
3
+
4
+ require 'webdav'
5
+
6
+ class CalDAV < WebDAV
7
+ class Resource
8
+ attr_reader :hash
9
+
10
+ def href
11
+ hash[:href]
12
+ end
13
+
14
+ def status
15
+ hash[:status]
16
+ end
17
+
18
+ def propstats
19
+ hash[:propstats]
20
+ end
21
+
22
+ # Navigation accessors. Strictly navigation, not parsing: they return the
23
+ # underlying iCalendar string or value.
24
+
25
+ def calendar_data
26
+ property(NAMESPACE, 'calendar-data')
27
+ end
28
+
29
+ def calendar_description
30
+ property(NAMESPACE, 'calendar-description')
31
+ end
32
+
33
+ def supported_calendar_component_set
34
+ property(NAMESPACE, 'supported-calendar-component-set')
35
+ end
36
+
37
+ def calendar_timezone
38
+ property(NAMESPACE, 'calendar-timezone')
39
+ end
40
+
41
+ # Matches a <calendar> element (under any namespace prefix) in the serialised
42
+ # resourcetype, rather than a bare 'calendar' substring: the substring also
43
+ # matches calendar-proxy-read/write collections, which are not calendars.
44
+ def is_calendar?
45
+ resource_type.match?(%r{<(?:\w+:)?calendar(?:\s[^>]*)?/?>})
46
+ end
47
+
48
+ private
49
+
50
+ def initialize(hash)
51
+ @hash = hash
52
+ end
53
+
54
+ def properties
55
+ @properties ||= propstats.each_with_object({}) do |propstat, result|
56
+ result.merge!(propstat[:properties])
57
+ end
58
+ end
59
+
60
+ def property(namespace, name)
61
+ properties.dig(namespace, name)
62
+ end
63
+
64
+ def resource_type
65
+ property(DAV_NAMESPACE, 'resourcetype').to_s
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,8 @@
1
+ # CalDAV/VERSION.rb
2
+ # CalDAV::VERSION
3
+
4
+ class WebDAV; end
5
+
6
+ class CalDAV < WebDAV
7
+ VERSION = '0.0.0'
8
+ end
data/lib/CalDAV.rb ADDED
@@ -0,0 +1,135 @@
1
+ # CalDAV.rb
2
+ # CalDAV
3
+
4
+ require 'webdav'
5
+
6
+ require_relative './CalDAV/Resource'
7
+ require_relative './CalDAV/MultiStatus'
8
+ require_relative './Net/HTTP/Mkcalendar'
9
+
10
+ class CalDAV < WebDAV
11
+ NAMESPACE = 'urn:ietf:params:xml:ns:caldav'
12
+ DAV_NAMESPACE = 'DAV:'
13
+
14
+ # Protocol verbs (RFC 4791)
15
+
16
+ # RFC 4791 §5.3.1. Creates a calendar collection.
17
+ def mkcalendar(path, body: nil)
18
+ response = request(:mkcalendar, path, body: body)
19
+ handle_response(response)
20
+ end
21
+
22
+ # RFC 4791 §7.8. REPORT with a <c:calendar-query> body.
23
+ def calendar_query(path, body:, depth: '1')
24
+ report_as_caldav(path, body: body, depth: depth)
25
+ end
26
+
27
+ # RFC 4791 §7.9. REPORT with a <c:calendar-multiget> body.
28
+ def calendar_multiget(path, body:, depth: '0')
29
+ report_as_caldav(path, body: body, depth: depth)
30
+ end
31
+
32
+ # RFC 4791 §7.10. REPORT with a <c:free-busy-query> body. Asymmetric: returns
33
+ # the raw response carrying iCalendar VFREEBUSY data, not a multistatus.
34
+ def freebusy_query(path, body:, depth: '0')
35
+ response = request(:report, path, body: body, headers: {'Depth' => depth})
36
+ handle_response(response)
37
+ end
38
+
39
+ # Discovery helpers. Return paths/strings. And object-oriented discovery API
40
+ # (Principal/Calendar objects) is is planned.
41
+
42
+ def current_user_principal(path = discovery_path)
43
+ resource = propfind_as_caldav(path, body: current_user_principal_body, depth: '0').resources.first
44
+ resource&.then{|r| href_property(r, DAV_NAMESPACE, 'current-user-principal')}
45
+ end
46
+
47
+ def calendar_home_set(principal = current_user_principal)
48
+ resource = propfind_as_caldav(principal, body: calendar_home_set_body, depth: '0').resources.first
49
+ resource&.then{|r| href_property(r, NAMESPACE, 'calendar-home-set')}
50
+ end
51
+
52
+ def calendars(home = calendar_home_set)
53
+ resources = propfind_as_caldav(home, body: calendars_body, depth: '1').resources
54
+ resources.select{|resource| resource.is_calendar?}.collect{|resource| resource.href}
55
+ end
56
+
57
+ private
58
+
59
+ # Send a REPORT and return the CalDAV-typed multistatus rather than the plain
60
+ # WebDAV one. Constructed from the raw response so the CalDAV resource wrappers
61
+ # are produced by CalDAV::MultiStatus's overridden parse_response.
62
+ def report_as_caldav(path, body:, depth:)
63
+ response = request(:report, path, body: body, headers: {'Depth' => depth})
64
+ raise WebDAV::Error.new(response) if response.code.to_i >= 400
65
+ CalDAV::MultiStatus.new(response)
66
+ end
67
+
68
+ # As report_as_caldav, but for PROPFIND. Needed because the inherited #propfind
69
+ # returns a plain WebDAV::MultiStatus whose resources are bare hashes, not
70
+ # CalDAV::Resource wrappers. Discovery relies on the wrappers (#is_calendar?,
71
+ # #href), so it routes through here.
72
+ def propfind_as_caldav(path, body:, depth:)
73
+ response = request(:propfind, path, body: body, headers: {'Depth' => depth})
74
+ raise WebDAV::Error.new(response) if response.code.to_i >= 400
75
+ CalDAV::MultiStatus.new(response)
76
+ end
77
+
78
+ # The discovery properties (current-user-principal, calendar-home-set) wrap
79
+ # their value in a nested <d:href>. webdav 0.2.0 renders a nested-element
80
+ # property as serialised markup, so the bare URL is lifted back out here.
81
+ # Caveat: when a server pretty-prints, webdav's `text || to_s` returns the
82
+ # leading whitespace text node and the href is lost upstream; recovering that
83
+ # waits for the webdav-objectifies-resources rework.
84
+ def href_property(resource, namespace, name)
85
+ resource.propstats.each do |propstat|
86
+ href = extract_href(propstat[:properties].dig(namespace, name))
87
+ return href if href
88
+ end
89
+ nil
90
+ end
91
+
92
+ def extract_href(value)
93
+ return nil unless value
94
+ match = value.match(%r{<(?:\w+:)?href[^>]*>(.*?)</(?:\w+:)?href>}m)
95
+ href = (match ? match[1] : value).strip
96
+ href.empty? ? nil : href
97
+ end
98
+
99
+ # Discovery starts from the path of the configured base URL, so a server that
100
+ # does not expose the current-user-principal at '/' is reached by giving its
101
+ # CalDAV context path in the base URL (e.g. https://host/dav/). Falls back to
102
+ # '/' when the base URL has no path.
103
+ def discovery_path
104
+ @uri.path.empty? ? '/' : @uri.path
105
+ end
106
+
107
+ def current_user_principal_body
108
+ <<~XML
109
+ <?xml version="1.0" encoding="UTF-8"?>
110
+ <d:propfind xmlns:d="DAV:">
111
+ <d:prop><d:current-user-principal/></d:prop>
112
+ </d:propfind>
113
+ XML
114
+ end
115
+
116
+ def calendar_home_set_body
117
+ <<~XML
118
+ <?xml version="1.0" encoding="UTF-8"?>
119
+ <d:propfind xmlns:d="DAV:" xmlns:c="#{NAMESPACE}">
120
+ <d:prop><c:calendar-home-set/></d:prop>
121
+ </d:propfind>
122
+ XML
123
+ end
124
+
125
+ def calendars_body
126
+ <<~XML
127
+ <?xml version="1.0" encoding="UTF-8"?>
128
+ <d:propfind xmlns:d="DAV:">
129
+ <d:prop>
130
+ <d:resourcetype/>
131
+ </d:prop>
132
+ </d:propfind>
133
+ XML
134
+ end
135
+ end
@@ -0,0 +1,16 @@
1
+ # Net/HTTP/Mkcalendar.rb
2
+ # Net::HTTP::Mkcalendar
3
+
4
+ require 'net/http'
5
+
6
+ module Net
7
+ class HTTP
8
+ class Mkcalendar < Net::HTTPRequest
9
+
10
+ METHOD = 'MKCALENDAR'
11
+ REQUEST_HAS_BODY = true
12
+ RESPONSE_HAS_BODY = true
13
+
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,96 @@
1
+ # test/CalDAV/Resource_test.rb
2
+
3
+ require_relative '../helper'
4
+
5
+ describe CalDAV::Resource do
6
+ let(:calendar_xml) do
7
+ <<~XML
8
+ <?xml version="1.0" encoding="UTF-8"?>
9
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
10
+ <d:response>
11
+ <d:href>/calendars/user/work/</d:href>
12
+ <d:propstat>
13
+ <d:prop>
14
+ <d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
15
+ <c:calendar-description>Work calendar</c:calendar-description>
16
+ </d:prop>
17
+ <d:status>HTTP/1.1 200 OK</d:status>
18
+ </d:propstat>
19
+ </d:response>
20
+ <d:response>
21
+ <d:href>/calendars/user/work/event.ics</d:href>
22
+ <d:propstat>
23
+ <d:prop>
24
+ <d:getetag>"abc123"</d:getetag>
25
+ <c:calendar-data>BEGIN:VCALENDAR</c:calendar-data>
26
+ </d:prop>
27
+ <d:status>HTTP/1.1 200 OK</d:status>
28
+ </d:propstat>
29
+ </d:response>
30
+ <d:response>
31
+ <d:href>/calendars/user/calendar-proxy-read/</d:href>
32
+ <d:propstat>
33
+ <d:prop>
34
+ <d:resourcetype><d:collection/><cs:calendar-proxy-read xmlns:cs="http://calendarserver.org/ns/"/></d:resourcetype>
35
+ </d:prop>
36
+ <d:status>HTTP/1.1 200 OK</d:status>
37
+ </d:propstat>
38
+ </d:response>
39
+ </d:multistatus>
40
+ XML
41
+ end
42
+
43
+ let(:multistatus){CalDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: calendar_xml))}
44
+ let(:calendar_resource){multistatus.resources[0]}
45
+ let(:event_resource){multistatus.resources[1]}
46
+ let(:proxy_resource){multistatus.resources[2]}
47
+
48
+ it "wraps resources as CalDAV::Resource" do
49
+ _(calendar_resource).must_be_kind_of CalDAV::Resource
50
+ end
51
+
52
+ it "exposes href" do
53
+ _(calendar_resource.href).must_equal '/calendars/user/work/'
54
+ end
55
+
56
+ it "reads calendar-description" do
57
+ _(calendar_resource.calendar_description).must_equal 'Work calendar'
58
+ end
59
+
60
+ it "reads calendar-data" do
61
+ _(event_resource.calendar_data).must_equal 'BEGIN:VCALENDAR'
62
+ end
63
+
64
+ it "identifies a calendar collection" do
65
+ _(calendar_resource.is_calendar?).must_equal true
66
+ end
67
+
68
+ it "identifies a non-calendar resource" do
69
+ _(event_resource.is_calendar?).must_equal false
70
+ end
71
+
72
+ it "does not mistake a calendar-proxy collection for a calendar" do
73
+ _(proxy_resource.is_calendar?).must_equal false
74
+ end
75
+
76
+ it "identifies a calendar from a pretty-printed (indented) resourcetype" do
77
+ pretty = CalDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML))
78
+ <?xml version="1.0" encoding="utf-8"?>
79
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
80
+ <d:response>
81
+ <d:href>/dav/calendars/user/x/work/</d:href>
82
+ <d:propstat>
83
+ <d:prop>
84
+ <d:resourcetype>
85
+ <d:collection/>
86
+ <c:calendar/>
87
+ </d:resourcetype>
88
+ </d:prop>
89
+ <d:status>HTTP/1.1 200 OK</d:status>
90
+ </d:propstat>
91
+ </d:response>
92
+ </d:multistatus>
93
+ XML
94
+ _(pretty.resources.first.is_calendar?).must_equal true
95
+ end
96
+ end
@@ -0,0 +1,182 @@
1
+ # test/caldav_test.rb
2
+
3
+ require_relative './helper'
4
+
5
+ describe CalDAV do
6
+ let(:base_uri){'https://caldav.example.com/'}
7
+ let(:caldav){CalDAV.new(base_uri, username: 'user', password: 'pass')}
8
+
9
+ let(:multistatus_response) do
10
+ MockResponse.new(
11
+ code: '207',
12
+ message: 'Multi-Status',
13
+ body: <<~XML,
14
+ <?xml version="1.0" encoding="UTF-8"?>
15
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
16
+ <d:response>
17
+ <d:href>/calendars/user/work/event.ics</d:href>
18
+ <d:propstat>
19
+ <d:prop>
20
+ <d:getetag>"abc123"</d:getetag>
21
+ <c:calendar-data>BEGIN:VCALENDAR</c:calendar-data>
22
+ </d:prop>
23
+ <d:status>HTTP/1.1 200 OK</d:status>
24
+ </d:propstat>
25
+ </d:response>
26
+ </d:multistatus>
27
+ XML
28
+ )
29
+ end
30
+
31
+ let(:created_response){MockResponse.new(code: '201', message: 'Created', body: '')}
32
+
33
+ describe "#mkcalendar" do
34
+ it "returns a Response" do
35
+ caldav.stub(:request, created_response) do
36
+ result = caldav.mkcalendar('/calendars/user/new/')
37
+ _(result).must_be_kind_of WebDAV::Response
38
+ _(result.code).must_equal 201
39
+ end
40
+ end
41
+ end
42
+
43
+ describe "#calendar_query" do
44
+ it "returns a CalDAV::MultiStatus" do
45
+ caldav.stub(:request, multistatus_response) do
46
+ result = caldav.calendar_query('/calendars/user/work/', body: '<c:calendar-query/>')
47
+ _(result).must_be_kind_of CalDAV::MultiStatus
48
+ _(result.resources.first).must_be_kind_of CalDAV::Resource
49
+ end
50
+ end
51
+ end
52
+
53
+ describe "#calendar_multiget" do
54
+ it "returns a CalDAV::MultiStatus" do
55
+ caldav.stub(:request, multistatus_response) do
56
+ result = caldav.calendar_multiget('/calendars/user/work/', body: '<c:calendar-multiget/>')
57
+ _(result).must_be_kind_of CalDAV::MultiStatus
58
+ end
59
+ end
60
+ end
61
+
62
+ describe "#freebusy_query" do
63
+ it "returns a raw Response carrying VFREEBUSY" do
64
+ freebusy = MockResponse.new(code: '200', message: 'OK', body: 'BEGIN:VCALENDAR')
65
+ caldav.stub(:request, freebusy) do
66
+ result = caldav.freebusy_query('/calendars/user/work/', body: '<c:free-busy-query/>')
67
+ _(result).must_be_kind_of WebDAV::Response
68
+ _(result.body).must_equal 'BEGIN:VCALENDAR'
69
+ end
70
+ end
71
+ end
72
+
73
+ describe "#current_user_principal" do
74
+ let(:response) do
75
+ MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
76
+ <?xml version="1.0" encoding="UTF-8"?>
77
+ <d:multistatus xmlns:d="DAV:">
78
+ <d:response>
79
+ <d:href>/</d:href>
80
+ <d:propstat>
81
+ <d:prop><d:current-user-principal><d:href>/dav/principals/user/abc/</d:href></d:current-user-principal></d:prop>
82
+ <d:status>HTTP/1.1 200 OK</d:status>
83
+ </d:propstat>
84
+ </d:response>
85
+ </d:multistatus>
86
+ XML
87
+ end
88
+
89
+ it "returns the principal href as a bare URL string" do
90
+ caldav.stub(:request, response) do
91
+ _(caldav.current_user_principal).must_equal '/dav/principals/user/abc/'
92
+ end
93
+ end
94
+
95
+ it "PROPFINDs the base URL's path by default, not '/'" do
96
+ dav = CalDAV.new('https://caldav.example.com/dav/', username: 'u', password: 'p')
97
+ captured = nil
98
+ responder = ->(verb, path, **kw){captured = path; response}
99
+ dav.stub(:request, responder) do
100
+ dav.current_user_principal
101
+ end
102
+ _(captured).must_equal '/dav/'
103
+ end
104
+
105
+ it "extracts the principal from a pretty-printed (indented) response" do
106
+ pretty = MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
107
+ <?xml version="1.0" encoding="utf-8"?>
108
+ <d:multistatus xmlns:d="DAV:">
109
+ <d:response>
110
+ <d:href>/dav/</d:href>
111
+ <d:propstat>
112
+ <d:prop>
113
+ <d:current-user-principal>
114
+ <d:href>/dav/principals/user/abc/</d:href>
115
+ </d:current-user-principal>
116
+ </d:prop>
117
+ <d:status>HTTP/1.1 200 OK</d:status>
118
+ </d:propstat>
119
+ </d:response>
120
+ </d:multistatus>
121
+ XML
122
+ caldav.stub(:request, pretty) do
123
+ _(caldav.current_user_principal).must_equal '/dav/principals/user/abc/'
124
+ end
125
+ end
126
+ end
127
+
128
+ describe "#calendar_home_set" do
129
+ let(:response) do
130
+ MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
131
+ <?xml version="1.0" encoding="UTF-8"?>
132
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
133
+ <d:response>
134
+ <d:href>/dav/principals/user/abc/</d:href>
135
+ <d:propstat>
136
+ <d:prop><c:calendar-home-set><d:href>/dav/calendars/user/abc/</d:href></c:calendar-home-set></d:prop>
137
+ <d:status>HTTP/1.1 200 OK</d:status>
138
+ </d:propstat>
139
+ </d:response>
140
+ </d:multistatus>
141
+ XML
142
+ end
143
+
144
+ it "returns the home-set href as a bare URL string" do
145
+ caldav.stub(:request, response) do
146
+ _(caldav.calendar_home_set('/dav/principals/user/abc/')).must_equal '/dav/calendars/user/abc/'
147
+ end
148
+ end
149
+ end
150
+
151
+ describe "#calendars" do
152
+ let(:response) do
153
+ MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
154
+ <?xml version="1.0" encoding="UTF-8"?>
155
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
156
+ <d:response>
157
+ <d:href>/dav/calendars/user/abc/</d:href>
158
+ <d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
159
+ </d:response>
160
+ <d:response>
161
+ <d:href>/dav/calendars/user/abc/work/</d:href>
162
+ <d:propstat><d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
163
+ </d:response>
164
+ <d:response>
165
+ <d:href>/dav/calendars/user/abc/inbox/</d:href>
166
+ <d:propstat><d:prop><d:resourcetype><d:collection/><c:schedule-inbox/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
167
+ </d:response>
168
+ <d:response>
169
+ <d:href>/dav/calendars/user/abc/proxy/</d:href>
170
+ <d:propstat><d:prop><d:resourcetype><d:collection/><cs:calendar-proxy-read/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
171
+ </d:response>
172
+ </d:multistatus>
173
+ XML
174
+ end
175
+
176
+ it "returns only the calendar collection hrefs as bare URL strings" do
177
+ caldav.stub(:request, response) do
178
+ _(caldav.calendars('/dav/calendars/user/abc/')).must_equal ['/dav/calendars/user/abc/work/']
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,21 @@
1
+ # test/Net/HTTP/Mkcalendar_test.rb
2
+
3
+ require_relative '../../helper'
4
+
5
+ describe Net::HTTP::Mkcalendar do
6
+ it "is a subclass of Net::HTTPRequest" do
7
+ _(Net::HTTP::Mkcalendar < Net::HTTPRequest).must_equal(true)
8
+ end
9
+
10
+ it "has the correct METHOD" do
11
+ _(Net::HTTP::Mkcalendar::METHOD).must_equal('MKCALENDAR')
12
+ end
13
+
14
+ it "accepts a body" do
15
+ _(Net::HTTP::Mkcalendar::REQUEST_HAS_BODY).must_equal(true)
16
+ end
17
+
18
+ it "expects a response body" do
19
+ _(Net::HTTP::Mkcalendar::RESPONSE_HAS_BODY).must_equal(true)
20
+ end
21
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,13 @@
1
+ # test/helper.rb
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest/mock'
5
+ require 'minitest/spec'
6
+
7
+ require_relative '../lib/caldav'
8
+
9
+ MockResponse = Struct.new(:code, :message, :body, :headers_hash, keyword_init: true) do
10
+ def [](key)
11
+ headers_hash&.dig(key)
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ # test/integration_test.rb
2
+
3
+ require_relative './helper'
4
+ require_relative './vcr_helper'
5
+
6
+ describe 'CalDAV integration' do
7
+ let(:caldav){caldav_client}
8
+
9
+ let(:events_query_body) do
10
+ <<~XML
11
+ <?xml version="1.0" encoding="UTF-8"?>
12
+ <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
13
+ <d:prop>
14
+ <d:getetag/>
15
+ <c:calendar-data/>
16
+ </d:prop>
17
+ <c:filter>
18
+ <c:comp-filter name="VCALENDAR">
19
+ <c:comp-filter name="VEVENT"/>
20
+ </c:comp-filter>
21
+ </c:filter>
22
+ </c:calendar-query>
23
+ XML
24
+ end
25
+
26
+ it "discovers principal, home-set and calendars as bare URL strings" do
27
+ with_caldav_cassette('discovery') do
28
+ principal = caldav.current_user_principal
29
+ _(principal).must_be_kind_of String
30
+ _(principal).wont_include '<'
31
+ home = caldav.calendar_home_set(principal)
32
+ _(home).must_be_kind_of String
33
+ _(home).wont_include '<'
34
+ calendars = caldav.calendars(home)
35
+ _(calendars).must_be_kind_of Array
36
+ calendars.each do |href|
37
+ _(href).must_be_kind_of String
38
+ _(href).wont_include '<'
39
+ end
40
+ end
41
+ end
42
+
43
+ it "queries the first calendar and returns CalDAV resources" do
44
+ with_caldav_cassette('calendar_query') do
45
+ calendar = caldav.calendars.first
46
+ skip 'no calendars on this account' unless calendar
47
+ result = caldav.calendar_query(calendar, body: events_query_body)
48
+ _(result).must_be_kind_of CalDAV::MultiStatus
49
+ result.resources.each{|resource| _(resource).must_be_kind_of CalDAV::Resource}
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # test/vcr_helper.rb
2
+
3
+ require 'uri'
4
+ require 'vcr'
5
+
6
+ EXAMPLE_CALDAV_URL = 'https://caldav.example.com/dav/'
7
+ CALDAV_URL = ENV.fetch('CALDAV_URL', EXAMPLE_CALDAV_URL)
8
+
9
+ # Replaces the account-identifier segment in CalDAV collection paths (e.g.
10
+ # /principals/user/<id>/ or /calendars/user/<id>/) with a fixed placeholder, so a
11
+ # committed cassette does not expose an account-specific id. The collection name
12
+ # after the id (a calendar name, say) is preserved. Covers the common
13
+ # collection/realm/id layout; the credential filters still scrub email-style ids.
14
+ ACCOUNT_PATH = %r{(/(?:principals|calendars)/[^/]+/)[^/]+/}
15
+
16
+ def anonymise_account_path(string)
17
+ string&.gsub(ACCOUNT_PATH, '\1anon/')
18
+ end
19
+
20
+ VCR.configure do |config|
21
+ config.cassette_library_dir = File.expand_path('cassettes', __dir__)
22
+ config.hook_into :webmock
23
+ config.filter_sensitive_data(URI(EXAMPLE_CALDAV_URL).host){URI(ENV['CALDAV_URL']).host if ENV['CALDAV_URL']}
24
+ # Basic auth is base64(user:pass), so the credential filters below would not catch it — filter the Authorization header itself too.
25
+ config.filter_sensitive_data('<BASIC_AUTH>'){|interaction| interaction.request.headers['Authorization']&.first}
26
+ config.filter_sensitive_data('<CALDAV_USERNAME>'){ENV['CALDAV_USERNAME']}
27
+ config.filter_sensitive_data('<CALDAV_PASSWORD>'){ENV['CALDAV_PASSWORD']}
28
+ # Anonymise account ids in request URIs and response bodies before writing. The
29
+ # rewrite is self-consistent (the same paths drive the next request), so the
30
+ # anonymised cassette still replays.
31
+ config.before_record do |interaction|
32
+ interaction.request.uri = anonymise_account_path(interaction.request.uri)
33
+ interaction.response.body = anonymise_account_path(interaction.response.body)
34
+ end
35
+ end
36
+
37
+ def caldav_client
38
+ CalDAV.new(
39
+ CALDAV_URL,
40
+ username: ENV.fetch('CALDAV_USERNAME', 'recorded'),
41
+ password: ENV.fetch('CALDAV_PASSWORD', 'recorded')
42
+ )
43
+ end
44
+
45
+ # Records against the real server when credentials are present; replays from the
46
+ # committed cassette otherwise. Skips when a real request would be needed but no
47
+ # credentials are set, so the suite stays green without an account. To re-record,
48
+ # delete the cassette and re-run with credentials.
49
+ def with_caldav_cassette(name)
50
+ cassette = File.join(VCR.configuration.cassette_library_dir, "#{name}.yml")
51
+ unless File.exist?(cassette) || ENV['CALDAV_USERNAME']
52
+ raise Minitest::Skip, "no cassette '#{name}.yml' and CALDAV_USERNAME unset; export CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD to record"
53
+ end
54
+ VCR.use_cassette(name){yield}
55
+ end
@@ -0,0 +1,31 @@
1
+ # test/vcr_helper_test.rb
2
+
3
+ require_relative './helper'
4
+ require_relative './vcr_helper'
5
+
6
+ describe 'anonymise_account_path' do
7
+ it "replaces the id under a principals collection" do
8
+ _(anonymise_account_path('/dav/principals/user/john@example.net/')).must_equal '/dav/principals/user/anon/'
9
+ end
10
+
11
+ it "replaces the id under a calendars collection, keeping the calendar name" do
12
+ _(anonymise_account_path('/dav/calendars/user/abc123/work/')).must_equal '/dav/calendars/user/anon/work/'
13
+ end
14
+
15
+ it "replaces an opaque (non-email) id" do
16
+ _(anonymise_account_path('/dav/principals/user/u9f8e7d6/')).must_equal '/dav/principals/user/anon/'
17
+ end
18
+
19
+ it "anonymises ids inside a serialised response body" do
20
+ body = '<d:href>/dav/calendars/user/abc123/</d:href>'
21
+ _(anonymise_account_path(body)).must_equal '<d:href>/dav/calendars/user/anon/</d:href>'
22
+ end
23
+
24
+ it "leaves unrelated paths untouched" do
25
+ _(anonymise_account_path('/dav/')).must_equal '/dav/'
26
+ end
27
+
28
+ it "handles nil" do
29
+ _(anonymise_account_path(nil)).must_be_nil
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: caldav.rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - thoran
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: webdav
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest-mock
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: vcr
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '6.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '6.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: webmock
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
96
+ description: A Ruby CalDAV client library, built on webdav (RFC 4791).
97
+ email: code@thoran.com
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - CHANGELOG
103
+ - Gemfile
104
+ - LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - caldav.rb.gemspec
108
+ - lib/CalDAV.rb
109
+ - lib/CalDAV/MultiStatus.rb
110
+ - lib/CalDAV/Resource.rb
111
+ - lib/CalDAV/VERSION.rb
112
+ - lib/Net/HTTP/Mkcalendar.rb
113
+ - test/CalDAV/Resource_test.rb
114
+ - test/CalDAV_test.rb
115
+ - test/Net/HTTP/Mkcalendar_test.rb
116
+ - test/helper.rb
117
+ - test/integration_test.rb
118
+ - test/vcr_helper.rb
119
+ - test/vcr_helper_test.rb
120
+ homepage: https://github.com/thoran/caldav
121
+ licenses:
122
+ - MIT
123
+ metadata: {}
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '3.2'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 4.0.14
139
+ specification_version: 4
140
+ summary: A Ruby CalDAV client library.
141
+ test_files: []