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,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 & Personal</d:href></d:response>"
|
|
56
|
+
xml = Protocol::Caldav::Multistatus.new([response]).to_xml
|
|
57
|
+
xml.should.include '&'
|
|
58
|
+
xml.should.not.include '&amp;'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|