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.
@@ -3,166 +3,21 @@
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.
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 Creating a regular event
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
- 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
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.join("\r\n")
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(event.start_time)}",
83
- "DTEND;VALUE=DATE:#{format_date(event.end_time)}",
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;TZID=#{event.timezone}:#{format_local_timestamp(start_time)}",
102
- "DTEND;TZID=#{event.timezone}:#{format_local_timestamp(end_time)}",
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
- vevent << "URL:#{escape_text(url)}"
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 << "SEQUENCE:#{event.sequence}"
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 special characters in text according to iCalendar spec.
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
- text.to_s
183
- .gsub('\\', '\\\\')
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 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.
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 Generate ICS content for a single event
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
- 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:#{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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CalInvite
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end
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
- # config.cache_store = :memory_store
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.2.1
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-08-10 00:00:00.000000000 Z
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/master/CHANGELOG.md
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
Binary file