async-caldav 1.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.
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Storage
10
+ class Mock < Protocol::Caldav::Storage
11
+ def initialize
12
+ @collections = {}
13
+ @items = {}
14
+ @sync_snapshots = {} # token => { collection_path => { item_path => etag } }
15
+ end
16
+
17
+ # --- Collections ---
18
+
19
+ def create_collection(path, props = {})
20
+ col = {
21
+ type: props[:type] || :collection,
22
+ displayname: props[:displayname],
23
+ description: props[:description],
24
+ color: props[:color],
25
+ props: props[:props] || {}
26
+ }
27
+ @collections[path] = col
28
+ col
29
+ end
30
+
31
+ def get_collection(path)
32
+ @collections[path]
33
+ end
34
+
35
+ def delete_collection(path)
36
+ if @collections.delete(path)
37
+ @items.delete_if { |k, _| k.start_with?(path) }
38
+ true
39
+ else
40
+ false
41
+ end
42
+ end
43
+
44
+ def list_collections(parent_path)
45
+ parent = parent_path.end_with?('/') ? parent_path : "#{parent_path}/"
46
+ @collections.select { |k, _| direct_child?(k, parent) }.to_a
47
+ end
48
+
49
+ def update_collection(path, props)
50
+ col = @collections[path]
51
+ if col
52
+ col[:displayname] = props[:displayname] if props.key?(:displayname)
53
+ col[:description] = props[:description] if props.key?(:description)
54
+ col[:color] = props[:color] if props.key?(:color)
55
+ col[:props] = (col[:props] || {}).merge(props[:props]) if props.key?(:props)
56
+ col
57
+ end
58
+ end
59
+
60
+ def collection_exists?(path)
61
+ @collections.key?(path)
62
+ end
63
+
64
+ # --- Items ---
65
+
66
+ def get_item(path)
67
+ @items[path]
68
+ end
69
+
70
+ def put_item(path, body, content_type)
71
+ etag = Protocol::Caldav::ETag.compute(body)
72
+ is_new = !@items.key?(path)
73
+ item = { body: body, content_type: content_type, etag: etag }
74
+ @items[path] = item
75
+ [item, is_new]
76
+ end
77
+
78
+ def delete_item(path)
79
+ !!@items.delete(path)
80
+ end
81
+
82
+ def list_items(collection_path)
83
+ prefix = collection_path.end_with?('/') ? collection_path : "#{collection_path}/"
84
+ @items.select { |k, _| k.start_with?(prefix) && k != collection_path }.to_a
85
+ end
86
+
87
+ def move_item(from_path, to_path)
88
+ item = @items.delete(from_path)
89
+ if item
90
+ @items[to_path] = item
91
+ item
92
+ end
93
+ end
94
+
95
+ def get_multi(paths)
96
+ paths.map { |p| [p, @items[p]] }
97
+ end
98
+
99
+ # --- Sync ---
100
+
101
+ def snapshot_sync(collection_path)
102
+ items = list_items(collection_path)
103
+ snapshot = {}
104
+ items.each { |path, data| snapshot[path] = data[:etag] }
105
+
106
+ # Compute a token from the current state
107
+ item_etags = items.map { |_, data| data[:etag] }
108
+ col = @collections[collection_path] || {}
109
+ ctag = Protocol::Caldav::CTag.compute(
110
+ path: collection_path,
111
+ displayname: col[:displayname],
112
+ description: col[:description],
113
+ color: col[:color],
114
+ item_etags: item_etags
115
+ )
116
+ token = "http://caldav.local/sync/#{ctag}"
117
+
118
+ @sync_snapshots[token] = { collection_path => snapshot }
119
+ token
120
+ end
121
+
122
+ def sync_changes(collection_path, token)
123
+ old_snapshot_entry = @sync_snapshots[token]
124
+ return nil unless old_snapshot_entry
125
+
126
+ old_snapshot = old_snapshot_entry[collection_path] || {}
127
+
128
+ # Take new snapshot
129
+ new_token = snapshot_sync(collection_path)
130
+ current_items = list_items(collection_path)
131
+ current = {}
132
+ current_items.each { |path, data| current[path] = data[:etag] }
133
+
134
+ changes = []
135
+
136
+ # Items that are new or modified
137
+ current.each do |path, etag|
138
+ if !old_snapshot.key?(path) || old_snapshot[path] != etag
139
+ changes << [path, :modified]
140
+ end
141
+ end
142
+
143
+ # Items that were deleted
144
+ old_snapshot.each_key do |path|
145
+ unless current.key?(path)
146
+ changes << [path, :deleted]
147
+ end
148
+ end
149
+
150
+ [new_token, changes]
151
+ end
152
+
153
+ # --- General ---
154
+
155
+ def exists?(path)
156
+ @items.key?(path) || @collections.key?(path)
157
+ end
158
+
159
+ def etag(path)
160
+ item = @items[path]
161
+ item ? item[:etag] : nil
162
+ end
163
+
164
+ private
165
+
166
+ def direct_child?(child, parent)
167
+ if child.start_with?(parent)
168
+ remainder = child[parent.length..]
169
+ remainder.chomp('/').count('/').zero? && !remainder.chomp('/').empty?
170
+ else
171
+ false
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+
180
+ test do
181
+ describe "Async::Caldav::Storage::Mock" do
182
+ it "creates and retrieves a collection" do
183
+ s = Async::Caldav::Storage::Mock.new
184
+ s.create_collection("/calendars/admin/cal1/", type: :calendar, displayname: "Cal 1")
185
+ col = s.get_collection("/calendars/admin/cal1/")
186
+ col.should.not.be.nil
187
+ col[:displayname].should.equal "Cal 1"
188
+ col[:type].should.equal :calendar
189
+ end
190
+
191
+ it "deletes a collection and its items" do
192
+ s = Async::Caldav::Storage::Mock.new
193
+ s.create_collection("/calendars/admin/cal/")
194
+ s.put_item("/calendars/admin/cal/event.ics", "VCALENDAR", "text/calendar")
195
+ s.delete_collection("/calendars/admin/cal/")
196
+ s.get_collection("/calendars/admin/cal/").should.be.nil
197
+ s.get_item("/calendars/admin/cal/event.ics").should.be.nil
198
+ end
199
+
200
+ it "delete_collection does not affect siblings" do
201
+ s = Async::Caldav::Storage::Mock.new
202
+ s.create_collection("/calendars/admin/a/")
203
+ s.create_collection("/calendars/admin/b/")
204
+ s.delete_collection("/calendars/admin/a/")
205
+ s.get_collection("/calendars/admin/b/").should.not.be.nil
206
+ end
207
+
208
+ it "delete_collection returns true/false" do
209
+ s = Async::Caldav::Storage::Mock.new
210
+ s.create_collection("/col/")
211
+ s.delete_collection("/col/").should.equal true
212
+ s.delete_collection("/col/").should.equal false
213
+ end
214
+
215
+ it "lists direct child collections only" do
216
+ s = Async::Caldav::Storage::Mock.new
217
+ s.create_collection("/calendars/admin/a/")
218
+ s.create_collection("/calendars/admin/b/")
219
+ s.create_collection("/calendars/admin/a/nested/")
220
+ list = s.list_collections("/calendars/admin/")
221
+ list.length.should.equal 2
222
+ end
223
+
224
+ it "list_collections returns empty on no children" do
225
+ s = Async::Caldav::Storage::Mock.new
226
+ s.list_collections("/nope/").should.equal []
227
+ end
228
+
229
+ it "updates collection properties, leaves others untouched" do
230
+ s = Async::Caldav::Storage::Mock.new
231
+ s.create_collection("/cal/", displayname: "Old", description: "Desc")
232
+ s.update_collection("/cal/", displayname: "New")
233
+ col = s.get_collection("/cal/")
234
+ col[:displayname].should.equal "New"
235
+ col[:description].should.equal "Desc"
236
+ end
237
+
238
+ it "update_collection returns nil for nonexistent" do
239
+ s = Async::Caldav::Storage::Mock.new
240
+ s.update_collection("/nope/", displayname: "X").should.be.nil
241
+ end
242
+
243
+ it "put_item returns is_new true for new, false for existing" do
244
+ s = Async::Caldav::Storage::Mock.new
245
+ _, is_new1 = s.put_item("/cal/ev.ics", "body1", "text/calendar")
246
+ is_new1.should.equal true
247
+ _, is_new2 = s.put_item("/cal/ev.ics", "body2", "text/calendar")
248
+ is_new2.should.equal false
249
+ end
250
+
251
+ it "put_item overwrites the body" do
252
+ s = Async::Caldav::Storage::Mock.new
253
+ s.put_item("/cal/ev.ics", "old", "text/calendar")
254
+ s.put_item("/cal/ev.ics", "new", "text/calendar")
255
+ s.get_item("/cal/ev.ics")[:body].should.equal "new"
256
+ end
257
+
258
+ it "get_item returns nil for nonexistent" do
259
+ s = Async::Caldav::Storage::Mock.new
260
+ s.get_item("/nope").should.be.nil
261
+ end
262
+
263
+ it "delete_item returns true/false" do
264
+ s = Async::Caldav::Storage::Mock.new
265
+ s.put_item("/cal/ev.ics", "d", "text/calendar")
266
+ s.delete_item("/cal/ev.ics").should.equal true
267
+ s.delete_item("/cal/ev.ics").should.equal false
268
+ end
269
+
270
+ it "list_items returns only items, not collections" do
271
+ s = Async::Caldav::Storage::Mock.new
272
+ s.create_collection("/cal/")
273
+ s.put_item("/cal/ev.ics", "data", "text/calendar")
274
+ items = s.list_items("/cal/")
275
+ items.length.should.equal 1
276
+ items[0][0].should.equal "/cal/ev.ics"
277
+ end
278
+
279
+ it "list_items on empty collection returns empty" do
280
+ s = Async::Caldav::Storage::Mock.new
281
+ s.list_items("/empty/").should.equal []
282
+ end
283
+
284
+ it "move_item removes source, creates destination" do
285
+ s = Async::Caldav::Storage::Mock.new
286
+ s.put_item("/cal/a.ics", "data", "text/calendar")
287
+ s.move_item("/cal/a.ics", "/cal/b.ics")
288
+ s.get_item("/cal/a.ics").should.be.nil
289
+ s.get_item("/cal/b.ics")[:body].should.equal "data"
290
+ end
291
+
292
+ it "move_item returns nil when source missing" do
293
+ s = Async::Caldav::Storage::Mock.new
294
+ s.move_item("/nope", "/dest").should.be.nil
295
+ end
296
+
297
+ it "get_multi returns results in input order with nils for missing" do
298
+ s = Async::Caldav::Storage::Mock.new
299
+ s.put_item("/cal/a.ics", "A", "text/calendar")
300
+ result = s.get_multi(["/cal/a.ics", "/cal/nope.ics"])
301
+ result.length.should.equal 2
302
+ result[0][1][:body].should.equal "A"
303
+ result[1][1].should.be.nil
304
+ end
305
+
306
+ it "get_multi with empty input returns empty" do
307
+ s = Async::Caldav::Storage::Mock.new
308
+ s.get_multi([]).should.equal []
309
+ end
310
+
311
+ it "exists? for items, collections, and nonexistent" do
312
+ s = Async::Caldav::Storage::Mock.new
313
+ s.exists?("/nope").should.equal false
314
+ s.create_collection("/col/")
315
+ s.exists?("/col/").should.equal true
316
+ s.put_item("/col/x.ics", "d", "text/calendar")
317
+ s.exists?("/col/x.ics").should.equal true
318
+ end
319
+
320
+ it "etag returns item etag, nil for nonexistent" do
321
+ s = Async::Caldav::Storage::Mock.new
322
+ s.put_item("/cal/ev.ics", "body", "text/calendar")
323
+ s.etag("/cal/ev.ics").should.not.be.nil
324
+ s.etag("/nope").should.be.nil
325
+ end
326
+
327
+ it "etag changes when body changes" do
328
+ s = Async::Caldav::Storage::Mock.new
329
+ s.put_item("/cal/ev.ics", "body1", "text/calendar")
330
+ e1 = s.etag("/cal/ev.ics")
331
+ s.put_item("/cal/ev.ics", "body2", "text/calendar")
332
+ e2 = s.etag("/cal/ev.ics")
333
+ e1.should.not.equal e2
334
+ end
335
+
336
+ it "snapshot_sync returns a token" do
337
+ s = Async::Caldav::Storage::Mock.new
338
+ s.create_collection("/cal/", type: :calendar)
339
+ s.put_item("/cal/ev.ics", "body", "text/calendar")
340
+ token = s.snapshot_sync("/cal/")
341
+ token.should.not.be.nil
342
+ token.should.include "http://caldav.local/sync/"
343
+ end
344
+
345
+ it "sync_changes returns empty changes when nothing changed" do
346
+ s = Async::Caldav::Storage::Mock.new
347
+ s.create_collection("/cal/", type: :calendar)
348
+ s.put_item("/cal/ev.ics", "body", "text/calendar")
349
+ token = s.snapshot_sync("/cal/")
350
+ new_token, changes = s.sync_changes("/cal/", token)
351
+ changes.should.equal []
352
+ new_token.should.equal token
353
+ end
354
+
355
+ it "sync_changes detects added items" do
356
+ s = Async::Caldav::Storage::Mock.new
357
+ s.create_collection("/cal/", type: :calendar)
358
+ token = s.snapshot_sync("/cal/")
359
+ s.put_item("/cal/new.ics", "body", "text/calendar")
360
+ _, changes = s.sync_changes("/cal/", token)
361
+ changes.length.should.equal 1
362
+ changes[0][0].should.equal "/cal/new.ics"
363
+ changes[0][1].should.equal :modified
364
+ end
365
+
366
+ it "sync_changes detects deleted items" do
367
+ s = Async::Caldav::Storage::Mock.new
368
+ s.create_collection("/cal/", type: :calendar)
369
+ s.put_item("/cal/ev.ics", "body", "text/calendar")
370
+ token = s.snapshot_sync("/cal/")
371
+ s.delete_item("/cal/ev.ics")
372
+ _, changes = s.sync_changes("/cal/", token)
373
+ changes.length.should.equal 1
374
+ changes[0][0].should.equal "/cal/ev.ics"
375
+ changes[0][1].should.equal :deleted
376
+ end
377
+
378
+ it "sync_changes detects modified items" do
379
+ s = Async::Caldav::Storage::Mock.new
380
+ s.create_collection("/cal/", type: :calendar)
381
+ s.put_item("/cal/ev.ics", "body1", "text/calendar")
382
+ token = s.snapshot_sync("/cal/")
383
+ s.put_item("/cal/ev.ics", "body2", "text/calendar")
384
+ _, changes = s.sync_changes("/cal/", token)
385
+ changes.length.should.equal 1
386
+ changes[0][1].should.equal :modified
387
+ end
388
+
389
+ it "sync_changes returns nil for invalid token" do
390
+ s = Async::Caldav::Storage::Mock.new
391
+ s.create_collection("/cal/", type: :calendar)
392
+ s.sync_changes("/cal/", "bogus-token").should.be.nil
393
+ end
394
+
395
+ it "same body produces same etag" do
396
+ s = Async::Caldav::Storage::Mock.new
397
+ s.put_item("/a.ics", "same", "text/calendar")
398
+ s.put_item("/b.ics", "same", "text/calendar")
399
+ s.etag("/a.ics").should.equal s.etag("/b.ics")
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Caldav
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'protocol/caldav'
4
+ require_relative 'caldav/version'
5
+ require_relative 'caldav/forward_auth'
6
+ require_relative 'caldav/storage/mock'
7
+ require_relative 'caldav/storage/filesystem'
8
+ require_relative 'caldav/handlers/options'
9
+ require_relative 'caldav/handlers/get'
10
+ require_relative 'caldav/handlers/head'
11
+ require_relative 'caldav/handlers/put'
12
+ require_relative 'caldav/handlers/delete'
13
+ require_relative 'caldav/handlers/move'
14
+ require_relative 'caldav/handlers/mkcol'
15
+ require_relative 'caldav/handlers/propfind'
16
+ require_relative 'caldav/handlers/proppatch'
17
+ require_relative 'caldav/handlers/report'
18
+ require_relative 'caldav/server'
19
+ require_relative 'caldav/client'
20
+
21
+ module Async
22
+ module Caldav
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: async-caldav
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan K
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: protocol-caldav
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: scampi
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.7
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.7
40
+ description: |
41
+ Native server for CalDAV/CardDAV.
42
+ Built on protocol-caldav for wire-format concerns.
43
+ email:
44
+ - nathankidd@hey.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/async/caldav.rb
50
+ - lib/async/caldav/client.rb
51
+ - lib/async/caldav/client/addressbook.rb
52
+ - lib/async/caldav/client/calendar.rb
53
+ - lib/async/caldav/forward_auth.rb
54
+ - lib/async/caldav/handlers/delete.rb
55
+ - lib/async/caldav/handlers/get.rb
56
+ - lib/async/caldav/handlers/head.rb
57
+ - lib/async/caldav/handlers/mkcol.rb
58
+ - lib/async/caldav/handlers/move.rb
59
+ - lib/async/caldav/handlers/options.rb
60
+ - lib/async/caldav/handlers/propfind.rb
61
+ - lib/async/caldav/handlers/proppatch.rb
62
+ - lib/async/caldav/handlers/put.rb
63
+ - lib/async/caldav/handlers/report.rb
64
+ - lib/async/caldav/server.rb
65
+ - lib/async/caldav/storage/filesystem.rb
66
+ - lib/async/caldav/storage/mock.rb
67
+ - lib/async/caldav/version.rb
68
+ homepage: https://github.com/n-at-han-k/async-caldav
69
+ licenses:
70
+ - Apache-2.0
71
+ metadata:
72
+ homepage_uri: https://github.com/n-at-han-k/async-caldav
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 3.2.0
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.7.2
88
+ specification_version: 4
89
+ summary: CalDAV/CardDAV server for the async ecosystem
90
+ test_files: []