async-caldav 1.2.4 → 1.3.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.
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
- require "async/caldav"
6
3
  require 'json'
7
4
  require 'fileutils'
8
5
 
@@ -15,7 +12,11 @@ module Async
15
12
  @sync_snapshots = {}
16
13
  FileUtils.mkdir_p(@root)
17
14
  @root_normalized = ::File.expand_path(@root)
18
- @root_with_separator = @root_normalized.end_with?(::File::SEPARATOR) ? @root_normalized : "#{@root_normalized}#{::File::SEPARATOR}"
15
+ if @root_normalized.end_with?(::File::SEPARATOR)
16
+ @root_with_separator = @root_normalized
17
+ else
18
+ @root_with_separator = "#{@root_normalized}#{::File::SEPARATOR}"
19
+ end
19
20
  end
20
21
 
21
22
  # --- Collections ---
@@ -24,11 +25,11 @@ module Async
24
25
  dir = full_path(path)
25
26
  FileUtils.mkdir_p(dir)
26
27
  meta = {
27
- "type" => (props[:type] || :collection).to_s,
28
+ "type" => (props[:type] || :collection).to_s,
28
29
  "displayname" => props[:displayname],
29
30
  "description" => props[:description],
30
- "color" => props[:color],
31
- "props" => props[:props] || {}
31
+ "color" => props[:color],
32
+ "props" => props[:props] || {},
32
33
  }
33
34
  File.write(File.join(dir, ".collection.json"), JSON.pretty_generate(meta))
34
35
  symbolize(meta)
@@ -36,8 +37,11 @@ module Async
36
37
 
37
38
  def get_collection(path)
38
39
  meta_file = File.join(full_path(path), ".collection.json")
39
- return nil unless File.exist?(meta_file)
40
- symbolize(JSON.parse(File.read(meta_file)))
40
+ if File.exist?(meta_file)
41
+ symbolize(JSON.parse(File.read(meta_file)))
42
+ else
43
+ nil
44
+ end
41
45
  end
42
46
 
43
47
  def delete_collection(path)
@@ -52,36 +56,49 @@ module Async
52
56
 
53
57
  def list_collections(parent_path)
54
58
  dir = full_path(parent_path)
55
- return [] unless File.directory?(dir)
56
-
57
- Dir.children(dir).filter_map do |name|
58
- child_dir = File.join(dir, name)
59
- meta_file = File.join(child_dir, ".collection.json")
60
- next unless File.directory?(child_dir) && File.exist?(meta_file)
61
-
62
- child_path = File.join(parent_path, name).sub(%r{/*$}, "/")
63
- [child_path, symbolize(JSON.parse(File.read(meta_file)))]
59
+ if File.directory?(dir)
60
+ Dir.children(dir).filter_map do |name|
61
+ child_dir = File.join(dir, name)
62
+ meta_file = File.join(child_dir, ".collection.json")
63
+ unless File.directory?(child_dir) && File.exist?(meta_file)
64
+ next
65
+ end
66
+
67
+ child_path = File.join(parent_path, name).sub(%r{/*$}, "/")
68
+ [child_path, symbolize(JSON.parse(File.read(meta_file)))]
69
+ end
70
+ else
71
+ []
64
72
  end
65
73
  end
66
74
 
67
75
  def update_collection(path, props)
68
76
  col = get_collection(path)
69
- return nil unless col
70
-
71
- col[:displayname] = props[:displayname] if props.key?(:displayname)
72
- col[:description] = props[:description] if props.key?(:description)
73
- col[:color] = props[:color] if props.key?(:color)
74
- col[:props] = (col[:props] || {}).merge(props[:props]) if props.key?(:props)
75
-
76
- meta = {
77
- "type" => col[:type].to_s,
78
- "displayname" => col[:displayname],
79
- "description" => col[:description],
80
- "color" => col[:color],
81
- "props" => col[:props] || {}
82
- }
83
- File.write(File.join(full_path(path), ".collection.json"), JSON.pretty_generate(meta))
84
- col
77
+ if col
78
+ if props.key?(:displayname)
79
+ col[:displayname] = props[:displayname]
80
+ end
81
+ if props.key?(:description)
82
+ col[:description] = props[:description]
83
+ end
84
+ if props.key?(:color)
85
+ col[:color] = props[:color]
86
+ end
87
+ if props.key?(:props)
88
+ col[:props] = (col[:props] || {}).merge(props[:props])
89
+ end
90
+ meta = {
91
+ "type" => col[:type].to_s,
92
+ "displayname" => col[:displayname],
93
+ "description" => col[:description],
94
+ "color" => col[:color],
95
+ "props" => col[:props] || {},
96
+ }
97
+ File.write(File.join(full_path(path), ".collection.json"), JSON.pretty_generate(meta))
98
+ col
99
+ else
100
+ nil
101
+ end
85
102
  end
86
103
 
87
104
  def collection_exists?(path)
@@ -124,18 +141,24 @@ module Async
124
141
 
125
142
  def list_items(collection_path)
126
143
  dir = full_path(collection_path)
127
- return [] unless File.directory?(dir)
128
-
129
- Dir.children(dir).filter_map do |name|
130
- next if name.start_with?(".")
131
- file = File.join(dir, name)
132
- next unless File.file?(file)
133
-
134
- item_path = File.join(collection_path, name)
135
- body = File.read(file)
136
- content_type = guess_content_type(name)
137
- etag = Protocol::Caldav::ETag.compute(body)
138
- [item_path, { body: body, content_type: content_type, etag: etag }]
144
+ if File.directory?(dir)
145
+ Dir.children(dir).filter_map do |name|
146
+ if name.start_with?(".")
147
+ next
148
+ end
149
+ file = File.join(dir, name)
150
+ unless File.file?(file)
151
+ next
152
+ end
153
+
154
+ item_path = File.join(collection_path, name)
155
+ body = File.read(file)
156
+ content_type = guess_content_type(name)
157
+ etag = Protocol::Caldav::ETag.compute(body)
158
+ [item_path, { body: body, content_type: content_type, etag: etag }]
159
+ end
160
+ else
161
+ []
139
162
  end
140
163
  end
141
164
 
@@ -180,11 +203,11 @@ module Async
180
203
  col = get_collection(collection_path) || {}
181
204
  item_etags = items.map { |_, data| data[:etag] }
182
205
  ctag = Protocol::Caldav::CTag.compute(
183
- path: collection_path,
206
+ path: collection_path,
184
207
  displayname: col[:displayname],
185
208
  description: col[:description],
186
- color: col[:color],
187
- item_etags: item_etags
209
+ color: col[:color],
210
+ item_etags: item_etags,
188
211
  )
189
212
  token = "http://caldav.local/sync/#{ctag}"
190
213
  @sync_snapshots[token] = { collection_path => snapshot }
@@ -193,69 +216,71 @@ module Async
193
216
 
194
217
  def sync_changes(collection_path, token)
195
218
  old_snapshot_entry = @sync_snapshots[token]
196
- return nil unless old_snapshot_entry
197
-
198
- old_snapshot = old_snapshot_entry[collection_path] || {}
199
- new_token = snapshot_sync(collection_path)
200
- current_items = list_items(collection_path)
201
- current = {}
202
- current_items.each { |path, data| current[path] = data[:etag] }
203
-
204
- changes = []
205
- current.each do |path, etag|
206
- if !old_snapshot.key?(path) || old_snapshot[path] != etag
207
- changes << [path, :modified]
219
+ if old_snapshot_entry
220
+ old_snapshot = old_snapshot_entry[collection_path] || {}
221
+ new_token = snapshot_sync(collection_path)
222
+ current_items = list_items(collection_path)
223
+ current = {}
224
+ current_items.each { |path, data| current[path] = data[:etag] }
225
+ changes = []
226
+ current.each do |path, etag|
227
+ if !old_snapshot.key?(path) || old_snapshot[path] != etag
228
+ changes << [path, :modified]
229
+ end
208
230
  end
231
+ old_snapshot.each_key do |path|
232
+ unless current.key?(path)
233
+ changes << [path, :deleted]
234
+ end
235
+ end
236
+ [new_token, changes]
237
+ else
238
+ nil
209
239
  end
210
- old_snapshot.each_key do |path|
211
- changes << [path, :deleted] unless current.key?(path)
212
- end
213
-
214
- [new_token, changes]
215
240
  end
216
241
 
217
242
  private
218
243
 
219
- def full_path(path)
220
- if valid_path?(path)
221
- expanded = ::File.expand_path(::File.join(@root, path))
222
- if expanded == @root_normalized || expanded.start_with?(@root_with_separator)
223
- expanded
244
+ def full_path(path)
245
+ if valid_path?(path)
246
+ expanded = ::File.expand_path(::File.join(@root, path))
247
+ if expanded == @root_normalized || expanded.start_with?(@root_with_separator)
248
+ expanded
249
+ else
250
+ raise ArgumentError, "path escapes root: #{path.inspect}"
251
+ end
224
252
  else
225
- raise ArgumentError, "path escapes root: #{path.inspect}"
253
+ raise ArgumentError, "invalid path: #{path.inspect}"
226
254
  end
227
- else
228
- raise ArgumentError, "invalid path: #{path.inspect}"
229
255
  end
230
- end
231
256
 
232
- def valid_path?(path)
233
- path.is_a?(String) && path.valid_encoding? && !path.include?("\0")
234
- end
257
+ def valid_path?(path)
258
+ path.is_a?(String) && path.valid_encoding? && !path.include?("\0")
259
+ end
235
260
 
236
- def stat(path)
237
- ::File.stat(full_path(path))
238
- rescue Errno::ENOENT, Errno::ELOOP
239
- nil
240
- end
261
+ def stat(path)
262
+ ::File.stat(full_path(path))
263
+ rescue Errno::ENOENT, Errno::ELOOP
264
+ nil
265
+ end
241
266
 
242
- def guess_content_type(path)
243
- case File.extname(path).downcase
244
- when ".ics" then "text/calendar"
245
- when ".vcf" then "text/vcard"
246
- else "application/octet-stream"
267
+ def guess_content_type(path)
268
+ case File.extname(path).downcase
269
+ when ".ics" then "text/calendar"
270
+ when ".vcf" then "text/vcard"
271
+ else "application/octet-stream"
272
+ end
247
273
  end
248
- end
249
274
 
250
- def symbolize(hash)
251
- {
252
- type: hash["type"]&.to_sym || :collection,
253
- displayname: hash["displayname"],
254
- description: hash["description"],
255
- color: hash["color"],
256
- props: hash["props"] || {}
257
- }
258
- end
275
+ def symbolize(hash)
276
+ {
277
+ type: hash["type"]&.to_sym || :collection,
278
+ displayname: hash["displayname"],
279
+ description: hash["description"],
280
+ color: hash["color"],
281
+ props: hash["props"] || {},
282
+ }
283
+ end
259
284
  end
260
285
  end
261
286
  end
@@ -263,156 +288,158 @@ end
263
288
 
264
289
  require 'tmpdir'
265
290
 
266
- test do
267
- describe "Async::Caldav::Storage::Filesystem" do
268
- it "creates and retrieves a collection" do
269
- Dir.mktmpdir do |dir|
270
- s = Async::Caldav::Storage::Filesystem.new(dir)
271
- s.create_collection("/cal/", type: :calendar, displayname: "Cal")
272
- col = s.get_collection("/cal/")
273
- col[:displayname].should.equal "Cal"
274
- col[:type].should.equal :calendar
275
- end
291
+ __END__
292
+
293
+ require_relative "../../caldav"
294
+
295
+ describe "Async::Caldav::Storage::Filesystem" do
296
+ it "creates and retrieves a collection" do
297
+ Dir.mktmpdir do |dir|
298
+ s = Async::Caldav::Storage::Filesystem.new(dir)
299
+ s.create_collection("/cal/", type: :calendar, displayname: "Cal")
300
+ col = s.get_collection("/cal/")
301
+ col[:displayname].should.equal "Cal"
302
+ col[:type].should.equal :calendar
276
303
  end
304
+ end
277
305
 
278
- it "deletes collection and its items" do
279
- Dir.mktmpdir do |dir|
280
- s = Async::Caldav::Storage::Filesystem.new(dir)
281
- s.create_collection("/cal/")
282
- s.put_item("/cal/ev.ics", "data", "text/calendar")
283
- s.delete_collection("/cal/")
284
- s.get_collection("/cal/").should.be.nil
285
- s.get_item("/cal/ev.ics").should.be.nil
286
- end
306
+ it "deletes collection and its items" do
307
+ Dir.mktmpdir do |dir|
308
+ s = Async::Caldav::Storage::Filesystem.new(dir)
309
+ s.create_collection("/cal/")
310
+ s.put_item("/cal/ev.ics", "data", "text/calendar")
311
+ s.delete_collection("/cal/")
312
+ s.get_collection("/cal/").should.be.nil
313
+ s.get_item("/cal/ev.ics").should.be.nil
287
314
  end
315
+ end
288
316
 
289
- it "lists direct child collections" do
290
- Dir.mktmpdir do |dir|
291
- s = Async::Caldav::Storage::Filesystem.new(dir)
292
- s.create_collection("/admin/a/")
293
- s.create_collection("/admin/b/")
294
- s.list_collections("/admin/").length.should.equal 2
295
- end
317
+ it "lists direct child collections" do
318
+ Dir.mktmpdir do |dir|
319
+ s = Async::Caldav::Storage::Filesystem.new(dir)
320
+ s.create_collection("/admin/a/")
321
+ s.create_collection("/admin/b/")
322
+ s.list_collections("/admin/").length.should.equal 2
296
323
  end
324
+ end
297
325
 
298
- it "updates collection properties" do
299
- Dir.mktmpdir do |dir|
300
- s = Async::Caldav::Storage::Filesystem.new(dir)
301
- s.create_collection("/cal/", displayname: "Old")
302
- s.update_collection("/cal/", displayname: "New")
303
- s.get_collection("/cal/")[:displayname].should.equal "New"
304
- end
326
+ it "updates collection properties" do
327
+ Dir.mktmpdir do |dir|
328
+ s = Async::Caldav::Storage::Filesystem.new(dir)
329
+ s.create_collection("/cal/", displayname: "Old")
330
+ s.update_collection("/cal/", displayname: "New")
331
+ s.get_collection("/cal/")[:displayname].should.equal "New"
305
332
  end
333
+ end
306
334
 
307
- it "puts and retrieves an item with etag" do
308
- Dir.mktmpdir do |dir|
309
- s = Async::Caldav::Storage::Filesystem.new(dir)
310
- item, is_new = s.put_item("/cal/ev.ics", "BEGIN:VCALENDAR", "text/calendar")
311
- is_new.should.equal true
312
- item[:etag].should.not.be.nil
313
- s.get_item("/cal/ev.ics")[:body].should.equal "BEGIN:VCALENDAR"
314
- end
335
+ it "puts and retrieves an item with etag" do
336
+ Dir.mktmpdir do |dir|
337
+ s = Async::Caldav::Storage::Filesystem.new(dir)
338
+ item, is_new = s.put_item("/cal/ev.ics", "BEGIN:VCALENDAR", "text/calendar")
339
+ is_new.should.equal true
340
+ item[:etag].should.not.be.nil
341
+ s.get_item("/cal/ev.ics")[:body].should.equal "BEGIN:VCALENDAR"
315
342
  end
343
+ end
316
344
 
317
- it "persists across instances" do
318
- Dir.mktmpdir do |dir|
319
- s1 = Async::Caldav::Storage::Filesystem.new(dir)
320
- s1.create_collection("/cal/", type: :calendar, displayname: "Persist")
321
- s1.put_item("/cal/ev.ics", "body", "text/calendar")
345
+ it "persists across instances" do
346
+ Dir.mktmpdir do |dir|
347
+ s1 = Async::Caldav::Storage::Filesystem.new(dir)
348
+ s1.create_collection("/cal/", type: :calendar, displayname: "Persist")
349
+ s1.put_item("/cal/ev.ics", "body", "text/calendar")
322
350
 
323
- s2 = Async::Caldav::Storage::Filesystem.new(dir)
324
- s2.get_collection("/cal/")[:displayname].should.equal "Persist"
325
- s2.get_item("/cal/ev.ics")[:body].should.equal "body"
326
- end
351
+ s2 = Async::Caldav::Storage::Filesystem.new(dir)
352
+ s2.get_collection("/cal/")[:displayname].should.equal "Persist"
353
+ s2.get_item("/cal/ev.ics")[:body].should.equal "body"
327
354
  end
355
+ end
328
356
 
329
- it "list_items excludes hidden files" do
330
- Dir.mktmpdir do |dir|
331
- s = Async::Caldav::Storage::Filesystem.new(dir)
332
- s.create_collection("/cal/")
333
- s.put_item("/cal/ev.ics", "EVENT", "text/calendar")
334
- items = s.list_items("/cal/")
335
- items.map(&:first).any? { |p| p.include?(".collection.json") }.should.equal false
336
- items.map(&:first).any? { |p| p.include?("ev.ics") }.should.equal true
337
- end
357
+ it "list_items excludes hidden files" do
358
+ Dir.mktmpdir do |dir|
359
+ s = Async::Caldav::Storage::Filesystem.new(dir)
360
+ s.create_collection("/cal/")
361
+ s.put_item("/cal/ev.ics", "EVENT", "text/calendar")
362
+ items = s.list_items("/cal/")
363
+ items.map(&:first).any? { |p| p.include?(".collection.json") }.should.equal false
364
+ items.map(&:first).any? { |p| p.include?("ev.ics") }.should.equal true
338
365
  end
366
+ end
339
367
 
340
- it "deletes an item" do
341
- Dir.mktmpdir do |dir|
342
- s = Async::Caldav::Storage::Filesystem.new(dir)
343
- s.put_item("/cal/ev.ics", "data", "text/calendar")
344
- s.delete_item("/cal/ev.ics").should.equal true
345
- s.get_item("/cal/ev.ics").should.be.nil
346
- s.delete_item("/cal/ev.ics").should.equal false
347
- end
368
+ it "deletes an item" do
369
+ Dir.mktmpdir do |dir|
370
+ s = Async::Caldav::Storage::Filesystem.new(dir)
371
+ s.put_item("/cal/ev.ics", "data", "text/calendar")
372
+ s.delete_item("/cal/ev.ics").should.equal true
373
+ s.get_item("/cal/ev.ics").should.be.nil
374
+ s.delete_item("/cal/ev.ics").should.equal false
348
375
  end
376
+ end
349
377
 
350
- it "moves an item" do
351
- Dir.mktmpdir do |dir|
352
- s = Async::Caldav::Storage::Filesystem.new(dir)
353
- s.put_item("/cal/a.ics", "data", "text/calendar")
354
- s.move_item("/cal/a.ics", "/cal/b.ics")
355
- s.get_item("/cal/a.ics").should.be.nil
356
- s.get_item("/cal/b.ics")[:body].should.equal "data"
357
- end
378
+ it "moves an item" do
379
+ Dir.mktmpdir do |dir|
380
+ s = Async::Caldav::Storage::Filesystem.new(dir)
381
+ s.put_item("/cal/a.ics", "data", "text/calendar")
382
+ s.move_item("/cal/a.ics", "/cal/b.ics")
383
+ s.get_item("/cal/a.ics").should.be.nil
384
+ s.get_item("/cal/b.ics")[:body].should.equal "data"
358
385
  end
386
+ end
359
387
 
360
- it "reports existence correctly" do
361
- Dir.mktmpdir do |dir|
362
- s = Async::Caldav::Storage::Filesystem.new(dir)
363
- s.exists?("/nope").should.equal false
364
- s.create_collection("/col/")
365
- s.exists?("/col/").should.equal true
366
- s.put_item("/col/x.ics", "d", "text/calendar")
367
- s.exists?("/col/x.ics").should.equal true
368
- end
388
+ it "reports existence correctly" do
389
+ Dir.mktmpdir do |dir|
390
+ s = Async::Caldav::Storage::Filesystem.new(dir)
391
+ s.exists?("/nope").should.equal false
392
+ s.create_collection("/col/")
393
+ s.exists?("/col/").should.equal true
394
+ s.put_item("/col/x.ics", "d", "text/calendar")
395
+ s.exists?("/col/x.ics").should.equal true
369
396
  end
397
+ end
370
398
 
371
- it "get_multi returns items and nils" do
372
- Dir.mktmpdir do |dir|
373
- s = Async::Caldav::Storage::Filesystem.new(dir)
374
- s.put_item("/cal/a.ics", "A", "text/calendar")
375
- result = s.get_multi(["/cal/a.ics", "/cal/nope.ics"])
376
- result.length.should.equal 2
377
- result[0][1][:body].should.equal "A"
378
- result[1][1].should.be.nil
379
- end
399
+ it "get_multi returns items and nils" do
400
+ Dir.mktmpdir do |dir|
401
+ s = Async::Caldav::Storage::Filesystem.new(dir)
402
+ s.put_item("/cal/a.ics", "A", "text/calendar")
403
+ result = s.get_multi(["/cal/a.ics", "/cal/nope.ics"])
404
+ result.length.should.equal 2
405
+ result[0][1][:body].should.equal "A"
406
+ result[1][1].should.be.nil
380
407
  end
408
+ end
381
409
 
382
- it "guesses content types from extension" do
383
- Dir.mktmpdir do |dir|
384
- s = Async::Caldav::Storage::Filesystem.new(dir)
385
- s.put_item("/cal/ev.ics", "cal", "text/calendar")
386
- s.put_item("/addr/c.vcf", "card", "text/vcard")
387
- s.get_item("/cal/ev.ics")[:content_type].should.equal "text/calendar"
388
- s.get_item("/addr/c.vcf")[:content_type].should.equal "text/vcard"
389
- end
410
+ it "guesses content types from extension" do
411
+ Dir.mktmpdir do |dir|
412
+ s = Async::Caldav::Storage::Filesystem.new(dir)
413
+ s.put_item("/cal/ev.ics", "cal", "text/calendar")
414
+ s.put_item("/addr/c.vcf", "card", "text/vcard")
415
+ s.get_item("/cal/ev.ics")[:content_type].should.equal "text/calendar"
416
+ s.get_item("/addr/c.vcf")[:content_type].should.equal "text/vcard"
390
417
  end
418
+ end
391
419
 
392
- it "rejects path traversal attempts" do
393
- Dir.mktmpdir do |dir|
394
- s = Async::Caldav::Storage::Filesystem.new(dir)
395
- lambda { s.get_item("/../../etc/passwd") }.should.raise ArgumentError
396
- lambda { s.put_item("/cal/../../escape.ics", "x", "text/calendar") }.should.raise ArgumentError
397
- end
420
+ it "rejects path traversal attempts" do
421
+ Dir.mktmpdir do |dir|
422
+ s = Async::Caldav::Storage::Filesystem.new(dir)
423
+ lambda { s.get_item("/../../etc/passwd") }.should.raise ArgumentError
424
+ lambda { s.put_item("/cal/../../escape.ics", "x", "text/calendar") }.should.raise ArgumentError
398
425
  end
426
+ end
399
427
 
400
- it "rejects NUL byte in path" do
401
- Dir.mktmpdir do |dir|
402
- s = Async::Caldav::Storage::Filesystem.new(dir)
403
- lambda { s.get_item("/cal\0/x.ics") }.should.raise ArgumentError
404
- end
428
+ it "rejects NUL byte in path" do
429
+ Dir.mktmpdir do |dir|
430
+ s = Async::Caldav::Storage::Filesystem.new(dir)
431
+ lambda { s.get_item("/cal\0/x.ics") }.should.raise ArgumentError
405
432
  end
433
+ end
406
434
 
407
- it "same body produces same etag across backends" do
408
- body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR"
409
- mock = Async::Caldav::Storage::Mock.new
410
- mock.put_item("/a.ics", body, "text/calendar")
411
- Dir.mktmpdir do |dir|
412
- fs = Async::Caldav::Storage::Filesystem.new(dir)
413
- fs.put_item("/a.ics", body, "text/calendar")
414
- mock.etag("/a.ics").should.equal fs.etag("/a.ics")
415
- end
435
+ it "same body produces same etag across backends" do
436
+ body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR"
437
+ mock = Async::Caldav::Storage::Mock.new
438
+ mock.put_item("/a.ics", body, "text/calendar")
439
+ Dir.mktmpdir do |dir|
440
+ fs = Async::Caldav::Storage::Filesystem.new(dir)
441
+ fs.put_item("/a.ics", body, "text/calendar")
442
+ mock.etag("/a.ics").should.equal fs.etag("/a.ics")
416
443
  end
417
444
  end
418
- end
445
+ end