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