calendav 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bae509ffc0254b00db9e4380a76278770a0868093ff69b597b6b10fcf9ecd01
4
- data.tar.gz: 26cfe5ccbc19dba770cf2d255cbb36bc3529039903af8d28281536f70cb55c49
3
+ metadata.gz: 5f622fc58cda7b2c94bf7c6eff0056db4532e281bfd1e556a43f16ba8edac222
4
+ data.tar.gz: 075b5acb58221171aec66d8e9d0234410fa3f30cd0c790776ca0f023187f0d27
5
5
  SHA512:
6
- metadata.gz: 56fd5c731ad0d4b05d29a68df6624bad4809a6a146806c8190ba765b233e39598572711ff6f5f61a59df09a8d919090b45641bfbc2214a1ad746f4a7f5bf8662
7
- data.tar.gz: fa897448f63e9c5a04f8c97acebb950d0213fbc8aa10eb8d4069aaaafbe9849148dafe526de9c78a6d947c4a43eaaa78260665dfd05d66b429c21d5e1caf5a7e
6
+ metadata.gz: 596ecccf27b7cd0aeb697b4863f3c10c79e9ec8aaf2ca66786e2f31a94e5cf865a5835a8942a4c0189f7bfe040f5cde540ee7f10a09e0ec3cb693ae152923d09
7
+ data.tar.gz: 0ff81da9e901dfe16081a31a4df72319a6462ef939aa4aa83ebcb74792684e9b26b9999430159300192816e9ce9fdd31edbf04f57c6b0b28aa0cd85e9d9fe726
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 0.5.0 - 2025-08-02
4
+
5
+ * Support for todos (Josh Huckabee #8, #9)
6
+ * Improved documentation for development/testing environments (Josh Huckabee #7)
7
+ * Support for expanded recurring events (Sayooj Surendran #12)
8
+ * Support for OAuth authentication header (Ilya Nikitenkov #13)
9
+ * Updated tested Ruby versions to 3.1-3.4 (2.7 and 3.0 are no longer officially supported).
10
+
11
+ ## 0.4.0 - 2023-02-27
12
+
13
+ * **Breaking**: calls to create/update events return event objects that only have a URL and etag populated (so: no calendar data), or nil if the request failed due to an etag precondition. This is instead of returning true on success or false on failure.
14
+ * Removed test files from the gem/gemspec.
15
+ * Increased nuance of error handler, raising a RedirectError (with location method) if the CalDAV server returns a redirect status.
16
+
3
17
  ## 0.3.0 - 2022-03-14
4
18
 
5
19
  * Add location to event wrapper.
data/README.md CHANGED
@@ -23,7 +23,7 @@ credentials = Calendav::Credentials::Standard.new(
23
23
  host: "https://www.example.com/caldav",
24
24
  username: "example",
25
25
  password: "secret",
26
- authentication: :basic_auth # or :bearer_token
26
+ authentication: :basic_auth # :bearer_token and :oauth also supported
27
27
  )
28
28
  ```
29
29
 
@@ -100,6 +100,16 @@ end
100
100
 
101
101
  The returned events have a unique URL (just like calendars), an `etag` (which changes when the event changes), and `calendar_data`, which is stored in the [iCalendar](https://datatracker.ietf.org/doc/html/rfc5545) format. The event objects returned currently parse the summary out of the calendar data, but nothing else - further attributes may be made visible, but for full control it's recommended you use the [icalendar](https://github.com/icalendar/icalendar) gem.
102
102
 
103
+ To get recurring events, you can use the optional `expand_recurring_events` argument.
104
+
105
+ ```ruby
106
+ events = client.events.list(
107
+ calendar_url, from: Time.new(2021, 1, 1), to: Time.new(2022, 1, 1),
108
+ expand_recurring_events: true
109
+ )
110
+ ```
111
+ The recurrent events will be present in the `calendar_data`.
112
+
103
113
  If you have an event's URL, you can fetch the details of just that event directly:
104
114
 
105
115
  ```ruby
@@ -121,7 +131,8 @@ end
121
131
  ics.publish
122
132
 
123
133
  identifier = "#{SecureRandom.uuid}.ics"
124
- event_url = client.events.create(calendar.url, identifier, ics.to_ical)
134
+ # The returned event has just the URL and the etag, no calendar data:
135
+ event_scaffold = client.events.create(calendar.url, identifier, ics.to_ical)
125
136
  ```
126
137
 
127
138
  *Please note*: some providers (definitely Google, possibly others) will not keep your supplied event identifier, but generate a new one. So, if you need an ongoing reference to the event, save the returned URL (rather than combining the calendar URL and your identifier).
@@ -129,17 +140,17 @@ event_url = client.events.create(calendar.url, identifier, ics.to_ical)
129
140
  Updating events is done in a similar manner - with the event's URL and the updated iCalendar content:
130
141
 
131
142
  ```ruby
132
- client.events.update(event_url, ics.to_ical)
143
+ event_scaffold = client.events.update(event_url, ics.to_ical)
133
144
  ```
134
145
 
135
- To ensure you're only changing a known version of an event, it's recommended you use that version's etag as a precondition check (the update call will return false if the event on the server has a different etag value):
146
+ To ensure you're only changing a known version of an event, it's recommended you use that version's etag as a precondition check (the update call will return nil if the event on the server has a different etag value):
136
147
 
137
148
  ```ruby
138
- event = client.events.find(event_url)
149
+ event = client.events.find(event_scaffold.url)
139
150
 
140
151
  # figure out the changes you want to make, generate the new ical data, and then:
141
152
 
142
- client.events.update(event_url, modified_ical, etag: event.etag)
153
+ client.events.update(event_scaffold.url, modified_ical, etag: event.etag)
143
154
  ```
144
155
 
145
156
  Deletion of events is done via the event's URL as well - some providers (including Apple) support the etag precondition check for this, but others (including Google) do not. In the latter situation, the deletion will happen regardless of the server event's etag value.
@@ -150,6 +161,55 @@ client.events.delete(event_url)
150
161
  client.events.delete(event_url, etag: event.etag)
151
162
  ```
152
163
 
164
+ ### Todos
165
+
166
+ You can also use a calendar's URL to retrieve its todos.
167
+
168
+ ```ruby
169
+ todos = client.todos.list(calendar_url)
170
+ todos.each do |todo|
171
+ puts todo.url
172
+ puts todo.summary
173
+ puts todo.status
174
+ puts todo.due
175
+ end
176
+ ```
177
+
178
+ Just like events, returned todos have a unique URL, an `etag` (which changes when the todo changes), and `calendar_data`. The todo objects returned currently parse the summary, status, and due date out of the calendar data.
179
+
180
+ If you have a todo's URL, you can fetch the details of just that todo directly:
181
+
182
+ ```ruby
183
+ todo = client.todos.find(todo_url)
184
+ ```
185
+
186
+ Creating todos, just like creating events, requires a unique identifier. You will also need to generate the iCalendar data - again, the [icalendar](https://github.com/icalendar/icalendar) gem is very helpful for this.
187
+
188
+ ```ruby
189
+ require "securerandom"
190
+ require "icalendar"
191
+
192
+ ics = Icalendar::Calendar.new
193
+ ics.todo do |todo|
194
+ # ...
195
+ end
196
+ ics.publish
197
+
198
+ identifier = "#{SecureRandom.uuid}.ics"
199
+ # The returned todo has just the URL and the etag, no calendar data:
200
+ todo_scaffold = client.todos.create(calendar.url, identifier, ics.to_ical)
201
+ ```
202
+
203
+ Updating and deleting todos is similar to updating events:
204
+
205
+ ```ruby
206
+ # Update the todo
207
+ client.todos.update(todo_url, ics.to_ical)
208
+
209
+ # Delete the todo
210
+ client.todos.delete(todo_url)
211
+ ```
212
+
153
213
  ### Synchronising
154
214
 
155
215
  If you are maintaining your own copy/history of events from a CalDAV server, then it's highly recommended you take advantage of the WebDAV-Sync protocol (should the calendar provider support it). Using Calendav, the suggested approach is:
@@ -197,7 +257,7 @@ While a lot of the core CalDAV functionality is covered and this gem is useful a
197
257
  * Further iCalendar parsing for Event objects.
198
258
  * Automated tests against FastMail and possibly other providers (Apple and Google are already covered).
199
259
  * Locking/unlocking of events as per the WebCAL RFC.
200
- * Support for VTODO, VJOURNAL, VFREEBUSY and any other components beyond VEVENT.
260
+ * Support for VJOURNAL, VFREEBUSY and any other components beyond VEVENT.
201
261
 
202
262
  ## Installation
203
263
 
@@ -231,11 +291,21 @@ The work done in previous Ruby CalDAV clients [RubyCaldav](https://github.com/di
231
291
 
232
292
  ## Development
233
293
 
234
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
294
+ After checking out the repo, run `bin/setup` to install dependencies and configure the required credentials. This will copy the `.env.example` file to a `.env` file. Make sure to configure the `.env` file with your own Apple and Google credentials.
295
+
296
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
235
297
 
236
298
  ## Tests
237
299
 
238
- The test suite currently only runs against Google and Apple accounts I've created especially for this purpose - and the credentials are not public. I realise this makes contributions more difficult, and I'm open to finding better ways to handle this. I did look into running a CalDAV server within the test suite, but couldn't find anything small and easy enough for that purpose. But also: testing against common CalDAV servers does help to ensure this gem is truly useful (and has knowledge of their idiosyncrasies).
300
+ The test suite depends on a locally running instance of a CalDAV server to work properly. We prefer [Radicale](https://radicale.org/v3.html) for this. You can run it locally with Docker and the provided configuration in `spec/data/radicale` with the following command:
301
+
302
+ docker run -v $PWD/spec/data/radicale:/var/radicale \
303
+ --name radicale \
304
+ --publish 8000:8000 \
305
+ --detach \
306
+ xlrl/radicale
307
+
308
+ With the CalDAV server running, you can now run `rake spec` to run the tests.
239
309
 
240
310
  ## Contributing
241
311
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./contextual_url"
4
- require_relative "./parsers/calendar_xml"
3
+ require_relative "contextual_url"
4
+ require_relative "parsers/calendar_xml"
5
5
 
6
6
  module Calendav
7
7
  class Calendar
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./contextual_url"
4
- require_relative "./endpoint"
5
- require_relative "./clients/calendars_client"
6
- require_relative "./clients/events_client"
7
- require_relative "./requests/current_user_principal"
3
+ require_relative "contextual_url"
4
+ require_relative "endpoint"
5
+ require_relative "clients/calendars_client"
6
+ require_relative "clients/events_client"
7
+ require_relative "clients/todos_client"
8
+ require_relative "requests/current_user_principal"
8
9
 
9
10
  module Calendav
10
11
  class Client
@@ -21,6 +22,10 @@ module Calendav
21
22
  @events = Clients::EventsClient.new(self, endpoint, credentials)
22
23
  end
23
24
 
25
+ def todos
26
+ @todos = Clients::TodosClient.new(self, endpoint, credentials)
27
+ end
28
+
24
29
  def principal_url
25
30
  @principal_url ||= begin
26
31
  request = Requests::CurrentUserPrincipal.call
@@ -82,7 +82,7 @@ module Calendav
82
82
  )
83
83
  end
84
84
 
85
- def update(url, attributes)
85
+ def update(url, attributes) # rubocop:disable Naming/PredicateMethod
86
86
  request = Requests::UpdateCalendar.call(attributes)
87
87
  endpoint
88
88
  .proppatch(request.to_xml, url: url)
@@ -17,7 +17,10 @@ module Calendav
17
17
  event_url = merged_url(calendar_url, event_identifier)
18
18
  result = endpoint.put(ics, url: event_url, content_type: :ics)
19
19
 
20
- result.headers["Location"] || event_url
20
+ Event.new(
21
+ url: result.headers["Location"] || event_url,
22
+ etag: result.headers["ETag"]
23
+ )
21
24
  end
22
25
 
23
26
  def delete(event_url, etag: nil)
@@ -36,8 +39,10 @@ module Calendav
36
39
  )
37
40
  end
38
41
 
39
- def list(calendar_url, from: nil, to: nil)
40
- request = Requests::ListEvents.call(from: from, to: to)
42
+ def list(calendar_url, from: nil, to: nil, expand_recurring_events: false)
43
+ request = Requests::ListEvents.call(
44
+ from: from, to: to, expand_recurring_events: expand_recurring_events
45
+ )
41
46
 
42
47
  endpoint
43
48
  .report(request.to_xml, url: calendar_url, depth: 1)
@@ -46,13 +51,16 @@ module Calendav
46
51
  end
47
52
 
48
53
  def update(event_url, ics, etag: nil)
49
- endpoint.put(
54
+ result = endpoint.put(
50
55
  ics, url: event_url, content_type: :ics, etag: etag
51
56
  )
52
57
 
53
- true
58
+ Event.new(
59
+ url: event_url,
60
+ etag: result.headers["ETag"]
61
+ )
54
62
  rescue PreconditionError
55
- false
63
+ nil
56
64
  end
57
65
 
58
66
  private
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../todo"
5
+ require_relative "../requests/list_todos"
6
+
7
+ module Calendav
8
+ module Clients
9
+ class TodosClient
10
+ def initialize(client, endpoint, credentials)
11
+ @client = client
12
+ @endpoint = endpoint
13
+ @credentials = credentials
14
+ end
15
+
16
+ def create(calendar_url, todo_identifier, ics)
17
+ todo_url = merged_url(calendar_url, todo_identifier)
18
+ result = endpoint.put(ics, url: todo_url, content_type: :ics)
19
+
20
+ Todo.new(
21
+ url: result.headers["Location"] || todo_url,
22
+ etag: result.headers["ETag"]
23
+ )
24
+ end
25
+
26
+ def delete(todo_url, etag: nil)
27
+ endpoint.delete(url: todo_url, etag: etag)
28
+ rescue PreconditionError
29
+ false
30
+ end
31
+
32
+ def find(todo_url)
33
+ response = endpoint.get(url: todo_url)
34
+
35
+ Todo.new(
36
+ url: todo_url,
37
+ calendar_data: response.body.to_s,
38
+ etag: response.headers["ETag"]
39
+ )
40
+ end
41
+
42
+ def list(calendar_url)
43
+ request = Requests::ListTodos.call
44
+
45
+ endpoint
46
+ .report(request.to_xml, url: calendar_url, depth: 1)
47
+ .reject { |node| node.xpath(".//caldav:calendar-data").text.empty? }
48
+ .collect { |node| Todo.from_xml(calendar_url, node) }
49
+ end
50
+
51
+ def update(todo_url, ics, etag: nil)
52
+ result = endpoint.put(
53
+ ics, url: todo_url, content_type: :ics, etag: etag
54
+ )
55
+
56
+ Todo.new(
57
+ url: todo_url,
58
+ etag: result.headers["ETag"]
59
+ )
60
+ rescue PreconditionError
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ attr_reader :client, :endpoint, :credentials
67
+
68
+ def merged_url(calendar_url, todo_identifier)
69
+ "#{calendar_url.delete_suffix('/')}/#{todo_identifier}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -3,7 +3,7 @@
3
3
  require "http"
4
4
  require "uri"
5
5
 
6
- require_relative "./standard"
6
+ require_relative "standard"
7
7
 
8
8
  module Calendav
9
9
  module Credentials
@@ -3,7 +3,7 @@
3
3
  require "http"
4
4
  require "uri"
5
5
 
6
- require_relative "./standard"
6
+ require_relative "standard"
7
7
 
8
8
  module Calendav
9
9
  module Credentials
@@ -3,7 +3,7 @@
3
3
  require "http"
4
4
  require "uri"
5
5
 
6
- require_relative "./standard"
6
+ require_relative "standard"
7
7
 
8
8
  module Calendav
9
9
  module Credentials
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./contextual_url"
4
- require_relative "./errors"
5
- require_relative "./parsers/response_xml"
3
+ require_relative "contextual_url"
4
+ require_relative "error_handler"
5
+ require_relative "parsers/response_xml"
6
6
 
7
7
  module Calendav
8
8
  class Endpoint
@@ -16,7 +16,7 @@ module Calendav
16
16
  @timeout = timeout
17
17
  end
18
18
 
19
- def delete(url:, etag: nil)
19
+ def delete(url:, etag: nil) # rubocop:disable Naming/PredicateMethod
20
20
  request(:delete, url: url, http: with_headers(etag: etag))
21
21
  .status
22
22
  .success?
@@ -79,12 +79,14 @@ module Calendav
79
79
 
80
80
  attr_reader :credentials, :timeout
81
81
 
82
- def authenticated
82
+ def authenticated # rubocop:disable Metrics/MethodLength
83
83
  case credentials.authentication
84
84
  when :basic_auth
85
85
  HTTP.basic_auth(user: credentials.username, pass: credentials.password)
86
86
  when :bearer_token
87
87
  HTTP.auth("Bearer #{credentials.password}")
88
+ when :oauth
89
+ HTTP.auth("OAuth #{credentials.password}")
88
90
  else
89
91
  raise "Unexpected authentication approach: " \
90
92
  "#{credentials.authentication}"
@@ -109,11 +111,7 @@ module Calendav
109
111
  verb, ContextualURL.call(credentials.host, url), body: body
110
112
  )
111
113
 
112
- return response if response.status.success?
113
-
114
- raise PreconditionError, response if response.status.code == 412
115
-
116
- raise RequestError, response
114
+ response.status.success? ? response : ErrorHandler.call(response)
117
115
  end
118
116
 
119
117
  def parse(response)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Calendav
6
+ class ErrorHandler
7
+ def self.call(...)
8
+ new(...).call
9
+ end
10
+
11
+ def initialize(response)
12
+ @response = response
13
+ end
14
+
15
+ def call
16
+ raise PreconditionError, response if status.code == 412
17
+ raise RedirectError, response if status.redirect?
18
+
19
+ raise RequestError, response
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :response
25
+
26
+ def headers
27
+ response.headers
28
+ end
29
+
30
+ def status
31
+ response.status
32
+ end
33
+ end
34
+ end
@@ -7,7 +7,7 @@ module Calendav
7
7
  attr_reader :xml, :original
8
8
 
9
9
  def initialize(xml, original)
10
- super original.message
10
+ super(original.message)
11
11
 
12
12
  @xml = xml
13
13
  @original = original
@@ -18,11 +18,17 @@ module Calendav
18
18
  attr_reader :response
19
19
 
20
20
  def initialize(response)
21
- super response.status.to_s
21
+ super(response.status.to_s)
22
22
 
23
23
  @response = response
24
24
  end
25
25
  end
26
26
 
27
+ class RedirectError < RequestError
28
+ def location
29
+ response.headers["Location"]
30
+ end
31
+ end
32
+
27
33
  PreconditionError = Class.new(RequestError)
28
34
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./contextual_url"
4
- require_relative "./parsers/event_xml"
3
+ require_relative "contextual_url"
4
+ require_relative "parsers/event_xml"
5
5
 
6
6
  module Calendav
7
7
  class Event
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ module Parsers
5
+ class TodoXML
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
@@ -11,9 +11,10 @@ module Calendav
11
11
  new(...).call
12
12
  end
13
13
 
14
- def initialize(from:, to:)
14
+ def initialize(from:, to:, expand_recurring_events:)
15
15
  @from = from
16
16
  @to = to
17
+ @expand_recurring_events = expand_recurring_events
17
18
  end
18
19
 
19
20
  def call
@@ -21,7 +22,13 @@ module Calendav
21
22
  xml["caldav"].public_send("calendar-query", NAMESPACES) do
22
23
  xml["dav"].prop do
23
24
  xml["dav"].getetag
24
- xml["caldav"].public_send(:"calendar-data")
25
+ if expand_recurring_events? && range?
26
+ xml["caldav"].public_send(:"calendar-data") do
27
+ xml["caldav"].expand(start: from, end: to)
28
+ end
29
+ else
30
+ xml["caldav"].public_send(:"calendar-data")
31
+ end
25
32
  end
26
33
  xml["caldav"].filter do
27
34
  xml["caldav"].public_send(:"comp-filter", name: "VCALENDAR") do
@@ -40,6 +47,10 @@ module Calendav
40
47
 
41
48
  private
42
49
 
50
+ def expand_recurring_events?
51
+ @expand_recurring_events
52
+ end
53
+
43
54
  def from
44
55
  return nil if @from.nil?
45
56
 
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ require_relative "../namespaces"
6
+
7
+ module Calendav
8
+ module Requests
9
+ class ListTodos
10
+ def self.call(...)
11
+ new.call
12
+ end
13
+
14
+ def call
15
+ Nokogiri::XML::Builder.new do |xml|
16
+ xml["caldav"].public_send("calendar-query", NAMESPACES) do
17
+ xml["dav"].prop do
18
+ xml["dav"].getetag
19
+ xml["caldav"].public_send(:"calendar-data")
20
+ end
21
+ xml["caldav"].filter do
22
+ xml["caldav"].public_send(:"comp-filter", name: "VCALENDAR") do
23
+ xml["caldav"].public_send(:"comp-filter", name: "VTODO")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def from
33
+ return nil if @from.nil?
34
+
35
+ @from.utc.iso8601.delete(":-")
36
+ end
37
+
38
+ def to
39
+ return nil if @to.nil?
40
+
41
+ @to.utc.iso8601.delete(":-")
42
+ end
43
+
44
+ def range?
45
+ to || from
46
+ end
47
+ end
48
+ end
49
+ end
@@ -38,6 +38,7 @@ module Calendav
38
38
  :"supported-calendar-component-set"
39
39
  ) do
40
40
  xml["caldav"].comp name: "VEVENT"
41
+ xml["caldav"].comp name: "VTODO"
41
42
  end
42
43
  end
43
44
  end