cal-invite 0.2.1 → 0.3.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/AGENTS.md +13 -8
- data/CHANGELOG.md +49 -0
- data/CONFIGURATION.md +25 -29
- data/README.md +32 -20
- data/SECURITY.md +39 -10
- data/assets/social/cal-invite-github.png +0 -0
- 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 +3 -8
- data/.DS_Store +0 -0
- data/CACHING.md +0 -148
- 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
|
@@ -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.0
|
|
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,26 +102,21 @@ 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
|
-
- CACHING.md
|
|
110
108
|
- CHANGELOG.md
|
|
111
|
-
- CLAUDE.md
|
|
112
109
|
- CODE_OF_CONDUCT.md
|
|
113
110
|
- CONFIGURATION.md
|
|
114
111
|
- LICENSE.txt
|
|
115
112
|
- README.md
|
|
116
113
|
- Rakefile
|
|
117
114
|
- SECURITY.md
|
|
115
|
+
- assets/social/cal-invite-github.png
|
|
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
|
data/.DS_Store
DELETED
|
Binary file
|