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