protocol-caldav 1.0.2 → 1.1.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 +4 -4
- data/lib/protocol/caldav/collection.rb +99 -75
- data/lib/protocol/caldav/constants.rb +23 -27
- data/lib/protocol/caldav/content_line.rb +94 -91
- data/lib/protocol/caldav/ctag.rb +46 -50
- data/lib/protocol/caldav/etag.rb +24 -28
- data/lib/protocol/caldav/filter/addressbook.rb +41 -28
- data/lib/protocol/caldav/filter/calendar.rb +65 -44
- data/lib/protocol/caldav/filter/match.rb +632 -556
- data/lib/protocol/caldav/filter/parser.rb +273 -252
- data/lib/protocol/caldav/ical/component.rb +49 -46
- data/lib/protocol/caldav/ical/expand.rb +148 -134
- data/lib/protocol/caldav/ical/freebusy.rb +106 -74
- data/lib/protocol/caldav/ical/parser.rb +139 -139
- data/lib/protocol/caldav/ical/property.rb +22 -21
- data/lib/protocol/caldav/ical/rrule.rb +346 -235
- data/lib/protocol/caldav/item.rb +41 -43
- data/lib/protocol/caldav/multistatus.rb +43 -46
- data/lib/protocol/caldav/path.rb +82 -79
- data/lib/protocol/caldav/storage.rb +24 -28
- data/lib/protocol/caldav/vcard/card.rb +25 -27
- data/lib/protocol/caldav/vcard/parser.rb +56 -59
- data/lib/protocol/caldav/version.rb +1 -1
- data/lib/protocol/caldav/xml.rb +84 -80
- data/lib/protocol/caldav/xml_builder.rb +4 -2
- metadata +18 -4
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "scampi"
|
|
5
|
-
|
|
6
3
|
require "protocol/caldav"
|
|
7
4
|
|
|
8
5
|
module Protocol
|
|
@@ -23,10 +20,14 @@ module Protocol
|
|
|
23
20
|
|
|
24
21
|
items.each do |item|
|
|
25
22
|
component = Parser.parse(item.body)
|
|
26
|
-
|
|
23
|
+
unless component
|
|
24
|
+
next
|
|
25
|
+
end
|
|
27
26
|
|
|
28
27
|
component.find_components('VEVENT').each do |vevent|
|
|
29
|
-
|
|
28
|
+
if vevent.find_property('RECURRENCE-ID')
|
|
29
|
+
next
|
|
30
|
+
end # skip overrides in this pass
|
|
30
31
|
|
|
31
32
|
status = vevent.find_property('STATUS')&.value&.strip&.upcase
|
|
32
33
|
transp = vevent.find_property('TRANSP')&.value&.strip&.upcase
|
|
@@ -35,62 +36,91 @@ module Protocol
|
|
|
35
36
|
|
|
36
37
|
rrule = vevent.find_property('RRULE')
|
|
37
38
|
if rrule
|
|
38
|
-
collect_recurring_periods(
|
|
39
|
+
collect_recurring_periods(
|
|
40
|
+
vevent,
|
|
41
|
+
rrule,
|
|
42
|
+
range_start,
|
|
43
|
+
range_end,
|
|
44
|
+
is_free,
|
|
45
|
+
busy_periods,
|
|
46
|
+
free_periods,
|
|
47
|
+
)
|
|
39
48
|
else
|
|
40
|
-
collect_single_period(
|
|
49
|
+
collect_single_period(
|
|
50
|
+
vevent,
|
|
51
|
+
range_start,
|
|
52
|
+
range_end,
|
|
53
|
+
is_free,
|
|
54
|
+
busy_periods,
|
|
55
|
+
free_periods,
|
|
56
|
+
)
|
|
41
57
|
end
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
|
|
45
|
-
serialize_freebusy(
|
|
61
|
+
serialize_freebusy(
|
|
62
|
+
busy_periods,
|
|
63
|
+
free_periods,
|
|
64
|
+
range_start,
|
|
65
|
+
range_end,
|
|
66
|
+
)
|
|
46
67
|
end
|
|
47
68
|
|
|
48
69
|
def collect_single_period(vevent, range_start, range_end, is_free, busy_periods, free_periods)
|
|
49
70
|
dtstart = Filter::Match.send(:parse_ical_datetime, vevent, 'DTSTART')
|
|
50
71
|
dtend = Filter::Match.send(:parse_ical_datetime, vevent, 'DTEND')
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
if dtstart
|
|
73
|
+
dtend ||= dtstart + 3600
|
|
74
|
+
if dtstart < range_end && dtend > range_start
|
|
75
|
+
period = "#{format_utc(dtstart)}/#{format_utc(dtend)}"
|
|
76
|
+
if is_free
|
|
77
|
+
free_periods << period
|
|
78
|
+
else
|
|
79
|
+
busy_periods << period
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
59
84
|
else
|
|
60
|
-
|
|
85
|
+
nil
|
|
61
86
|
end
|
|
62
87
|
end
|
|
63
88
|
|
|
64
89
|
def collect_recurring_periods(vevent, rrule, range_start, range_end, is_free, busy_periods, free_periods)
|
|
65
90
|
dtstart = Filter::Match.send(:parse_ical_datetime, vevent, 'DTSTART')
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
exdates = vevent.find_all_properties('EXDATE').filter_map do |ex|
|
|
72
|
-
Filter::Match.send(:parse_datetime_string, ex.value.strip)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
occurrences = Rrule.expand(
|
|
76
|
-
dtstart: dtstart,
|
|
77
|
-
rrule_value: rrule.value.strip,
|
|
78
|
-
range_start: range_start - duration,
|
|
79
|
-
range_end: range_end,
|
|
80
|
-
exdates: exdates,
|
|
81
|
-
max_count: 10000
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
occurrences.each do |occ_start|
|
|
85
|
-
occ_end = occ_start + duration
|
|
86
|
-
next unless occ_start < range_end && occ_end > range_start
|
|
87
|
-
|
|
88
|
-
period = "#{format_utc(occ_start)}/#{format_utc(occ_end)}"
|
|
89
|
-
if is_free
|
|
90
|
-
free_periods << period
|
|
91
|
+
if dtstart
|
|
92
|
+
dtend = Filter::Match.send(:parse_ical_datetime, vevent, 'DTEND')
|
|
93
|
+
if dtend
|
|
94
|
+
duration = (dtend - dtstart).to_i
|
|
91
95
|
else
|
|
92
|
-
|
|
96
|
+
duration = 3600
|
|
97
|
+
end
|
|
98
|
+
exdates = vevent.find_all_properties('EXDATE').filter_map do |ex|
|
|
99
|
+
Filter::Match.send(:parse_datetime_string, ex.value.strip)
|
|
93
100
|
end
|
|
101
|
+
occurrences = Rrule.expand(
|
|
102
|
+
dtstart: dtstart,
|
|
103
|
+
rrule_value: rrule.value.strip,
|
|
104
|
+
range_start: range_start - duration,
|
|
105
|
+
range_end: range_end,
|
|
106
|
+
exdates: exdates,
|
|
107
|
+
max_count: 10000,
|
|
108
|
+
)
|
|
109
|
+
occurrences.each do |occ_start|
|
|
110
|
+
occ_end = occ_start + duration
|
|
111
|
+
unless occ_start < range_end && occ_end > range_start
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
period = "#{format_utc(occ_start)}/#{format_utc(occ_end)}"
|
|
116
|
+
if is_free
|
|
117
|
+
free_periods << period
|
|
118
|
+
else
|
|
119
|
+
busy_periods << period
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
nil
|
|
94
124
|
end
|
|
95
125
|
end
|
|
96
126
|
|
|
@@ -120,42 +150,44 @@ module Protocol
|
|
|
120
150
|
time.utc.strftime('%Y%m%dT%H%M%SZ')
|
|
121
151
|
end
|
|
122
152
|
|
|
123
|
-
private_class_method :collect_single_period,
|
|
124
|
-
|
|
153
|
+
private_class_method :collect_single_period,
|
|
154
|
+
:collect_recurring_periods,
|
|
155
|
+
:serialize_freebusy,
|
|
156
|
+
:format_utc
|
|
125
157
|
end
|
|
126
158
|
end
|
|
127
159
|
end
|
|
128
160
|
end
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
162
|
+
__END__
|
|
163
|
+
|
|
164
|
+
describe "Protocol::Caldav::Ical::FreeBusy" do
|
|
165
|
+
FakeItem = Struct.new(:body)
|
|
166
|
+
|
|
167
|
+
it "returns VCALENDAR with VFREEBUSY" do
|
|
168
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260115T090000Z\r\nDTEND:20260115T100000Z\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
169
|
+
items = [FakeItem.new(ical)]
|
|
170
|
+
result = Protocol::Caldav::Ical::FreeBusy.generate(
|
|
171
|
+
items,
|
|
172
|
+
range_start: Time.utc(2026, 1, 1),
|
|
173
|
+
range_end: Time.utc(2026, 2, 1)
|
|
174
|
+
)
|
|
175
|
+
result.should.include "BEGIN:VCALENDAR"
|
|
176
|
+
result.should.include "BEGIN:VFREEBUSY"
|
|
177
|
+
result.should.include "FREEBUSY;FBTYPE=BUSY:20260115T090000Z/20260115T100000Z"
|
|
178
|
+
result.should.include "END:VFREEBUSY"
|
|
179
|
+
result.should.include "END:VCALENDAR"
|
|
180
|
+
end
|
|
148
181
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
end
|
|
182
|
+
it "empty items list returns empty freebusy" do
|
|
183
|
+
result = Protocol::Caldav::Ical::FreeBusy.generate(
|
|
184
|
+
[],
|
|
185
|
+
range_start: Time.utc(2026, 1, 1),
|
|
186
|
+
range_end: Time.utc(2026, 2, 1)
|
|
187
|
+
)
|
|
188
|
+
result.should.include "BEGIN:VCALENDAR"
|
|
189
|
+
result.should.include "BEGIN:VFREEBUSY"
|
|
190
|
+
result.should.include "END:VFREEBUSY"
|
|
191
|
+
result.should.not.include "FREEBUSY;FBTYPE=BUSY"
|
|
160
192
|
end
|
|
161
193
|
end
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "scampi"
|
|
5
|
-
|
|
6
3
|
require "protocol/caldav"
|
|
7
4
|
|
|
8
5
|
module Protocol
|
|
@@ -12,45 +9,49 @@ module Protocol
|
|
|
12
9
|
module_function
|
|
13
10
|
|
|
14
11
|
def parse(text)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
next unless parsed
|
|
27
|
-
|
|
28
|
-
name, params, value = parsed
|
|
29
|
-
|
|
30
|
-
if name.casecmp?('BEGIN')
|
|
31
|
-
comp = Component.new(name: value.strip.upcase)
|
|
32
|
-
if current
|
|
33
|
-
stack.push(current)
|
|
12
|
+
if text.nil? || text.strip.empty?
|
|
13
|
+
nil
|
|
14
|
+
else
|
|
15
|
+
text = text.sub(/\A\xEF\xBB\xBF/, '')
|
|
16
|
+
lines = ContentLine.unfold(text).split("\n").map(&:strip).reject(&:empty?)
|
|
17
|
+
stack = []
|
|
18
|
+
current = nil
|
|
19
|
+
lines.each do |line|
|
|
20
|
+
parsed = ContentLine.parse_line(line)
|
|
21
|
+
unless parsed
|
|
22
|
+
next
|
|
34
23
|
end
|
|
35
|
-
current = comp
|
|
36
|
-
elsif name.casecmp?('END')
|
|
37
|
-
end_name = value.strip.upcase
|
|
38
|
-
raise ParseError, "Mismatched END:#{end_name} (expected END:#{current&.name})" if current.nil? || !current.name.casecmp?(end_name)
|
|
39
24
|
|
|
40
|
-
|
|
41
|
-
|
|
25
|
+
name, params, value = parsed
|
|
26
|
+
|
|
27
|
+
if name.casecmp?('BEGIN')
|
|
28
|
+
comp = Component.new(name: value.strip.upcase)
|
|
29
|
+
if current
|
|
30
|
+
stack.push(current)
|
|
31
|
+
end
|
|
32
|
+
current = comp
|
|
33
|
+
elsif name.casecmp?('END')
|
|
34
|
+
end_name = value.strip.upcase
|
|
35
|
+
if current.nil? || !current.name.casecmp?(end_name)
|
|
36
|
+
raise ParseError, "Mismatched END:#{end_name} (expected END:#{current&.name})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if stack.empty?
|
|
40
|
+
break
|
|
41
|
+
else
|
|
42
|
+
parent = stack.pop
|
|
43
|
+
parent.components << current
|
|
44
|
+
current = parent
|
|
45
|
+
end
|
|
42
46
|
else
|
|
43
|
-
|
|
44
|
-
parent.components << current
|
|
45
|
-
current = parent
|
|
47
|
+
current&.properties&.push(Property.new(name: name, params: params, value: value))
|
|
46
48
|
end
|
|
47
|
-
else
|
|
48
|
-
current&.properties&.push(Property.new(name: name, params: params, value: value))
|
|
49
49
|
end
|
|
50
|
+
if current && stack.any?
|
|
51
|
+
raise ParseError, "Unclosed component: #{current&.name}"
|
|
52
|
+
end
|
|
53
|
+
current
|
|
50
54
|
end
|
|
51
|
-
|
|
52
|
-
raise ParseError, "Unclosed component: #{current&.name}" if current && stack.any?
|
|
53
|
-
current
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
end
|
|
@@ -59,133 +60,132 @@ module Protocol
|
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
__END__
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
end
|
|
65
|
+
describe "Protocol::Caldav::Ical::Parser" do
|
|
66
|
+
def parse(text)
|
|
67
|
+
Protocol::Caldav::Ical::Parser.parse(text)
|
|
68
|
+
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
describe "line unfolding" do
|
|
71
|
+
it "unfolds CRLF SPACE continuations" do
|
|
72
|
+
ical = "BEGIN:VCALENDAR\r\nSUMMARY:Annual\r\n planning\r\nEND:VCALENDAR"
|
|
73
|
+
c = parse(ical)
|
|
74
|
+
c.find_property("SUMMARY").value.should.equal "Annualplanning"
|
|
75
|
+
end
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
it "unfolds CRLF TAB continuations" do
|
|
78
|
+
ical = "BEGIN:VCALENDAR\r\nSUMMARY:Annual\r\n\tplanning\r\nEND:VCALENDAR"
|
|
79
|
+
c = parse(ical)
|
|
80
|
+
c.find_property("SUMMARY").value.should.equal "Annualplanning"
|
|
81
|
+
end
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
end
|
|
83
|
+
it "handles three consecutive folded lines" do
|
|
84
|
+
ical = "BEGIN:VCALENDAR\r\nX-LONG:a\r\n b\r\n c\r\nEND:VCALENDAR"
|
|
85
|
+
c = parse(ical)
|
|
86
|
+
c.find_property("X-LONG").value.should.equal "abc"
|
|
87
87
|
end
|
|
88
|
+
end
|
|
88
89
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
describe "component nesting" do
|
|
91
|
+
it "parses a flat VCALENDAR with no children" do
|
|
92
|
+
c = parse("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR")
|
|
93
|
+
c.name.should.equal "VCALENDAR"
|
|
94
|
+
c.components.should.equal []
|
|
95
|
+
end
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
it "parses one VEVENT inside VCALENDAR" do
|
|
98
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Test\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
99
|
+
c = parse(ical)
|
|
100
|
+
c.components.length.should.equal 1
|
|
101
|
+
c.components[0].name.should.equal "VEVENT"
|
|
102
|
+
c.components[0].find_property("SUMMARY").value.should.equal "Test"
|
|
103
|
+
end
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
it "parses multiple sibling VEVENTs" do
|
|
106
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:A\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
107
|
+
c = parse(ical)
|
|
108
|
+
c.components.length.should.equal 2
|
|
109
|
+
end
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
it "parses VALARM nested inside VEVENT" do
|
|
112
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR"
|
|
113
|
+
c = parse(ical)
|
|
114
|
+
vevent = c.components[0]
|
|
115
|
+
vevent.components.length.should.equal 1
|
|
116
|
+
vevent.components[0].name.should.equal "VALARM"
|
|
117
|
+
end
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
it "raises on mismatched END" do
|
|
120
|
+
ical = "BEGIN:VEVENT\r\nEND:VTODO"
|
|
121
|
+
lambda { parse(ical) }.should.raise Protocol::Caldav::ParseError
|
|
122
|
+
end
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
it "handles BEGIN/END names case-insensitively" do
|
|
125
|
+
c = parse("begin:vcalendar\r\nend:vcalendar")
|
|
126
|
+
c.name.should.equal "VCALENDAR"
|
|
127
|
+
end
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
end
|
|
129
|
+
it "preserves component order" do
|
|
130
|
+
ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nBEGIN:VTODO\r\nEND:VTODO\r\nEND:VCALENDAR"
|
|
131
|
+
c = parse(ical)
|
|
132
|
+
c.components[0].name.should.equal "VEVENT"
|
|
133
|
+
c.components[1].name.should.equal "VTODO"
|
|
134
134
|
end
|
|
135
|
+
end
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
describe "property parsing" do
|
|
138
|
+
it "parses a property with no parameters" do
|
|
139
|
+
c = parse("BEGIN:VCALENDAR\r\nSUMMARY:Hello\r\nEND:VCALENDAR")
|
|
140
|
+
c.find_property("SUMMARY").value.should.equal "Hello"
|
|
141
|
+
c.find_property("SUMMARY").params.should.equal({})
|
|
142
|
+
end
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
it "parses a property with one parameter" do
|
|
145
|
+
c = parse("BEGIN:VCALENDAR\r\nDTSTART;TZID=America/New_York:20260101T090000\r\nEND:VCALENDAR")
|
|
146
|
+
p = c.find_property("DTSTART")
|
|
147
|
+
p.params["TZID"].should.equal "America/New_York"
|
|
148
|
+
p.value.should.equal "20260101T090000"
|
|
149
|
+
end
|
|
149
150
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
it "handles a property value containing colons" do
|
|
152
|
+
c = parse("BEGIN:VCALENDAR\r\nURL:https://example.com:8080/path\r\nEND:VCALENDAR")
|
|
153
|
+
c.find_property("URL").value.should.equal "https://example.com:8080/path"
|
|
154
|
+
end
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
it "handles an empty property value" do
|
|
157
|
+
c = parse("BEGIN:VCALENDAR\r\nDESCRIPTION:\r\nEND:VCALENDAR")
|
|
158
|
+
c.find_property("DESCRIPTION").value.should.equal ""
|
|
159
|
+
end
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
end
|
|
161
|
+
it "handles X-prefix custom properties" do
|
|
162
|
+
c = parse("BEGIN:VCALENDAR\r\nX-WR-CALNAME:My Calendar\r\nEND:VCALENDAR")
|
|
163
|
+
c.find_property("X-WR-CALNAME").value.should.equal "My Calendar"
|
|
164
164
|
end
|
|
165
|
+
end
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
describe "edge cases" do
|
|
168
|
+
it "returns nil for empty input" do
|
|
169
|
+
parse("").should.be.nil
|
|
170
|
+
end
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
it "returns nil for nil input" do
|
|
173
|
+
parse(nil).should.be.nil
|
|
174
|
+
end
|
|
174
175
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
it "tolerates trailing whitespace and blank lines" do
|
|
177
|
+
c = parse("BEGIN:VCALENDAR\r\n\r\nVERSION:2.0\r\n\r\nEND:VCALENDAR\r\n \r\n")
|
|
178
|
+
c.name.should.equal "VCALENDAR"
|
|
179
|
+
end
|
|
179
180
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
it "tolerates BOM at start of file" do
|
|
182
|
+
c = parse("\xEF\xBB\xBFBEGIN:VCALENDAR\r\nEND:VCALENDAR")
|
|
183
|
+
c.name.should.equal "VCALENDAR"
|
|
184
|
+
end
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
end
|
|
186
|
+
it "tolerates LF-only line endings" do
|
|
187
|
+
c = parse("BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR")
|
|
188
|
+
c.name.should.equal "VCALENDAR"
|
|
189
189
|
end
|
|
190
190
|
end
|
|
191
191
|
end
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "scampi"
|
|
5
|
-
|
|
6
3
|
module Protocol
|
|
7
4
|
module Caldav
|
|
8
5
|
module Ical
|
|
9
|
-
Property = Struct.new(
|
|
6
|
+
Property = Struct.new(
|
|
7
|
+
:name,
|
|
8
|
+
:params,
|
|
9
|
+
:value,
|
|
10
|
+
keyword_init: true,
|
|
11
|
+
) do
|
|
10
12
|
def param(key)
|
|
11
13
|
params[key.upcase]
|
|
12
14
|
end
|
|
@@ -15,25 +17,24 @@ module Protocol
|
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
__END__
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
end
|
|
22
|
+
describe "Protocol::Caldav::Ical::Property" do
|
|
23
|
+
it "exposes name, params, value" do
|
|
24
|
+
p = Protocol::Caldav::Ical::Property.new(name: "SUMMARY", params: {}, value: "Hello")
|
|
25
|
+
p.name.should.equal "SUMMARY"
|
|
26
|
+
p.params.should.equal({})
|
|
27
|
+
p.value.should.equal "Hello"
|
|
28
|
+
end
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
it "param lookup is case-insensitive on parameter names" do
|
|
31
|
+
p = Protocol::Caldav::Ical::Property.new(name: "DTSTART", params: {"TZID" => "UTC"}, value: "x")
|
|
32
|
+
p.param("tzid").should.equal "UTC"
|
|
33
|
+
p.param("TZID").should.equal "UTC"
|
|
34
|
+
end
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
36
|
+
it "value is the raw string, no type coercion" do
|
|
37
|
+
p = Protocol::Caldav::Ical::Property.new(name: "DTSTART", params: {}, value: "20260101T090000Z")
|
|
38
|
+
p.value.should.equal "20260101T090000Z"
|
|
38
39
|
end
|
|
39
40
|
end
|