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.
@@ -0,0 +1,776 @@
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 Filter
11
+ module Match
12
+ module_function
13
+
14
+ # --- Calendar (RFC 4791 §9.7) ---
15
+
16
+ def calendar?(filter, component)
17
+ return false unless component
18
+ comp_filter_matches?(filter, component)
19
+ end
20
+
21
+ # --- Addressbook (RFC 6352 §10.5) ---
22
+
23
+ def addressbook?(filter, card)
24
+ return false unless card
25
+ return true if filter.prop_filters.empty?
26
+
27
+ if filter.test == 'allof'
28
+ filter.prop_filters.all? { |pf| card_prop_filter_matches?(pf, card) }
29
+ else
30
+ filter.prop_filters.any? { |pf| card_prop_filter_matches?(pf, card) }
31
+ end
32
+ end
33
+
34
+ # --- Private: Calendar matching ---
35
+
36
+ def comp_filter_matches?(filter, component)
37
+ return false unless component.name.casecmp?(filter.name)
38
+ return false if filter.is_not_defined
39
+
40
+ # Time-range check on this component
41
+ return false if filter.time_range && !time_range_matches?(filter.time_range, component)
42
+
43
+ # All nested prop-filters must match (AND)
44
+ return false unless filter.prop_filters.all? { |pf| prop_filter_matches?(pf, component) }
45
+
46
+ # All nested comp-filters must match
47
+ filter.comp_filters.all? do |cf|
48
+ children = component.find_components(cf.name)
49
+ if cf.is_not_defined
50
+ children.empty?
51
+ else
52
+ children.any? { |child| comp_filter_matches?(cf, child) }
53
+ end
54
+ end
55
+ end
56
+
57
+ def time_range_matches?(tr, component)
58
+ # Handle recurring events via RRULE expansion
59
+ rrule_prop = component.find_property('RRULE')
60
+ if rrule_prop
61
+ return rrule_time_range_matches?(tr, component, rrule_prop)
62
+ end
63
+
64
+ filter_start = tr.start_time ? parse_datetime_string(tr.start_time) : Time.at(0).utc
65
+ filter_end = tr.end_time ? parse_datetime_string(tr.end_time) : Time.utc(9999)
66
+
67
+ comp_start = parse_ical_datetime(component, 'DTSTART')
68
+
69
+ # VTODO special cases per RFC 4791 §9.9
70
+ if component.name.casecmp?('VTODO')
71
+ due = parse_ical_datetime(component, 'DUE')
72
+ completed = parse_ical_datetime(component, 'COMPLETED')
73
+ created = parse_ical_datetime(component, 'CREATED')
74
+
75
+ if comp_start && due
76
+ return comp_start < filter_end && due > filter_start
77
+ elsif comp_start
78
+ return comp_start < filter_end && (comp_start + 1) > filter_start
79
+ elsif due
80
+ return (due - 1) < filter_end && due > filter_start
81
+ elsif completed
82
+ return completed >= filter_start && completed < filter_end
83
+ elsif created
84
+ return created >= filter_start && created < filter_end
85
+ else
86
+ return true
87
+ end
88
+ end
89
+
90
+ # VJOURNAL: uses DTSTART only (instantaneous)
91
+ if component.name.casecmp?('VJOURNAL')
92
+ return false unless comp_start
93
+ return comp_start >= filter_start && comp_start < filter_end
94
+ end
95
+
96
+ # VEVENT
97
+ comp_end = parse_ical_datetime(component, 'DTEND') ||
98
+ parse_duration_end(component, comp_start) ||
99
+ (comp_start ? comp_start + 1 : nil)
100
+
101
+ return false unless comp_start
102
+
103
+ # Half-open overlap: [comp_start, comp_end) overlaps [filter_start, filter_end)
104
+ comp_start < filter_end && (comp_end || comp_start + 1) > filter_start
105
+ end
106
+
107
+ def parse_ical_datetime(component, prop_name)
108
+ prop = component.find_property(prop_name)
109
+ return nil unless prop
110
+ parse_datetime_string(prop.value.strip)
111
+ end
112
+
113
+ def parse_duration_end(component, start_time)
114
+ return nil unless start_time
115
+ dur = component.find_property('DURATION')
116
+ return nil unless dur
117
+ val = dur.value.strip
118
+ seconds = 0
119
+ if m = val.match(/P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/)
120
+ seconds += (m[1]&.to_i || 0) * 86400
121
+ seconds += (m[2]&.to_i || 0) * 3600
122
+ seconds += (m[3]&.to_i || 0) * 60
123
+ seconds += (m[4]&.to_i || 0)
124
+ end
125
+ seconds > 0 ? start_time + seconds : nil
126
+ end
127
+
128
+ def parse_datetime_string(str)
129
+ return nil unless str && !str.empty?
130
+ str = str.strip
131
+ if str.length == 8 # DATE format: YYYYMMDD
132
+ Time.utc(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
133
+ elsif str.end_with?('Z') # UTC datetime
134
+ s = str.chomp('Z')
135
+ Time.utc(s[0..3].to_i, s[4..5].to_i, s[6..7].to_i, s[9..10].to_i, s[11..12].to_i, s[13..14].to_i)
136
+ else # Floating datetime -- treat as UTC per RFC 4791 §9.9
137
+ Time.utc(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i, str[9..10].to_i, str[11..12].to_i, str[13..14].to_i)
138
+ end
139
+ rescue ArgumentError
140
+ nil
141
+ end
142
+
143
+ def prop_filter_matches?(filter, component)
144
+ properties = component.find_all_properties(filter.name)
145
+
146
+ if filter.is_not_defined
147
+ return properties.empty?
148
+ end
149
+
150
+ return false if properties.empty?
151
+
152
+ properties.any? do |prop|
153
+ next false if filter.text_match && !text_match_matches?(filter.text_match, prop.value)
154
+ filter.param_filters.all? { |pf| param_filter_matches?(pf, prop) }
155
+ end
156
+ end
157
+
158
+ def param_filter_matches?(filter, property)
159
+ param_value = property.param(filter.name)
160
+
161
+ if filter.is_not_defined
162
+ return param_value.nil?
163
+ end
164
+
165
+ return false if param_value.nil?
166
+
167
+ if filter.text_match
168
+ text_match_matches?(filter.text_match, param_value)
169
+ else
170
+ true
171
+ end
172
+ end
173
+
174
+ def text_match_matches?(matcher, value)
175
+ result = case matcher.match_type
176
+ when 'equals' then collate_equal?(matcher.collation, value, matcher.value)
177
+ when 'starts-with' then collate_starts?(matcher.collation, value, matcher.value)
178
+ when 'ends-with' then collate_ends?(matcher.collation, value, matcher.value)
179
+ else collate_contains?(matcher.collation, value, matcher.value)
180
+ end
181
+ matcher.negate_condition ? !result : result
182
+ end
183
+
184
+ def collate_contains?(collation, haystack, needle)
185
+ if collation == 'i;octet'
186
+ haystack.include?(needle)
187
+ else
188
+ haystack.downcase.include?(needle.downcase)
189
+ end
190
+ end
191
+
192
+ def collate_equal?(collation, a, b)
193
+ if collation == 'i;octet'
194
+ a == b
195
+ else
196
+ a.casecmp?(b)
197
+ end
198
+ end
199
+
200
+ def collate_starts?(collation, haystack, needle)
201
+ if collation == 'i;octet'
202
+ haystack.start_with?(needle)
203
+ else
204
+ haystack.downcase.start_with?(needle.downcase)
205
+ end
206
+ end
207
+
208
+ def collate_ends?(collation, haystack, needle)
209
+ if collation == 'i;octet'
210
+ haystack.end_with?(needle)
211
+ else
212
+ haystack.downcase.end_with?(needle.downcase)
213
+ end
214
+ end
215
+
216
+ # --- Private: Addressbook matching ---
217
+
218
+ def card_prop_filter_matches?(filter, card)
219
+ properties = card.find_all_properties(filter.name)
220
+
221
+ if filter.is_not_defined
222
+ return properties.empty?
223
+ end
224
+
225
+ return false if properties.empty?
226
+
227
+ properties.any? do |prop|
228
+ next false if filter.text_match && !text_match_matches?(filter.text_match, prop.value)
229
+ filter.param_filters.all? { |pf| param_filter_matches?(pf, prop) }
230
+ end
231
+ end
232
+
233
+ def rrule_time_range_matches?(tr, component, rrule_prop)
234
+ dtstart = parse_ical_datetime(component, 'DTSTART')
235
+ return false unless dtstart
236
+
237
+ filter_start = tr.start_time ? parse_datetime_string(tr.start_time) : Time.at(0).utc
238
+ filter_end = tr.end_time ? parse_datetime_string(tr.end_time) : Time.utc(9999)
239
+
240
+ exdates = component.find_all_properties('EXDATE').filter_map do |ex|
241
+ parse_datetime_string(ex.value.strip)
242
+ end
243
+
244
+ dtend = parse_ical_datetime(component, 'DTEND')
245
+ duration = if dtend
246
+ dtend - dtstart
247
+ else
248
+ dur_prop = component.find_property('DURATION')
249
+ dur_prop ? parse_duration_seconds(dur_prop.value.strip) : 1
250
+ end
251
+
252
+ adjusted_start = filter_start - [duration, 0].max
253
+ occurrences = Ical::Rrule.expand(
254
+ dtstart: dtstart,
255
+ rrule_value: rrule_prop.value.strip,
256
+ range_start: adjusted_start,
257
+ range_end: filter_end,
258
+ exdates: exdates,
259
+ max_count: 10000
260
+ )
261
+
262
+ occurrences.any? do |occ_start|
263
+ occ_end = occ_start + duration
264
+ occ_start < filter_end && occ_end > filter_start
265
+ end
266
+ end
267
+
268
+ def parse_duration_seconds(val)
269
+ seconds = 0
270
+ if m = val.match(/P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/)
271
+ seconds += (m[1]&.to_i || 0) * 604800
272
+ seconds += (m[2]&.to_i || 0) * 86400
273
+ seconds += (m[3]&.to_i || 0) * 3600
274
+ seconds += (m[4]&.to_i || 0) * 60
275
+ seconds += (m[5]&.to_i || 0)
276
+ end
277
+ seconds > 0 ? seconds : 1
278
+ end
279
+
280
+ private_class_method :comp_filter_matches?, :prop_filter_matches?,
281
+ :param_filter_matches?, :text_match_matches?,
282
+ :collate_contains?, :collate_equal?,
283
+ :collate_starts?, :collate_ends?,
284
+ :card_prop_filter_matches?,
285
+ :time_range_matches?, :parse_ical_datetime,
286
+ :parse_duration_end, :parse_datetime_string,
287
+ :rrule_time_range_matches?, :parse_duration_seconds
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ test do
294
+ def parse_ical(text)
295
+ Protocol::Caldav::Ical::Parser.parse(text)
296
+ end
297
+
298
+ def parse_vcard(text)
299
+ Protocol::Caldav::Vcard::Parser.parse(text)
300
+ end
301
+
302
+ def comp_filter(name, **opts)
303
+ Protocol::Caldav::Filter::Calendar::CompFilter.new(name: name, **opts)
304
+ end
305
+
306
+ def prop_filter(name, **opts)
307
+ Protocol::Caldav::Filter::Calendar::PropFilter.new(name: name, **opts)
308
+ end
309
+
310
+ def text_match(value, **opts)
311
+ Protocol::Caldav::Filter::Calendar::TextMatch.new(value: value, **opts)
312
+ end
313
+
314
+ def ab_filter(**opts)
315
+ Protocol::Caldav::Filter::Addressbook::Filter.new(**opts)
316
+ end
317
+
318
+ def ab_prop_filter(name, **opts)
319
+ Protocol::Caldav::Filter::Addressbook::PropFilter.new(name: name, **opts)
320
+ end
321
+
322
+ def ab_text_match(value, **opts)
323
+ Protocol::Caldav::Filter::Addressbook::TextMatch.new(value: value, **opts)
324
+ end
325
+
326
+ describe "Protocol::Caldav::Filter::Match" do
327
+ describe "CompFilter against component" do
328
+ it "matches when component name equals filter name" do
329
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nEND:VCALENDAR")
330
+ Protocol::Caldav::Filter::Match.calendar?(comp_filter("VCALENDAR"), vcal).should.equal true
331
+ end
332
+
333
+ it "does not match when names differ" do
334
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nEND:VCALENDAR")
335
+ Protocol::Caldav::Filter::Match.calendar?(comp_filter("VEVENT"), vcal).should.equal false
336
+ end
337
+
338
+ it "with is_not_defined: does not match when component present" do
339
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nEND:VCALENDAR")
340
+ Protocol::Caldav::Filter::Match.calendar?(comp_filter("VCALENDAR", is_not_defined: true), vcal).should.equal false
341
+ end
342
+
343
+ it "all nested prop-filters must match (AND semantics)" do
344
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
345
+ f = comp_filter("VCALENDAR", comp_filters: [
346
+ comp_filter("VEVENT", prop_filters: [
347
+ prop_filter("SUMMARY"),
348
+ prop_filter("DESCRIPTION") # absent -> fails
349
+ ])
350
+ ])
351
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
352
+ end
353
+
354
+ it "empty filter matches any component of that name" do
355
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nEND:VCALENDAR")
356
+ Protocol::Caldav::Filter::Match.calendar?(comp_filter("VCALENDAR"), vcal).should.equal true
357
+ end
358
+
359
+ it "is_not_defined on nested comp-filter: matches when absent" do
360
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR")
361
+ f = comp_filter("VCALENDAR", comp_filters: [
362
+ comp_filter("VTODO", is_not_defined: true)
363
+ ])
364
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
365
+ end
366
+
367
+ it "is_not_defined on nested comp-filter: does not match when present" do
368
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR")
369
+ f = comp_filter("VCALENDAR", comp_filters: [
370
+ comp_filter("VEVENT", is_not_defined: true)
371
+ ])
372
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
373
+ end
374
+ end
375
+
376
+ describe "PropFilter" do
377
+ it "matches when property exists (defined-only check)" do
378
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Test\r\nEND:VEVENT\r\nEND:VCALENDAR")
379
+ f = comp_filter("VCALENDAR", comp_filters: [
380
+ comp_filter("VEVENT", prop_filters: [prop_filter("SUMMARY")])
381
+ ])
382
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
383
+ end
384
+
385
+ it "does not match when property absent" do
386
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR")
387
+ f = comp_filter("VCALENDAR", comp_filters: [
388
+ comp_filter("VEVENT", prop_filters: [prop_filter("SUMMARY")])
389
+ ])
390
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
391
+ end
392
+
393
+ it "with is_not_defined: matches when property absent" do
394
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR")
395
+ f = comp_filter("VCALENDAR", comp_filters: [
396
+ comp_filter("VEVENT", prop_filters: [prop_filter("STATUS", is_not_defined: true)])
397
+ ])
398
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
399
+ end
400
+
401
+ it "with is_not_defined: does not match when property present" do
402
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSTATUS:CONFIRMED\r\nEND:VEVENT\r\nEND:VCALENDAR")
403
+ f = comp_filter("VCALENDAR", comp_filters: [
404
+ comp_filter("VEVENT", prop_filters: [prop_filter("STATUS", is_not_defined: true)])
405
+ ])
406
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
407
+ end
408
+
409
+ it "multi-value: matches if any instance satisfies" do
410
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE:alice\r\nATTENDEE:bob\r\nEND:VEVENT\r\nEND:VCALENDAR")
411
+ f = comp_filter("VCALENDAR", comp_filters: [
412
+ comp_filter("VEVENT", prop_filters: [
413
+ prop_filter("ATTENDEE", text_match: text_match("bob"))
414
+ ])
415
+ ])
416
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
417
+ end
418
+ end
419
+
420
+ describe "TextMatch" do
421
+ it "contains (default) matches substring" do
422
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Team Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
423
+ f = comp_filter("VCALENDAR", comp_filters: [
424
+ comp_filter("VEVENT", prop_filters: [
425
+ prop_filter("SUMMARY", text_match: text_match("Meeting"))
426
+ ])
427
+ ])
428
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
429
+ end
430
+
431
+ it "equals matches whole-string equality" do
432
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
433
+ f = comp_filter("VCALENDAR", comp_filters: [
434
+ comp_filter("VEVENT", prop_filters: [
435
+ prop_filter("SUMMARY", text_match: text_match("Meeting", match_type: "equals"))
436
+ ])
437
+ ])
438
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
439
+ end
440
+
441
+ it "equals rejects partial match" do
442
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Team Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
443
+ f = comp_filter("VCALENDAR", comp_filters: [
444
+ comp_filter("VEVENT", prop_filters: [
445
+ prop_filter("SUMMARY", text_match: text_match("Meeting", match_type: "equals"))
446
+ ])
447
+ ])
448
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
449
+ end
450
+
451
+ it "starts-with matches prefix" do
452
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Team Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
453
+ f = comp_filter("VCALENDAR", comp_filters: [
454
+ comp_filter("VEVENT", prop_filters: [
455
+ prop_filter("SUMMARY", text_match: text_match("Team", match_type: "starts-with"))
456
+ ])
457
+ ])
458
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
459
+ end
460
+
461
+ it "ends-with matches suffix" do
462
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Team Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
463
+ f = comp_filter("VCALENDAR", comp_filters: [
464
+ comp_filter("VEVENT", prop_filters: [
465
+ prop_filter("SUMMARY", text_match: text_match("Meeting", match_type: "ends-with"))
466
+ ])
467
+ ])
468
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
469
+ end
470
+
471
+ it "i;ascii-casemap (default) is case-insensitive" do
472
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:MEETING\r\nEND:VEVENT\r\nEND:VCALENDAR")
473
+ f = comp_filter("VCALENDAR", comp_filters: [
474
+ comp_filter("VEVENT", prop_filters: [
475
+ prop_filter("SUMMARY", text_match: text_match("meeting"))
476
+ ])
477
+ ])
478
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
479
+ end
480
+
481
+ it "i;octet is case-sensitive" do
482
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:MEETING\r\nEND:VEVENT\r\nEND:VCALENDAR")
483
+ f = comp_filter("VCALENDAR", comp_filters: [
484
+ comp_filter("VEVENT", prop_filters: [
485
+ prop_filter("SUMMARY", text_match: text_match("meeting", collation: "i;octet"))
486
+ ])
487
+ ])
488
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
489
+ end
490
+
491
+ it "negate-condition inverts the result" do
492
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")
493
+ f = comp_filter("VCALENDAR", comp_filters: [
494
+ comp_filter("VEVENT", prop_filters: [
495
+ prop_filter("SUMMARY", text_match: text_match("Meeting", negate_condition: true))
496
+ ])
497
+ ])
498
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
499
+ end
500
+
501
+ it "negate-condition on non-match produces true" do
502
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Lunch\r\nEND:VEVENT\r\nEND:VCALENDAR")
503
+ f = comp_filter("VCALENDAR", comp_filters: [
504
+ comp_filter("VEVENT", prop_filters: [
505
+ prop_filter("SUMMARY", text_match: text_match("Meeting", negate_condition: true))
506
+ ])
507
+ ])
508
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
509
+ end
510
+ end
511
+
512
+ describe "text-match on SUMMARY only matches SUMMARY property" do
513
+ it "does not match when text appears in DESCRIPTION but not SUMMARY" do
514
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Lunch\r\nDESCRIPTION:Alice is coming\r\nEND:VEVENT\r\nEND:VCALENDAR")
515
+ f = comp_filter("VCALENDAR", comp_filters: [
516
+ comp_filter("VEVENT", prop_filters: [
517
+ prop_filter("SUMMARY", text_match: text_match("Alice"))
518
+ ])
519
+ ])
520
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
521
+ end
522
+ end
523
+
524
+ describe "Addressbook filter" do
525
+ it "anyof returns true if any prop-filter matches" do
526
+ card = parse_vcard("BEGIN:VCARD\r\nFN:John\r\nEMAIL:john@x.com\r\nEND:VCARD")
527
+ f = ab_filter(test: "anyof", prop_filters: [
528
+ ab_prop_filter("FN", text_match: ab_text_match("Jane")),
529
+ ab_prop_filter("EMAIL", text_match: ab_text_match("john"))
530
+ ])
531
+ Protocol::Caldav::Filter::Match.addressbook?(f, card).should.equal true
532
+ end
533
+
534
+ it "allof returns true only if all prop-filters match" do
535
+ card = parse_vcard("BEGIN:VCARD\r\nFN:John\r\nEMAIL:john@x.com\r\nEND:VCARD")
536
+ f = ab_filter(test: "allof", prop_filters: [
537
+ ab_prop_filter("FN", text_match: ab_text_match("John")),
538
+ ab_prop_filter("EMAIL", text_match: ab_text_match("jane"))
539
+ ])
540
+ Protocol::Caldav::Filter::Match.addressbook?(f, card).should.equal false
541
+ end
542
+
543
+ it "allof returns true when all match" do
544
+ card = parse_vcard("BEGIN:VCARD\r\nFN:John\r\nEMAIL:john@x.com\r\nEND:VCARD")
545
+ f = ab_filter(test: "allof", prop_filters: [
546
+ ab_prop_filter("FN", text_match: ab_text_match("John")),
547
+ ab_prop_filter("EMAIL", text_match: ab_text_match("john"))
548
+ ])
549
+ Protocol::Caldav::Filter::Match.addressbook?(f, card).should.equal true
550
+ end
551
+
552
+ it "empty filter list returns true" do
553
+ card = parse_vcard("BEGIN:VCARD\r\nFN:John\r\nEND:VCARD")
554
+ f = ab_filter(prop_filters: [])
555
+ Protocol::Caldav::Filter::Match.addressbook?(f, card).should.equal true
556
+ end
557
+ end
558
+
559
+ describe "ParamFilter" do
560
+ it "matches when parameter exists (defined-only check)" do
561
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@x.com\r\nEND:VEVENT\r\nEND:VCALENDAR")
562
+ pf = Protocol::Caldav::Filter::Calendar::ParamFilter.new(name: "PARTSTAT")
563
+ f = comp_filter("VCALENDAR", comp_filters: [
564
+ comp_filter("VEVENT", prop_filters: [
565
+ prop_filter("ATTENDEE", param_filters: [pf])
566
+ ])
567
+ ])
568
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
569
+ end
570
+
571
+ it "does not match when parameter absent" do
572
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE:mailto:alice@x.com\r\nEND:VEVENT\r\nEND:VCALENDAR")
573
+ pf = Protocol::Caldav::Filter::Calendar::ParamFilter.new(name: "PARTSTAT")
574
+ f = comp_filter("VCALENDAR", comp_filters: [
575
+ comp_filter("VEVENT", prop_filters: [
576
+ prop_filter("ATTENDEE", param_filters: [pf])
577
+ ])
578
+ ])
579
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
580
+ end
581
+
582
+ it "with is_not_defined: matches when parameter absent" do
583
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE:mailto:alice@x.com\r\nEND:VEVENT\r\nEND:VCALENDAR")
584
+ pf = Protocol::Caldav::Filter::Calendar::ParamFilter.new(name: "PARTSTAT", is_not_defined: true)
585
+ f = comp_filter("VCALENDAR", comp_filters: [
586
+ comp_filter("VEVENT", prop_filters: [
587
+ prop_filter("ATTENDEE", param_filters: [pf])
588
+ ])
589
+ ])
590
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
591
+ end
592
+
593
+ it "with text-match: matches when parameter value matches" do
594
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@x.com\r\nEND:VEVENT\r\nEND:VCALENDAR")
595
+ pf = Protocol::Caldav::Filter::Calendar::ParamFilter.new(
596
+ name: "PARTSTAT",
597
+ text_match: text_match("ACCEPTED", match_type: "equals")
598
+ )
599
+ f = comp_filter("VCALENDAR", comp_filters: [
600
+ comp_filter("VEVENT", prop_filters: [
601
+ prop_filter("ATTENDEE", param_filters: [pf])
602
+ ])
603
+ ])
604
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
605
+ end
606
+
607
+ it "with text-match: does not match when parameter value differs" do
608
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE;PARTSTAT=DECLINED:mailto:alice@x.com\r\nEND:VEVENT\r\nEND:VCALENDAR")
609
+ pf = Protocol::Caldav::Filter::Calendar::ParamFilter.new(
610
+ name: "PARTSTAT",
611
+ text_match: text_match("ACCEPTED", match_type: "equals")
612
+ )
613
+ f = comp_filter("VCALENDAR", comp_filters: [
614
+ comp_filter("VEVENT", prop_filters: [
615
+ prop_filter("ATTENDEE", param_filters: [pf])
616
+ ])
617
+ ])
618
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
619
+ end
620
+ end
621
+
622
+ describe "time-range on VEVENT" do
623
+ it "matches overlapping event" do
624
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260115T090000Z\r\nDTEND:20260115T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")
625
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
626
+ f = comp_filter("VCALENDAR", comp_filters: [
627
+ comp_filter("VEVENT", time_range: tr)
628
+ ])
629
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
630
+ end
631
+
632
+ it "does not match event fully before range" do
633
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20251215T090000Z\r\nDTEND:20251215T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")
634
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
635
+ f = comp_filter("VCALENDAR", comp_filters: [
636
+ comp_filter("VEVENT", time_range: tr)
637
+ ])
638
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
639
+ end
640
+
641
+ it "does not match event fully after range" do
642
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260215T090000Z\r\nDTEND:20260215T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")
643
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
644
+ f = comp_filter("VCALENDAR", comp_filters: [
645
+ comp_filter("VEVENT", time_range: tr)
646
+ ])
647
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
648
+ end
649
+
650
+ it "half-open: event ending exactly at range start does not match" do
651
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20251231T230000Z\r\nDTEND:20260101T000000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")
652
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
653
+ f = comp_filter("VCALENDAR", comp_filters: [
654
+ comp_filter("VEVENT", time_range: tr)
655
+ ])
656
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
657
+ end
658
+
659
+ it "half-open: event starting exactly at range start matches" do
660
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T000000Z\r\nDTEND:20260101T010000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")
661
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
662
+ f = comp_filter("VCALENDAR", comp_filters: [
663
+ comp_filter("VEVENT", time_range: tr)
664
+ ])
665
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
666
+ end
667
+ end
668
+
669
+ describe "time-range on VJOURNAL" do
670
+ it "matches VJOURNAL with DTSTART in range" do
671
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VJOURNAL\r\nDTSTART:20260115T090000Z\r\nSUMMARY:Note\r\nEND:VJOURNAL\r\nEND:VCALENDAR")
672
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
673
+ f = comp_filter("VCALENDAR", comp_filters: [
674
+ comp_filter("VJOURNAL", time_range: tr)
675
+ ])
676
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
677
+ end
678
+
679
+ it "does not match VJOURNAL with DTSTART outside range" do
680
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VJOURNAL\r\nDTSTART:20260315T090000Z\r\nSUMMARY:Note\r\nEND:VJOURNAL\r\nEND:VCALENDAR")
681
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
682
+ f = comp_filter("VCALENDAR", comp_filters: [
683
+ comp_filter("VJOURNAL", time_range: tr)
684
+ ])
685
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
686
+ end
687
+
688
+ it "does not match VJOURNAL without DTSTART" do
689
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VJOURNAL\r\nSUMMARY:Undated\r\nEND:VJOURNAL\r\nEND:VCALENDAR")
690
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
691
+ f = comp_filter("VCALENDAR", comp_filters: [
692
+ comp_filter("VJOURNAL", time_range: tr)
693
+ ])
694
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
695
+ end
696
+ end
697
+
698
+ describe "time-range on VEVENT with RRULE" do
699
+ it "matches recurring event with occurrence in range" do
700
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=30\r\nEND:VEVENT\r\nEND:VCALENDAR")
701
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260115T000000Z", end_time: "20260116T000000Z")
702
+ f = comp_filter("VCALENDAR", comp_filters: [
703
+ comp_filter("VEVENT", time_range: tr)
704
+ ])
705
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
706
+ end
707
+
708
+ it "does not match recurring event when all occurrences are outside range" do
709
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\nEND:VCALENDAR")
710
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260201T000000Z", end_time: "20260301T000000Z")
711
+ f = comp_filter("VCALENDAR", comp_filters: [
712
+ comp_filter("VEVENT", time_range: tr)
713
+ ])
714
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
715
+ end
716
+
717
+ it "matches weekly recurring event" do
718
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260105T090000Z\r\nDTEND:20260105T100000Z\r\nRRULE:FREQ=WEEKLY;COUNT=10\r\nEND:VEVENT\r\nEND:VCALENDAR")
719
+ # The 3rd occurrence (Jan 19) should be in this range
720
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260119T000000Z", end_time: "20260120T000000Z")
721
+ f = comp_filter("VCALENDAR", comp_filters: [
722
+ comp_filter("VEVENT", time_range: tr)
723
+ ])
724
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
725
+ end
726
+
727
+ it "respects EXDATE when filtering recurring events" do
728
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEXDATE:20260102T090000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")
729
+ # Range covers only Jan 2 — but that's excluded
730
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260102T000000Z", end_time: "20260102T120000Z")
731
+ f = comp_filter("VCALENDAR", comp_filters: [
732
+ comp_filter("VEVENT", time_range: tr)
733
+ ])
734
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
735
+ end
736
+ end
737
+
738
+ describe "time-range on VTODO" do
739
+ it "matches VTODO with DTSTART and DUE in range" do
740
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nDTSTART:20260115T090000Z\r\nDUE:20260116T090000Z\r\nEND:VTODO\r\nEND:VCALENDAR")
741
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
742
+ f = comp_filter("VCALENDAR", comp_filters: [
743
+ comp_filter("VTODO", time_range: tr)
744
+ ])
745
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
746
+ end
747
+
748
+ it "does not match VTODO outside range" do
749
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nDTSTART:20260301T090000Z\r\nDUE:20260302T090000Z\r\nEND:VTODO\r\nEND:VCALENDAR")
750
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
751
+ f = comp_filter("VCALENDAR", comp_filters: [
752
+ comp_filter("VTODO", time_range: tr)
753
+ ])
754
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal false
755
+ end
756
+
757
+ it "matches VTODO with only COMPLETED in range" do
758
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nCOMPLETED:20260115T090000Z\r\nEND:VTODO\r\nEND:VCALENDAR")
759
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
760
+ f = comp_filter("VCALENDAR", comp_filters: [
761
+ comp_filter("VTODO", time_range: tr)
762
+ ])
763
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
764
+ end
765
+
766
+ it "matches VTODO with no dates (always matches per RFC 4791)" do
767
+ vcal = parse_ical("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Undated task\r\nEND:VTODO\r\nEND:VCALENDAR")
768
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
769
+ f = comp_filter("VCALENDAR", comp_filters: [
770
+ comp_filter("VTODO", time_range: tr)
771
+ ])
772
+ Protocol::Caldav::Filter::Match.calendar?(f, vcal).should.equal true
773
+ end
774
+ end
775
+ end
776
+ end