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,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "date"
6
+
7
+ module Protocol
8
+ module Caldav
9
+ module Ical
10
+ module Rrule
11
+ DAYS = { 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6, 'SU' => 0 }.freeze
12
+
13
+ module_function
14
+
15
+ # Expand an RRULE into concrete occurrence start times within a range.
16
+ #
17
+ # @param dtstart [Time] base event start
18
+ # @param rrule_value [String] RRULE property value (e.g. "FREQ=DAILY;COUNT=5")
19
+ # @param range_start [Time] query window start
20
+ # @param range_end [Time] query window end
21
+ # @param exdates [Array<Time>] excluded dates
22
+ # @param max_count [Integer] safety limit to prevent infinite expansion
23
+ # @return [Array<Time>] occurrence start times within range
24
+ def expand(dtstart:, rrule_value:, range_start:, range_end:, exdates: [], max_count: 10000)
25
+ parts = parse_rrule(rrule_value)
26
+ freq = parts['FREQ']
27
+ return [dtstart] unless freq
28
+
29
+ interval = (parts['INTERVAL'] || '1').to_i
30
+ count = parts['COUNT']&.to_i
31
+ until_time = parts['UNTIL'] ? parse_dt(parts['UNTIL']) : nil
32
+ byday = parts['BYDAY']&.split(',')
33
+ bymonthday = parts['BYMONTHDAY']&.split(',')&.map(&:to_i)
34
+ bymonth = parts['BYMONTH']&.split(',')&.map(&:to_i)
35
+
36
+ occurrences = []
37
+ current = dtstart
38
+ generated = 0
39
+
40
+ loop do
41
+ # Stop conditions
42
+ break if until_time && current > until_time
43
+ break if current > range_end
44
+ break if count && generated >= count
45
+ break if occurrences.length >= max_count
46
+
47
+ candidates = expand_freq(freq, current, interval, byday, bymonthday, bymonth, dtstart)
48
+
49
+ candidates.each do |candidate|
50
+ break if until_time && candidate > until_time
51
+ break if count && generated >= count
52
+ break if occurrences.length >= max_count
53
+
54
+ generated += 1
55
+
56
+ next if candidate < range_start && candidate < dtstart
57
+ next if candidate >= range_end
58
+ next if exdates.any? { |ex| times_equal?(candidate, ex) }
59
+
60
+ occurrences << candidate if candidate >= range_start
61
+ end
62
+
63
+ current = advance(freq, current, interval)
64
+ end
65
+
66
+ occurrences.sort
67
+ end
68
+
69
+ def parse_rrule(value)
70
+ parts = {}
71
+ value.split(';').each do |part|
72
+ key, val = part.split('=', 2)
73
+ parts[key.upcase] = val
74
+ end
75
+ parts
76
+ end
77
+
78
+ def parse_dt(str)
79
+ str = str.strip
80
+ if str.length == 8
81
+ Time.utc(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
82
+ else
83
+ s = str.chomp('Z')
84
+ 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)
85
+ end
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ def times_equal?(a, b)
91
+ # Compare ignoring sub-second precision; also handle DATE vs DATETIME
92
+ a.year == b.year && a.month == b.month && a.day == b.day &&
93
+ a.hour == b.hour && a.min == b.min
94
+ end
95
+
96
+ def expand_freq(freq, current, interval, byday, bymonthday, bymonth, dtstart)
97
+ case freq
98
+ when 'DAILY'
99
+ [current]
100
+ when 'WEEKLY'
101
+ if byday
102
+ week_start = current - (current.wday * 86400)
103
+ byday.map do |day_str|
104
+ wday = DAYS[day_str.gsub(/[^A-Z]/, '')]
105
+ next nil unless wday
106
+ candidate = week_start + (wday * 86400)
107
+ # Preserve time of day from dtstart
108
+ Time.utc(candidate.year, candidate.month, candidate.day, dtstart.hour, dtstart.min, dtstart.sec)
109
+ end.compact.select { |c| c >= current }.sort
110
+ else
111
+ [current]
112
+ end
113
+ when 'MONTHLY'
114
+ if bymonthday
115
+ bymonthday.filter_map do |day|
116
+ days_in_month = Date.new(current.year, current.month, -1).day
117
+ actual_day = day > 0 ? day : days_in_month + day + 1
118
+ next nil if actual_day < 1 || actual_day > days_in_month
119
+ Time.utc(current.year, current.month, actual_day, dtstart.hour, dtstart.min, dtstart.sec)
120
+ end.sort
121
+ elsif byday
122
+ expand_byday_monthly(current, byday, dtstart)
123
+ else
124
+ [current]
125
+ end
126
+ when 'YEARLY'
127
+ if bymonth
128
+ bymonth.map do |month|
129
+ Time.utc(current.year, month, dtstart.day, dtstart.hour, dtstart.min, dtstart.sec)
130
+ end.sort
131
+ else
132
+ [current]
133
+ end
134
+ else
135
+ [current]
136
+ end
137
+ end
138
+
139
+ def expand_byday_monthly(current, byday, dtstart)
140
+ results = []
141
+ byday.each do |day_str|
142
+ match = day_str.match(/^(-?\d+)?([A-Z]{2})$/)
143
+ next unless match
144
+ ordinal = match[1]&.to_i
145
+ wday = DAYS[match[2]]
146
+ next unless wday
147
+
148
+ if ordinal
149
+ candidate = nth_weekday(current.year, current.month, wday, ordinal)
150
+ results << Time.utc(candidate.year, candidate.month, candidate.day, dtstart.hour, dtstart.min, dtstart.sec) if candidate
151
+ else
152
+ # All matching weekdays in the month
153
+ (1..5).each do |n|
154
+ candidate = nth_weekday(current.year, current.month, wday, n)
155
+ break unless candidate
156
+ results << Time.utc(candidate.year, candidate.month, candidate.day, dtstart.hour, dtstart.min, dtstart.sec)
157
+ end
158
+ end
159
+ end
160
+ results.sort
161
+ end
162
+
163
+ def nth_weekday(year, month, wday, n)
164
+ require 'date'
165
+ first = Date.new(year, month, 1)
166
+ last = Date.new(year, month, -1)
167
+
168
+ if n > 0
169
+ day = first + ((wday - first.wday + 7) % 7) + (7 * (n - 1))
170
+ day.month == month ? day : nil
171
+ else
172
+ day = last - ((last.wday - wday + 7) % 7) + (7 * (n + 1))
173
+ day.month == month ? day : nil
174
+ end
175
+ end
176
+
177
+ def advance(freq, current, interval)
178
+ case freq
179
+ when 'DAILY'
180
+ current + (86400 * interval)
181
+ when 'WEEKLY'
182
+ current + (604800 * interval)
183
+ when 'MONTHLY'
184
+ advance_months(current, interval)
185
+ when 'YEARLY'
186
+ advance_months(current, interval * 12)
187
+ else
188
+ current + 86400
189
+ end
190
+ end
191
+
192
+ def advance_months(time, months)
193
+ month = time.month + months
194
+ year = time.year + ((month - 1) / 12)
195
+ month = ((month - 1) % 12) + 1
196
+ max_day = Date.new(year, month, -1).day
197
+ day = [time.day, max_day].min
198
+ Time.utc(year, month, day, time.hour, time.min, time.sec)
199
+ end
200
+
201
+ private_class_method :parse_rrule, :parse_dt, :times_equal?, :expand_freq,
202
+ :expand_byday_monthly, :nth_weekday, :advance, :advance_months
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+
209
+ test do
210
+ describe "Protocol::Caldav::Ical::Rrule" do
211
+ def expand(**kwargs)
212
+ Protocol::Caldav::Ical::Rrule.expand(**kwargs)
213
+ end
214
+
215
+ it "FREQ=DAILY expands daily occurrences" do
216
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
217
+ result = expand(
218
+ dtstart: dtstart,
219
+ rrule_value: "FREQ=DAILY",
220
+ range_start: Time.utc(2026, 1, 1),
221
+ range_end: Time.utc(2026, 1, 4)
222
+ )
223
+ result.length.should.equal 3
224
+ result[0].should.equal Time.utc(2026, 1, 1, 9, 0, 0)
225
+ result[1].should.equal Time.utc(2026, 1, 2, 9, 0, 0)
226
+ result[2].should.equal Time.utc(2026, 1, 3, 9, 0, 0)
227
+ end
228
+
229
+ it "COUNT limits the total occurrences" do
230
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
231
+ result = expand(
232
+ dtstart: dtstart,
233
+ rrule_value: "FREQ=DAILY;COUNT=2",
234
+ range_start: Time.utc(2026, 1, 1),
235
+ range_end: Time.utc(2026, 12, 31)
236
+ )
237
+ result.length.should.equal 2
238
+ end
239
+
240
+ it "UNTIL stops at the given time" do
241
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
242
+ result = expand(
243
+ dtstart: dtstart,
244
+ rrule_value: "FREQ=DAILY;UNTIL=20260103T090000Z",
245
+ range_start: Time.utc(2026, 1, 1),
246
+ range_end: Time.utc(2026, 12, 31)
247
+ )
248
+ result.length.should.equal 3
249
+ result.last.should.equal Time.utc(2026, 1, 3, 9, 0, 0)
250
+ end
251
+
252
+ it "EXDATE excludes specific dates" do
253
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
254
+ exdates = [Time.utc(2026, 1, 2, 9, 0, 0)]
255
+ result = expand(
256
+ dtstart: dtstart,
257
+ rrule_value: "FREQ=DAILY;COUNT=3",
258
+ range_start: Time.utc(2026, 1, 1),
259
+ range_end: Time.utc(2026, 12, 31),
260
+ exdates: exdates
261
+ )
262
+ result.length.should.equal 2
263
+ result.should.not.include Time.utc(2026, 1, 2, 9, 0, 0)
264
+ end
265
+
266
+ it "FREQ=WEEKLY with BYDAY expands on specified days" do
267
+ dtstart = Time.utc(2026, 1, 5, 9, 0, 0) # Monday
268
+ result = expand(
269
+ dtstart: dtstart,
270
+ rrule_value: "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6",
271
+ range_start: Time.utc(2026, 1, 1),
272
+ range_end: Time.utc(2026, 12, 31)
273
+ )
274
+ result.length.should.equal 6
275
+ result[0].wday.should.equal 1 # Monday
276
+ result[1].wday.should.equal 3 # Wednesday
277
+ result[2].wday.should.equal 5 # Friday
278
+ end
279
+
280
+ it "FREQ=WEEKLY without BYDAY defaults to dtstart weekday" do
281
+ dtstart = Time.utc(2026, 1, 7, 9, 0, 0) # Wednesday
282
+ result = expand(
283
+ dtstart: dtstart,
284
+ rrule_value: "FREQ=WEEKLY;COUNT=3",
285
+ range_start: Time.utc(2026, 1, 1),
286
+ range_end: Time.utc(2026, 12, 31)
287
+ )
288
+ result.length.should.equal 3
289
+ result.each { |t| t.wday.should.equal 3 }
290
+ end
291
+
292
+ it "FREQ=MONTHLY with BYMONTHDAY" do
293
+ dtstart = Time.utc(2026, 1, 15, 10, 0, 0)
294
+ result = expand(
295
+ dtstart: dtstart,
296
+ rrule_value: "FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3",
297
+ range_start: Time.utc(2026, 1, 1),
298
+ range_end: Time.utc(2026, 12, 31)
299
+ )
300
+ result.length.should.equal 3
301
+ result[0].day.should.equal 15
302
+ result[1].month.should.equal 2
303
+ result[2].month.should.equal 3
304
+ end
305
+
306
+ it "FREQ=MONTHLY with BYDAY (second Monday)" do
307
+ dtstart = Time.utc(2026, 1, 12, 9, 0, 0) # 2nd Monday of Jan 2026
308
+ result = expand(
309
+ dtstart: dtstart,
310
+ rrule_value: "FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
311
+ range_start: Time.utc(2026, 1, 1),
312
+ range_end: Time.utc(2026, 12, 31)
313
+ )
314
+ result.length.should.equal 3
315
+ result.each { |t| t.wday.should.equal 1 } # all Mondays
316
+ result[0].day.should.equal 12 # Jan
317
+ result[1].day.should.equal 9 # Feb 2nd Monday
318
+ end
319
+
320
+ it "FREQ=YEARLY with BYMONTH" do
321
+ dtstart = Time.utc(2026, 3, 15, 9, 0, 0)
322
+ result = expand(
323
+ dtstart: dtstart,
324
+ rrule_value: "FREQ=YEARLY;BYMONTH=3,6;COUNT=4",
325
+ range_start: Time.utc(2026, 1, 1),
326
+ range_end: Time.utc(2028, 12, 31)
327
+ )
328
+ result.length.should.equal 4
329
+ result[0].month.should.equal 3
330
+ result[1].month.should.equal 6
331
+ result[2].month.should.equal 3
332
+ result[2].year.should.equal 2027
333
+ end
334
+
335
+ it "INTERVAL > 1 spaces occurrences" do
336
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
337
+ result = expand(
338
+ dtstart: dtstart,
339
+ rrule_value: "FREQ=DAILY;INTERVAL=3;COUNT=3",
340
+ range_start: Time.utc(2026, 1, 1),
341
+ range_end: Time.utc(2026, 12, 31)
342
+ )
343
+ result.length.should.equal 3
344
+ result[0].day.should.equal 1
345
+ result[1].day.should.equal 4
346
+ result[2].day.should.equal 7
347
+ end
348
+
349
+ it "negative BYMONTHDAY (-1 = last day of month)" do
350
+ dtstart = Time.utc(2026, 1, 31, 9, 0, 0)
351
+ result = expand(
352
+ dtstart: dtstart,
353
+ rrule_value: "FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3",
354
+ range_start: Time.utc(2026, 1, 1),
355
+ range_end: Time.utc(2026, 12, 31)
356
+ )
357
+ result.length.should.equal 3
358
+ result[0].day.should.equal 31 # Jan
359
+ result[1].day.should.equal 28 # Feb
360
+ result[2].day.should.equal 31 # Mar
361
+ end
362
+
363
+ it "UNTIL with DATE-only format" do
364
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
365
+ result = expand(
366
+ dtstart: dtstart,
367
+ rrule_value: "FREQ=DAILY;UNTIL=20260104",
368
+ range_start: Time.utc(2026, 1, 1),
369
+ range_end: Time.utc(2026, 12, 31)
370
+ )
371
+ result.length.should.equal 3
372
+ result.last.day.should.equal 3
373
+ end
374
+
375
+ it "COUNT + INTERVAL combined" do
376
+ dtstart = Time.utc(2026, 1, 1, 9, 0, 0)
377
+ result = expand(
378
+ dtstart: dtstart,
379
+ rrule_value: "FREQ=WEEKLY;INTERVAL=2;COUNT=3",
380
+ range_start: Time.utc(2026, 1, 1),
381
+ range_end: Time.utc(2026, 12, 31)
382
+ )
383
+ result.length.should.equal 3
384
+ # Every 2 weeks: Jan 1, Jan 15, Jan 29
385
+ (result[1] - result[0]).should.equal 14 * 86400
386
+ end
387
+
388
+ it "returns just dtstart when no valid FREQ" do
389
+ dtstart = Time.utc(2026, 6, 15, 10, 0, 0)
390
+ result = expand(
391
+ dtstart: dtstart,
392
+ rrule_value: "BOGUS=STUFF",
393
+ range_start: Time.utc(2026, 1, 1),
394
+ range_end: Time.utc(2026, 12, 31)
395
+ )
396
+ result.should.equal [dtstart]
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,120 @@
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
+ class Item
10
+ attr_reader :path, :body, :content_type, :etag
11
+
12
+ def initialize(path:, body:, content_type:, etag:, new_record: false)
13
+ @path = path
14
+ @body = body
15
+ @content_type = content_type
16
+ @etag = etag
17
+ @new_record = new_record
18
+ end
19
+
20
+ def new?
21
+ @new_record
22
+ end
23
+
24
+ def to_propfind_xml
25
+ <<~XML
26
+ <d:response>
27
+ <d:href>#{Xml.escape(@path.to_s)}</d:href>
28
+ <d:propstat>
29
+ <d:prop>
30
+ <d:getetag>#{Xml.escape(@etag)}</d:getetag>
31
+ <d:getcontenttype>#{Xml.escape(@content_type)}</d:getcontenttype>
32
+ </d:prop>
33
+ <d:status>HTTP/1.1 200 OK</d:status>
34
+ </d:propstat>
35
+ </d:response>
36
+ XML
37
+ end
38
+
39
+ def to_propname_xml
40
+ <<~XML
41
+ <d:response>
42
+ <d:href>#{Xml.escape(@path.to_s)}</d:href>
43
+ <d:propstat>
44
+ <d:prop>
45
+ <d:getetag/>
46
+ <d:getcontenttype/>
47
+ </d:prop>
48
+ <d:status>HTTP/1.1 200 OK</d:status>
49
+ </d:propstat>
50
+ </d:response>
51
+ XML
52
+ end
53
+
54
+ def to_report_xml(data_tag:)
55
+ <<~XML
56
+ <d:response>
57
+ <d:href>#{Xml.escape(@path.to_s)}</d:href>
58
+ <d:propstat>
59
+ <d:prop>
60
+ <d:getetag>#{Xml.escape(@etag)}</d:getetag>
61
+ <#{data_tag}>#{Xml.escape(@body)}</#{data_tag}>
62
+ </d:prop>
63
+ <d:status>HTTP/1.1 200 OK</d:status>
64
+ </d:propstat>
65
+ </d:response>
66
+ XML
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ test do
73
+ def normalize(xml)
74
+ xml.gsub(/>\s+</, '><').strip
75
+ end
76
+
77
+ describe "Protocol::Caldav::Item" do
78
+ def make_item(**opts)
79
+ defaults = {
80
+ path: Protocol::Caldav::Path.new("/calendars/admin/work/event.ics"),
81
+ body: "BEGIN:VCALENDAR\r\nEND:VCALENDAR",
82
+ content_type: "text/calendar",
83
+ etag: '"abc123"'
84
+ }
85
+ Protocol::Caldav::Item.new(**defaults.merge(opts))
86
+ end
87
+
88
+ it "exposes path, body, content_type, etag" do
89
+ item = make_item
90
+ item.path.to_s.should.equal "/calendars/admin/work/event.ics"
91
+ item.body.should.include "VCALENDAR"
92
+ item.content_type.should.equal "text/calendar"
93
+ item.etag.should.equal '"abc123"'
94
+ end
95
+
96
+ it "new? returns new_record state" do
97
+ make_item(new_record: true).new?.should.equal true
98
+ make_item(new_record: false).new?.should.equal false
99
+ end
100
+
101
+ it "to_propfind_xml includes etag and content-type" do
102
+ xml = make_item.to_propfind_xml
103
+ xml.should.include "getetag"
104
+ xml.should.include "getcontenttype"
105
+ xml.should.include "text/calendar"
106
+ end
107
+
108
+ it "to_report_xml includes data tag with body" do
109
+ xml = make_item.to_report_xml(data_tag: "c:calendar-data")
110
+ xml.should.include "c:calendar-data"
111
+ xml.should.include "VCALENDAR"
112
+ end
113
+
114
+ it "to_propname_xml includes empty prop elements" do
115
+ xml = make_item.to_propname_xml
116
+ xml.should.include "<d:getetag/>"
117
+ xml.should.include "<d:getcontenttype/>"
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ module Protocol
7
+ module Caldav
8
+ class Multistatus
9
+ def initialize(responses)
10
+ @responses = responses
11
+ end
12
+
13
+ def to_xml
14
+ <<~XML
15
+ <?xml version="1.0" encoding="UTF-8"?>
16
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cr="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/" xmlns:x="http://apple.com/ns/ical/">
17
+ #{@responses.join}
18
+ </d:multistatus>
19
+ XML
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+
26
+ test do
27
+ def normalize(xml)
28
+ xml.gsub(/>\s+</, '><').strip
29
+ end
30
+
31
+ describe "Protocol::Caldav::Multistatus" do
32
+ it "declares all four required namespaces" do
33
+ xml = Protocol::Caldav::Multistatus.new([]).to_xml
34
+ xml.should.include 'xmlns:d="DAV:"'
35
+ xml.should.include 'xmlns:c="urn:ietf:params:xml:ns:caldav"'
36
+ xml.should.include 'xmlns:cr="urn:ietf:params:xml:ns:carddav"'
37
+ xml.should.include 'xmlns:cs="http://calendarserver.org/ns/"'
38
+ end
39
+
40
+ it "emits responses in the order given" do
41
+ responses = ["<d:response><d:href>/a</d:href></d:response>",
42
+ "<d:response><d:href>/b</d:href></d:response>"]
43
+ xml = Protocol::Caldav::Multistatus.new(responses).to_xml
44
+ xml.index("/a").should.be < xml.index("/b")
45
+ end
46
+
47
+ it "produces valid XML for empty response array" do
48
+ xml = Protocol::Caldav::Multistatus.new([]).to_xml
49
+ xml.should.include '<?xml version="1.0"'
50
+ xml.should.include '<d:multistatus'
51
+ xml.should.include '</d:multistatus>'
52
+ end
53
+
54
+ it "does not double-escape pre-escaped XML in responses" do
55
+ response = "<d:response><d:href>/Work &amp; Personal</d:href></d:response>"
56
+ xml = Protocol::Caldav::Multistatus.new([response]).to_xml
57
+ xml.should.include '&amp;'
58
+ xml.should.not.include '&amp;amp;'
59
+ end
60
+ end
61
+ end