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