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,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
|