cal-invite 0.1.2 → 0.1.4

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.
@@ -3,75 +3,166 @@
3
3
  # lib/cal_invite/providers/ical.rb
4
4
  module CalInvite
5
5
  module Providers
6
+ # iCalendar format provider for generating ICS (iCalendar) content.
7
+ # This provider generates calendar content following the iCalendar specification (RFC 5545).
8
+ # It supports single events, all-day events, and multi-day sessions with proper timezone handling.
9
+ #
10
+ # @example Creating a regular event
11
+ # event = CalInvite::Event.new(
12
+ # title: "Team Meeting",
13
+ # start_time: Time.now,
14
+ # end_time: Time.now + 3600,
15
+ # timezone: 'America/New_York'
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
+ # ics_content = CalInvite::Providers::Ical.new(event).generate
6
29
  class Ical < BaseProvider
30
+ # Generates an iCalendar format string containing the event details.
31
+ # Includes proper calendar headers, timezone information (if needed),
32
+ # and one or more event blocks.
33
+ #
34
+ # @return [String] The complete iCalendar format content
7
35
  def generate
8
36
  [
9
37
  "BEGIN:VCALENDAR",
10
38
  "VERSION:2.0",
11
39
  "PRODID:-//CalInvite//Ruby//EN",
40
+ "CALSCALE:GREGORIAN",
41
+ "METHOD:PUBLISH",
42
+ generate_timezone,
12
43
  generate_events,
13
44
  "END:VCALENDAR"
14
- ].join("\n")
45
+ ].compact.join("\r\n")
15
46
  end
16
47
 
17
48
  private
18
49
 
50
+ # Generates the event blocks (VEVENT) for the calendar.
51
+ # Handles both single events and multi-day sessions.
52
+ #
53
+ # @return [String] The formatted event block(s)
19
54
  def generate_events
20
55
  if event.multi_day_sessions.any?
21
- event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\n")
56
+ event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\r\n")
22
57
  else
23
58
  generate_vevent
24
59
  end
25
60
  end
26
61
 
62
+ # Generates a single VEVENT block with all event details.
63
+ # Handles both all-day events and time-specific events.
64
+ #
65
+ # @param session [Hash, nil] Optional session details for multi-day events
66
+ # @return [String] The formatted VEVENT block
67
+ # @raise [ArgumentError] If required time fields are missing for non-all-day events
27
68
  def generate_vevent(session = nil)
28
69
  lines = ["BEGIN:VEVENT"]
29
70
 
30
71
  if event.all_day
31
72
  start_date = event.start_time || Time.now
32
73
  end_date = event.end_time || (start_date + 86400)
33
-
34
74
  lines << "DTSTART;VALUE=DATE:#{format_date(start_date)}"
35
75
  lines << "DTEND;VALUE=DATE:#{format_date(end_date)}"
36
76
  else
37
77
  start_time = session ? session[:start_time] : event.start_time
38
78
  end_time = session ? session[:end_time] : event.end_time
39
-
40
79
  raise ArgumentError, "Start time is required for non-all-day events" unless start_time
41
80
  raise ArgumentError, "End time is required for non-all-day events" unless end_time
42
-
43
- lines << "DTSTART:#{format_time(start_time)}"
44
- lines << "DTEND:#{format_time(end_time)}"
81
+ lines << "DTSTART;TZID=#{event.timezone}:#{format_local_time(start_time)}"
82
+ lines << "DTEND;TZID=#{event.timezone}:#{format_local_time(end_time)}"
45
83
  end
46
84
 
85
+ # Required fields
47
86
  lines.concat([
48
- "SUMMARY:#{event.title}",
49
- "UID:#{generate_uid}"
87
+ "SUMMARY:#{escape_text(event.title)}",
88
+ "UID:#{generate_uid}",
89
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}"
50
90
  ])
51
91
 
52
- lines << "DESCRIPTION:#{format_description}" if format_description
53
- lines << "LOCATION:#{format_location}" if format_location
92
+ # Optional fields
93
+ lines << "DESCRIPTION:#{escape_text(format_description)}" if format_description
94
+ lines << "LOCATION:#{escape_text(format_location)}" if format_location
95
+ lines << "URL:#{escape_text(format_url)}" if format_url
54
96
 
97
+ # Attendees
55
98
  if attendees = attendees_list
56
99
  attendees.each do |attendee|
57
- lines << "ATTENDEE:mailto:#{attendee}"
100
+ lines << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
58
101
  end
59
102
  end
60
103
 
61
104
  lines << "END:VEVENT"
62
- lines.join("\n")
105
+ lines.join("\r\n")
106
+ end
107
+
108
+ # Generates the timezone block (VTIMEZONE) for the calendar.
109
+ # Only included for non-all-day events.
110
+ #
111
+ # @return [String, nil] The formatted timezone block, or nil for all-day events
112
+ def generate_timezone
113
+ return nil if event.all_day # No timezone needed for all-day events
114
+ [
115
+ "BEGIN:VTIMEZONE",
116
+ "TZID:#{event.timezone}",
117
+ "END:VTIMEZONE"
118
+ ].join("\r\n")
63
119
  end
64
120
 
121
+ # Generates a unique identifier for the calendar event.
122
+ # Format: timestamp-randomhex@cal-invite
123
+ #
124
+ # @return [String] The generated UID
65
125
  def generate_uid
66
- SecureRandom.uuid
126
+ "#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
67
127
  end
68
128
 
129
+ # Formats a time object as a date string in iCalendar format.
130
+ #
131
+ # @param time [Time] The time to format
132
+ # @return [String] The formatted date (YYYYMMDD)
69
133
  def format_date(time)
70
134
  time.strftime("%Y%m%d")
71
135
  end
72
136
 
73
- def format_time(time)
74
- time.utc.strftime("%Y%m%dT%H%M%SZ")
137
+ # Formats a time object as a local time string in iCalendar format.
138
+ # Times are assumed to be in the correct timezone already.
139
+ #
140
+ # @param time [Time] The time to format
141
+ # @return [String] The formatted local time (YYYYMMDDTHHmmSS)
142
+ def format_local_time(time)
143
+ time.strftime("%Y%m%dT%H%M%S")
144
+ end
145
+
146
+ # Formats a time object as an UTC timestamp in iCalendar format.
147
+ #
148
+ # @param time [Time] The time to format
149
+ # @return [String] The formatted UTC timestamp (YYYYMMDDTHHmmSSZ)
150
+ def format_timestamp(time)
151
+ time.strftime("%Y%m%dT%H%M%SZ")
152
+ end
153
+
154
+ # Escapes special characters in text according to iCalendar spec.
155
+ # Handles backslashes, newlines, commas, and semicolons.
156
+ #
157
+ # @param text [String, nil] The text to escape
158
+ # @return [String] The escaped text
159
+ def escape_text(text)
160
+ return '' if text.nil?
161
+ text.to_s
162
+ .gsub('\\', '\\\\')
163
+ .gsub("\n", '\\n')
164
+ .gsub(',', '\\,')
165
+ .gsub(';', '\\;')
75
166
  end
76
167
  end
77
168
  end
@@ -3,7 +3,43 @@
3
3
  # lib/cal_invite/providers/ics.rb
4
4
  module CalInvite
5
5
  module Providers
6
+ # Generic ICS provider for generating standard iCalendar (.ics) files.
7
+ # This provider generates ICS files that are compatible with most calendar applications.
8
+ # Supports all-day events, regular events, and multi-day sessions with proper timezone handling.
9
+ #
10
+ # @example Creating a regular event ICS file
11
+ # event = CalInvite::Event.new(
12
+ # title: "Team Meeting",
13
+ # start_time: Time.now,
14
+ # end_time: Time.now + 3600,
15
+ # timezone: 'America/New_York'
16
+ # )
17
+ # ics = CalInvite::Providers::Ics.new(event)
18
+ # ics_content = ics.generate
19
+ #
20
+ # @example Creating an all-day event ICS file
21
+ # event = CalInvite::Event.new(
22
+ # title: "Company Holiday",
23
+ # all_day: true,
24
+ # start_time: Date.today,
25
+ # end_time: Date.today + 1
26
+ # )
27
+ # ics_content = CalInvite::Providers::Ics.new(event).generate
28
+ #
29
+ # @example Creating a multi-day event ICS file
30
+ # event = CalInvite::Event.new(
31
+ # title: "Conference",
32
+ # multi_day_sessions: [
33
+ # { start_time: Time.parse("2024-04-01 09:00"), end_time: Time.parse("2024-04-01 17:00") },
34
+ # { start_time: Time.parse("2024-04-02 09:00"), end_time: Time.parse("2024-04-02 17:00") }
35
+ # ]
36
+ # )
37
+ # ics_content = CalInvite::Providers::Ics.new(event).generate
6
38
  class Ics < BaseProvider
39
+ # Generates the complete ICS calendar content with proper calendar properties.
40
+ # Handles all event types: all-day, regular, and multi-day sessions.
41
+ #
42
+ # @return [String] The complete ICS calendar content in iCalendar format
7
43
  def generate
8
44
  calendar_lines = [
9
45
  "BEGIN:VCALENDAR",
@@ -13,8 +49,9 @@ module CalInvite
13
49
  "METHOD:PUBLISH"
14
50
  ]
15
51
 
16
- # Add events (either single or multiple sessions)
17
- if event.multi_day_sessions.any?
52
+ if event.all_day
53
+ calendar_lines.concat(generate_all_day_event)
54
+ elsif event.multi_day_sessions.any?
18
55
  event.multi_day_sessions.each do |session|
19
56
  calendar_lines.concat(generate_vevent(session[:start_time], session[:end_time]))
20
57
  end
@@ -28,62 +65,114 @@ module CalInvite
28
65
 
29
66
  private
30
67
 
68
+ # Generates an all-day event component (VEVENT) with appropriate formatting.
69
+ #
70
+ # @return [Array<String>] Array of iCalendar format lines for the all-day event
71
+ def generate_all_day_event
72
+ vevent = [
73
+ "BEGIN:VEVENT",
74
+ "UID:#{generate_uid}",
75
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}",
76
+ "DTSTART;VALUE=DATE:#{format_date(event.start_time)}",
77
+ "DTEND;VALUE=DATE:#{format_date(event.end_time)}",
78
+ "SUMMARY:#{escape_text(event.title)}"
79
+ ]
80
+ add_optional_fields(vevent)
81
+ vevent << "END:VEVENT"
82
+ vevent
83
+ end
84
+
85
+ # Generates a regular or multi-day session event component (VEVENT).
86
+ #
87
+ # @param start_time [Time] The event start time
88
+ # @param end_time [Time] The event end time
89
+ # @return [Array<String>] Array of iCalendar format lines for the event
31
90
  def generate_vevent(start_time, end_time)
32
91
  vevent = [
33
92
  "BEGIN:VEVENT",
34
93
  "UID:#{generate_uid}",
35
- "DTSTAMP:#{format_timestamp(Time.now)}",
94
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}",
36
95
  "DTSTART;TZID=#{event.timezone}:#{format_local_timestamp(start_time)}",
37
96
  "DTEND;TZID=#{event.timezone}:#{format_local_timestamp(end_time)}",
38
97
  "SUMMARY:#{escape_text(event.title)}"
39
98
  ]
99
+ add_optional_fields(vevent)
100
+ vevent << "END:VEVENT"
101
+ vevent
102
+ end
40
103
 
41
- # Add description (including notes if present)
42
- if desc = format_description
43
- vevent << "DESCRIPTION:#{escape_text(desc)}"
104
+ # Adds optional fields to the event component if they exist.
105
+ # Handles description, location, URL, and attendees.
106
+ #
107
+ # @param vevent [Array<String>] The current event lines array
108
+ # @return [void]
109
+ def add_optional_fields(vevent)
110
+ description_parts = []
111
+ description_parts << format_description if format_description
112
+ if description_parts.any?
113
+ vevent << "DESCRIPTION:#{escape_text(description_parts.join('\n\n'))}"
44
114
  end
45
115
 
46
- # Add location and/or URL
47
- if event.location
48
- vevent << "LOCATION:#{escape_text(event.location)}"
116
+ if location = format_location
117
+ vevent << "LOCATION:#{escape_text(location)}"
49
118
  end
50
119
 
51
- if event.url
52
- vevent << "URL:#{escape_text(event.url)}"
120
+ if url = format_url
121
+ vevent << "URL:#{escape_text(url)}"
53
122
  end
54
123
 
55
- # Add attendees if showing is enabled
56
124
  if attendees_list.any?
57
125
  attendees_list.each do |attendee|
58
126
  vevent << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
59
127
  end
60
128
  end
61
-
62
- vevent << "END:VEVENT"
63
- vevent
64
129
  end
65
130
 
131
+ # Formats a time object as an UTC timestamp in iCalendar format.
132
+ #
133
+ # @param time [Time] The time to format
134
+ # @return [String] The formatted UTC timestamp (YYYYMMDDTHHmmSSZ)
66
135
  def format_timestamp(time)
67
136
  time.utc.strftime("%Y%m%dT%H%M%SZ")
68
137
  end
69
138
 
139
+ # Formats a time object as a date string in iCalendar format.
140
+ #
141
+ # @param time [Time] The time to format
142
+ # @return [String] The formatted date (YYYYMMDD)
143
+ def format_date(time)
144
+ time.strftime("%Y%m%d")
145
+ end
146
+
147
+ # Formats a time object as a local timestamp in iCalendar format.
148
+ # Note: Times are expected to be in UTC already.
149
+ #
150
+ # @param time [Time] The time to format
151
+ # @return [String] The formatted local time (YYYYMMDDTHHmmSS)
70
152
  def format_local_timestamp(time)
71
- event.localize_time(time).strftime("%Y%m%dT%H%M%S")
153
+ time.strftime("%Y%m%dT%H%M%S")
72
154
  end
73
155
 
156
+ # Generates a unique identifier for the calendar event.
157
+ # Format: timestamp-randomhex@cal-invite
158
+ #
159
+ # @return [String] The generated UID
74
160
  def generate_uid
75
161
  "#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
76
162
  end
77
163
 
164
+ # Escapes special characters in text according to iCalendar spec.
165
+ #
166
+ # @param text [String, nil] The text to escape
167
+ # @return [String] The escaped text, or empty string if input was nil
78
168
  def escape_text(text)
79
169
  return '' if text.nil?
80
-
81
170
  text.to_s
82
171
  .gsub('\\', '\\\\')
83
172
  .gsub("\n", '\\n')
84
173
  .gsub(',', '\\,')
85
174
  .gsub(';', '\\;')
86
- end
175
+ end
87
176
  end
88
177
  end
89
178
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/cal_invite/providers/ics_content.rb
4
+ module CalInvite
5
+ module Providers
6
+ # ICS content provider for generating calendar files in iCalendar format.
7
+ # This provider focuses on generating standards-compliant ICS content
8
+ # that can be used directly or wrapped for file download.
9
+ #
10
+ # @example Generate ICS content for a single event
11
+ # event = CalInvite::Event.new(
12
+ # title: "Team Meeting",
13
+ # start_time: Time.now,
14
+ # end_time: Time.now + 3600
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
+ # ics_content = CalInvite::Providers::IcsContent.new(event).generate
28
+ class IcsContent < BaseProvider
29
+ # Generates the complete ICS calendar content with all event details.
30
+ # Handles both single events and multi-day sessions.
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:PUBLISH"
40
+ ]
41
+
42
+ if event.multi_day_sessions.any?
43
+ event.multi_day_sessions.each do |session|
44
+ calendar_lines.concat(generate_vevent(session[:start_time], session[:end_time]))
45
+ end
46
+ else
47
+ calendar_lines.concat(generate_vevent(event.start_time, event.end_time))
48
+ end
49
+
50
+ calendar_lines << "END:VCALENDAR"
51
+ calendar_lines.join("\r\n")
52
+ end
53
+
54
+ private
55
+
56
+ # Generates a single VEVENT component with proper event properties.
57
+ #
58
+ # @param start_time [Time] The event start time
59
+ # @param end_time [Time] The event end time
60
+ # @return [Array<String>] Array of iCalendar format lines for the event
61
+ def generate_vevent(start_time, end_time)
62
+ [
63
+ "BEGIN:VEVENT",
64
+ "UID:#{generate_uid}",
65
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}",
66
+ "DTSTART:#{format_timestamp(start_time)}",
67
+ "DTEND:#{format_timestamp(end_time)}",
68
+ "SUMMARY:#{escape_text(event.title)}",
69
+ description_line,
70
+ location_line,
71
+ url_line,
72
+ attendee_lines,
73
+ "END:VEVENT"
74
+ ].compact
75
+ end
76
+
77
+ # Formats a time object as an UTC timestamp in iCalendar format.
78
+ #
79
+ # @param time [Time] The time to format
80
+ # @return [String] The formatted UTC timestamp (YYYYMMDDTHHmmSSZ)
81
+ def format_timestamp(time)
82
+ time.utc.strftime("%Y%m%dT%H%M%SZ")
83
+ end
84
+
85
+ # Generates a unique identifier for the calendar event.
86
+ # Format: timestamp-randomhex@cal-invite
87
+ #
88
+ # @return [String] The generated UID
89
+ def generate_uid
90
+ "#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
91
+ end
92
+
93
+ # Escapes special characters in text according to iCalendar spec.
94
+ #
95
+ # @param text [String, nil] The text to escape
96
+ # @return [String] The escaped text, or empty string if input was nil
97
+ def escape_text(text)
98
+ return '' if text.nil?
99
+ text.to_s
100
+ .gsub('\\', '\\\\')
101
+ .gsub("\n", '\\n')
102
+ .gsub(',', '\\,')
103
+ .gsub(';', '\\;')
104
+ end
105
+
106
+ # Generates the DESCRIPTION line if a description exists.
107
+ #
108
+ # @return [String, nil] The formatted DESCRIPTION line, or nil if no description
109
+ def description_line
110
+ return nil unless event.description
111
+ "DESCRIPTION:#{escape_text(event.description)}"
112
+ end
113
+
114
+ # Generates the LOCATION line if a location exists.
115
+ #
116
+ # @return [String, nil] The formatted LOCATION line, or nil if no location
117
+ def location_line
118
+ return nil unless event.location
119
+ "LOCATION:#{escape_text(event.location)}"
120
+ end
121
+
122
+ # Generates the URL line if a URL exists.
123
+ #
124
+ # @return [String, nil] The formatted URL line, or nil if no URL
125
+ def url_line
126
+ return nil unless event.url
127
+ "URL:#{escape_text(event.url)}"
128
+ end
129
+
130
+ # Generates ATTENDEE lines for all attendees if showing attendees is enabled.
131
+ #
132
+ # @return [Array<String>, nil] Array of ATTENDEE lines, or nil if no attendees or not showing
133
+ def attendee_lines
134
+ return nil unless event.show_attendees && event.attendees&.any?
135
+ event.attendees.map { |attendee| "ATTENDEE;RSVP=TRUE:mailto:#{attendee}" }
136
+ end
137
+ end
138
+
139
+ # Module for handling ICS file downloads with proper headers and file naming.
140
+ # Provides utility methods for preparing ICS content for HTTP download.
141
+ module IcsDownload
142
+ # Generates appropriate HTTP headers for ICS file download.
143
+ #
144
+ # @param filename [String] The desired filename for the download
145
+ # @return [Hash] HTTP headers for the ICS file download
146
+ def self.headers(filename)
147
+ {
148
+ 'Content-Type' => 'text/calendar; charset=UTF-8',
149
+ 'Content-Disposition' => "attachment; filename=#{sanitize_filename(filename)}"
150
+ }
151
+ end
152
+
153
+ # Sanitizes a filename by removing potentially problematic characters.
154
+ #
155
+ # @param filename [String] The filename to sanitize
156
+ # @return [String] The sanitized filename
157
+ def self.sanitize_filename(filename)
158
+ filename.gsub(/[^0-9A-Za-z.\-]/, '_')
159
+ end
160
+
161
+ # Wraps ICS content with appropriate download information.
162
+ #
163
+ # @param content [String] The ICS calendar content
164
+ # @param title [String] The event title to use in the filename
165
+ # @return [Hash] Hash containing content and headers for download
166
+ # @example
167
+ # result = IcsDownload.wrap_for_download(ics_content, "team-meeting")
168
+ # # => { content: "BEGIN:VCALENDAR...", headers: { 'Content-Type' => '...' } }
169
+ def self.wrap_for_download(content, title)
170
+ filename = sanitize_filename("#{title.downcase}_#{Time.now.strftime('%Y%m%d')}.ics")
171
+ {
172
+ content: content,
173
+ headers: headers(filename)
174
+ }
175
+ end
176
+ end
177
+
178
+ # Compatibility class aliases
179
+ # @api private
180
+ class Ics < IcsContent; end
181
+ # @api private
182
+ class Ical < IcsContent; end
183
+ end
184
+ end
@@ -3,7 +3,37 @@
3
3
  # lib/cal_invite/providers/office365.rb
4
4
  module CalInvite
5
5
  module Providers
6
+ # Microsoft Office 365 provider for generating calendar event URLs.
7
+ # This provider generates URLs that open the Office 365 web calendar
8
+ # with a pre-filled event creation form. Supports both all-day and
9
+ # time-specific events with proper timezone handling.
10
+ #
11
+ # @example Creating a regular event URL
12
+ # event = CalInvite::Event.new(
13
+ # title: "Team Meeting",
14
+ # start_time: Time.now,
15
+ # end_time: Time.now + 3600,
16
+ # description: "Weekly team sync"
17
+ # )
18
+ # office365 = CalInvite::Providers::Office365.new(event)
19
+ # url = office365.generate
20
+ #
21
+ # @example Creating an all-day event URL with attendees
22
+ # event = CalInvite::Event.new(
23
+ # title: "Company Off-Site",
24
+ # all_day: true,
25
+ # start_time: Date.today,
26
+ # end_time: Date.today + 1,
27
+ # attendees: ["john@example.com", "jane@example.com"],
28
+ # show_attendees: true
29
+ # )
30
+ # url = CalInvite::Providers::Office365.new(event).generate
6
31
  class Office365 < BaseProvider
32
+ # Generates an Office 365 calendar URL for the event.
33
+ # Automatically handles both all-day and time-specific events.
34
+ #
35
+ # @return [String] The Office 365 calendar URL
36
+ # @raise [ArgumentError] If required time fields are missing for non-all-day events
7
37
  def generate
8
38
  if event.all_day
9
39
  generate_all_day_event
@@ -14,6 +44,10 @@ module CalInvite
14
44
 
15
45
  private
16
46
 
47
+ # Generates a URL for an all-day event.
48
+ # Uses simplified date format and sets the allday flag.
49
+ #
50
+ # @return [String] The Office 365 calendar URL for an all-day event
17
51
  def generate_all_day_event
18
52
  params = {
19
53
  subject: url_encode(event.title),
@@ -27,11 +61,15 @@ module CalInvite
27
61
 
28
62
  params[:startdt] = url_encode(format_date(start_date))
29
63
  params[:enddt] = url_encode(format_date(end_date))
30
-
31
64
  add_optional_params(params)
32
65
  build_url(params)
33
66
  end
34
67
 
68
+ # Generates a URL for a regular (time-specific) event.
69
+ # Includes specific start and end times in UTC format.
70
+ #
71
+ # @return [String] The Office 365 calendar URL for a regular event
72
+ # @raise [ArgumentError] If start_time or end_time is missing
35
73
  def generate_single_event
36
74
  params = {
37
75
  subject: url_encode(event.title),
@@ -43,27 +81,39 @@ module CalInvite
43
81
 
44
82
  params[:startdt] = url_encode(format_time(event.start_time))
45
83
  params[:enddt] = url_encode(format_time(event.end_time))
46
-
47
84
  add_optional_params(params)
48
85
  build_url(params)
49
86
  end
50
87
 
88
+ # Formats a time object as a date string for Office 365.
89
+ #
90
+ # @param time [Time] The time to format
91
+ # @return [String] The formatted date (YYYY-MM-DD)
51
92
  def format_date(time)
52
93
  time.strftime('%Y-%m-%d')
53
94
  end
54
95
 
96
+ # Formats a time object as a UTC timestamp for Office 365.
97
+ # Office 365 handles timezone conversion on their end.
98
+ #
99
+ # @param time [Time] The time to format
100
+ # @return [String] The formatted UTC time (YYYY-MM-DDThh:mm:ssZ)
55
101
  def format_time(time)
56
- time.utc.strftime('%Y-%m-%dT%H:%M:%S')
102
+ # Always use UTC format, timezone is handled by the calendar
103
+ time.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
57
104
  end
58
105
 
106
+ # Adds optional parameters to the URL parameters hash.
107
+ # Handles description, virtual meeting URL, location, and attendees.
108
+ #
109
+ # @param params [Hash] The parameters hash to update
110
+ # @return [Hash] The updated parameters hash
59
111
  def add_optional_params(params)
60
- if description = format_description
61
- params[:body] = url_encode(description)
62
- end
63
-
64
- if location = format_location
65
- params[:location] = url_encode(location)
66
- end
112
+ description_parts = []
113
+ description_parts << format_description if format_description
114
+ description_parts << "Virtual Meeting URL: #{format_url}" if format_url
115
+ params[:body] = url_encode(description_parts.join("\n\n")) if description_parts.any?
116
+ params[:location] = url_encode(format_location) if format_location
67
117
 
68
118
  if attendees = attendees_list
69
119
  params[:to] = url_encode(attendees.join(';'))
@@ -72,6 +122,10 @@ module CalInvite
72
122
  params
73
123
  end
74
124
 
125
+ # Builds the final Office 365 calendar URL.
126
+ #
127
+ # @param params [Hash] The parameters to include in the URL
128
+ # @return [String] The complete Office 365 calendar URL
75
129
  def build_url(params)
76
130
  query = params.map { |k, v| "#{k}=#{v}" }.join('&')
77
131
  "https://outlook.office.com/owa/?#{query}"