cal-invite 0.2.1 → 0.3.1
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/AGENTS.md +13 -8
- data/CACHING.md +190 -98
- data/CHANGELOG.md +61 -2
- data/CONFIGURATION.md +25 -29
- data/README.md +132 -190
- data/Rakefile +2 -1
- data/SECURITY.md +39 -10
- data/lib/cal_invite/configuration.rb +20 -11
- data/lib/cal_invite/event.rb +87 -114
- data/lib/cal_invite/ical_timezone.rb +64 -18
- data/lib/cal_invite/providers/base_provider.rb +302 -264
- data/lib/cal_invite/providers/ical.rb +7 -152
- data/lib/cal_invite/providers/ics.rb +68 -13
- data/lib/cal_invite/providers/ics_content.rb +7 -129
- data/lib/cal_invite/providers.rb +8 -0
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +1 -8
- metadata +4 -9
- data/.DS_Store +0 -0
- data/CLAUDE.md +0 -49
- data/lib/.DS_Store +0 -0
- data/lib/cal_invite/.DS_Store +0 -0
- data/lib/cal_invite/caching.rb +0 -74
- data/lib/tasks/rdoc.rake +0 -13
|
@@ -3,166 +3,21 @@
|
|
|
3
3
|
# lib/cal_invite/providers/ical.rb
|
|
4
4
|
module CalInvite
|
|
5
5
|
module Providers
|
|
6
|
-
# iCalendar
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# iCalendar provider (`:ical`), for Apple Calendar and other iCalendar apps.
|
|
7
|
+
# Produces the same RFC 5545 content as {Ics}; kept as its own provider
|
|
8
|
+
# symbol for backward compatibility.
|
|
9
9
|
#
|
|
10
|
-
# @example
|
|
10
|
+
# @example
|
|
11
11
|
# event = CalInvite::Event.new(
|
|
12
12
|
# title: "Team Meeting",
|
|
13
13
|
# start_time: Time.now,
|
|
14
14
|
# end_time: Time.now + 3600,
|
|
15
15
|
# timezone: 'America/New_York'
|
|
16
16
|
# )
|
|
17
|
-
# ical = CalInvite::Providers::Ical.new(event)
|
|
18
|
-
# ics_content = ical.generate
|
|
19
|
-
#
|
|
20
|
-
# @example Creating a multi-day event
|
|
21
|
-
# event = CalInvite::Event.new(
|
|
22
|
-
# title: "Conference",
|
|
23
|
-
# multi_day_sessions: [
|
|
24
|
-
# { start_time: Time.now, end_time: Time.now + 3600 },
|
|
25
|
-
# { start_time: Time.now + 86400, end_time: Time.now + 90000 }
|
|
26
|
-
# ]
|
|
27
|
-
# )
|
|
28
17
|
# ics_content = CalInvite::Providers::Ical.new(event).generate
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# and one or more event blocks.
|
|
33
|
-
#
|
|
34
|
-
# @return [String] The complete iCalendar format content
|
|
35
|
-
def generate
|
|
36
|
-
[
|
|
37
|
-
"BEGIN:VCALENDAR",
|
|
38
|
-
"VERSION:2.0",
|
|
39
|
-
"PRODID:-//CalInvite//Ruby//EN",
|
|
40
|
-
"CALSCALE:GREGORIAN",
|
|
41
|
-
"METHOD:#{method_value}",
|
|
42
|
-
(event.calendar_name ? "X-WR-CALNAME:#{escape_text(event.calendar_name)}" : nil),
|
|
43
|
-
generate_timezone,
|
|
44
|
-
generate_events,
|
|
45
|
-
"END:VCALENDAR"
|
|
46
|
-
].compact.join("\r\n")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
# Generates the event blocks (VEVENT) for the calendar.
|
|
52
|
-
# Handles both single events and multi-day sessions.
|
|
53
|
-
#
|
|
54
|
-
# @return [String] The formatted event block(s)
|
|
55
|
-
def generate_events
|
|
56
|
-
if event.multi_day_sessions.any?
|
|
57
|
-
event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\r\n")
|
|
58
|
-
else
|
|
59
|
-
generate_vevent
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Generates a single VEVENT block with all event details.
|
|
64
|
-
# Handles both all-day events and time-specific events.
|
|
65
|
-
#
|
|
66
|
-
# @param session [Hash, nil] Optional session details for multi-day events
|
|
67
|
-
# @return [String] The formatted VEVENT block
|
|
68
|
-
# @raise [ArgumentError] If required time fields are missing for non-all-day events
|
|
69
|
-
def generate_vevent(session = nil)
|
|
70
|
-
lines = ["BEGIN:VEVENT"]
|
|
71
|
-
|
|
72
|
-
if event.all_day
|
|
73
|
-
start_date = event.start_time || Time.now
|
|
74
|
-
end_date = event.end_time || (start_date + 86400)
|
|
75
|
-
lines << "DTSTART;VALUE=DATE:#{format_date(start_date)}"
|
|
76
|
-
lines << "DTEND;VALUE=DATE:#{format_date(end_date)}"
|
|
77
|
-
else
|
|
78
|
-
start_time = session ? session[:start_time] : event.start_time
|
|
79
|
-
end_time = session ? session[:end_time] : event.end_time
|
|
80
|
-
raise ArgumentError, "Start time is required for non-all-day events" unless start_time
|
|
81
|
-
raise ArgumentError, "End time is required for non-all-day events" unless end_time
|
|
82
|
-
lines << "DTSTART;TZID=#{event.timezone}:#{format_local_time(start_time)}"
|
|
83
|
-
lines << "DTEND;TZID=#{event.timezone}:#{format_local_time(end_time)}"
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Required fields
|
|
87
|
-
lines.concat([
|
|
88
|
-
"SUMMARY:#{escape_text(event.title)}",
|
|
89
|
-
"UID:#{event.uid}",
|
|
90
|
-
"DTSTAMP:#{format_timestamp(Time.now.utc)}"
|
|
91
|
-
])
|
|
92
|
-
|
|
93
|
-
# Optional fields
|
|
94
|
-
lines << "DESCRIPTION:#{escape_text(format_description)}" if format_description
|
|
95
|
-
lines << "LOCATION:#{escape_text(format_location)}" if format_location
|
|
96
|
-
lines << "URL:#{escape_text(format_url)}" if format_url
|
|
97
|
-
lines << geo_line if geo_line
|
|
98
|
-
lines << organizer_line if organizer_line
|
|
99
|
-
|
|
100
|
-
# Attendees
|
|
101
|
-
attendees_list.each { |attendee| lines << attendee_line(attendee) }
|
|
102
|
-
|
|
103
|
-
lines << rrule_line if rrule_line
|
|
104
|
-
lines << "SEQUENCE:#{event.sequence}"
|
|
105
|
-
lines << status_line
|
|
106
|
-
lines << transp_line
|
|
107
|
-
lines << busystatus_line
|
|
108
|
-
lines << class_line
|
|
109
|
-
lines.concat(importance_lines)
|
|
110
|
-
lines << disallow_counter_line if disallow_counter_line
|
|
111
|
-
lines.concat(valarm_lines)
|
|
112
|
-
lines << "END:VEVENT"
|
|
113
|
-
lines.join("\r\n")
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Generates the timezone block (VTIMEZONE) for the calendar, with real
|
|
117
|
-
# STANDARD/DAYLIGHT observances derived from the timezone's transition rules.
|
|
118
|
-
# Only included for non-all-day events with a recognized, non-UTC timezone.
|
|
119
|
-
#
|
|
120
|
-
# @return [String, nil] The formatted timezone block, or nil if not applicable
|
|
121
|
-
def generate_timezone
|
|
122
|
-
return nil if event.all_day # No timezone needed for all-day events
|
|
123
|
-
|
|
124
|
-
vtimezone_lines&.join("\r\n")
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Formats a time object as a date string in iCalendar format.
|
|
128
|
-
#
|
|
129
|
-
# @param time [Time] The time to format
|
|
130
|
-
# @return [String] The formatted date (YYYYMMDD)
|
|
131
|
-
def format_date(time)
|
|
132
|
-
time.strftime("%Y%m%d")
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Formats a time object as a local time string in iCalendar format.
|
|
136
|
-
# Converts from UTC to the event's timezone; times are expected to be in
|
|
137
|
-
# UTC already.
|
|
138
|
-
#
|
|
139
|
-
# @param time [Time] The time to format
|
|
140
|
-
# @return [String] The formatted local time (YYYYMMDDTHHmmSS)
|
|
141
|
-
def format_local_time(time)
|
|
142
|
-
local_wall_time(time).strftime("%Y%m%dT%H%M%S")
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Formats a time object as an UTC timestamp in iCalendar format.
|
|
146
|
-
#
|
|
147
|
-
# @param time [Time] The time to format
|
|
148
|
-
# @return [String] The formatted UTC timestamp (YYYYMMDDTHHmmSSZ)
|
|
149
|
-
def format_timestamp(time)
|
|
150
|
-
time.strftime("%Y%m%dT%H%M%SZ")
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Escapes special characters in text according to iCalendar spec.
|
|
154
|
-
# Handles backslashes, newlines, commas, and semicolons.
|
|
155
|
-
#
|
|
156
|
-
# @param text [String, nil] The text to escape
|
|
157
|
-
# @return [String] The escaped text
|
|
158
|
-
def escape_text(text)
|
|
159
|
-
return '' if text.nil?
|
|
160
|
-
text.to_s
|
|
161
|
-
.gsub('\\', '\\\\')
|
|
162
|
-
.gsub("\n", '\\n')
|
|
163
|
-
.gsub(',', '\\,')
|
|
164
|
-
.gsub(';', '\\;')
|
|
165
|
-
end
|
|
18
|
+
#
|
|
19
|
+
# @see Ics
|
|
20
|
+
class Ical < Ics
|
|
166
21
|
end
|
|
167
22
|
end
|
|
168
23
|
end
|
|
@@ -7,6 +7,9 @@ module CalInvite
|
|
|
7
7
|
# This provider generates ICS files that are compatible with most calendar applications.
|
|
8
8
|
# Supports all-day events, regular events, and multi-day sessions with proper timezone handling.
|
|
9
9
|
#
|
|
10
|
+
# This is the single RFC 5545 generator for the gem: {Ical} and {IcsContent}
|
|
11
|
+
# are subclasses kept for their public names and produce identical output.
|
|
12
|
+
#
|
|
10
13
|
# @example Creating a regular event ICS file
|
|
11
14
|
# event = CalInvite::Event.new(
|
|
12
15
|
# title: "Team Meeting",
|
|
@@ -66,21 +69,60 @@ module CalInvite
|
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
calendar_lines << "END:VCALENDAR"
|
|
69
|
-
calendar_lines
|
|
72
|
+
ensure_single_line_values!(calendar_lines)
|
|
73
|
+
calendar_lines.map { |line| fold_line(line) }.join("\r\n")
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
private
|
|
73
77
|
|
|
78
|
+
# Maximum content line length in octets, excluding the CRLF (RFC 5545 §3.1).
|
|
79
|
+
MAX_LINE_OCTETS = 75
|
|
80
|
+
|
|
81
|
+
# Folds a content line longer than 75 octets into CRLF + single-space
|
|
82
|
+
# continuation lines (RFC 5545 §3.1). Splits only between characters, so a
|
|
83
|
+
# multi-byte UTF-8 character is never cut in half.
|
|
84
|
+
#
|
|
85
|
+
# @param line [String] A single unfolded content line
|
|
86
|
+
# @return [String] The folded line (unchanged if already short enough)
|
|
87
|
+
def fold_line(line)
|
|
88
|
+
return line if line.bytesize <= MAX_LINE_OCTETS
|
|
89
|
+
|
|
90
|
+
segments = [+""]
|
|
91
|
+
line.each_char do |char|
|
|
92
|
+
segments << +" " if segments.last.bytesize + char.bytesize > MAX_LINE_OCTETS
|
|
93
|
+
segments.last << char
|
|
94
|
+
end
|
|
95
|
+
segments.join("\r\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Last line of defense against content-line injection: a CR or LF inside a
|
|
99
|
+
# value would end the line and let the rest of the value be parsed as new
|
|
100
|
+
# properties/components. TEXT values are escaped before reaching here, so
|
|
101
|
+
# this only fires for unescaped values (UID, URL, RRULE, emails, ...).
|
|
102
|
+
#
|
|
103
|
+
# @param lines [Array<String>] The content lines about to be joined
|
|
104
|
+
# @raise [ArgumentError] if any line contains a CR or LF
|
|
105
|
+
# @return [void]
|
|
106
|
+
def ensure_single_line_values!(lines)
|
|
107
|
+
line = lines.find { |l| l.match?(/[\r\n]/) }
|
|
108
|
+
return unless line
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "#{line[/\A[A-Z-]+/]} value must not contain line breaks"
|
|
111
|
+
end
|
|
112
|
+
|
|
74
113
|
# Generates an all-day event component (VEVENT) with appropriate formatting.
|
|
114
|
+
# Defaults to today (and a one-day span) when start/end times are omitted.
|
|
75
115
|
#
|
|
76
116
|
# @return [Array<String>] Array of iCalendar format lines for the all-day event
|
|
77
117
|
def generate_all_day_event
|
|
118
|
+
start_date = event.start_time || Time.now
|
|
119
|
+
end_date = event.end_time || (start_date + 86400)
|
|
78
120
|
vevent = [
|
|
79
121
|
"BEGIN:VEVENT",
|
|
80
122
|
"UID:#{event.uid}",
|
|
81
123
|
"DTSTAMP:#{format_timestamp(Time.now.utc)}",
|
|
82
|
-
"DTSTART;VALUE=DATE:#{format_date(
|
|
83
|
-
"DTEND;VALUE=DATE:#{format_date(
|
|
124
|
+
"DTSTART;VALUE=DATE:#{format_date(start_date)}",
|
|
125
|
+
"DTEND;VALUE=DATE:#{format_date(end_date)}",
|
|
84
126
|
"SUMMARY:#{escape_text(event.title)}"
|
|
85
127
|
]
|
|
86
128
|
add_optional_fields(vevent)
|
|
@@ -98,8 +140,8 @@ module CalInvite
|
|
|
98
140
|
"BEGIN:VEVENT",
|
|
99
141
|
"UID:#{event.uid}",
|
|
100
142
|
"DTSTAMP:#{format_timestamp(Time.now.utc)}",
|
|
101
|
-
"DTSTART
|
|
102
|
-
"DTEND
|
|
143
|
+
date_time_line("DTSTART", start_time),
|
|
144
|
+
date_time_line("DTEND", end_time),
|
|
103
145
|
"SUMMARY:#{escape_text(event.title)}"
|
|
104
146
|
]
|
|
105
147
|
add_optional_fields(vevent)
|
|
@@ -125,7 +167,8 @@ module CalInvite
|
|
|
125
167
|
end
|
|
126
168
|
|
|
127
169
|
if url = format_url
|
|
128
|
-
|
|
170
|
+
# URL is a URI value, not TEXT: RFC 5545 escaping would corrupt it.
|
|
171
|
+
vevent << "URL:#{url}"
|
|
129
172
|
end
|
|
130
173
|
|
|
131
174
|
vevent << geo_line if geo_line
|
|
@@ -137,7 +180,7 @@ module CalInvite
|
|
|
137
180
|
attendees_list.each { |attendee| vevent << attendee_line(attendee) }
|
|
138
181
|
|
|
139
182
|
vevent << rrule_line if rrule_line
|
|
140
|
-
vevent <<
|
|
183
|
+
vevent << sequence_line
|
|
141
184
|
vevent << status_line
|
|
142
185
|
vevent << transp_line
|
|
143
186
|
vevent << busystatus_line
|
|
@@ -163,6 +206,20 @@ module CalInvite
|
|
|
163
206
|
time.strftime("%Y%m%d")
|
|
164
207
|
end
|
|
165
208
|
|
|
209
|
+
# Builds a DTSTART/DTEND line: a `Z`-suffixed UTC timestamp for UTC events,
|
|
210
|
+
# otherwise local wall-clock time referencing the event's VTIMEZONE.
|
|
211
|
+
#
|
|
212
|
+
# @param name [String] The property name ("DTSTART" or "DTEND")
|
|
213
|
+
# @param time [Time] The time to format (UTC)
|
|
214
|
+
# @return [String] The content line
|
|
215
|
+
def date_time_line(name, time)
|
|
216
|
+
if CalInvite::IcalTimezone.utc?(event.timezone)
|
|
217
|
+
"#{name}:#{format_timestamp(time)}"
|
|
218
|
+
else
|
|
219
|
+
"#{name};TZID=#{event.timezone}:#{format_local_timestamp(time)}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
166
223
|
# Formats a time object as a local timestamp in iCalendar format.
|
|
167
224
|
# Converts from UTC to the event's timezone; times are expected to be in
|
|
168
225
|
# UTC already.
|
|
@@ -173,17 +230,15 @@ module CalInvite
|
|
|
173
230
|
local_wall_time(time).strftime("%Y%m%dT%H%M%S")
|
|
174
231
|
end
|
|
175
232
|
|
|
176
|
-
# Escapes
|
|
233
|
+
# Escapes a TEXT value per RFC 5545 §3.3.11: backslash, semicolon and comma
|
|
234
|
+
# are backslash-escaped, and any line break (CRLF, CR or LF) becomes `\n`.
|
|
177
235
|
#
|
|
178
236
|
# @param text [String, nil] The text to escape
|
|
179
237
|
# @return [String] The escaped text, or empty string if input was nil
|
|
180
238
|
def escape_text(text)
|
|
181
239
|
return '' if text.nil?
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
.gsub("\n", '\\n')
|
|
185
|
-
.gsub(',', '\\,')
|
|
186
|
-
.gsub(';', '\\;')
|
|
240
|
+
|
|
241
|
+
text.to_s.gsub(/\r\n|[\r\n\\;,]/) { |match| match.match?(/[\r\n]/) ? '\\n' : "\\#{match}" }
|
|
187
242
|
end
|
|
188
243
|
end
|
|
189
244
|
end
|
|
@@ -3,142 +3,20 @@
|
|
|
3
3
|
# lib/cal_invite/providers/ics_content.rb
|
|
4
4
|
module CalInvite
|
|
5
5
|
module Providers
|
|
6
|
-
# ICS content provider for generating
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# ICS content provider, for generating `.ics` content directly (outside
|
|
7
|
+
# Event#generate_calendar_url). Produces the same RFC 5545 content as {Ics};
|
|
8
|
+
# kept for backward compatibility. Pair with {IcsDownload} to serve it as a file.
|
|
9
9
|
#
|
|
10
|
-
# @example
|
|
10
|
+
# @example
|
|
11
11
|
# event = CalInvite::Event.new(
|
|
12
12
|
# title: "Team Meeting",
|
|
13
13
|
# start_time: Time.now,
|
|
14
14
|
# end_time: Time.now + 3600
|
|
15
15
|
# )
|
|
16
|
-
# generator = CalInvite::Providers::IcsContent.new(event)
|
|
17
|
-
# ics_content = generator.generate
|
|
18
|
-
#
|
|
19
|
-
# @example Generate ICS content for a multi-day event
|
|
20
|
-
# event = CalInvite::Event.new(
|
|
21
|
-
# title: "Conference",
|
|
22
|
-
# multi_day_sessions: [
|
|
23
|
-
# { start_time: Time.parse("2024-04-01 09:00"), end_time: Time.parse("2024-04-01 17:00") },
|
|
24
|
-
# { start_time: Time.parse("2024-04-02 09:00"), end_time: Time.parse("2024-04-02 17:00") }
|
|
25
|
-
# ]
|
|
26
|
-
# )
|
|
27
16
|
# ics_content = CalInvite::Providers::IcsContent.new(event).generate
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
#
|
|
32
|
-
# @return [String] The complete ICS calendar content in iCalendar format
|
|
33
|
-
def generate
|
|
34
|
-
calendar_lines = [
|
|
35
|
-
"BEGIN:VCALENDAR",
|
|
36
|
-
"VERSION:2.0",
|
|
37
|
-
"PRODID:-//CalInvite//EN",
|
|
38
|
-
"CALSCALE:GREGORIAN",
|
|
39
|
-
"METHOD:#{method_value}"
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
calendar_lines << "X-WR-CALNAME:#{escape_text(event.calendar_name)}" if event.calendar_name
|
|
43
|
-
|
|
44
|
-
if event.multi_day_sessions.any?
|
|
45
|
-
event.multi_day_sessions.each do |session|
|
|
46
|
-
calendar_lines.concat(generate_vevent(session[:start_time], session[:end_time]))
|
|
47
|
-
end
|
|
48
|
-
else
|
|
49
|
-
calendar_lines.concat(generate_vevent(event.start_time, event.end_time))
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
calendar_lines << "END:VCALENDAR"
|
|
53
|
-
calendar_lines.join("\r\n")
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
# Generates a single VEVENT component with proper event properties.
|
|
59
|
-
#
|
|
60
|
-
# @param start_time [Time] The event start time
|
|
61
|
-
# @param end_time [Time] The event end time
|
|
62
|
-
# @return [Array<String>] Array of iCalendar format lines for the event
|
|
63
|
-
def generate_vevent(start_time, end_time)
|
|
64
|
-
[
|
|
65
|
-
"BEGIN:VEVENT",
|
|
66
|
-
"UID:#{event.uid}",
|
|
67
|
-
"DTSTAMP:#{format_timestamp(Time.now.utc)}",
|
|
68
|
-
"DTSTART:#{format_timestamp(start_time)}",
|
|
69
|
-
"DTEND:#{format_timestamp(end_time)}",
|
|
70
|
-
"SUMMARY:#{escape_text(event.title)}",
|
|
71
|
-
description_line,
|
|
72
|
-
location_line,
|
|
73
|
-
url_line,
|
|
74
|
-
geo_line,
|
|
75
|
-
organizer_line,
|
|
76
|
-
attendee_lines,
|
|
77
|
-
rrule_line,
|
|
78
|
-
"SEQUENCE:#{event.sequence}",
|
|
79
|
-
status_line,
|
|
80
|
-
transp_line,
|
|
81
|
-
busystatus_line,
|
|
82
|
-
class_line,
|
|
83
|
-
importance_lines,
|
|
84
|
-
disallow_counter_line,
|
|
85
|
-
valarm_lines,
|
|
86
|
-
"END:VEVENT"
|
|
87
|
-
].compact
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# Formats a time object as an UTC timestamp in iCalendar format.
|
|
91
|
-
#
|
|
92
|
-
# @param time [Time] The time to format
|
|
93
|
-
# @return [String] The formatted UTC timestamp (YYYYMMDDTHHmmSSZ)
|
|
94
|
-
def format_timestamp(time)
|
|
95
|
-
time.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Escapes special characters in text according to iCalendar spec.
|
|
99
|
-
#
|
|
100
|
-
# @param text [String, nil] The text to escape
|
|
101
|
-
# @return [String] The escaped text, or empty string if input was nil
|
|
102
|
-
def escape_text(text)
|
|
103
|
-
return '' if text.nil?
|
|
104
|
-
text.to_s
|
|
105
|
-
.gsub('\\', '\\\\')
|
|
106
|
-
.gsub("\n", '\\n')
|
|
107
|
-
.gsub(',', '\\,')
|
|
108
|
-
.gsub(';', '\\;')
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Generates the DESCRIPTION line if a description exists.
|
|
112
|
-
#
|
|
113
|
-
# @return [String, nil] The formatted DESCRIPTION line, or nil if no description
|
|
114
|
-
def description_line
|
|
115
|
-
return nil unless event.description
|
|
116
|
-
"DESCRIPTION:#{escape_text(event.description)}"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Generates the LOCATION line if a location exists.
|
|
120
|
-
#
|
|
121
|
-
# @return [String, nil] The formatted LOCATION line, or nil if no location
|
|
122
|
-
def location_line
|
|
123
|
-
return nil unless event.location
|
|
124
|
-
"LOCATION:#{escape_text(event.location)}"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Generates the URL line if a URL exists.
|
|
128
|
-
#
|
|
129
|
-
# @return [String, nil] The formatted URL line, or nil if no URL
|
|
130
|
-
def url_line
|
|
131
|
-
return nil unless event.url
|
|
132
|
-
"URL:#{escape_text(event.url)}"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Generates ATTENDEE lines for all attendees if showing attendees is enabled.
|
|
136
|
-
#
|
|
137
|
-
# @return [Array<String>, nil] Array of ATTENDEE lines, or nil if no attendees or not showing
|
|
138
|
-
def attendee_lines
|
|
139
|
-
return nil if attendees_list.empty?
|
|
140
|
-
attendees_list.map { |attendee| attendee_line(attendee) }
|
|
141
|
-
end
|
|
17
|
+
#
|
|
18
|
+
# @see Ics
|
|
19
|
+
class IcsContent < Ics
|
|
142
20
|
end
|
|
143
21
|
|
|
144
22
|
# Module for handling ICS file downloads with proper headers and file naming.
|
data/lib/cal_invite/providers.rb
CHANGED
|
@@ -22,3 +22,11 @@ module CalInvite
|
|
|
22
22
|
autoload :Ics, 'cal_invite/providers/ics'
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
# Deprecated top-level alias, kept for one release so code that referenced
|
|
27
|
+
# ::BaseProvider keeps working. Skipped if the host app defines its own.
|
|
28
|
+
# Remove in 0.4.0.
|
|
29
|
+
unless Object.const_defined?(:BaseProvider, false)
|
|
30
|
+
BaseProvider = CalInvite::Providers::BaseProvider
|
|
31
|
+
Object.deprecate_constant(:BaseProvider)
|
|
32
|
+
end
|
data/lib/cal_invite/version.rb
CHANGED
data/lib/cal_invite.rb
CHANGED
|
@@ -3,14 +3,12 @@ require 'logger'
|
|
|
3
3
|
require 'mutex_m'
|
|
4
4
|
require 'bigdecimal'
|
|
5
5
|
require 'active_support'
|
|
6
|
-
require 'active_support/core_ext'
|
|
7
6
|
require 'securerandom'
|
|
8
7
|
require 'time'
|
|
9
8
|
require 'uri'
|
|
10
9
|
|
|
11
10
|
require 'cal_invite/version'
|
|
12
11
|
require 'cal_invite/configuration'
|
|
13
|
-
require 'cal_invite/caching'
|
|
14
12
|
require 'cal_invite/ical_timezone'
|
|
15
13
|
require 'cal_invite/event'
|
|
16
14
|
require 'cal_invite/providers'
|
|
@@ -21,7 +19,6 @@ require 'cal_invite/providers'
|
|
|
21
19
|
# @example Configure the gem
|
|
22
20
|
# CalInvite.configure do |config|
|
|
23
21
|
# config.timezone = 'America/New_York'
|
|
24
|
-
# config.cache_store = :memory_store
|
|
25
22
|
# end
|
|
26
23
|
#
|
|
27
24
|
# @example Create and generate a calendar URL
|
|
@@ -49,8 +46,7 @@ module CalInvite
|
|
|
49
46
|
# @example
|
|
50
47
|
# CalInvite.configure do |config|
|
|
51
48
|
# config.timezone = 'UTC'
|
|
52
|
-
|
|
53
|
-
# end
|
|
49
|
+
# end
|
|
54
50
|
def configure
|
|
55
51
|
self.configuration ||= Configuration.new
|
|
56
52
|
yield(configuration)
|
|
@@ -62,8 +58,5 @@ module CalInvite
|
|
|
62
58
|
def reset_configuration!
|
|
63
59
|
self.configuration = Configuration.new
|
|
64
60
|
end
|
|
65
|
-
|
|
66
|
-
# Include caching methods at the module level
|
|
67
|
-
include Caching
|
|
68
61
|
end
|
|
69
62
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cal-invite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stephane Paquet
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -102,13 +102,11 @@ executables: []
|
|
|
102
102
|
extensions: []
|
|
103
103
|
extra_rdoc_files: []
|
|
104
104
|
files:
|
|
105
|
-
- ".DS_Store"
|
|
106
105
|
- ".rdoc_options"
|
|
107
106
|
- ".rubocop.yml"
|
|
108
107
|
- AGENTS.md
|
|
109
108
|
- CACHING.md
|
|
110
109
|
- CHANGELOG.md
|
|
111
|
-
- CLAUDE.md
|
|
112
110
|
- CODE_OF_CONDUCT.md
|
|
113
111
|
- CONFIGURATION.md
|
|
114
112
|
- LICENSE.txt
|
|
@@ -118,10 +116,7 @@ files:
|
|
|
118
116
|
- gemfiles/Gemfile.rails6
|
|
119
117
|
- gemfiles/Gemfile.rails7
|
|
120
118
|
- gemfiles/Gemfile.rails8
|
|
121
|
-
- lib/.DS_Store
|
|
122
119
|
- lib/cal_invite.rb
|
|
123
|
-
- lib/cal_invite/.DS_Store
|
|
124
|
-
- lib/cal_invite/caching.rb
|
|
125
120
|
- lib/cal_invite/configuration.rb
|
|
126
121
|
- lib/cal_invite/event.rb
|
|
127
122
|
- lib/cal_invite/ical_timezone.rb
|
|
@@ -135,7 +130,6 @@ files:
|
|
|
135
130
|
- lib/cal_invite/providers/outlook.rb
|
|
136
131
|
- lib/cal_invite/providers/yahoo.rb
|
|
137
132
|
- lib/cal_invite/version.rb
|
|
138
|
-
- lib/tasks/rdoc.rake
|
|
139
133
|
- sig/cal/invite.rbs
|
|
140
134
|
homepage: https://github.com/DashAPI-ai/cal-invite
|
|
141
135
|
licenses:
|
|
@@ -143,7 +137,8 @@ licenses:
|
|
|
143
137
|
metadata:
|
|
144
138
|
homepage_uri: https://github.com/DashAPI-ai/cal-invite
|
|
145
139
|
source_code_uri: https://github.com/DashAPI-ai/cal-invite
|
|
146
|
-
changelog_uri: https://github.com/DashAPI-ai/cal-invite/blob/
|
|
140
|
+
changelog_uri: https://github.com/DashAPI-ai/cal-invite/blob/main/CHANGELOG.md
|
|
141
|
+
documentation_uri: https://dashapi-ai.github.io/cal-invite/
|
|
147
142
|
post_install_message:
|
|
148
143
|
rdoc_options:
|
|
149
144
|
- "--title"
|
data/.DS_Store
DELETED
|
Binary file
|
data/CLAUDE.md
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
CalInvite is a Ruby gem for generating calendar invitations (URLs and .ics files) across multiple platforms: Google, Outlook, Outlook 365, Yahoo, Apple iCal, and generic .ics. Ruby >= 3.0.0, Rails 6.0–8.0 compatible, depends on `activesupport`.
|
|
8
|
-
|
|
9
|
-
## Commands
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
bundle install # install dependencies
|
|
13
|
-
rake test # run full test suite (default rake task)
|
|
14
|
-
rake test TEST=test/cal_invite/event_test.rb # run a single test file
|
|
15
|
-
ruby -Itest -Ilib test/cal_invite/event_test.rb # run a single test file directly
|
|
16
|
-
rake rdoc # generate RDoc documentation into doc/
|
|
17
|
-
rake rdoc:clean # remove generated doc/
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Tests are Minitest-based, files live under `test/cal_invite/`, named `*_test.rb`. Coverage is collected via SimpleCov (see `coverage/`).
|
|
21
|
-
|
|
22
|
-
## Architecture
|
|
23
|
-
|
|
24
|
-
- **`CalInvite` module** (`lib/cal_invite.rb`) — top-level entry point. Holds a singleton `CalInvite.configuration` (a `Configuration` instance) set via `CalInvite.configure { |c| ... }`. Mixes in `Caching` at the module level for class-level cache helpers.
|
|
25
|
-
|
|
26
|
-
- **`CalInvite::Event`** (`lib/cal_invite/event.rb`) — the central object. Holds all event attributes (title, start/end time, location, url, attendees, timezone, notes, multi-day sessions, all-day flag). `generate_calendar_url(provider)` is the main API: it validates the event, checks the cache (MD5 hash of all attributes + provider as key), and if not cached, resolves `CalInvite::Providers::<CapitalizedProvider>`, instantiates it with `self`, and calls `#generate`. Location and virtual-meeting URL are intentionally separate fields (not merged) so each provider can format them appropriately.
|
|
27
|
-
|
|
28
|
-
- **`CalInvite::Providers`** (`lib/cal_invite/providers.rb`) — namespace with `SUPPORTED_PROVIDERS = %i[google ical outlook yahoo ics office365]`. Provider classes are autoloaded from `lib/cal_invite/providers/*.rb`. To add a new provider: create a class extending `BaseProvider`, implement `#generate`, register it here with `autoload`, and add its symbol to `SUPPORTED_PROVIDERS`.
|
|
29
|
-
|
|
30
|
-
- **`BaseProvider`** (`lib/cal_invite/providers/base_provider.rb`) — abstract base class (note: it's a top-level `class BaseProvider`, not namespaced under `CalInvite::Providers`). Defines the `#generate` contract (must raise `NotImplementedError` if not overridden) plus shared helpers used by subclasses: URL-building (`url_encode`, `format_description`, `format_location`, `format_url`, `format_description_with_url`, `add_optional_params`) and iCalendar property formatting (`attendees_list`, `attendee_emails`, `attendee_hash_value`, `attendee_line`, `organizer_line`, `method_value`, `status_line`, `geo_line`, `transp_line`, `busystatus_line`, `class_line`, `importance_lines`, `disallow_counter_line`, `rrule_line`, `valarm_lines`, `local_wall_time`, `vtimezone_lines`). `attendee_hash_value(attendee, key)` is the correct way to read an attendee-hash field (tries symbol then string key) — don't reintroduce `attendee[:key] || attendee["key"]`, which silently drops explicit `false` values. Each concrete provider (`Google`, `Outlook`, `Office365`, `Yahoo`, `Ical`, `Ics`) builds a provider-specific URL or content string from the `Event` using these helpers.
|
|
31
|
-
|
|
32
|
-
- **`Ics` / `IcsContent` providers** (`ics.rb`, `ics_content.rb`) — generate actual `.ics` file content (RFC 5545) rather than a URL, for direct download flows (see README's `send_data` controller example). `IcsDownload` lives in `ics_content.rb` alongside `IcsContent` (not an alias). All ics-family providers accept a `method:` (`:publish`/`:request`/`:cancel`/`:reply`/`:counter`/`:decline_counter`) passed through `Event#generate_calendar_url`. Use `BaseProvider#method_value` (not `method.to_s.upcase`) when emitting the `METHOD:` line — `:decline_counter` must render as `METHOD:DECLINECOUNTER` (one word) per RFC 5545, unlike its Ruby symbol. `:request` plus `Event#organizer` produces an RFC 5545 meeting request that mail clients render with RSVP actions; `:cancel` (reusing the same `Event#uid`, with `Event#sequence` bumped) produces a real cancellation; `:reply`/`:counter`/`:decline_counter` all omit `RSVP=TRUE` on `ATTENDEE` lines (none of them request a further response). `Event#uid` is stable (auto-generated and memoized per instance, or pass your own) — required so a later `:request`/`:cancel` is recognized as updating the original invite rather than creating a new one. `Event#attendees` entries can be plain email strings or `{ email:, name:, partstat:, rsvp: }` hashes (`partstat:` drives `PARTSTAT=` on the `ATTENDEE` line, default `NEEDS-ACTION`; `rsvp:` overrides the method-derived `RSVP=TRUE`/absent default — needed for e.g. a registration-confirmation invite where the attendee is already `:accepted` and `rsvp: false` suppresses it under `method: :request`). Optional `Event` fields `geo`, `reminders`, `busy`, `visibility`, `rrule`, `calendar_name` map to `GEO:`, `VALARM`, `TRANSP:`, `CLASS:`, `RRULE:`, and `X-WR-CALNAME` respectively; `importance`/`allow_counter` map to Outlook's non-standard `X-MICROSOFT-CDO-IMPORTANCE`/`X-MICROSOFT-DISALLOW-COUNTER` (plus standard `PRIORITY:` and `X-MICROSOFT-CDO-BUSYSTATUS` alongside `TRANSP:`) — safe to always emit since unrecognized `X-` properties are required to be ignored by other clients. See `CONFIGURATION.md`.
|
|
33
|
-
- **Timezones** — `:ics`/`:ical` build a real `VTIMEZONE` (via `CalInvite::IcalTimezone`, backed by `tzinfo`) for any recognized IANA `timezone` other than `'UTC'`, and correctly convert `start_time`/`end_time` (always UTC) to that zone's local wall-clock time for `DTSTART;TZID=...`. `'UTC'` uses plain `Z`-suffixed timestamps with no `VTIMEZONE`.
|
|
34
|
-
|
|
35
|
-
- **`Caching`** (`lib/cal_invite/caching.rb`) — an `ActiveSupport::Concern` mixed into `CalInvite` at the class level. Provides `fetch_from_cache`, `write_to_cache`, `read_from_cache`, `clear_cache!`, `clear_event_cache!`, `clear_provider_cache!`, all keyed under `CalInvite.configuration.cache_prefix`. Works with any `ActiveSupport::Cache::Store`-compatible store, or a custom object responding to `read`/`write`/`delete`. See also `CACHING.md`.
|
|
36
|
-
|
|
37
|
-
- **`Configuration`** (`lib/cal_invite/configuration.rb`) — holds `cache_store`, `cache_prefix` (default `'cal_invite'`), `cache_expires_in` (default 24 hours), `webhook_secret`, `timezone` (default `'UTC'`). `cache_store=` accepts `:memory_store`, `:null_store`, or any object implementing `read`/`write`/`delete`.
|
|
38
|
-
|
|
39
|
-
## Conventions
|
|
40
|
-
|
|
41
|
-
- Always pass times to `Event.new` in UTC; use the `timezone` attribute only to control display/formatting in the generated invite.
|
|
42
|
-
- `location` (physical) and `url` (virtual meeting link) are kept as distinct fields throughout the provider layer — don't conflate them when adding provider logic.
|
|
43
|
-
- All-day events skip `start_time`/`end_time` validation; multi-day events use the separate `multi_day_sessions` array instead of `start_time`/`end_time`.
|
|
44
|
-
- YARD-style doc comments (`@param`, `@return`, `@example`) are used throughout `lib/` and feed the RDoc-generated docs published to GitHub Pages (`.github/workflows/documentation.yml`) — keep them accurate when changing public method signatures.
|
|
45
|
-
- CalInvite is outbound-only: it renders `.ics` content/URLs and has no concept of inbound RSVP replies. It also can't express Google/Microsoft Calendar-API-only concepts like guest permissions (`guestsCanInviteOthers` etc.) since those have no `.ics`/URL representation at all — but it does generate the genuinely protocol-level equivalents that exist: `METHOD:COUNTER`/`DECLINECOUNTER` and Outlook's `X-MICROSOFT-*` extensions (`allow_counter`, `importance`, `busy`). See CONFIGURATION.md's "Tracking RSVPs" and "Guest permissions" sections for the full breakdown of what's real vs. not addable, and why.
|
|
46
|
-
|
|
47
|
-
## Example app (`Example/calendar_app`)
|
|
48
|
-
|
|
49
|
-
Rails 8.1 app demonstrating the gem. Its Gemfile depends on the **published** `cal-invite` gem (never commit it pointing at `path: "../.."`) — when testing example-app changes that rely on unreleased gem code (like the `Meeting` demo below, which needs the still-unreleased 0.2.0), temporarily uncomment the `path: "../.."` line locally, `bundle install`, test, then revert the Gemfile and re-run `bundle install` against rubygems before committing. `Meeting`/`MeetingAttendee` (with a migration) is a working reference implementation of the `uid`/`sequence` persistence pattern CONFIGURATION.md describes — see `app/models/meeting.rb`, `app/controllers/meetings_controller.rb` (create/reschedule/cancel), and `app/controllers/event_replies_controller.rb` (illustrative inbound `METHOD:REPLY` parsing, not wired to a real inbound-email provider). This demo won't actually run against the currently-published gem until 0.2.0 ships.
|
data/lib/.DS_Store
DELETED
|
Binary file
|
data/lib/cal_invite/.DS_Store
DELETED
|
Binary file
|