calendav 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3afd38354db59046e38adcad9be4376d7354b4acf4f97205bcbdc313d0bf1706
4
- data.tar.gz: 3642fbb9d38108adcdf5af63c41e4f01c846b86a1a36225e8fd9bd92b58ae088
3
+ metadata.gz: e72b5067dad00dc1074cbd5cf7d0b3c28845b624d47ff5e992e3b78a9a65fa0d
4
+ data.tar.gz: 11c9e3e362f36c91323141650a0d3415914f87aed208ea4716d8b0253ffd2c6e
5
5
  SHA512:
6
- metadata.gz: c54490f1ae94b3fb42c273777ccc29c342273bf8585b8270842e3402d383301867ec750eb2a0aaa3e51f770ef7f53a1094c4427e2e65de75fa3ba489eaa91667
7
- data.tar.gz: 5e1855a5fde3e0adf27afbe387224617ffea0163eb82c6325320e2905a4bf176ff803cd8d6500da097a4001f35657e2be1144f8abbf9cfdca413d05267920f60
6
+ metadata.gz: 89da478d32baf4bbcfbd3c13ca2f89d5071f2dd4953889830d6f996d54bc4b5fb14feeac89af0c03891f047bf0e5b67fe7d88267df9ab0998a825387dbabf159
7
+ data.tar.gz: 05737755ec19e2c6b1e1f06b3f9273428e92d69381c19aee5cca14738bb9d63f243181a3c02b8d3d69ceb7e1b8c851847932aec8a81ae770a2d7c28d19357f12
data/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
- ## [Unreleased]
1
+ ## Unreleased
2
2
 
3
- ## [0.0.1] - 2021-06-13
3
+ ...
4
+
5
+ ## 0.1.0 - 2021-06-14
6
+
7
+ * Support creating and deleting calendars.
8
+ * Updating of events (with optional etag check).
9
+ * List all events on a calendar (no timespan required).
10
+ * Updating of calendars.
11
+ * Allow for etag check to be used on event deletions.
12
+ * Finding of single events and calendars by URLs.
13
+ * Check if calendar creation is possible.
14
+ * WebDAV-Sync support.
15
+ * A vastly more useful README.
16
+
17
+ ## 0.0.1 - 2021-06-13
4
18
 
5
19
  An initial release with very limited (and likely buggy) support:
6
20
 
data/README.md CHANGED
@@ -2,75 +2,203 @@
2
2
 
3
3
  A library for interacting with CalDAV servers via Ruby.
4
4
 
5
- At the time of writing, this gem is definitely not ready for production, nor is it anywhere close to supporting a decent set of features. Currently it covers the following:
5
+ ## Features
6
6
 
7
- * Determining the principal URL.
8
- * Determining the calendar home path.
9
- * Listing available calendars.
10
- * Creating events on a calendar.
11
- * Listing events on a calendar within a given timespan.
12
- * Deleting events on a calendar.
7
+ ### Credentials and Accounts
13
8
 
14
- Other features on the roadmap, in a rough order of priority:
9
+ Calendav has support for a few calendar providers built-in by default: Apple, FastMail, and Google.
15
10
 
16
- * Consistent access to calendar/event URLs.
17
- * Updating events.
18
- * List all events on a calendar.
19
- * Create new calendars.
20
- * Update calendars.
21
- * Delete calendars.
22
- * Enable etag validation for updates/deletions (If-Match header).
23
- * Solid exception handling.
24
- * Use WebDAV-Sync to get changes since last sync.
25
- * Enable locking/unlocking when making changes.
26
- * Allow requesting only certain properties for calendars/events.
11
+ ```ruby
12
+ credentials = Calendav.credentials(
13
+ :apple, username: "example@icloud.com", password: "app-specific-password"
14
+ )
15
+ ```
27
16
 
28
- Also, the plan is to have a test suite that covers multiple CalDAV server implementations.
17
+ Both Apple and FastMail expect app-specific passwords (rather than the account's main password). Google expects an OAuth 2 access token for the password instead.
29
18
 
30
- ## Usage
19
+ You can also create credentials for other providers:
31
20
 
32
21
  ```ruby
33
- credentials = Calendav.credentials(
34
- :google, username: "example@gmail.com", password: "oauth-access-token"
35
- )
36
- # Also supported are FastMail and Apple, where the passwords should be
37
- # app-specific, with authentication via Basic Auth. Google uses a Bearer Token
38
- # for authentication (supplied as the password).
39
- #
40
- # Otherwise, to compose a more custom set of credentials:
41
22
  credentials = Calendav::Credentials::Standard.new(
42
23
  host: "https://www.example.com/caldav",
43
24
  username: "example",
44
25
  password: "secret",
45
26
  authentication: :basic_auth # or :bearer_token
46
27
  )
28
+ ```
47
29
 
48
- client = Calendav.client credentials
30
+ You can use credentials to create a new client instance. If required, you can confirm they're valid by checking if a principal URL for the account can be returned.
31
+
32
+ ```ruby
33
+ client = Calendav.client(credentials)
49
34
  puts client.principal_url
35
+ ```
50
36
 
51
- puts client.calendars.home_url
37
+ ### Calendars
38
+
39
+ You can retrieve a list of all available calendars:
40
+
41
+ ```ruby
52
42
  calendars = client.calendars.list
53
- calendars.each { |calendar| puts calendar.path, calendar.display_name }
43
+ calendars.each do |calendar|
44
+ puts calendar.url
45
+ puts calendar.display_name
46
+ end
47
+ ```
48
+
49
+ All calendars returned will have a URL - and this is the primary and unique reference to the calendar, and will not change. They should also have a display name, description, etag, ctag, time zone and color - but not all providers support all of these properties.
50
+
51
+ If you already have the Calendar's URL, then you can request its information directly as well:
52
+
53
+ ```ruby
54
+ calendar = client.calendars.find(calendar_url)
55
+ ```
56
+
57
+ Some providers (though not Google) allow you to create calendars via the CalDAV protocol. You must supply the identifier of this calendar, which is the final part of its URL - and so must be unique for the account. Using a UUID could be a good option.
58
+
59
+ ```ruby
60
+ require "securerandom"
61
+
62
+ identifier = SecureRandom.uuid
63
+
64
+ calendar_url = client.calendars.create(identifier, display_name: "My Calendar")
65
+ ```
54
66
 
67
+ You can also edit calendar details, and delete them (if the provide allows such actions). The allowed attributes are `display_name`, `description`, `time_zone` and `color`.
68
+
69
+ ```ruby
70
+ client.calendars.update(calendar_url, display_name: "Altered Calendar")
71
+ client.calendars.delete(calendar_url)
72
+ ```
73
+
74
+ It is possible to check whether a calendar provider supports calendar creation/deletion:
75
+
76
+ ```ruby
77
+ client.calendars.create? # => true/false
78
+ ```
79
+
80
+ Also, if it's useful, you can access the account's calendar home path (the root directory for that account's calendars):
81
+
82
+ ```ruby
83
+ client.calendars.home_url
84
+ ```
85
+
86
+ ### Events
87
+
88
+ Once you have a calendar's URL, you can retrieve events from it. Unless you are making a copy of the calendar for synchronisation purposes, it's recommended you limit the request to a specific timeframe (though the `from` and `to` arguments are optional).
89
+
90
+ ```ruby
55
91
  events = client.events.list(
56
- calendar.path, from: Time.new(2021, 1, 1), to: Time.new(2022, 1, 1)
92
+ calendar_url, from: Time.new(2021, 1, 1), to: Time.new(2022, 1, 1)
57
93
  )
94
+ events.each do |event|
95
+ puts event.url
96
+ puts event.summary
97
+ puts event.calendar_data
98
+ end
99
+ ```
100
+
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
+
103
+ If you have an event's URL, you can fetch the details of just that event directly:
104
+
105
+ ```ruby
106
+ event = client.events.find(event_url)
107
+ ```
108
+
109
+ Creating events, just like creating calendars, requires a unique identifier. There are no hard requirements for the format from the perspective of CalDAV generally, but some providers require the identifier to have the extension `.ics`.
110
+
111
+ You will also need to generate the iCalendar data - again, the [icalendar](https://github.com/icalendar/icalendar) gem is very helpful for this.
112
+
113
+ ```ruby
114
+ require "securerandom"
115
+ require "icalendar"
58
116
 
59
- # use the icalendar gem to generate ICS strings
60
117
  ics = Icalendar::Calendar.new
61
118
  ics.event do |event|
62
119
  # ...
63
120
  end
64
121
  ics.publish
65
122
 
66
- # You need to provide the expected filename for the event:
67
123
  identifier = "#{SecureRandom.uuid}.ics"
68
- # but the server may change it on you, so the new, full URL is returned:
69
- event_url = client.events.create(calendar.path, identifier, ics.to_ical)
124
+ event_url = client.events.create(calendar.url, identifier, ics.to_ical)
125
+ ```
126
+
127
+ *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).
128
+
129
+ Updating events is done in a similar manner - with the event's URL and the updated iCalendar content:
130
+
131
+ ```ruby
132
+ client.events.update(event_url, ics.to_ical)
133
+ ```
134
+
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):
136
+
137
+ ```ruby
138
+ event = client.events.find(event_url)
139
+
140
+ # figure out the changes you want to make, generate the new ical data, and then:
141
+
142
+ client.events.update(event_url, modified_ical, etag: event.etag)
143
+ ```
70
144
 
145
+ 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.
146
+
147
+ ```ruby
71
148
  client.events.delete(event_url)
149
+
150
+ client.events.delete(event_url, etag: event.etag)
151
+ ```
152
+
153
+ ### Synchronising
154
+
155
+ 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:
156
+
157
+ * Get a token for a specific calendar
158
+ * Request all events for that calendar
159
+ * Then, to get just the changed/deleted events, use the token to request the delta.
160
+ * That request will return a new token, which you use in the _next_ delta request, and so forth.
161
+
162
+ These `sync_token` values are not returned by default, but can be requested on a per-calendar basis:
163
+
164
+ ```ruby
165
+ token = subject.calendars.find(calendar_url, sync: true).sync_token
166
+ ```
167
+
168
+ To retrieve all events for that calendar (when starting the initial synchronisation process):
169
+
170
+ ```ruby
171
+ events = subject.events.list(calendar_url)
72
172
  ```
73
173
 
174
+ And then to retrieve the delta changes and a new `sync_token`:
175
+
176
+ ```ruby
177
+ collection = subject.calendars.sync(calendar_url, token)
178
+ collection.changes.each { |change| puts change.url }
179
+ collection.deletions.each { |deletion_url| puts deletion_url }
180
+ token = collection.sync_token
181
+ ```
182
+
183
+ The `deletions` array is the event URLs for any events that have been removed since your previous sync - no other details are available.
184
+
185
+ The `changes` array are Event objects - but some may not have their `calendar_data` populated, as not all calendar providers supply this as part of requesting the synchronisation delta. Apple does not return this information, but Google does. So, you may need to request the full event object if required:
186
+
187
+ ```ruby
188
+ events = collection.changes.collect do |event|
189
+ event.unloaded? ? client.events.find(event.url) : event
190
+ end
191
+ ```
192
+
193
+ ### Still to be implemented
194
+
195
+ While a lot of the core CalDAV functionality is covered and this gem is useful as it stands, the following features are on the roadmap:
196
+
197
+ * Further iCalendar parsing for Event objects.
198
+ * Automated tests against FastMail and possibly other providers (Apple and Google are already covered).
199
+ * Locking/unlocking of events as per the WebCAL RFC.
200
+ * Support for VTODO, VJOURNAL, VFREEBUSY and any other components beyond VEVENT.
201
+
74
202
  ## Installation
75
203
 
76
204
  Add this line to your application's Gemfile:
@@ -87,16 +215,32 @@ Or install it yourself as:
87
215
 
88
216
  $ gem install calendav
89
217
 
218
+ ## API Design
219
+
220
+ I've thus far preferred separation between data objects (`Calendav::Event` and `Calendav::Calendar`), and the interaction layer (via `Calendav::Client`) - as opposed to, say, a more ActiveRecord-like manner of interactions from within data objects.
221
+
222
+ I've chosen the separated approach because it allows actions to occur on specific calendars or events without having a full data tree - this keeps requests to calendar providers to a minimum. You don't want to be loading everything from their servers every time you're reading/modifying one event!
223
+
224
+ One thing that will likely evolve is how identifiers are handled for new calendars and events - while allowing custom ones to be provided will remain, I'll be looking at autogeneration with UUIDs to ensure a simpler approach is possible.
225
+
226
+ Similarly, providing translation around event/calendar/time-zone details (via [icalendar](https://github.com/icalendar/icalendar)) is also a consideration.
227
+
228
+ ## Thanks
229
+
230
+ The work done in previous Ruby CalDAV clients [RubyCaldav](https://github.com/digITpro/caldav_client) and [caldav](https://github.com/collectiveidea/caldav) has been very helpful, even though the codebases haven't seen updates in many years. I also found Sabre's documentation on [building a CalDAV client](https://sabre.io/dav/building-a-caldav-client/) to be extremely useful. Thank you to those teams for their hard work!
231
+
90
232
  ## Development
91
233
 
92
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.
93
235
 
236
+ ## Tests
237
+
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).
239
+
94
240
  ## Contributing
95
241
 
96
242
  Bug reports and pull requests are welcome on GitHub at https://github.com/pat/calendav. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/pat/calendav/blob/main/CODE_OF_CONDUCT.md).
97
243
 
98
- The test suite currently only runs against a Google account 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).
99
-
100
244
  ## License
101
245
 
102
246
  The gem is available as open source under the terms of the [Hippocratic License](https://firstdonoharm.dev).
@@ -1,55 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./xml_processor"
3
+ require_relative "./contextual_url"
4
+ require_relative "./parsers/calendar_xml"
4
5
 
5
6
  module Calendav
6
7
  class Calendar
7
- attr_reader :path
8
+ attr_reader :url, :display_name, :description, :ctag, :etag, :time_zone,
9
+ :color, :components, :reports, :sync_token
8
10
 
9
- def self.from_xml(node)
11
+ def self.from_xml(host, node)
10
12
  new(
11
- node.xpath("./dav:href").text,
12
- node.xpath(".//dav:prop/*").to_xml,
13
- node.namespaces
13
+ ContextualURL.call(host, node.xpath("./dav:href").text),
14
+ Parsers::CalendarXML.call(node)
14
15
  )
15
16
  end
16
17
 
17
- def initialize(path, attribute_nodes, namespaces = {})
18
- @path = path
19
- @attribute_nodes = attribute_nodes
20
- @namespaces = namespaces
21
- end
22
-
23
- def display_name
24
- attribute_value fragment.xpath("//dav:displayname")
25
- end
26
-
27
- def ctag
28
- attribute_value fragment.xpath("//cs:getctag")
29
- end
30
-
31
- def etag
32
- attribute_value fragment.xpath("//dav:getetag")
33
- end
34
-
35
- private
36
-
37
- attr_reader :attribute_nodes, :namespaces
38
-
39
- def fragment
40
- @fragment ||= XMLProcessor.call(
41
- "<nodes>#{attribute_nodes}</nodes>", namespaces
42
- )
43
- end
44
-
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
18
+ def initialize(url, attributes = {})
19
+ @url = url
20
+ @display_name = attributes[:display_name]
21
+ @description = attributes[:description]
22
+ @ctag = attributes[:ctag]
23
+ @etag = attributes[:etag]
24
+ @time_zone = attributes[:time_zone]
25
+ @color = attributes[:color]
26
+ @components = attributes[:components]
27
+ @reports = attributes[:reports]
28
+ @sync_token = attributes[:sync_token]
53
29
  end
54
30
  end
55
31
  end
@@ -23,13 +23,11 @@ module Calendav
23
23
  def principal_url
24
24
  @principal_url ||= begin
25
25
  request = Requests::CurrentUserPrincipal.call
26
+ response = endpoint.propfind(request.to_xml).first
26
27
 
27
28
  ContextualURL.call(
28
- credentials,
29
- endpoint
30
- .propfind(request.to_xml)
31
- .xpath(".//dav:current-user-principal/dav:href")
32
- .text
29
+ credentials.host,
30
+ response.xpath(".//dav:current-user-principal/dav:href").text
33
31
  )
34
32
  end
35
33
  end
@@ -3,13 +3,20 @@
3
3
  require "securerandom"
4
4
 
5
5
  require_relative "../calendar"
6
+ require_relative "../parsers/sync_xml"
6
7
  require_relative "../requests/calendar_home_set"
7
8
  require_relative "../requests/list_calendars"
8
9
  require_relative "../requests/make_calendar"
10
+ require_relative "../requests/sync_collection"
11
+ require_relative "../requests/update_calendar"
9
12
 
10
13
  module Calendav
11
14
  module Clients
12
15
  class CalendarsClient
16
+ DEFAULT_ATTRIBUTES = %i[
17
+ display_name resource_type etag ctag color components reports
18
+ ].freeze
19
+
13
20
  def initialize(client, endpoint, credentials)
14
21
  @client = client
15
22
  @endpoint = endpoint
@@ -19,47 +26,82 @@ module Calendav
19
26
  def home_url
20
27
  @home_url ||= begin
21
28
  request = Requests::CalendarHomeSet.call
29
+ response = endpoint.propfind(request.to_xml, url: principal_url).first
22
30
 
23
31
  ContextualURL.call(
24
- credentials,
25
- endpoint
26
- .propfind(request.to_xml, url: client.principal_url)
27
- .xpath(".//caldav:calendar-home-set/dav:href")
28
- .text
32
+ credentials.host,
33
+ response.xpath(".//caldav:calendar-home-set/dav:href").text
29
34
  )
30
35
  end
31
36
  end
32
37
 
33
- def create(attributes)
34
- request = Requests::MakeCalendar.call(attributes)
35
-
36
- id = SecureRandom.uuid
37
- id = "/#{id}" unless home_url.end_with?("/")
38
- url = home_url + id
38
+ def create?
39
+ options.include?("MKCOL") || options.include?("MKCALENDAR")
40
+ end
39
41
 
40
- endpoint.mkcalendar(request.to_xml, url: url)
42
+ def create(identifier, attributes)
43
+ request = Requests::MakeCalendar.call(attributes)
44
+ url = merged_url(identifier)
45
+ result = endpoint.mkcalendar(request.to_xml, url: url)
41
46
 
42
- url
47
+ result.headers["Location"] || url
43
48
  end
44
49
 
45
50
  def delete(url)
46
51
  endpoint.delete(url: url)
47
52
  end
48
53
 
49
- def list
50
- request = Requests::ListCalendars.call
54
+ def find(url, attributes: DEFAULT_ATTRIBUTES, sync: false)
55
+ attributes = (attributes.dup << :sync_token) if sync
56
+
57
+ list(url, depth: 0, attributes: attributes).first
58
+ end
59
+
60
+ def list(url = home_url, depth: 1, attributes: DEFAULT_ATTRIBUTES)
61
+ request = Requests::ListCalendars.call(attributes)
51
62
  calendar_xpath = ".//dav:resourcetype/caldav:calendar"
52
63
 
53
64
  endpoint
54
- .propfind(request.to_xml, url: home_url, depth: 1)
55
- .xpath(".//dav:response")
65
+ .propfind(request.to_xml, url: url, depth: depth)
56
66
  .select { |node| node.xpath(calendar_xpath).any? }
57
- .collect { |node| Calendar.from_xml(node) }
67
+ .collect { |node| Calendar.from_xml(home_url, node) }
68
+ end
69
+
70
+ def options
71
+ endpoint
72
+ .options(url: home_url)
73
+ .headers["Allow"]
74
+ .split(", ")
75
+ end
76
+
77
+ def sync(url, token)
78
+ request = Requests::SyncCollection.call(token)
79
+
80
+ Parsers::SyncXML.call(
81
+ url, endpoint.report(request.to_xml, url: url, depth: nil)
82
+ )
83
+ end
84
+
85
+ def update(url, attributes)
86
+ request = Requests::UpdateCalendar.call(attributes)
87
+ endpoint
88
+ .proppatch(request.to_xml, url: url)
89
+ .first
90
+ .xpath(".//dav:status")
91
+ .text["200 OK"] == "200 OK"
58
92
  end
59
93
 
60
94
  private
61
95
 
62
96
  attr_reader :client, :endpoint, :credentials
97
+
98
+ def merged_url(identifier)
99
+ "#{home_url.delete_suffix('/')}/#{identifier.delete_suffix('/')}/"
100
+ end
101
+
102
+ def principal_url
103
+ client.principal_url
104
+ end
63
105
  end
64
106
  end
65
107
  end