cal-invite 0.3.0 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a320d848302c940f549394fa7d2d76c2160415efa3eb4ef007e3613c9ef9196
4
- data.tar.gz: 25bd69ff530c5b2479c0ac5f23862618089384c53994a10eaec3c8e3b59b8ce3
3
+ metadata.gz: 859781c1ae62094d375450f6a4ce69578eeae805a9986fca889927cdd49f446c
4
+ data.tar.gz: aed11a758d0b1a9d4eabf09e56609c9f1bd800b85503a5e171c7c9858f03c6dd
5
5
  SHA512:
6
- metadata.gz: dd3569a38b9f741d27dfe5964b98223e425539e94bca9e3a6c90b5f1baa1a2d390af7935d39c3fb04ab1272bf9d501ab3759b867d8926bc287f2019ece5021bf
7
- data.tar.gz: fc43475e14f1eb253319de3d671288a261fe296b006e4807b388cfbc96707738e36f15ed0a4e1e0698afed32d5a67cd0adf3fcb36bb19fee6c8b001ec72b536c
6
+ metadata.gz: ee1118ede1a8f029d66d59d22615d95eaf17ffcc9290fe82a3d59e98808d4f1b5de58df375c88ebc1f4d9f62228437632a1724fceccb28b039f8002174e51f94
7
+ data.tar.gz: 259efb6d6829b774aeb6d2d35e0c1518c00831cd81340ff519b3a7f4b7e726b0fb881f586bcf20fe98a89f4a46e4057e70aafc6dc790f28f213f9afa75f96f43
data/AGENTS.md CHANGED
@@ -45,7 +45,7 @@ Tests are Minitest-based, files live under `test/cal_invite/`, named `*_test.rb`
45
45
 
46
46
  ## Example app (`Example/calendar_app`)
47
47
 
48
- 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, 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).
48
+ 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, 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, plus `show` serving a cached `.ics` with `stale?` + `Rails.cache` per CACHING.md), and `app/controllers/event_replies_controller.rb` (illustrative inbound `METHOD:REPLY` parsing, not wired to a real inbound-email provider).
49
49
 
50
50
  ## PRs & commits
51
51
 
data/CACHING.md ADDED
@@ -0,0 +1,240 @@
1
+ # Caching CalInvite output in your application
2
+
3
+ CalInvite doesn't cache anything (see the [0.3.0 changelog](CHANGELOG.md)). Building a URL or `.ics` file is in-memory string work that takes microseconds, and the gem can't know when your data changes. Your application does, so caching belongs there.
4
+
5
+ This guide shows a few ways to do it in plain Ruby, Rails, Sinatra, Hanami and Grape. It isn't exhaustive. Every example was run against the framework version named in its section.
6
+
7
+ ## Contents
8
+
9
+ - [Do you need a cache?](#do-you-need-a-cache)
10
+ - [Four rules for correct caching](#four-rules-for-correct-caching)
11
+ - [The example model](#the-example-model)
12
+ - [Plain Ruby](#plain-ruby)
13
+ - [Rails 8.1](#rails-81)
14
+ - [Sinatra](#sinatra)
15
+ - [Hanami](#hanami)
16
+ - [Grape](#grape)
17
+
18
+ ## Do you need a cache?
19
+
20
+ Usually not for the output itself. Caching it pays off in two cases:
21
+
22
+ - **Repeated downloads of the same `.ics`.** Calendar apps that subscribe to a feed poll it every few minutes to hours. HTTP caching (`ETag`/`Last-Modified` and a `304 Not Modified` reply) saves the bandwidth and lets the client skip re-importing an unchanged file. This is the most useful kind of caching for calendar files.
23
+ - **Expensive work before the gem runs.** If building the `Event` means loading a meeting, its attendees and its venue from the database, cache the result to skip those queries. CalInvite's own share of the time is negligible.
24
+
25
+ Don't cache invites you send by email (`method: :request`, `:cancel`, ...). Each send should be generated when it's sent; see rule 3.
26
+
27
+ ## Four rules for correct caching
28
+
29
+ 1. **Key on the data's version, not on the output.** The key needs the record's identity, something that changes whenever the record changes (`updated_at`, a version column, the invite `sequence`), and every input that changes the output: provider, `method:`, and anything that varies per viewer, such as whether attendees are shown.
30
+ 2. **Persist the event's `uid`.** Without an explicit `uid:`, each `Event` gets a random one, so two builds of the same meeting produce different `.ics` content. Store the `uid` on your record and pass it in.
31
+ 3. **Never build an `ETag` by hashing the `.ics` content.** Every generation writes the current time into `DTSTAMP`, so the hash changes on every request and the client never gets a `304`. Derive the `ETag` from the record's version. A cached `.ics` also keeps the `DTSTAMP` from when it was cached. That's harmless for downloads and feeds, but it's another reason to generate emailed invites fresh.
32
+ 4. **Include the configured timezone if it can change at runtime.** `CalInvite.configuration.timezone` is the default `timezone` for events. Most apps set it once at boot, so it doesn't need to be in the key.
33
+
34
+ ## The example model
35
+
36
+ The plain Ruby, Sinatra, Hanami and Grape examples use this stand-in for a database record. In Rails it's an Active Record model with the same `to_cal_invite_event` method.
37
+
38
+ ```ruby
39
+ require "cal_invite"
40
+
41
+ Meeting = Struct.new(:id, :title, :starts_at, :ends_at, :uid, :sequence, :updated_at, keyword_init: true) do
42
+ def to_cal_invite_event
43
+ CalInvite::Event.new(title: title, start_time: starts_at, end_time: ends_at, uid: uid, sequence: sequence)
44
+ end
45
+
46
+ # Changes whenever the meeting changes (rule 1).
47
+ def cache_version
48
+ "#{id}-#{updated_at.to_i}-#{sequence}"
49
+ end
50
+ end
51
+
52
+ MEETINGS = {
53
+ 1 => Meeting.new(id: 1, title: "Design review", uid: "meeting-1@example.com", sequence: 0,
54
+ starts_at: Time.utc(2026, 11, 2, 15), ends_at: Time.utc(2026, 11, 2, 16),
55
+ updated_at: Time.utc(2026, 10, 20, 9))
56
+ }
57
+ ```
58
+
59
+ ## Plain Ruby
60
+
61
+ Written for Ruby 3.4 and run on Ruby 4.0. Ruby 4.0 doesn't add anything that changes these patterns; see [Ruby 4.0](#ruby-40) below.
62
+
63
+ ### A small in-process LRU cache
64
+
65
+ A `Hash` keeps insertion order, so deleting and re-inserting a key on each hit makes the first key the least recently used one. A `Mutex` keeps it safe under threaded servers like Puma.
66
+
67
+ ```ruby
68
+ class IcsCache
69
+ def initialize(max_entries: 1_000)
70
+ @max_entries = max_entries
71
+ @entries = Hash.new(capacity: max_entries) # Ruby 3.4+: allocate the table once
72
+ @lock = Mutex.new
73
+ end
74
+
75
+ def fetch(key)
76
+ @lock.synchronize do
77
+ return @entries[key] = @entries.delete(key) if @entries.key?(key) # move to most recent
78
+ end
79
+
80
+ value = yield # generate outside the lock
81
+ @lock.synchronize do
82
+ @entries[key] = value
83
+ @entries.shift while @entries.size > @max_entries # evict least recently used
84
+ end
85
+ value
86
+ end
87
+ end
88
+
89
+ ICS_CACHE = IcsCache.new
90
+
91
+ def meeting_ics(meeting)
92
+ ICS_CACHE.fetch([meeting.cache_version, :ics, :publish]) do
93
+ meeting.to_cal_invite_event.generate_calendar_url(:ics)
94
+ end
95
+ end
96
+ ```
97
+
98
+ When a meeting changes, its `cache_version` changes, so the next call misses and the old entry ages out. Nothing has to be deleted by hand.
99
+
100
+ Each server process has its own copy of this cache. That's fine for a single process or a small app. If several processes or machines should share one cache, use a shared store such as Redis.
101
+
102
+ ### ActiveSupport cache stores
103
+
104
+ CalInvite already depends on `activesupport`, so its cache stores are available without Rails. `MemoryStore` adds a size limit and expiry to the pattern above, and the same `fetch` call works with `RedisCacheStore` (add the `redis` gem) or `FileStore` when processes need to share a cache.
105
+
106
+ ```ruby
107
+ require "active_support"
108
+
109
+ STORE = ActiveSupport::Cache::MemoryStore.new(size: 32 * 1024 * 1024, expires_in: 3600)
110
+
111
+ def meeting_ics_from_store(meeting)
112
+ STORE.fetch(["meeting-ics", meeting.cache_version]) do
113
+ meeting.to_cal_invite_event.generate_calendar_url(:ics)
114
+ end
115
+ end
116
+ ```
117
+
118
+ On ActiveSupport 7.x and earlier, loading the cache stores also loads some of ActiveSupport's core extensions. ActiveSupport 8 doesn't.
119
+
120
+ ### Ruby 4.0
121
+
122
+ Nothing in Ruby 4.0 improves this kind of caching. The headline changes are the `Ractor::Port` API, `Set` becoming a core class, and the experimental `Ruby::Box` isolation. The `Mutex`-guarded `Hash` above can't be shared between Ractors, but web servers and frameworks run requests on threads or processes rather than Ractors, so this doesn't affect normal applications. Both examples above run unchanged on 4.0.
123
+
124
+ ## Rails 8.1
125
+
126
+ Tested with Rails 8.1. `Rails.cache` is the cache store (Solid Cache by default since Rails 8.0), and `stale?` handles conditional GET: it sets `ETag` and `Last-Modified` from the record and answers `304 Not Modified` when the client's copy is current. Rails already knows the `text/calendar` MIME type as `:ics`.
127
+
128
+ ```ruby
129
+ # config/routes.rb
130
+ resources :meetings, only: :show
131
+
132
+ # app/models/meeting.rb
133
+ class Meeting < ApplicationRecord
134
+ def to_cal_invite_event
135
+ CalInvite::Event.new(title: title, start_time: starts_at, end_time: ends_at, uid: uid, sequence: sequence)
136
+ end
137
+ end
138
+
139
+ # app/controllers/meetings_controller.rb
140
+ class MeetingsController < ApplicationController
141
+ def show
142
+ meeting = Meeting.find(params[:id])
143
+
144
+ respond_to do |format|
145
+ format.ics do
146
+ # ETag from meeting.cache_key_with_version, Last-Modified from updated_at (rule 3)
147
+ if stale?(meeting, public: true)
148
+ ics = Rails.cache.fetch([meeting, :ics, :publish]) do
149
+ meeting.to_cal_invite_event.generate_calendar_url(:ics)
150
+ end
151
+ send_data ics, type: :ics, disposition: "inline", filename: "meeting-#{meeting.id}.ics"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ ```
158
+
159
+ `GET /meetings/1.ics` returns the file, and a repeat request with the `ETag` it got back returns `304`. Passing `meeting` in the cache key uses its `cache_key_with_version`, which changes with `updated_at`, so updating the record (including bumping `sequence`) also refreshes the cached copy. If `sequence` lives somewhere that doesn't touch `updated_at`, add it to both the key and `stale?(etag: ...)`.
160
+
161
+ The [example app](Example/calendar_app) has a complete version in `MeetingsController#show`, with tests. Its meetings list attendees, so it keeps `Cache-Control` private (no `public: true`), uses `belongs_to :meeting, touch: true` so an attendee's RSVP change expires the cache, and bumps `sequence` with `increment!(:sequence, touch: true)`, because `increment!` alone doesn't update `updated_at`.
162
+
163
+ ## Sinatra
164
+
165
+ Tested with Sinatra 4.2. The `etag` and `last_modified` helpers stop the route with a `304` when the client's copy is current, so the `.ics` is only generated, or read from the cache, for clients that need it.
166
+
167
+ ```ruby
168
+ require "sinatra/base"
169
+
170
+ class CalendarApp < Sinatra::Base
171
+ get "/meetings/:id.ics" do
172
+ meeting = MEETINGS.fetch(params[:id].to_i) { halt 404 }
173
+
174
+ cache_control :public, max_age: 300
175
+ last_modified meeting.updated_at
176
+ etag meeting.cache_version # halts with 304 if If-None-Match matches
177
+
178
+ content_type "text/calendar", charset: "utf-8"
179
+ meeting_ics(meeting) # the IcsCache helper from the plain Ruby section
180
+ end
181
+ end
182
+ ```
183
+
184
+ ## Hanami
185
+
186
+ Tested with hanami-action 3.0, the action library in Hanami 2.3 and later. Register the `ics` format once in `config/app.rb` with `config.actions.formats.register(:ics, "text/calendar")`. Hanami 2.2 and earlier used `formats.add`, which was removed in hanami-action 3.0.
187
+
188
+ `response.fresh` sets the `ETag`/`Last-Modified` headers and halts with `304` when the request is still fresh, before the `.ics` is built.
189
+
190
+ ```ruby
191
+ module Calendar
192
+ module Actions
193
+ module Meetings
194
+ class Show < Calendar::Action
195
+ def handle(request, response)
196
+ meeting = MEETINGS.fetch(request.params[:id].to_i) { halt 404 }
197
+
198
+ response.cache_control :public, max_age: 300
199
+ response.fresh etag: meeting.cache_version, last_modified: meeting.updated_at
200
+
201
+ response.format = :ics
202
+ response.body = meeting_ics(meeting)
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ ## Grape
211
+
212
+ Tested with Grape 4.0. Grape has no conditional-GET helper, so the endpoint compares `If-None-Match` itself. Registering an `ics` content type and a pass-through formatter makes `GET /meetings/1.ics` return the `.ics` string as-is, without JSON-encoding it.
213
+
214
+ ```ruby
215
+ require "grape"
216
+
217
+ class CalendarAPI < Grape::API
218
+ content_type :ics, "text/calendar"
219
+ formatter :ics, ->(object, _env) { object }
220
+
221
+ resource :meetings do
222
+ get ":id" do
223
+ meeting = MEETINGS.fetch(params[:id].to_i) { error!("Not found", 404) }
224
+ etag = %("#{meeting.cache_version}")
225
+
226
+ header "ETag", etag
227
+ header "Cache-Control", "public, max-age=300"
228
+ if env["HTTP_IF_NONE_MATCH"] == etag
229
+ status 304
230
+ return ""
231
+ end
232
+
233
+ content_type "text/calendar; charset=utf-8"
234
+ meeting_ics(meeting)
235
+ end
236
+ end
237
+ end
238
+ ```
239
+
240
+ The same API can still serve JSON from other endpoints. The `.ics` extension selects the formatter.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Cal Invite
2
2
 
3
+ ## [v0.3.2] - 2026-10-22
4
+
5
+ No changes to the library code; this release is documentation and CI.
6
+
7
+ - **API docs layout fixed.** The README's wide banner image ran under the "On This Page" sidebar on the published docs. RDoc now loads `assets/rdoc-overrides.css`, which keeps images inside the content column.
8
+ - **Docs deploy on their own.** The documentation workflow runs on every push to `main` that changes a file the docs render, and can be started by hand from the Actions tab on any branch. Release tags still publish the docs as before.
9
+ - **Faster CI for doc-only changes.** Changes that touch only Markdown files or `assets/` skip the test matrix. Tag pushes always run the full pipeline.
10
+ - **Docs checked on pull requests.** A PR that changes a file the docs render now runs the RDoc build, so a broken build fails before merge. Nothing is deployed from a PR.
11
+ - **Workflow lint and wider test matrix.** `actionlint` runs on the workflows, and the test matrix adds Ruby 3.4.
12
+ - **CI tidy-ups.** Superseded pull request runs are cancelled. Documentation deploys queue instead of cancelling each other. Workflow permissions are narrowed, and the test job has a 20-minute timeout.
13
+
14
+ ## [v0.3.1] - 2026-10-22
15
+
16
+ No changes to the library code; this release is packaging and documentation.
17
+
18
+ - **Smaller gem.** The package no longer includes `assets/` (README images) or the unused `lib/tasks/rdoc.rake`: 52 KB instead of 973 KB.
19
+ - **API reference fixed.** The published docs at https://dashapi-ai.github.io/cal-invite/ were missing `lib/cal_invite.rb` and every provider class (`Google`, `Outlook`, `Office365`, `Yahoo`, `Ics`, `Ical`, `IcsContent`, `IcsDownload`, `BaseProvider`), because the docs workflow's `lib/**/*.rb` glob only matched one directory level. The workflow now runs `rake rdoc` and also publishes the README, CONFIGURATION.md, CACHING.md and CHANGELOG.md.
20
+ - **New CACHING.md** on caching CalInvite output in your app (plain Ruby, Rails 8.1, Sinatra, Hanami, Grape), including HTTP caching for `.ics` feeds. The example app gains a cached `.ics` download (`MeetingsController#show`).
21
+ - **README rewritten** for 0.3.0, with accurate examples that run in plain Ruby.
22
+ - **Gem metadata:** the RubyGems changelog link pointed at a non-existent `master` branch; it now points at `main`, and a documentation link was added.
23
+
3
24
  ## [v0.3.0] - 2026-10-22
4
25
 
5
26
  ### Upgrading from 0.2.x
@@ -105,7 +126,7 @@ These changes can break existing callers:
105
126
 
106
127
  ## [v0.1.2] - 2024-12-17
107
128
 
108
- ⚠️ This version should not be used
129
+ **Warning:** This version should not be used
109
130
 
110
131
  - Add support to Microsoft office 365 calendar invite URL
111
132
  - Update the README
@@ -114,7 +135,7 @@ These changes can break existing callers:
114
135
 
115
136
  ## [v0.1.1] - 2024-12-17
116
137
 
117
- ⚠️ This version should not be used
138
+ **Warning:** This version should not be used
118
139
 
119
140
  Fixing a bug in the gemspec file
120
141
 
data/CONFIGURATION.md CHANGED
@@ -99,7 +99,7 @@ event.generate_calendar_url(provider, method: :publish)
99
99
  | `provider`| `Symbol` | required | One of the provider symbols above (a String also works). Anything else raises `ArgumentError` listing the supported providers. `:ics_content` is accepted as a legacy alias of `:ics`. |
100
100
  | `method` | `Symbol` | `:publish` | `:publish`, `:request`, `:cancel`, `:reply`, `:counter`, or `:decline_counter`. Only honored by `:ics`/`:ical` — ignored by URL-based providers. `:request` requires `organizer` to be set on the event; see below. `:cancel` requires reusing the original `uid` — see [Updating and cancelling invites](#updating-and-cancelling-invites). `:reply`/`:counter`/`:decline_counter` omit `RSVP=TRUE` on `ATTENDEE` lines — see [Attendee RSVP status and METHOD:REPLY](#attendee-rsvp-status-and-methodreply) and [Attendee-proposed reschedules (COUNTER)](#attendee-proposed-reschedules-counter). Note: `:decline_counter` renders as `METHOD:DECLINECOUNTER` (one word, per RFC 5545). |
101
101
 
102
- Output is generated fresh on every call. CalInvite doesn't cache anything: building the output takes microseconds, and a cached `.ics` would keep a stale `DTSTAMP`. If you want caching anyway, do it in your app, keyed on whatever identifies the content and changes with it:
102
+ Output is generated fresh on every call. CalInvite doesn't cache anything: building the output takes microseconds, and a cached `.ics` would keep a stale `DTSTAMP`. If you want caching anyway, do it in your app, keyed on whatever identifies the content and changes with it (more examples, including HTTP caching, in [CACHING.md](CACHING.md)):
103
103
 
104
104
  ```ruby
105
105
  Rails.cache.fetch([meeting, meeting.sequence, :ics]) do
data/README.md CHANGED
@@ -1,280 +1,210 @@
1
- # 📅 CalInvite
1
+ # CalInvite
2
2
 
3
- A Ruby gem for generating calendar invitations across multiple calendar platforms with webhook support.
3
+ ![CalInvite: calendar invitations for Ruby. Google, Outlook, Apple, Yahoo, .ics](assets/social/cal-invite-github.webp)
4
4
 
5
- [![Gem Version](https://badge.fury.io/rb/cal-invite.svg)](https://badge.fury.io/rb/cal-invite)
6
- ![Build Status](https://github.com/DashAPI-ai/cal-invite/actions/workflows/main.yml/badge.svg)
5
+ Generate "add to calendar" links and `.ics` calendar invites from Ruby, for Google Calendar, Outlook, Outlook 365, Yahoo, Apple Calendar, and any client that reads iCalendar files.
7
6
 
7
+ [![Gem Version](https://img.shields.io/gem/v/cal-invite)](https://rubygems.org/gems/cal-invite)
8
+ ![Build Status](https://github.com/DashAPI-ai/cal-invite/actions/workflows/main.yml/badge.svg)
8
9
  ![License](https://img.shields.io/github/license/DashAPI-ai/cal-invite.svg)
9
10
 
10
- ## Compatibility
11
-
12
- - Ruby >= 3.0.0
13
- - Rails 6.0, 6.1, 7.0, 7.1, 8.0, 8.1
14
-
15
- ## Supported Calendar Platforms
11
+ You describe an event once and get a link or `.ics` content for each provider. With `method: :request`, the `.ics` is an RFC 5545 meeting request that Gmail, Outlook and Apple Mail show with Accept/Decline buttons, and later updates or cancellations replace the original invite. Any IANA timezone gets a full `VTIMEZONE` with correctly converted times. Attribute values are escaped, so end-user input can't add properties to an invite.
16
12
 
17
- Direct Integration:
18
- - Apple iCal (with proper timezone support)
19
- - Microsoft Outlook
20
- - Microsoft Outlook 365
21
- - Google Calendar
22
- - Yahoo Calendar
23
- - Standard .ics file generation
13
+ ## Contents
24
14
 
25
- Any calendar application that supports the iCalendar (.ics) standard should work, including but not limited to:
26
- - Proton Calendar
27
- - FastMail Calendar
28
- - Thunderbird Calendar
29
- - Zoho Calendar
30
- - Microsoft Teams Calendar
31
- - Zoom Calendar Integration
15
+ - [Installation](#installation)
16
+ - [Quick start](#quick-start)
17
+ - [Providers](#providers)
18
+ - [More examples](#more-examples)
19
+ - [Serving a downloadable .ics file](#serving-a-downloadable-ics-file)
20
+ - [Sending email invites with RSVP](#sending-email-invites-with-rsvp)
21
+ - [Things to know](#things-to-know)
22
+ - [Upgrading to 0.3.0](#upgrading-to-030)
23
+ - [Documentation](#documentation)
24
+ - [Development](#development)
25
+ - [Sponsor](#sponsor)
32
26
 
33
27
  ## Installation
34
28
 
35
- Add this line to your application's Gemfile:
29
+ CalInvite is published on [RubyGems](https://rubygems.org/gems/cal-invite). It requires Ruby 3.0 or newer. It works with or without Rails, and supports Rails 6.0 through 8.1.
36
30
 
37
31
  ```ruby
38
- gem 'cal-invite'
32
+ # Gemfile
33
+ gem "cal-invite"
39
34
  ```
40
35
 
41
- And then execute:
42
36
  ```bash
43
- $ bundle install
37
+ bundle install
44
38
  ```
45
39
 
46
- Or install it yourself as:
47
- ```bash
48
- $ gem install cal-invite
40
+ ## Quick start
41
+
42
+ ```ruby
43
+ require "cal_invite"
44
+
45
+ event = CalInvite::Event.new(
46
+ title: "Team Meeting",
47
+ start_time: Time.utc(2026, 11, 2, 15, 0), # always pass UTC
48
+ end_time: Time.utc(2026, 11, 2, 16, 0),
49
+ timezone: "America/New_York", # how the time is displayed
50
+ description: "Weekly sync",
51
+ location: "Conference Room A", # physical place
52
+ url: "https://zoom.us/j/123456789" # virtual meeting link
53
+ )
54
+
55
+ event.generate_calendar_url(:google) # => "https://calendar.google.com/calendar/render?action=TEMPLATE&..."
56
+ event.generate_calendar_url(:ics) # => "BEGIN:VCALENDAR\r\n..." (.ics file content)
49
57
  ```
50
58
 
51
- ## Usage
59
+ ## Providers
52
60
 
53
- ### Basic Event Creation
61
+ | Provider | Returns | Notes |
62
+ |--------------|---------|-------|
63
+ | `:google` | URL | Opens Google Calendar's "add event" page. |
64
+ | `:outlook` | URL | Outlook.com (personal accounts). |
65
+ | `:office365` | URL | Outlook on the web for work/school (Microsoft 365) accounts. |
66
+ | `:yahoo` | URL | One URL per session for multi-day events, separated by newlines. |
67
+ | `:ics` | `.ics` content | Standard iCalendar file; works with Apple Calendar, Thunderbird, Proton, Fastmail and others. |
68
+ | `:ical` | `.ics` content | Same content as `:ics`; kept for Apple Calendar naming. |
54
69
 
55
- Important notes:
56
- - Always provide times in UTC
57
- - Use the timezone parameter to specify the display timezone
58
- - Location and URL are handled separately for better calendar integration
70
+ Only `:ics` and `:ical` support the `method:` option (`:publish` by default, also `:request`, `:cancel`, `:reply`, `:counter`, `:decline_counter`). Any other provider name raises `ArgumentError`.
59
71
 
60
- For the full list of `Event` attributes, providers, and configuration options, see [CONFIGURATION.md](CONFIGURATION.md).
72
+ ## More examples
61
73
 
62
74
  ```ruby
63
- # Create an event with physical location
64
- event = CalInvite::Event.new(
65
- title: "Team Meeting",
66
- start_time: Time.current.utc, # Always use UTC times
67
- end_time: Time.current.utc + 2.hours,
68
- description: "Weekly team sync",
69
- location: "Conference Room A", # Physical location
70
- timezone: "America/New_York", # Display timezone
71
- attendees: ["person@example.com"],
72
- show_attendees: true,
73
- notes: "Please bring your laptop"
74
- )
75
-
76
- # Create an event with both physical and virtual locations
77
- event = CalInvite::Event.new(
78
- title: "Hybrid Meeting",
79
- start_time: Time.current.utc,
80
- end_time: Time.current.utc + 2.hours,
81
- description: "Weekly team sync",
82
- location: "Conference Room A", # Physical location
83
- url: "https://zoom.us/j/123456789", # Virtual meeting URL
84
- timezone: "America/New_York",
85
- attendees: ["person@example.com"],
75
+ # Attendees: email strings, or hashes with a display name and RSVP status
76
+ CalInvite::Event.new(
77
+ title: "Planning",
78
+ start_time: Time.utc(2026, 11, 2, 15), end_time: Time.utc(2026, 11, 2, 16),
79
+ attendees: ["bob@example.com", { email: "ada@example.com", name: "Ada", partstat: :accepted }],
86
80
  show_attendees: true
87
81
  )
88
82
 
89
83
  # All-day event
90
- event = CalInvite::Event.new(
91
- title: "Company All-Day Event",
92
- start_time: Date.today.beginning_of_day.utc,
93
- end_time: Date.today.end_of_day.utc,
94
- all_day: true,
95
- timezone: "America/New_York"
84
+ CalInvite::Event.new(title: "Company Offsite", start_time: Time.utc(2026, 12, 4), all_day: true)
85
+
86
+ # Recurring event, with a reminder 15 minutes before
87
+ CalInvite::Event.new(
88
+ title: "Standup",
89
+ start_time: Time.utc(2026, 11, 2, 14), end_time: Time.utc(2026, 11, 2, 14, 15),
90
+ timezone: "Europe/Paris",
91
+ rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
92
+ reminders: [15]
96
93
  )
97
94
 
98
- # Multi-day event
99
- event = CalInvite::Event.new(
95
+ # Multi-day event with separate sessions
96
+ CalInvite::Event.new(
100
97
  title: "Training Workshop",
101
98
  multi_day_sessions: [
102
- {
103
- start_time: Time.parse("2024-03-01 08:00:00 UTC"),
104
- end_time: Time.parse("2024-03-01 17:00:00 UTC")
105
- },
106
- {
107
- start_time: Time.parse("2024-03-02 09:00:00 UTC"),
108
- end_time: Time.parse("2024-03-02 13:00:00 UTC")
109
- }
99
+ { start_time: Time.utc(2026, 3, 1, 13), end_time: Time.utc(2026, 3, 1, 22) },
100
+ { start_time: Time.utc(2026, 3, 2, 14), end_time: Time.utc(2026, 3, 2, 18) }
110
101
  ],
111
- description: "Advanced Ruby Training",
112
- location: "Training Center",
113
- url: "https://zoom.us/j/123456789", # Virtual meeting URL kept separate
114
102
  timezone: "America/New_York",
115
- notes: "Bring your own laptop"
103
+ location: "Training Center"
116
104
  )
117
-
118
- # Generate calendar URLs
119
- ical_url = event.generate_calendar_url(:ical)
120
- google_url = event.generate_calendar_url(:google)
121
- outlook_url = event.generate_calendar_url(:outlook)
122
- outlook365_url = event.generate_calendar_url(:office365)
123
- yahoo_url = event.generate_calendar_url(:yahoo)
124
105
  ```
125
106
 
126
- ### Implementing ICS Downloads
107
+ For a multi-day event, `:ics`, `:ical` and `:yahoo` produce one entry per session. Google and Outlook can only show one range, so they show the whole span, from the first session's start to the last session's end.
127
108
 
128
- To enable ICS file downloads in your application, you'll need to:
109
+ Every attribute (`organizer`, `geo`, `busy`, `visibility`, `importance`, `calendar_name`, ...) is listed in [CONFIGURATION.md](CONFIGURATION.md#event-attributes).
129
110
 
130
- 1. Create an endpoint that will handle the download request
131
- 2. Generate the ICS content
132
- 3. Send the file to the user
133
-
134
- Here's a basic example of the controller logic:
111
+ ## Serving a downloadable .ics file
135
112
 
136
113
  ```ruby
137
- # In your controller action
138
- def download_calendar
139
- event = # ... your event creation logic ...
140
-
141
- content = CalInvite::Providers::Ics.new(event).generate
142
- filename = "#{event.title.downcase.gsub(/[^0-9A-Za-z.\-]/, '_')}_#{Time.now.strftime('%Y%m%d')}.ics"
143
-
144
- send_data(
145
- content,
146
- filename: filename,
147
- type: 'text/calendar; charset=UTF-8',
148
- disposition: 'attachment'
114
+ # app/controllers/events_controller.rb
115
+ def calendar
116
+ event = CalInvite::Event.new(...)
117
+ download = CalInvite::Providers::IcsDownload.wrap_for_download(
118
+ event.generate_calendar_url(:ics), event.title
149
119
  )
120
+
121
+ send_data download[:content],
122
+ type: download[:headers]["Content-Type"],
123
+ filename: "#{CalInvite::Providers::IcsDownload.sanitize_filename(event.title)}.ics",
124
+ disposition: "attachment"
150
125
  end
151
126
  ```
152
127
 
153
- You can implement this in any controller and route that makes sense for your application's architecture.
154
-
155
- ### Sending Email Meeting Invites (RSVP-capable)
156
-
157
- By default, a `.ics` attachment opens as a file in most mail clients. To get
158
- Gmail/Outlook/Apple Mail to render the message as an invite with Accept/Decline
159
- actions (like Luma or Google Calendar invites do), set an `organizer` on the
160
- event and pass `method: :request` when generating. Full walkthrough and all
161
- options in [CONFIGURATION.md](CONFIGURATION.md#email-meeting-invites-rsvp-capable).
128
+ ## Sending email invites with RSVP
162
129
 
163
- ### ICS File Generation
130
+ A plain `.ics` attachment opens as a file. To have mail clients show it as a meeting invite with Accept/Decline buttons, you need three things: an `organizer`, `method: :request`, and a matching `method=REQUEST` on the attachment's content type.
164
131
 
165
- The gem provides two ways to generate ICS files:
166
-
167
- 1. Direct content generation:
168
132
  ```ruby
169
133
  event = CalInvite::Event.new(
170
- title: "Meeting",
171
- start_time: Time.current.utc,
172
- end_time: Time.current.utc + 1.hour,
173
- timezone: "America/New_York"
134
+ title: "Design Review",
135
+ start_time: Time.utc(2026, 11, 2, 15), end_time: Time.utc(2026, 11, 2, 16),
136
+ organizer: { name: "Jane Doe", email: "jane@example.com" },
137
+ attendees: ["guest@example.com"],
138
+ show_attendees: true
174
139
  )
175
140
 
176
- # Generate ICS content
177
- content = CalInvite::Providers::Ics.new(event).generate
141
+ # In your ActionMailer method:
142
+ attachments["invite.ics"] = {
143
+ mime_type: "text/calendar; method=REQUEST",
144
+ content: event.generate_calendar_url(:ics, method: :request)
145
+ }
178
146
  ```
179
147
 
180
- 2. Rails controller integration:
181
- ```ruby
182
- # In your controller
183
- def download_ics
184
- event = CalInvite::Event.new(
185
- title: "Meeting",
186
- start_time: Time.current.utc,
187
- end_time: Time.current.utc + 1.hour,
188
- timezone: "America/New_York"
189
- )
190
-
191
- content = CalInvite::Providers::Ics.new(event).generate
192
- filename = "#{event.title.downcase.gsub(/[^0-9A-Za-z.\-]/, '_')}_#{Time.now.strftime('%Y%m%d')}.ics"
193
-
194
- send_data(
195
- content,
196
- filename: filename,
197
- type: 'text/calendar; charset=UTF-8',
198
- disposition: 'attachment'
199
- )
200
- end
201
- ```
202
-
203
- ### Important Notes
204
-
205
- 1. Time Handling:
206
- - Always provide times in UTC to the Event constructor
207
- - Use the timezone parameter to specify the display timezone
208
- - All-day events should use beginning_of_day.utc and end_of_day.utc
148
+ Save `event.uid`. To reschedule or cancel later, rebuild the event with the same `uid` and a higher `sequence`, then send `method: :request` again (for a reschedule) or `method: :cancel`. See [Updating and cancelling invites](CONFIGURATION.md#updating-and-cancelling-invites). The [example Rails app](Example/calendar_app) has a working implementation.
209
149
 
210
- 2. Location and URL:
211
- - Physical location goes in the `location` parameter
212
- - Virtual meeting URL goes in the `url` parameter
213
- - They are handled separately for better calendar integration
150
+ ## Things to know
214
151
 
215
- 3. ICS Files:
216
- - Both Apple iCal and standard ICS files now properly handle timezones
217
- - Attendees are properly formatted with RSVP options
218
- - Virtual meeting URLs are properly separated from physical locations
152
+ - Pass `start_time` and `end_time` as UTC `Time`s. `timezone` only controls how the time is displayed. It must be `"UTC"` or an IANA name such as `"America/New_York"`, and defaults to `CalInvite.configuration.timezone` (`"UTC"`).
153
+ - Put the physical place in `location` and the video-call link in `url`. Each provider formats them its own way.
154
+ - Invalid input raises `ArgumentError`: a misspelled attribute (`locaton:`), an unknown timezone, an end time before the start time, or an unsupported provider.
155
+ - CalInvite generates links and invites but doesn't receive RSVP replies. It also can't set options that exist only in the Google and Microsoft APIs, such as guest permissions. See [Tracking RSVPs](CONFIGURATION.md#tracking-rsvps-what-the-gem-does-and-doesnt-do).
156
+ - Output is cheap to build, so nothing is cached and it's generated fresh on every call. To cache it in your app, or answer repeat downloads with `304 Not Modified`, see [CACHING.md](CACHING.md).
219
157
 
220
- ## Caching
158
+ Global settings:
221
159
 
222
- CalInvite doesn't cache anything. Generated URLs and `.ics` content are cheap to build and always fresh. The old `cache_store`/`cache_prefix`/`cache_expires_in` settings are deprecated no-ops (removed in 0.4.0). See [CONFIGURATION.md](CONFIGURATION.md#generate_calendar_url) for caching at your own layer, and for every other option.
223
-
224
- ## Development
225
-
226
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
160
+ ```ruby
161
+ # config/initializers/cal_invite.rb
162
+ CalInvite.configure do |config|
163
+ config.timezone = "America/New_York" # default for events that don't set one
164
+ end
165
+ ```
227
166
 
228
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
167
+ ## Upgrading to 0.3.0
229
168
 
230
- ## Testing
169
+ 0.3.0 is stricter. Unknown attributes, invalid timezones and invalid time ranges now raise `ArgumentError`, where 0.2.x silently produced a broken invite. The gem also no longer loads all of `active_support/core_ext`. In plain Ruby, `1.hour` and `Time.current` no longer work unless you require them yourself.
231
170
 
232
- Add test(s) as necessary.
171
+ The `cache_store`/`cache_prefix`/`cache_expires_in` settings and the top-level `::BaseProvider` constant are deprecated. They still work, but print a warning, and they'll be removed in 0.4.0. The full list of changes is in [CHANGELOG.md](CHANGELOG.md).
233
172
 
234
- Run all the tests before submitting: `bundle exec rake test`
173
+ ## Documentation
235
174
 
236
- There are three ways to exercise the gem while working on a change:
175
+ - [CONFIGURATION.md](CONFIGURATION.md): every attribute, provider and option, plus guides for email invites, updates and cancellations, RSVP status, COUNTER proposals, and timezones.
176
+ - [API reference](https://dashapi-ai.github.io/cal-invite/): generated from the YARD comments (`bundle exec rake rdoc` builds it locally).
177
+ - [CACHING.md](CACHING.md): caching output in plain Ruby, Rails, Sinatra, Hanami and Grape, including HTTP caching for `.ics` feeds.
178
+ - [CHANGELOG.md](CHANGELOG.md): release history.
237
179
 
238
- **1. Unit tests** (Minitest, under `test/cal_invite/`) — use these to lock a change in:
180
+ ## Development
239
181
 
240
182
  ```bash
241
- bundle exec rake test # full suite (+ SimpleCov report in coverage/)
242
- bundle exec rake test TEST=test/cal_invite/event_test.rb # a single file
183
+ bin/setup # install dependencies
184
+ bundle exec rake test # full suite (+ coverage report in coverage/)
185
+ bundle exec rake test TEST=test/cal_invite/event_test.rb # one file
186
+ bin/console # IRB with the local lib/ loaded
243
187
  ```
244
188
 
245
- **2. Interactive console** — `bin/console` opens IRB with your local `lib/` loaded, handy for eyeballing generated URLs and `.ics` output:
189
+ To try unreleased changes in the [example Rails app](Example/calendar_app), switch its Gemfile to the commented-out `gem "cal-invite", path: "../../"` line and run `bundle install`. Switch it back before committing.
246
190
 
247
- ```ruby
248
- event = CalInvite::Event.new(
249
- title: "Demo",
250
- start_time: Time.utc(2026, 11, 1, 15),
251
- end_time: Time.utc(2026, 11, 1, 16),
252
- timezone: "America/New_York",
253
- organizer: { email: "me@example.com", name: "Me" },
254
- attendees: ["guest@example.com"],
255
- show_attendees: true
256
- )
257
-
258
- puts event.generate_calendar_url(:ics, method: :request) # raw .ics content
259
- event.generate_calendar_url(:google) # provider URL
260
- ```
261
-
262
- **3. Example Rails app** (`Example/calendar_app`) — for end-to-end checks in a real browser or mail client (downloading the `.ics`, RSVP buttons, etc.). Its Gemfile points at the published gem; to test unreleased changes, temporarily swap `gem "cal-invite"` for the commented-out `gem "cal-invite", path: "../../"` line and run `bundle install`. Revert the Gemfile and re-run `bundle install` before committing.
191
+ To release, bump `lib/cal_invite/version.rb`, update `CHANGELOG.md`, merge to `main`, and push a `vX.Y.Z` tag. CI then runs the tests, publishes the gem to RubyGems and deploys the docs.
263
192
 
264
193
  ## Contributing
265
194
 
266
- Bug reports and pull requests are welcome on GitHub at https://github.com/DashAPI-ai/cal-invite. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/DashAPI-ai/cal-invite/blob/master/CODE_OF_CONDUCT.md).
195
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/DashAPI-ai/cal-invite). Please add tests for your change and run `bundle exec rake test` before opening a PR. To report a security issue, see [SECURITY.md](SECURITY.md). Everyone taking part in the project is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
267
196
 
268
- ## Documentation
197
+ ## Sponsor
269
198
 
270
- The documentation is spread across the README, [CONFIGURATION.md](CONFIGURATION.md), and the doc folder.
199
+ CalInvite is sponsored by [DashAPI](https://dashapi.ai).
271
200
 
272
- The documentation can be generated using `bundle exec rake rdoc`
201
+ <a href="https://dashapi.ai">
202
+ <picture>
203
+ <source media="(prefers-color-scheme: dark)" srcset="assets/sponsors/dashapi-logo-dark.svg">
204
+ <img src="assets/sponsors/dashapi-logo-light.svg" alt="DashAPI" width="140">
205
+ </picture>
206
+ </a>
273
207
 
274
208
  ## License
275
209
 
276
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
277
-
278
- ## Code of Conduct
279
-
280
- Everyone interacting in the Cal::Invite project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/DashAPI-ai/cal-invite/blob/master/CODE_OF_CONDUCT.md).
210
+ Released under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -23,9 +23,11 @@ RDoc::Task.new(:rdoc) do |rdoc|
23
23
  rdoc.options << '--markup' << 'markdown'
24
24
  rdoc.options << '--all'
25
25
  rdoc.options << '--exclude' << '^(test|spec|features)/'
26
+ rdoc.options << '--template-stylesheets' << 'assets/rdoc-overrides.css'
26
27
 
27
28
  # Include files to document
28
- rdoc.rdoc_files.include('README.md', 'LICENSE.txt', 'lib/**/*.rb')
29
+ rdoc.rdoc_files.include('README.md', 'CONFIGURATION.md', 'CACHING.md', 'CHANGELOG.md',
30
+ 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'LICENSE.txt', 'lib/**/*.rb')
29
31
  rdoc.rdoc_files.exclude('lib/cal_invite/version.rb')
30
32
  end
31
33
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CalInvite
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cal-invite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephane Paquet
@@ -105,6 +105,7 @@ files:
105
105
  - ".rdoc_options"
106
106
  - ".rubocop.yml"
107
107
  - AGENTS.md
108
+ - CACHING.md
108
109
  - CHANGELOG.md
109
110
  - CODE_OF_CONDUCT.md
110
111
  - CONFIGURATION.md
@@ -112,7 +113,6 @@ files:
112
113
  - README.md
113
114
  - Rakefile
114
115
  - SECURITY.md
115
- - assets/social/cal-invite-github.png
116
116
  - gemfiles/Gemfile.rails6
117
117
  - gemfiles/Gemfile.rails7
118
118
  - gemfiles/Gemfile.rails8
@@ -130,7 +130,6 @@ files:
130
130
  - lib/cal_invite/providers/outlook.rb
131
131
  - lib/cal_invite/providers/yahoo.rb
132
132
  - lib/cal_invite/version.rb
133
- - lib/tasks/rdoc.rake
134
133
  - sig/cal/invite.rbs
135
134
  homepage: https://github.com/DashAPI-ai/cal-invite
136
135
  licenses:
@@ -138,7 +137,8 @@ licenses:
138
137
  metadata:
139
138
  homepage_uri: https://github.com/DashAPI-ai/cal-invite
140
139
  source_code_uri: https://github.com/DashAPI-ai/cal-invite
141
- 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/
142
142
  post_install_message:
143
143
  rdoc_options:
144
144
  - "--title"
Binary file
data/lib/tasks/rdoc.rake DELETED
@@ -1,13 +0,0 @@
1
- # lib/tasks/rdoc.rake
2
- require 'rdoc/task'
3
-
4
- RDoc::Task.new do |rdoc|
5
- rdoc.rdoc_dir = 'doc'
6
- rdoc.title = 'CalInvite Documentation'
7
- rdoc.main = 'README.md'
8
- rdoc.options << '--line-numbers'
9
- rdoc.options << '--charset' << 'UTF-8'
10
- rdoc.options << '--markup' << 'markdown'
11
- rdoc.rdoc_files.include('README.md', 'LICENSE', 'lib/**/*.rb')
12
- rdoc.rdoc_files.exclude('lib/cal_invite/version.rb')
13
- end