protocol-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.
- checksums.yaml +7 -0
- data/lib/protocol/caldav/collection.rb +173 -0
- data/lib/protocol/caldav/constants.rb +54 -0
- data/lib/protocol/caldav/content_line.rb +154 -0
- data/lib/protocol/caldav/ctag.rb +78 -0
- data/lib/protocol/caldav/etag.rb +51 -0
- data/lib/protocol/caldav/filter/addressbook.rb +65 -0
- data/lib/protocol/caldav/filter/calendar.rb +83 -0
- data/lib/protocol/caldav/filter/match.rb +776 -0
- data/lib/protocol/caldav/filter/parser.rb +391 -0
- data/lib/protocol/caldav/ical/component.rb +84 -0
- data/lib/protocol/caldav/ical/expand.rb +208 -0
- data/lib/protocol/caldav/ical/freebusy.rb +161 -0
- data/lib/protocol/caldav/ical/parser.rb +191 -0
- data/lib/protocol/caldav/ical/property.rb +39 -0
- data/lib/protocol/caldav/ical/rrule.rb +399 -0
- data/lib/protocol/caldav/item.rb +120 -0
- data/lib/protocol/caldav/multistatus.rb +61 -0
- data/lib/protocol/caldav/path.rb +185 -0
- data/lib/protocol/caldav/storage.rb +120 -0
- data/lib/protocol/caldav/vcard/card.rb +58 -0
- data/lib/protocol/caldav/vcard/parser.rb +88 -0
- data/lib/protocol/caldav/version.rb +7 -0
- data/lib/protocol/caldav/xml.rb +122 -0
- data/lib/protocol/caldav.rb +30 -0
- metadata +95 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
require 'rexml/document'
|
|
7
|
+
require "protocol/caldav"
|
|
8
|
+
|
|
9
|
+
module Protocol
|
|
10
|
+
module Caldav
|
|
11
|
+
module Filter
|
|
12
|
+
module Parser
|
|
13
|
+
CALDAV_NS = Constants::CALDAV_NS
|
|
14
|
+
CARDDAV_NS = Constants::CARDDAV_NS
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def parse_calendar(xml_string)
|
|
19
|
+
return nil if xml_string.nil? || xml_string.strip.empty?
|
|
20
|
+
|
|
21
|
+
doc = REXML::Document.new(xml_string)
|
|
22
|
+
ns = { 'c' => CALDAV_NS }
|
|
23
|
+
|
|
24
|
+
filter_el = REXML::XPath.first(doc, './/c:filter', ns)
|
|
25
|
+
return nil unless filter_el
|
|
26
|
+
|
|
27
|
+
comp_el = REXML::XPath.first(filter_el, 'c:comp-filter', ns)
|
|
28
|
+
return nil unless comp_el
|
|
29
|
+
|
|
30
|
+
parse_comp_filter(comp_el, ns)
|
|
31
|
+
rescue REXML::ParseException => e
|
|
32
|
+
raise ParseError, "Malformed XML: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_addressbook(xml_string)
|
|
36
|
+
return nil if xml_string.nil? || xml_string.strip.empty?
|
|
37
|
+
|
|
38
|
+
doc = REXML::Document.new(xml_string)
|
|
39
|
+
ns = { 'cr' => CARDDAV_NS }
|
|
40
|
+
|
|
41
|
+
filter_el = REXML::XPath.first(doc, './/cr:filter', ns)
|
|
42
|
+
return nil unless filter_el
|
|
43
|
+
|
|
44
|
+
test = filter_el.attributes['test'] || 'anyof'
|
|
45
|
+
prop_filters = REXML::XPath.match(filter_el, 'cr:prop-filter', ns).map do |el|
|
|
46
|
+
parse_addressbook_prop_filter(el, ns)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Addressbook::Filter.new(test: test, prop_filters: prop_filters)
|
|
50
|
+
rescue REXML::ParseException => e
|
|
51
|
+
raise ParseError, "Malformed XML: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Private helpers ---
|
|
55
|
+
|
|
56
|
+
def parse_comp_filter(el, ns)
|
|
57
|
+
name = el.attributes['name']
|
|
58
|
+
raise ParseError, "comp-filter missing required 'name' attribute" unless name
|
|
59
|
+
|
|
60
|
+
is_not_defined = REXML::XPath.first(el, 'c:is-not-defined', ns) != nil
|
|
61
|
+
time_range = parse_time_range(REXML::XPath.first(el, 'c:time-range', ns))
|
|
62
|
+
|
|
63
|
+
prop_filters = REXML::XPath.match(el, 'c:prop-filter', ns).map do |pf_el|
|
|
64
|
+
parse_prop_filter(pf_el, ns)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
comp_filters = REXML::XPath.match(el, 'c:comp-filter', ns).map do |cf_el|
|
|
68
|
+
parse_comp_filter(cf_el, ns)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Calendar::CompFilter.new(
|
|
72
|
+
name: name,
|
|
73
|
+
is_not_defined: is_not_defined,
|
|
74
|
+
time_range: time_range,
|
|
75
|
+
prop_filters: prop_filters,
|
|
76
|
+
comp_filters: comp_filters
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_prop_filter(el, ns)
|
|
81
|
+
name = el.attributes['name']
|
|
82
|
+
raise ParseError, "prop-filter missing required 'name' attribute" unless name
|
|
83
|
+
|
|
84
|
+
is_not_defined = REXML::XPath.first(el, 'c:is-not-defined', ns) != nil
|
|
85
|
+
time_range = parse_time_range(REXML::XPath.first(el, 'c:time-range', ns))
|
|
86
|
+
text_match = parse_text_match(REXML::XPath.first(el, 'c:text-match', ns))
|
|
87
|
+
|
|
88
|
+
param_filters = REXML::XPath.match(el, 'c:param-filter', ns).map do |pf_el|
|
|
89
|
+
parse_param_filter(pf_el, ns)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Calendar::PropFilter.new(
|
|
93
|
+
name: name,
|
|
94
|
+
is_not_defined: is_not_defined,
|
|
95
|
+
time_range: time_range,
|
|
96
|
+
text_match: text_match,
|
|
97
|
+
param_filters: param_filters
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_param_filter(el, ns)
|
|
102
|
+
name = el.attributes['name']
|
|
103
|
+
is_not_defined = REXML::XPath.first(el, 'c:is-not-defined', ns) != nil
|
|
104
|
+
text_match = parse_text_match(REXML::XPath.first(el, 'c:text-match', ns))
|
|
105
|
+
|
|
106
|
+
Calendar::ParamFilter.new(name: name, is_not_defined: is_not_defined, text_match: text_match)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_text_match(el)
|
|
110
|
+
return nil unless el
|
|
111
|
+
|
|
112
|
+
Calendar::TextMatch.new(
|
|
113
|
+
value: el.text&.strip || '',
|
|
114
|
+
collation: el.attributes['collation'] || 'i;ascii-casemap',
|
|
115
|
+
match_type: el.attributes['match-type'] || 'contains',
|
|
116
|
+
negate_condition: el.attributes['negate-condition'] == 'yes'
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_time_range(el)
|
|
121
|
+
return nil unless el
|
|
122
|
+
|
|
123
|
+
Calendar::TimeRange.new(
|
|
124
|
+
start_time: el.attributes['start'],
|
|
125
|
+
end_time: el.attributes['end']
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_addressbook_prop_filter(el, ns)
|
|
130
|
+
name = el.attributes['name']
|
|
131
|
+
raise ParseError, "prop-filter missing required 'name' attribute" unless name
|
|
132
|
+
|
|
133
|
+
is_not_defined = REXML::XPath.first(el, 'cr:is-not-defined', ns) != nil
|
|
134
|
+
text_match_el = REXML::XPath.first(el, 'cr:text-match', ns)
|
|
135
|
+
text_match = if text_match_el
|
|
136
|
+
Addressbook::TextMatch.new(
|
|
137
|
+
value: text_match_el.text&.strip || '',
|
|
138
|
+
collation: text_match_el.attributes['collation'] || 'i;ascii-casemap',
|
|
139
|
+
match_type: text_match_el.attributes['match-type'] || 'contains',
|
|
140
|
+
negate_condition: text_match_el.attributes['negate-condition'] == 'yes'
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
param_filters = REXML::XPath.match(el, 'cr:param-filter', ns).map do |pf_el|
|
|
145
|
+
pf_name = pf_el.attributes['name']
|
|
146
|
+
pf_is_not_defined = REXML::XPath.first(pf_el, 'cr:is-not-defined', ns) != nil
|
|
147
|
+
pf_text_match_el = REXML::XPath.first(pf_el, 'cr:text-match', ns)
|
|
148
|
+
pf_text_match = if pf_text_match_el
|
|
149
|
+
Addressbook::TextMatch.new(
|
|
150
|
+
value: pf_text_match_el.text&.strip || '',
|
|
151
|
+
collation: pf_text_match_el.attributes['collation'] || 'i;ascii-casemap',
|
|
152
|
+
match_type: pf_text_match_el.attributes['match-type'] || 'contains',
|
|
153
|
+
negate_condition: pf_text_match_el.attributes['negate-condition'] == 'yes'
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
Addressbook::ParamFilter.new(name: pf_name, is_not_defined: pf_is_not_defined, text_match: pf_text_match)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Addressbook::PropFilter.new(
|
|
160
|
+
name: name,
|
|
161
|
+
is_not_defined: is_not_defined,
|
|
162
|
+
text_match: text_match,
|
|
163
|
+
param_filters: param_filters
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private_class_method :parse_comp_filter, :parse_prop_filter, :parse_param_filter,
|
|
168
|
+
:parse_text_match, :parse_time_range, :parse_addressbook_prop_filter
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class ParseError < StandardError; end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
test do
|
|
178
|
+
describe "Protocol::Caldav::Filter::Parser" do
|
|
179
|
+
describe "calendar parser" do
|
|
180
|
+
it "parses a single comp-filter" do
|
|
181
|
+
xml = '<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav"><c:comp-filter name="VCALENDAR"/></c:filter>'
|
|
182
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
183
|
+
f.name.should.equal "VCALENDAR"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "parses nested comp-filters" do
|
|
187
|
+
xml = <<~XML
|
|
188
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
189
|
+
<c:comp-filter name="VCALENDAR">
|
|
190
|
+
<c:comp-filter name="VEVENT"/>
|
|
191
|
+
</c:comp-filter>
|
|
192
|
+
</c:filter>
|
|
193
|
+
XML
|
|
194
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
195
|
+
f.comp_filters.length.should.equal 1
|
|
196
|
+
f.comp_filters[0].name.should.equal "VEVENT"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "parses is-not-defined" do
|
|
200
|
+
xml = <<~XML
|
|
201
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
202
|
+
<c:comp-filter name="VCALENDAR">
|
|
203
|
+
<c:comp-filter name="VTODO"><c:is-not-defined/></c:comp-filter>
|
|
204
|
+
</c:comp-filter>
|
|
205
|
+
</c:filter>
|
|
206
|
+
XML
|
|
207
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
208
|
+
f.comp_filters[0].is_not_defined.should.equal true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "parses prop-filter with no children as defined-only check" do
|
|
212
|
+
xml = <<~XML
|
|
213
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
214
|
+
<c:comp-filter name="VCALENDAR">
|
|
215
|
+
<c:prop-filter name="SUMMARY"/>
|
|
216
|
+
</c:comp-filter>
|
|
217
|
+
</c:filter>
|
|
218
|
+
XML
|
|
219
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
220
|
+
f.prop_filters.length.should.equal 1
|
|
221
|
+
f.prop_filters[0].name.should.equal "SUMMARY"
|
|
222
|
+
f.prop_filters[0].text_match.should.be.nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "parses text-match with default collation and match-type" do
|
|
226
|
+
xml = <<~XML
|
|
227
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
228
|
+
<c:comp-filter name="VCALENDAR">
|
|
229
|
+
<c:prop-filter name="SUMMARY">
|
|
230
|
+
<c:text-match>Meeting</c:text-match>
|
|
231
|
+
</c:prop-filter>
|
|
232
|
+
</c:comp-filter>
|
|
233
|
+
</c:filter>
|
|
234
|
+
XML
|
|
235
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
236
|
+
tm = f.prop_filters[0].text_match
|
|
237
|
+
tm.value.should.equal "Meeting"
|
|
238
|
+
tm.collation.should.equal "i;ascii-casemap"
|
|
239
|
+
tm.match_type.should.equal "contains"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "parses text-match with explicit attributes" do
|
|
243
|
+
xml = <<~XML
|
|
244
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
245
|
+
<c:comp-filter name="VCALENDAR">
|
|
246
|
+
<c:prop-filter name="SUMMARY">
|
|
247
|
+
<c:text-match collation="i;octet" match-type="equals">X</c:text-match>
|
|
248
|
+
</c:prop-filter>
|
|
249
|
+
</c:comp-filter>
|
|
250
|
+
</c:filter>
|
|
251
|
+
XML
|
|
252
|
+
tm = Protocol::Caldav::Filter::Parser.parse_calendar(xml).prop_filters[0].text_match
|
|
253
|
+
tm.collation.should.equal "i;octet"
|
|
254
|
+
tm.match_type.should.equal "equals"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "parses negate-condition" do
|
|
258
|
+
xml = <<~XML
|
|
259
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
260
|
+
<c:comp-filter name="VCALENDAR">
|
|
261
|
+
<c:prop-filter name="SUMMARY">
|
|
262
|
+
<c:text-match negate-condition="yes">X</c:text-match>
|
|
263
|
+
</c:prop-filter>
|
|
264
|
+
</c:comp-filter>
|
|
265
|
+
</c:filter>
|
|
266
|
+
XML
|
|
267
|
+
tm = Protocol::Caldav::Filter::Parser.parse_calendar(xml).prop_filters[0].text_match
|
|
268
|
+
tm.negate_condition.should.equal true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
it "parses time-range with start and end" do
|
|
272
|
+
xml = <<~XML
|
|
273
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
274
|
+
<c:comp-filter name="VCALENDAR">
|
|
275
|
+
<c:comp-filter name="VEVENT">
|
|
276
|
+
<c:time-range start="20260101T000000Z" end="20260201T000000Z"/>
|
|
277
|
+
</c:comp-filter>
|
|
278
|
+
</c:comp-filter>
|
|
279
|
+
</c:filter>
|
|
280
|
+
XML
|
|
281
|
+
tr = Protocol::Caldav::Filter::Parser.parse_calendar(xml).comp_filters[0].time_range
|
|
282
|
+
tr.start_time.should.equal "20260101T000000Z"
|
|
283
|
+
tr.end_time.should.equal "20260201T000000Z"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it "parses time-range with only start" do
|
|
287
|
+
xml = <<~XML
|
|
288
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
289
|
+
<c:comp-filter name="VCALENDAR">
|
|
290
|
+
<c:comp-filter name="VEVENT">
|
|
291
|
+
<c:time-range start="20260101T000000Z"/>
|
|
292
|
+
</c:comp-filter>
|
|
293
|
+
</c:comp-filter>
|
|
294
|
+
</c:filter>
|
|
295
|
+
XML
|
|
296
|
+
tr = Protocol::Caldav::Filter::Parser.parse_calendar(xml).comp_filters[0].time_range
|
|
297
|
+
tr.start_time.should.equal "20260101T000000Z"
|
|
298
|
+
tr.end_time.should.be.nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "returns nil for missing filter element" do
|
|
302
|
+
Protocol::Caldav::Filter::Parser.parse_calendar('<d:propfind xmlns:d="DAV:"/>').should.be.nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "returns nil for empty filter" do
|
|
306
|
+
Protocol::Caldav::Filter::Parser.parse_calendar('<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav"/>').should.be.nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "returns nil for nil input" do
|
|
310
|
+
Protocol::Caldav::Filter::Parser.parse_calendar(nil).should.be.nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "accepts any namespace prefix bound to caldav NS" do
|
|
314
|
+
xml = '<cal:filter xmlns:cal="urn:ietf:params:xml:ns:caldav"><cal:comp-filter name="VCALENDAR"/></cal:filter>'
|
|
315
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
316
|
+
f.name.should.equal "VCALENDAR"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "parses param-filter inside prop-filter" do
|
|
320
|
+
xml = <<~XML
|
|
321
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
322
|
+
<c:comp-filter name="VCALENDAR">
|
|
323
|
+
<c:comp-filter name="VEVENT">
|
|
324
|
+
<c:prop-filter name="ATTENDEE">
|
|
325
|
+
<c:param-filter name="PARTSTAT">
|
|
326
|
+
<c:text-match>ACCEPTED</c:text-match>
|
|
327
|
+
</c:param-filter>
|
|
328
|
+
</c:prop-filter>
|
|
329
|
+
</c:comp-filter>
|
|
330
|
+
</c:comp-filter>
|
|
331
|
+
</c:filter>
|
|
332
|
+
XML
|
|
333
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
334
|
+
pf = f.comp_filters[0].prop_filters[0]
|
|
335
|
+
pf.name.should.equal "ATTENDEE"
|
|
336
|
+
pf.param_filters.length.should.equal 1
|
|
337
|
+
pf.param_filters[0].name.should.equal "PARTSTAT"
|
|
338
|
+
pf.param_filters[0].text_match.value.should.equal "ACCEPTED"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "parses param-filter with is-not-defined" do
|
|
342
|
+
xml = <<~XML
|
|
343
|
+
<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
344
|
+
<c:comp-filter name="VCALENDAR">
|
|
345
|
+
<c:comp-filter name="VEVENT">
|
|
346
|
+
<c:prop-filter name="ATTENDEE">
|
|
347
|
+
<c:param-filter name="PARTSTAT">
|
|
348
|
+
<c:is-not-defined/>
|
|
349
|
+
</c:param-filter>
|
|
350
|
+
</c:prop-filter>
|
|
351
|
+
</c:comp-filter>
|
|
352
|
+
</c:comp-filter>
|
|
353
|
+
</c:filter>
|
|
354
|
+
XML
|
|
355
|
+
f = Protocol::Caldav::Filter::Parser.parse_calendar(xml)
|
|
356
|
+
pf = f.comp_filters[0].prop_filters[0].param_filters[0]
|
|
357
|
+
pf.is_not_defined.should.equal true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "raises on comp-filter with no name attribute" do
|
|
361
|
+
xml = '<c:filter xmlns:c="urn:ietf:params:xml:ns:caldav"><c:comp-filter/></c:filter>'
|
|
362
|
+
lambda { Protocol::Caldav::Filter::Parser.parse_calendar(xml) }.should.raise Protocol::Caldav::ParseError
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
describe "addressbook parser" do
|
|
367
|
+
it "parses a prop-filter" do
|
|
368
|
+
xml = '<cr:filter xmlns:cr="urn:ietf:params:xml:ns:carddav"><cr:prop-filter name="FN"/></cr:filter>'
|
|
369
|
+
f = Protocol::Caldav::Filter::Parser.parse_addressbook(xml)
|
|
370
|
+
f.prop_filters.length.should.equal 1
|
|
371
|
+
f.prop_filters[0].name.should.equal "FN"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it "parses test attribute on filter" do
|
|
375
|
+
xml = '<cr:filter xmlns:cr="urn:ietf:params:xml:ns:carddav" test="allof"><cr:prop-filter name="FN"/></cr:filter>'
|
|
376
|
+
f = Protocol::Caldav::Filter::Parser.parse_addressbook(xml)
|
|
377
|
+
f.test.should.equal "allof"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it "defaults test to anyof" do
|
|
381
|
+
xml = '<cr:filter xmlns:cr="urn:ietf:params:xml:ns:carddav"><cr:prop-filter name="FN"/></cr:filter>'
|
|
382
|
+
f = Protocol::Caldav::Filter::Parser.parse_addressbook(xml)
|
|
383
|
+
f.test.should.equal "anyof"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it "returns nil for nil input" do
|
|
387
|
+
Protocol::Caldav::Filter::Parser.parse_addressbook(nil).should.be.nil
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "protocol/caldav"
|
|
6
|
+
|
|
7
|
+
module Protocol
|
|
8
|
+
module Caldav
|
|
9
|
+
module Ical
|
|
10
|
+
Component = Struct.new(:name, :properties, :components, keyword_init: true) do
|
|
11
|
+
def initialize(name:, properties: [], components: [])
|
|
12
|
+
super(name: name, properties: properties, components: components)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def find_property(prop_name)
|
|
16
|
+
properties.find { |p| p.name.casecmp?(prop_name) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_all_properties(prop_name)
|
|
20
|
+
properties.select { |p| p.name.casecmp?(prop_name) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_components(comp_name)
|
|
24
|
+
components.select { |c| c.name.casecmp?(comp_name) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
test do
|
|
32
|
+
describe "Protocol::Caldav::Ical::Component" do
|
|
33
|
+
def prop(name, value, params: {})
|
|
34
|
+
Protocol::Caldav::Ical::Property.new(name: name, params: params, value: value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def comp(name, properties: [], components: [])
|
|
38
|
+
Protocol::Caldav::Ical::Component.new(name: name, properties: properties, components: components)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "find_property returns the first property with that name" do
|
|
42
|
+
c = comp("VEVENT", properties: [prop("SUMMARY", "First"), prop("SUMMARY", "Second")])
|
|
43
|
+
c.find_property("SUMMARY").value.should.equal "First"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "find_property is case-insensitive" do
|
|
47
|
+
c = comp("VEVENT", properties: [prop("SUMMARY", "Hello")])
|
|
48
|
+
c.find_property("summary").value.should.equal "Hello"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "find_property returns nil when absent" do
|
|
52
|
+
c = comp("VEVENT", properties: [])
|
|
53
|
+
c.find_property("SUMMARY").should.be.nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "find_components returns all matching children" do
|
|
57
|
+
c = comp("VCALENDAR", components: [comp("VEVENT"), comp("VTODO"), comp("VEVENT")])
|
|
58
|
+
c.find_components("VEVENT").length.should.equal 2
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "find_components returns empty array when none match" do
|
|
62
|
+
c = comp("VCALENDAR", components: [comp("VEVENT")])
|
|
63
|
+
c.find_components("VTODO").should.equal []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "find_components does not recurse into grandchildren" do
|
|
67
|
+
inner = comp("VALARM")
|
|
68
|
+
vevent = comp("VEVENT", components: [inner])
|
|
69
|
+
vcal = comp("VCALENDAR", components: [vevent])
|
|
70
|
+
vcal.find_components("VALARM").should.equal []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "a component with no properties or sub-components is valid" do
|
|
74
|
+
c = comp("VEVENT")
|
|
75
|
+
c.properties.should.equal []
|
|
76
|
+
c.components.should.equal []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "find_all_properties returns all matching" do
|
|
80
|
+
c = comp("VEVENT", properties: [prop("ATTENDEE", "a"), prop("SUMMARY", "x"), prop("ATTENDEE", "b")])
|
|
81
|
+
c.find_all_properties("ATTENDEE").length.should.equal 2
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
require "protocol/caldav"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Caldav
|
|
10
|
+
module Ical
|
|
11
|
+
module Expand
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Expand a recurring VCALENDAR into individual instances.
|
|
15
|
+
# Returns a serialized VCALENDAR string with RRULE removed
|
|
16
|
+
# and each occurrence as a separate VEVENT with RECURRENCE-ID.
|
|
17
|
+
def expand(component, range_start:, range_end:, max_occurrences: 10000)
|
|
18
|
+
return serialize_component(component) unless component.name.casecmp?('VCALENDAR')
|
|
19
|
+
|
|
20
|
+
# Find the base VEVENT with RRULE
|
|
21
|
+
base = component.find_components('VEVENT').find { |v| v.find_property('RRULE') }
|
|
22
|
+
return serialize_component(component) unless base
|
|
23
|
+
|
|
24
|
+
# Collect override VEVENTs (same UID, has RECURRENCE-ID)
|
|
25
|
+
uid = base.find_property('UID')&.value
|
|
26
|
+
overrides = {}
|
|
27
|
+
component.find_components('VEVENT').each do |v|
|
|
28
|
+
rid = v.find_property('RECURRENCE-ID')
|
|
29
|
+
if rid && v.find_property('UID')&.value == uid
|
|
30
|
+
rid_time = Filter::Match.send(:parse_datetime_string, rid.value.strip)
|
|
31
|
+
overrides[rid_time] = v if rid_time
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
dtstart_prop = base.find_property('DTSTART')
|
|
36
|
+
dtstart = Filter::Match.send(:parse_datetime_string, dtstart_prop.value.strip)
|
|
37
|
+
return serialize_component(component) unless dtstart
|
|
38
|
+
|
|
39
|
+
dtend = Filter::Match.send(:parse_ical_datetime, base, 'DTEND')
|
|
40
|
+
duration = dtend ? (dtend - dtstart).to_i : 3600
|
|
41
|
+
|
|
42
|
+
rrule = base.find_property('RRULE').value.strip
|
|
43
|
+
exdates = base.find_all_properties('EXDATE').filter_map do |ex|
|
|
44
|
+
Filter::Match.send(:parse_datetime_string, ex.value.strip)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
occurrences = Rrule.expand(
|
|
48
|
+
dtstart: dtstart,
|
|
49
|
+
rrule_value: rrule,
|
|
50
|
+
range_start: range_start,
|
|
51
|
+
range_end: range_end,
|
|
52
|
+
exdates: exdates,
|
|
53
|
+
max_count: max_occurrences
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
instances = occurrences.map do |occ_start|
|
|
57
|
+
override = overrides[occ_start]
|
|
58
|
+
if override
|
|
59
|
+
serialize_vevent_instance(override, occ_start)
|
|
60
|
+
else
|
|
61
|
+
serialize_expanded_instance(base, occ_start, duration)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Also include overrides that aren't in the base RRULE expansion
|
|
66
|
+
overrides.each do |rid_time, override_vevent|
|
|
67
|
+
next if occurrences.any? { |o| Rrule.send(:times_equal?, o, rid_time) }
|
|
68
|
+
odt = Filter::Match.send(:parse_ical_datetime, override_vevent, 'DTSTART')
|
|
69
|
+
next unless odt && odt >= range_start && odt < range_end
|
|
70
|
+
instances << serialize_vevent_instance(override_vevent, rid_time)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
"BEGIN:VCALENDAR\r\n#{instances.join}END:VCALENDAR\r\n"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def serialize_expanded_instance(base, occ_start, duration)
|
|
77
|
+
occ_end = occ_start + duration
|
|
78
|
+
lines = []
|
|
79
|
+
lines << "BEGIN:VEVENT"
|
|
80
|
+
lines << "RECURRENCE-ID:#{format_utc(occ_start)}"
|
|
81
|
+
lines << "DTSTART:#{format_utc(occ_start)}"
|
|
82
|
+
lines << "DTEND:#{format_utc(occ_end)}"
|
|
83
|
+
|
|
84
|
+
base.properties.each do |prop|
|
|
85
|
+
next if %w[DTSTART DTEND RRULE EXDATE RDATE EXRULE RECURRENCE-ID DURATION].include?(prop.name.upcase)
|
|
86
|
+
lines << "#{prop.name}:#{prop.value}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines << "END:VEVENT"
|
|
90
|
+
lines.map { |l| "#{l}\r\n" }.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def serialize_vevent_instance(vevent, rid_time)
|
|
94
|
+
lines = []
|
|
95
|
+
lines << "BEGIN:VEVENT"
|
|
96
|
+
has_rid = false
|
|
97
|
+
vevent.properties.each do |prop|
|
|
98
|
+
next if %w[RRULE EXDATE RDATE EXRULE].include?(prop.name.upcase)
|
|
99
|
+
has_rid = true if prop.name.casecmp?('RECURRENCE-ID')
|
|
100
|
+
lines << "#{prop.name}:#{prop.value}"
|
|
101
|
+
end
|
|
102
|
+
lines.insert(1, "RECURRENCE-ID:#{format_utc(rid_time)}") unless has_rid
|
|
103
|
+
lines << "END:VEVENT"
|
|
104
|
+
lines.map { |l| "#{l}\r\n" }.join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def serialize_component(component)
|
|
108
|
+
lines = ["BEGIN:#{component.name}\r\n"]
|
|
109
|
+
component.properties.each { |p| lines << "#{p.name}:#{p.value}\r\n" }
|
|
110
|
+
component.components.each { |c| lines << serialize_component(c) }
|
|
111
|
+
lines << "END:#{component.name}\r\n"
|
|
112
|
+
lines.join
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def format_utc(time)
|
|
116
|
+
time.utc.strftime('%Y%m%dT%H%M%SZ')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private_class_method :serialize_expanded_instance, :serialize_vevent_instance,
|
|
120
|
+
:serialize_component, :format_utc
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
test do
|
|
127
|
+
describe "Protocol::Caldav::Ical::Expand" do
|
|
128
|
+
def parse(text)
|
|
129
|
+
Protocol::Caldav::Ical::Parser.parse(text)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "returns serialized component when not VCALENDAR" do
|
|
133
|
+
vevent = parse("BEGIN:VEVENT\r\nSUMMARY:Hello\r\nEND:VEVENT")
|
|
134
|
+
result = Protocol::Caldav::Ical::Expand.expand(vevent, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
|
|
135
|
+
result.should.include "BEGIN:VEVENT"
|
|
136
|
+
result.should.include "SUMMARY:Hello"
|
|
137
|
+
result.should.include "END:VEVENT"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "returns serialized component when no RRULE found" do
|
|
141
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
142
|
+
component = parse(ical)
|
|
143
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
|
|
144
|
+
result.should.include "BEGIN:VCALENDAR"
|
|
145
|
+
result.should.include "SUMMARY:Once"
|
|
146
|
+
result.should.include "END:VCALENDAR"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "expands daily RRULE into individual instances" do
|
|
150
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:expand-test\r\nSUMMARY:Daily\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
151
|
+
component = parse(ical)
|
|
152
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
|
|
153
|
+
result.scan("BEGIN:VEVENT").length.should.equal 3
|
|
154
|
+
result.should.include "RECURRENCE-ID"
|
|
155
|
+
result.should.include "SUMMARY:Daily"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "removes RRULE from expanded output" do
|
|
159
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nUID:no-rrule\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
160
|
+
component = parse(ical)
|
|
161
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
|
|
162
|
+
result.should.not.include "RRULE"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "applies override VEVENT replacing base occurrence" do
|
|
166
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:override-test\r\nSUMMARY:Base\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTART:20260102T140000Z\r\nDTEND:20260102T150000Z\r\nRECURRENCE-ID:20260102T090000Z\r\nUID:override-test\r\nSUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
167
|
+
component = parse(ical)
|
|
168
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
|
|
169
|
+
result.scan("BEGIN:VEVENT").length.should.equal 3
|
|
170
|
+
result.should.include "SUMMARY:Override"
|
|
171
|
+
result.should.include "SUMMARY:Base"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "non-recurring event passes through unchanged" do
|
|
175
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nUID:single\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
176
|
+
component = parse(ical)
|
|
177
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 12, 31))
|
|
178
|
+
result.scan("BEGIN:VEVENT").length.should.equal 1
|
|
179
|
+
result.should.include "SUMMARY:Once"
|
|
180
|
+
result.should.not.include "RECURRENCE-ID"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "respects EXDATE exclusions" do
|
|
184
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE:20260102T090000Z\r\nUID:exdate-test\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
185
|
+
component = parse(ical)
|
|
186
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
|
|
187
|
+
result.scan("BEGIN:VEVENT").length.should.equal 2
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "only returns instances within range" do
|
|
191
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=10\r\nUID:range-test\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
192
|
+
component = parse(ical)
|
|
193
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 3), range_end: Time.utc(2026, 1, 6))
|
|
194
|
+
result.scan("BEGIN:VEVENT").length.should.equal 3
|
|
195
|
+
result.should.include "20260103T090000Z"
|
|
196
|
+
result.should.include "20260105T090000Z"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "preserves non-recurrence properties in expanded instances" do
|
|
200
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nUID:props-test\r\nSUMMARY:Keep Me\r\nLOCATION:Room 1\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
201
|
+
component = parse(ical)
|
|
202
|
+
result = Protocol::Caldav::Ical::Expand.expand(component, range_start: Time.utc(2026, 1, 1), range_end: Time.utc(2026, 1, 10))
|
|
203
|
+
# Both instances should have SUMMARY and LOCATION
|
|
204
|
+
result.scan("SUMMARY:Keep Me").length.should.equal 2
|
|
205
|
+
result.scan("LOCATION:Room 1").length.should.equal 2
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|