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,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+ require 'json'
7
+ require 'fileutils'
8
+
9
+ module Async
10
+ module Caldav
11
+ module Storage
12
+ class Filesystem < Protocol::Caldav::Storage
13
+ def initialize(root)
14
+ @root = root
15
+ @sync_snapshots = {}
16
+ FileUtils.mkdir_p(@root)
17
+ end
18
+
19
+ # --- Collections ---
20
+
21
+ def create_collection(path, props = {})
22
+ dir = full_path(path)
23
+ FileUtils.mkdir_p(dir)
24
+ meta = {
25
+ "type" => (props[:type] || :collection).to_s,
26
+ "displayname" => props[:displayname],
27
+ "description" => props[:description],
28
+ "color" => props[:color],
29
+ "props" => props[:props] || {}
30
+ }
31
+ File.write(File.join(dir, ".collection.json"), JSON.pretty_generate(meta))
32
+ symbolize(meta)
33
+ end
34
+
35
+ def get_collection(path)
36
+ meta_file = File.join(full_path(path), ".collection.json")
37
+ return nil unless File.exist?(meta_file)
38
+ symbolize(JSON.parse(File.read(meta_file)))
39
+ end
40
+
41
+ def delete_collection(path)
42
+ dir = full_path(path)
43
+ if File.directory?(dir)
44
+ FileUtils.rm_rf(dir)
45
+ true
46
+ else
47
+ false
48
+ end
49
+ end
50
+
51
+ def list_collections(parent_path)
52
+ dir = full_path(parent_path)
53
+ return [] unless File.directory?(dir)
54
+
55
+ Dir.children(dir).filter_map do |name|
56
+ child_dir = File.join(dir, name)
57
+ meta_file = File.join(child_dir, ".collection.json")
58
+ next unless File.directory?(child_dir) && File.exist?(meta_file)
59
+
60
+ child_path = File.join(parent_path, name).sub(%r{/*$}, "/")
61
+ [child_path, symbolize(JSON.parse(File.read(meta_file)))]
62
+ end
63
+ end
64
+
65
+ def update_collection(path, props)
66
+ col = get_collection(path)
67
+ return nil unless col
68
+
69
+ col[:displayname] = props[:displayname] if props.key?(:displayname)
70
+ col[:description] = props[:description] if props.key?(:description)
71
+ col[:color] = props[:color] if props.key?(:color)
72
+ col[:props] = (col[:props] || {}).merge(props[:props]) if props.key?(:props)
73
+
74
+ meta = {
75
+ "type" => col[:type].to_s,
76
+ "displayname" => col[:displayname],
77
+ "description" => col[:description],
78
+ "color" => col[:color],
79
+ "props" => col[:props] || {}
80
+ }
81
+ File.write(File.join(full_path(path), ".collection.json"), JSON.pretty_generate(meta))
82
+ col
83
+ end
84
+
85
+ def collection_exists?(path)
86
+ File.exist?(File.join(full_path(path), ".collection.json"))
87
+ end
88
+
89
+ # --- Items ---
90
+
91
+ def get_item(path)
92
+ file = full_path(path)
93
+ return nil unless File.file?(file)
94
+
95
+ body = File.read(file)
96
+ content_type = guess_content_type(path)
97
+ etag = Protocol::Caldav::ETag.compute(body)
98
+ { body: body, content_type: content_type, etag: etag }
99
+ end
100
+
101
+ def put_item(path, body, content_type)
102
+ file = full_path(path)
103
+ is_new = !File.exist?(file)
104
+ FileUtils.mkdir_p(File.dirname(file))
105
+ File.write(file, body)
106
+ etag = Protocol::Caldav::ETag.compute(body)
107
+ item = { body: body, content_type: content_type, etag: etag }
108
+ [item, is_new]
109
+ end
110
+
111
+ def delete_item(path)
112
+ file = full_path(path)
113
+ if File.file?(file)
114
+ File.delete(file)
115
+ true
116
+ else
117
+ false
118
+ end
119
+ end
120
+
121
+ def list_items(collection_path)
122
+ dir = full_path(collection_path)
123
+ return [] unless File.directory?(dir)
124
+
125
+ Dir.children(dir).filter_map do |name|
126
+ next if name.start_with?(".")
127
+ file = File.join(dir, name)
128
+ next unless File.file?(file)
129
+
130
+ item_path = File.join(collection_path, name)
131
+ body = File.read(file)
132
+ content_type = guess_content_type(name)
133
+ etag = Protocol::Caldav::ETag.compute(body)
134
+ [item_path, { body: body, content_type: content_type, etag: etag }]
135
+ end
136
+ end
137
+
138
+ def move_item(from_path, to_path)
139
+ src = full_path(from_path)
140
+ dst = full_path(to_path)
141
+ return nil unless File.file?(src)
142
+
143
+ FileUtils.mkdir_p(File.dirname(dst))
144
+ FileUtils.mv(src, dst)
145
+ get_item(to_path)
146
+ end
147
+
148
+ def get_multi(paths)
149
+ paths.map { |p| [p, get_item(p)] }
150
+ end
151
+
152
+ # --- General ---
153
+
154
+ def exists?(path)
155
+ fp = full_path(path)
156
+ File.exist?(fp) || collection_exists?(path)
157
+ end
158
+
159
+ def etag(path)
160
+ item = get_item(path)
161
+ item ? item[:etag] : nil
162
+ end
163
+
164
+ # --- Sync ---
165
+
166
+ def snapshot_sync(collection_path)
167
+ items = list_items(collection_path)
168
+ snapshot = {}
169
+ items.each { |path, data| snapshot[path] = data[:etag] }
170
+
171
+ col = get_collection(collection_path) || {}
172
+ item_etags = items.map { |_, data| data[:etag] }
173
+ ctag = Protocol::Caldav::CTag.compute(
174
+ path: collection_path,
175
+ displayname: col[:displayname],
176
+ description: col[:description],
177
+ color: col[:color],
178
+ item_etags: item_etags
179
+ )
180
+ token = "http://caldav.local/sync/#{ctag}"
181
+ @sync_snapshots[token] = { collection_path => snapshot }
182
+ token
183
+ end
184
+
185
+ def sync_changes(collection_path, token)
186
+ old_snapshot_entry = @sync_snapshots[token]
187
+ return nil unless old_snapshot_entry
188
+
189
+ old_snapshot = old_snapshot_entry[collection_path] || {}
190
+ new_token = snapshot_sync(collection_path)
191
+ current_items = list_items(collection_path)
192
+ current = {}
193
+ current_items.each { |path, data| current[path] = data[:etag] }
194
+
195
+ changes = []
196
+ current.each do |path, etag|
197
+ if !old_snapshot.key?(path) || old_snapshot[path] != etag
198
+ changes << [path, :modified]
199
+ end
200
+ end
201
+ old_snapshot.each_key do |path|
202
+ changes << [path, :deleted] unless current.key?(path)
203
+ end
204
+
205
+ [new_token, changes]
206
+ end
207
+
208
+ private
209
+
210
+ def full_path(path)
211
+ File.join(@root, path)
212
+ end
213
+
214
+ def guess_content_type(path)
215
+ case File.extname(path).downcase
216
+ when ".ics" then "text/calendar"
217
+ when ".vcf" then "text/vcard"
218
+ else "application/octet-stream"
219
+ end
220
+ end
221
+
222
+ def symbolize(hash)
223
+ {
224
+ type: hash["type"]&.to_sym || :collection,
225
+ displayname: hash["displayname"],
226
+ description: hash["description"],
227
+ color: hash["color"],
228
+ props: hash["props"] || {}
229
+ }
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ require 'tmpdir'
237
+
238
+ test do
239
+ describe "Async::Caldav::Storage::Filesystem" do
240
+ it "creates and retrieves a collection" do
241
+ Dir.mktmpdir do |dir|
242
+ s = Async::Caldav::Storage::Filesystem.new(dir)
243
+ s.create_collection("/cal/", type: :calendar, displayname: "Cal")
244
+ col = s.get_collection("/cal/")
245
+ col[:displayname].should.equal "Cal"
246
+ col[:type].should.equal :calendar
247
+ end
248
+ end
249
+
250
+ it "deletes collection and its items" do
251
+ Dir.mktmpdir do |dir|
252
+ s = Async::Caldav::Storage::Filesystem.new(dir)
253
+ s.create_collection("/cal/")
254
+ s.put_item("/cal/ev.ics", "data", "text/calendar")
255
+ s.delete_collection("/cal/")
256
+ s.get_collection("/cal/").should.be.nil
257
+ s.get_item("/cal/ev.ics").should.be.nil
258
+ end
259
+ end
260
+
261
+ it "lists direct child collections" do
262
+ Dir.mktmpdir do |dir|
263
+ s = Async::Caldav::Storage::Filesystem.new(dir)
264
+ s.create_collection("/admin/a/")
265
+ s.create_collection("/admin/b/")
266
+ s.list_collections("/admin/").length.should.equal 2
267
+ end
268
+ end
269
+
270
+ it "updates collection properties" do
271
+ Dir.mktmpdir do |dir|
272
+ s = Async::Caldav::Storage::Filesystem.new(dir)
273
+ s.create_collection("/cal/", displayname: "Old")
274
+ s.update_collection("/cal/", displayname: "New")
275
+ s.get_collection("/cal/")[:displayname].should.equal "New"
276
+ end
277
+ end
278
+
279
+ it "puts and retrieves an item with etag" do
280
+ Dir.mktmpdir do |dir|
281
+ s = Async::Caldav::Storage::Filesystem.new(dir)
282
+ item, is_new = s.put_item("/cal/ev.ics", "BEGIN:VCALENDAR", "text/calendar")
283
+ is_new.should.equal true
284
+ item[:etag].should.not.be.nil
285
+ s.get_item("/cal/ev.ics")[:body].should.equal "BEGIN:VCALENDAR"
286
+ end
287
+ end
288
+
289
+ it "persists across instances" do
290
+ Dir.mktmpdir do |dir|
291
+ s1 = Async::Caldav::Storage::Filesystem.new(dir)
292
+ s1.create_collection("/cal/", type: :calendar, displayname: "Persist")
293
+ s1.put_item("/cal/ev.ics", "body", "text/calendar")
294
+
295
+ s2 = Async::Caldav::Storage::Filesystem.new(dir)
296
+ s2.get_collection("/cal/")[:displayname].should.equal "Persist"
297
+ s2.get_item("/cal/ev.ics")[:body].should.equal "body"
298
+ end
299
+ end
300
+
301
+ it "list_items excludes hidden files" do
302
+ Dir.mktmpdir do |dir|
303
+ s = Async::Caldav::Storage::Filesystem.new(dir)
304
+ s.create_collection("/cal/")
305
+ s.put_item("/cal/ev.ics", "EVENT", "text/calendar")
306
+ items = s.list_items("/cal/")
307
+ items.map(&:first).any? { |p| p.include?(".collection.json") }.should.equal false
308
+ items.map(&:first).any? { |p| p.include?("ev.ics") }.should.equal true
309
+ end
310
+ end
311
+
312
+ it "deletes an item" do
313
+ Dir.mktmpdir do |dir|
314
+ s = Async::Caldav::Storage::Filesystem.new(dir)
315
+ s.put_item("/cal/ev.ics", "data", "text/calendar")
316
+ s.delete_item("/cal/ev.ics").should.equal true
317
+ s.get_item("/cal/ev.ics").should.be.nil
318
+ s.delete_item("/cal/ev.ics").should.equal false
319
+ end
320
+ end
321
+
322
+ it "moves an item" do
323
+ Dir.mktmpdir do |dir|
324
+ s = Async::Caldav::Storage::Filesystem.new(dir)
325
+ s.put_item("/cal/a.ics", "data", "text/calendar")
326
+ s.move_item("/cal/a.ics", "/cal/b.ics")
327
+ s.get_item("/cal/a.ics").should.be.nil
328
+ s.get_item("/cal/b.ics")[:body].should.equal "data"
329
+ end
330
+ end
331
+
332
+ it "reports existence correctly" do
333
+ Dir.mktmpdir do |dir|
334
+ s = Async::Caldav::Storage::Filesystem.new(dir)
335
+ s.exists?("/nope").should.equal false
336
+ s.create_collection("/col/")
337
+ s.exists?("/col/").should.equal true
338
+ s.put_item("/col/x.ics", "d", "text/calendar")
339
+ s.exists?("/col/x.ics").should.equal true
340
+ end
341
+ end
342
+
343
+ it "get_multi returns items and nils" do
344
+ Dir.mktmpdir do |dir|
345
+ s = Async::Caldav::Storage::Filesystem.new(dir)
346
+ s.put_item("/cal/a.ics", "A", "text/calendar")
347
+ result = s.get_multi(["/cal/a.ics", "/cal/nope.ics"])
348
+ result.length.should.equal 2
349
+ result[0][1][:body].should.equal "A"
350
+ result[1][1].should.be.nil
351
+ end
352
+ end
353
+
354
+ it "guesses content types from extension" do
355
+ Dir.mktmpdir do |dir|
356
+ s = Async::Caldav::Storage::Filesystem.new(dir)
357
+ s.put_item("/cal/ev.ics", "cal", "text/calendar")
358
+ s.put_item("/addr/c.vcf", "card", "text/vcard")
359
+ s.get_item("/cal/ev.ics")[:content_type].should.equal "text/calendar"
360
+ s.get_item("/addr/c.vcf")[:content_type].should.equal "text/vcard"
361
+ end
362
+ end
363
+
364
+ it "same body produces same etag across backends" do
365
+ body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR"
366
+ mock = Async::Caldav::Storage::Mock.new
367
+ mock.put_item("/a.ics", body, "text/calendar")
368
+ Dir.mktmpdir do |dir|
369
+ fs = Async::Caldav::Storage::Filesystem.new(dir)
370
+ fs.put_item("/a.ics", body, "text/calendar")
371
+ mock.etag("/a.ics").should.equal fs.etag("/a.ics")
372
+ end
373
+ end
374
+ end
375
+ end