async-caldav 1.2.3 → 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,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
- require "async/caldav"
6
-
7
3
  module Async
8
4
  module Caldav
9
5
  module Handlers
@@ -13,99 +9,108 @@ module Async
13
9
  def call(path:, body:, storage:, resource_type: nil, **)
14
10
  # Detect sync-collection report
15
11
  if body&.include?('sync-collection')
16
- return handle_sync_collection(path: path, body: body, storage: storage)
17
- end
18
- col_path = path.ensure_trailing_slash
19
- items = storage.list_items(col_path.to_s)
20
-
21
- data_tag = resource_type == :addressbook ? 'cr:address-data' : 'c:calendar-data'
22
-
23
- # Parse expand-property if present
24
- expand_range = parse_expand(body)
25
-
26
- # Parse filter if present (multiget requests have no filter — ignore parse errors)
27
- filter = begin
12
+ handle_sync_collection(path: path, body: body, storage: storage)
13
+ else
14
+ col_path = path.ensure_trailing_slash
15
+ items = storage.list_items(col_path.to_s)
28
16
  if resource_type == :addressbook
29
- Protocol::Caldav::Filter::Parser.parse_addressbook(body)
17
+ data_tag = 'cr:address-data'
30
18
  else
31
- Protocol::Caldav::Filter::Parser.parse_calendar(body)
19
+ data_tag = 'c:calendar-data'
32
20
  end
33
- rescue Protocol::Caldav::ParseError
34
- nil
35
- end
36
-
37
- # Check for multiget (href list)
38
- hrefs = extract_hrefs(body)
39
-
40
- if hrefs && !hrefs.empty?
41
- # Calendar-multiget / addressbook-multiget
42
- multi = storage.get_multi(hrefs)
43
- items = multi.select { |_, data| data }
44
- end
45
-
46
- xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
47
- items.each do |item_path, data|
48
- next unless data
21
+ expand_range = parse_expand(body)
22
+ filter = parse_filter(body, resource_type)
23
+ hrefs = extract_hrefs(body)
24
+ if hrefs && !hrefs.empty?
25
+ # Calendar-multiget / addressbook-multiget
26
+ multi = storage.get_multi(hrefs)
27
+ items = multi.select { |_, data| data }
28
+ end
29
+ xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
30
+ items.each do |item_path, data|
31
+ unless data
32
+ next
33
+ end
49
34
 
50
- # Apply filter
51
- if filter
52
- if resource_type == :addressbook
53
- card = Protocol::Caldav::Vcard::Parser.parse(data[:body])
54
- next unless card && Protocol::Caldav::Filter::Match.addressbook?(filter, card)
55
- else
56
- component = Protocol::Caldav::Ical::Parser.parse(data[:body])
57
- next unless component && Protocol::Caldav::Filter::Match.calendar?(filter, component)
35
+ # Apply filter
36
+ if filter
37
+ if resource_type == :addressbook
38
+ card = Protocol::Caldav::Vcard::Parser.parse(data[:body])
39
+ unless card && Protocol::Caldav::Filter::Match.addressbook?(filter, card)
40
+ next
41
+ end
42
+ else
43
+ component = Protocol::Caldav::Ical::Parser.parse(data[:body])
44
+ unless component && Protocol::Caldav::Filter::Match.calendar?(filter, component)
45
+ next
46
+ end
47
+ end
58
48
  end
59
- end
60
49
 
61
- item_body = data[:body]
62
-
63
- # Apply expand if requested (calendar items only)
64
- if expand_range && resource_type != :addressbook
65
- component = Protocol::Caldav::Ical::Parser.parse(item_body)
66
- if component
67
- item_body = Protocol::Caldav::Ical::Expand.expand(
68
- component,
69
- range_start: expand_range[:start],
70
- range_end: expand_range[:end]
71
- )
50
+ item_body = data[:body]
51
+
52
+ # Apply expand if requested (calendar items only)
53
+ if expand_range && resource_type != :addressbook
54
+ component = Protocol::Caldav::Ical::Parser.parse(item_body)
55
+ if component
56
+ item_body = Protocol::Caldav::Ical::Expand.expand(
57
+ component,
58
+ range_start: expand_range[:start],
59
+ range_end: expand_range[:end],
60
+ )
61
+ end
72
62
  end
73
- end
74
63
 
75
- item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
76
- item = Protocol::Caldav::Item.new(
77
- path: item_p,
78
- body: item_body,
79
- content_type: data[:content_type],
80
- etag: data[:etag]
81
- )
82
- item.build_report(x, data_tag: data_tag)
64
+ item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
65
+ item = Protocol::Caldav::Item.new(
66
+ path: item_p,
67
+ body: item_body,
68
+ content_type: data[:content_type],
69
+ etag: data[:etag],
70
+ )
71
+ item.build_report(x, data_tag: data_tag)
72
+ end
83
73
  end
74
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
84
75
  end
76
+ end
85
77
 
86
- [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
78
+ def parse_filter(body, resource_type)
79
+ if resource_type == :addressbook
80
+ Protocol::Caldav::Filter::Parser.parse_addressbook(body)
81
+ else
82
+ Protocol::Caldav::Filter::Parser.parse_calendar(body)
83
+ end
84
+ rescue Protocol::Caldav::ParseError
85
+ nil
87
86
  end
88
87
 
89
88
  def handle_sync_collection(path:, body:, storage:)
90
89
  col_path = path.ensure_trailing_slash.to_s
91
-
92
- # Extract sync-token from request
93
- token_match = body.match(/<[^>]*sync-token[^>]*>(?:<!\[CDATA\[)?([^<\]]*?)(?:\]\]>)?</)
94
- old_token = token_match ? token_match[1].strip : nil
95
- old_token = nil if old_token&.empty?
90
+ old_token = extract_sync_token(body)
96
91
 
97
92
  if old_token
98
- # Incremental sync
99
- result = storage.sync_changes(col_path, old_token)
100
- unless result
101
- error_xml = Builder::XmlMarkup.new
102
- error_xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
103
- error_xml.tag!("d:error", "xmlns:d" => "DAV:") do
104
- error_xml.tag!("d:valid-sync-token")
105
- end
106
- return [403, { 'content-type' => 'application/xml' }, [error_xml.target!]]
93
+ incremental_sync(col_path, old_token, storage)
94
+ else
95
+ initial_sync(col_path, storage)
96
+ end
97
+ end
98
+
99
+ def extract_sync_token(body)
100
+ token_match = body.match(/<[^>]*sync-token[^>]*>(?:<!\[CDATA\[)?([^<\]]*?)(?:\]\]>)?</)
101
+ if token_match
102
+ token = token_match[1].strip
103
+ if token.empty?
104
+ nil
105
+ else
106
+ token
107
107
  end
108
+ end
109
+ end
108
110
 
111
+ def incremental_sync(col_path, old_token, storage)
112
+ result = storage.sync_changes(col_path, old_token)
113
+ if result
109
114
  new_token, changes = result
110
115
  xml = Protocol::Caldav::XmlBuilder.multistatus do |x|
111
116
  changes.each do |item_path, status|
@@ -124,116 +129,142 @@ module Async
124
129
  end
125
130
  x.tag!("d:sync-token", new_token)
126
131
  end
132
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
127
133
  else
128
- # Initial sync return all items
129
- new_token = storage.snapshot_sync(col_path)
130
- items = storage.list_items(col_path)
131
- xml = Protocol::Caldav::XmlBuilder.multistatus do |x|
132
- items.each do |item_path, data|
133
- Protocol::Caldav::XmlBuilder.response(x, href: item_path) do
134
- Protocol::Caldav::XmlBuilder.propstat_ok(x) do
135
- x.tag!("d:getetag", data[:etag])
136
- end
134
+ # The token names a snapshot the storage no longer holds; RFC 6578
135
+ # says to answer with valid-sync-token so the client resyncs whole.
136
+ error_xml = Builder::XmlMarkup.new
137
+ error_xml.instruct! :xml, version: "1.0", encoding: "UTF-8"
138
+ error_xml.tag!("d:error", "xmlns:d" => "DAV:") do
139
+ error_xml.tag!("d:valid-sync-token")
140
+ end
141
+ [403, { 'content-type' => 'application/xml' }, [error_xml.target!]]
142
+ end
143
+ end
144
+
145
+ def initial_sync(col_path, storage)
146
+ new_token = storage.snapshot_sync(col_path)
147
+ items = storage.list_items(col_path)
148
+ xml = Protocol::Caldav::XmlBuilder.multistatus do |x|
149
+ items.each do |item_path, data|
150
+ Protocol::Caldav::XmlBuilder.response(x, href: item_path) do
151
+ Protocol::Caldav::XmlBuilder.propstat_ok(x) do
152
+ x.tag!("d:getetag", data[:etag])
137
153
  end
138
154
  end
139
- x.tag!("d:sync-token", new_token)
140
155
  end
156
+ x.tag!("d:sync-token", new_token)
141
157
  end
142
158
 
143
159
  [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
144
160
  end
145
161
 
146
162
  def parse_expand(body)
147
- return nil unless body
148
- match = body.match(/<[^>]*expand[^>]*start\s*=\s*["']([^"']+)["'][^>]*end\s*=\s*["']([^"']+)["']/)
149
- return nil unless match
150
- start_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[1])
151
- end_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[2])
152
- return nil unless start_time && end_time
153
- { start: start_time, end: end_time }
163
+ if body
164
+ match = body.match(/<[^>]*expand[^>]*start\s*=\s*["']([^"']+)["'][^>]*end\s*=\s*["']([^"']+)["']/)
165
+ if match
166
+ start_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[1])
167
+ end_time = Protocol::Caldav::Filter::Match.send(:parse_datetime_string, match[2])
168
+ if start_time && end_time
169
+ { start: start_time, end: end_time }
170
+ else
171
+ nil
172
+ end
173
+ else
174
+ nil
175
+ end
176
+ else
177
+ nil
178
+ end
154
179
  end
155
180
 
156
181
  def extract_hrefs(body)
157
- return nil unless body
158
- body.scan(/<[^>]*href[^>]*>([^<]+)</).map { |m| m[0].strip }
182
+ if body
183
+ body.scan(/<[^>]*href[^>]*>([^<]+)</).map { |m| m[0].strip }
184
+ else
185
+ nil
186
+ end
159
187
  end
160
188
 
161
189
  private_class_method :extract_hrefs, :handle_sync_collection, :parse_expand
190
+ private_class_method :parse_filter, :extract_sync_token, :incremental_sync, :initial_sync
162
191
  end
163
192
  end
164
193
  end
165
194
  end
166
195
 
167
- test do
168
- def normalize(xml)
169
- xml.gsub(/>\s+</, '><').strip
170
- end
196
+ __END__
171
197
 
172
- describe "Async::Caldav::Handlers::Report" do
173
- def call(**opts)
174
- Async::Caldav::Handlers::Report.call(**opts)
175
- end
198
+ require_relative "../../caldav"
176
199
 
177
- def path(p, s)
178
- Protocol::Caldav::Path.new(p, storage_class: s)
179
- end
200
+ def normalize(xml)
201
+ xml.gsub(/>\s+</, '><').strip
202
+ end
180
203
 
181
- it "returns 207 with all items when no filter" do
182
- s = Async::Caldav::Storage::Mock.new
183
- s.create_collection('/cal/')
184
- s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:A\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
185
- s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
186
- status, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
187
- status.should.equal 207
188
- body[0].should.include 'a.ics'
189
- body[0].should.include 'b.ics'
190
- end
204
+ describe "Async::Caldav::Handlers::Report" do
205
+ def call(**opts)
206
+ Async::Caldav::Handlers::Report.call(**opts)
207
+ end
191
208
 
192
- it "filters items by comp-filter" do
193
- s = Async::Caldav::Storage::Mock.new
194
- s.create_collection('/cal/')
195
- s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
196
- s.put_item('/cal/td.ics', "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
197
-
198
- filter_xml = <<~XML
199
- <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
200
- <c:comp-filter name="VCALENDAR">
201
- <c:comp-filter name="VEVENT"/>
202
- </c:comp-filter>
203
- </c:filter>
204
- XML
205
-
206
- _, _, body = call(path: path('/cal/', s), storage: s, body: filter_xml, resource_type: :calendar)
207
- body[0].should.include 'ev.ics'
208
- body[0].should.not.include 'td.ics'
209
- end
209
+ def path(p, s)
210
+ Protocol::Caldav::Path.new(p, storage_class: s)
211
+ end
210
212
 
211
- it "uses c:calendar-data tag for calendars" do
212
- s = Async::Caldav::Storage::Mock.new
213
- s.create_collection('/cal/')
214
- s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
215
- _, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
216
- body[0].should.include 'c:calendar-data'
217
- end
213
+ it "returns 207 with all items when no filter" do
214
+ s = Async::Caldav::Storage::Mock.new
215
+ s.create_collection('/cal/')
216
+ s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:A\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
217
+ s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
218
+ status, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
219
+ status.should.equal 207
220
+ body[0].should.include 'a.ics'
221
+ body[0].should.include 'b.ics'
222
+ end
218
223
 
219
- it "uses cr:address-data tag for addressbooks" do
220
- s = Async::Caldav::Storage::Mock.new
221
- s.create_collection('/addr/')
222
- s.put_item('/addr/c.vcf', "BEGIN:VCARD\r\nFN:John\r\nEND:VCARD", 'text/vcard')
223
- _, _, body = call(path: path('/addr/', s), storage: s, body: '', resource_type: :addressbook)
224
- body[0].should.include 'cr:address-data'
225
- end
224
+ it "filters items by comp-filter" do
225
+ s = Async::Caldav::Storage::Mock.new
226
+ s.create_collection('/cal/')
227
+ s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
228
+ s.put_item('/cal/td.ics', "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
229
+
230
+ filter_xml = <<~XML
231
+ <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
232
+ <c:comp-filter name="VCALENDAR">
233
+ <c:comp-filter name="VEVENT"/>
234
+ </c:comp-filter>
235
+ </c:filter>
236
+ XML
237
+
238
+ _, _, body = call(path: path('/cal/', s), storage: s, body: filter_xml, resource_type: :calendar)
239
+ body[0].should.include 'ev.ics'
240
+ body[0].should.not.include 'td.ics'
241
+ end
226
242
 
227
- it "handles calendar-multiget with hrefs" do
228
- s = Async::Caldav::Storage::Mock.new
229
- s.create_collection('/cal/')
230
- s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
231
- s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
243
+ it "uses c:calendar-data tag for calendars" do
244
+ s = Async::Caldav::Storage::Mock.new
245
+ s.create_collection('/cal/')
246
+ s.put_item('/cal/ev.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
247
+ _, _, body = call(path: path('/cal/', s), storage: s, body: '', resource_type: :calendar)
248
+ body[0].should.include 'c:calendar-data'
249
+ end
232
250
 
233
- multiget_body = '<d:href>/cal/a.ics</d:href>'
234
- _, _, body = call(path: path('/cal/', s), storage: s, body: multiget_body, resource_type: :calendar)
235
- body[0].should.include 'a.ics'
236
- body[0].should.not.include 'b.ics'
237
- end
251
+ it "uses cr:address-data tag for addressbooks" do
252
+ s = Async::Caldav::Storage::Mock.new
253
+ s.create_collection('/addr/')
254
+ s.put_item('/addr/c.vcf', "BEGIN:VCARD\r\nFN:John\r\nEND:VCARD", 'text/vcard')
255
+ _, _, body = call(path: path('/addr/', s), storage: s, body: '', resource_type: :addressbook)
256
+ body[0].should.include 'cr:address-data'
238
257
  end
239
- end
258
+
259
+ it "handles calendar-multiget with hrefs" do
260
+ s = Async::Caldav::Storage::Mock.new
261
+ s.create_collection('/cal/')
262
+ s.put_item('/cal/a.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
263
+ s.put_item('/cal/b.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
264
+
265
+ multiget_body = '<d:href>/cal/a.ics</d:href>'
266
+ _, _, body = call(path: path('/cal/', s), storage: s, body: multiget_body, resource_type: :calendar)
267
+ body[0].should.include 'a.ics'
268
+ body[0].should.not.include 'b.ics'
269
+ end
270
+ end