cal-invite 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +13 -8
- data/CACHING.md +190 -98
- data/CHANGELOG.md +61 -2
- data/CONFIGURATION.md +25 -29
- data/README.md +132 -190
- data/Rakefile +2 -1
- data/SECURITY.md +39 -10
- data/lib/cal_invite/configuration.rb +20 -11
- data/lib/cal_invite/event.rb +87 -114
- data/lib/cal_invite/ical_timezone.rb +64 -18
- data/lib/cal_invite/providers/base_provider.rb +302 -264
- data/lib/cal_invite/providers/ical.rb +7 -152
- data/lib/cal_invite/providers/ics.rb +68 -13
- data/lib/cal_invite/providers/ics_content.rb +7 -129
- data/lib/cal_invite/providers.rb +8 -0
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +1 -8
- metadata +4 -9
- data/.DS_Store +0 -0
- data/CLAUDE.md +0 -49
- data/lib/.DS_Store +0 -0
- data/lib/cal_invite/.DS_Store +0 -0
- data/lib/cal_invite/caching.rb +0 -74
- data/lib/tasks/rdoc.rake +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af172acc91308a8bcfdf60019e87a5e01fb8f23baa9287b7d1d2c72b282f58be
|
|
4
|
+
data.tar.gz: dd38efbb8511d896e5d6f9c427f060ea8b1a04e7c6b8561b790ff14cdf63effe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82b6aeffacee1b90bce91595a28d864f879d09be68a9503abf3290529ca738080cf90fd4cb7c5dec3d641789c6ea4c36d7193aa8b9d76a7605ed510f72c0a59f
|
|
7
|
+
data.tar.gz: 6663085d67c0376b52556b6d09a5a575f8bb8005f6276719e27d1acf3233c0087a656b56df4bf29a835230db602048c3759cf31f3fc9912c0c2509f89e58bb31
|
data/AGENTS.md
CHANGED
|
@@ -21,26 +21,31 @@ Tests are Minitest-based, files live under `test/cal_invite/`, named `*_test.rb`
|
|
|
21
21
|
|
|
22
22
|
## Architecture
|
|
23
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| ... }`.
|
|
24
|
+
- **`CalInvite` module** (`lib/cal_invite.rb`) — top-level entry point. Holds a singleton `CalInvite.configuration` (a `Configuration` instance) set via `CalInvite.configure { |c| ... }`.
|
|
25
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,
|
|
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, resolves `CalInvite::Providers::<CapitalizedProvider>`, instantiates it with `self`, and calls `#generate`. The gem deliberately caches **nothing** (output caching and the cache helpers were removed in 0.3.0; see CONFIGURATION.md's `generate_calendar_url` section for why) — don't reintroduce it. Location and virtual-meeting URL are intentionally separate fields (not merged) so each provider can format them appropriately.
|
|
27
27
|
|
|
28
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
29
|
|
|
30
|
-
- **`BaseProvider`** (`lib/cal_invite/providers/base_provider.rb`) — abstract base class
|
|
30
|
+
- **`CalInvite::Providers::BaseProvider`** (`lib/cal_invite/providers/base_provider.rb`) — abstract base class. A deprecated top-level `::BaseProvider` alias (defined in `providers.rb` via `Object.deprecate_constant`, skipped if the host app already has a `BaseProvider`) remains until 0.4.0 — don't reference it from gem code. 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
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` is
|
|
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). `Ics` is the **only** `.ics` generator: `Ical` and `IcsContent` are empty subclasses kept for their public names, so make content changes in `ics.rb` only. `IcsDownload` (HTTP header/filename helpers) 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`. `Event#validate!` rejects any other `timezone` (`IcalTimezone.valid?`), so providers can rely on it being UTC or a real IANA zone.
|
|
33
34
|
|
|
34
|
-
- **`
|
|
35
|
-
|
|
36
|
-
- **`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`.
|
|
35
|
+
- **`Configuration`** (`lib/cal_invite/configuration.rb`) — holds `webhook_secret` and `timezone` (default `'UTC'`; the default `Event#timezone` when `timezone:` isn't passed). `cache_store`/`cache_prefix`/`cache_expires_in` are deprecated no-ops kept until 0.4.0: their setters warn (`uplevel: 2`, so the warning points at the caller's initializer) but still store the value so app code reading them keeps working.
|
|
37
36
|
|
|
38
37
|
## Conventions
|
|
39
38
|
|
|
40
39
|
- Always pass times to `Event.new` in UTC; use the `timezone` attribute only to control display/formatting in the generated invite.
|
|
40
|
+
- **Treat every `Event` attribute as untrusted input** (host apps pass end-user strings: titles, display names, ...). In `.ics` output: TEXT values go through `Ics#escape_text`; parameter values (e.g. `CN=`) through `BaseProvider#quoted_param` (RFC 6868); enumerated/numeric values are whitelisted or coerced (`class_line`, `geo_line`, `sequence_line`); and `Ics#ensure_single_line_values!` raises on any CR/LF left in a content line — never bypass it or interpolate a raw attribute into a property/parameter. URL providers must pass every value through `url_encode`. Add a case to `test/cal_invite/ics_security_test.rb` for any new property.
|
|
41
41
|
- `location` (physical) and `url` (virtual meeting link) are kept as distinct fields throughout the provider layer — don't conflate them when adding provider logic.
|
|
42
|
-
- 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`.
|
|
42
|
+
- 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`. `Event#start_time`/`#end_time` are readers that fall back to the earliest session start / latest session end when not set explicitly (computed on read, so they follow `update_attributes`), which is what single-range URL providers use.
|
|
43
43
|
- 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.
|
|
44
|
+
- 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.
|
|
45
|
+
|
|
46
|
+
## Example app (`Example/calendar_app`)
|
|
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, 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).
|
|
44
49
|
|
|
45
50
|
## PRs & commits
|
|
46
51
|
|
data/CACHING.md
CHANGED
|
@@ -1,148 +1,240 @@
|
|
|
1
|
-
|
|
1
|
+
# Caching CalInvite output in your application
|
|
2
2
|
|
|
3
|
-
CalInvite
|
|
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
4
|
|
|
5
|
-
|
|
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
6
|
|
|
7
|
-
|
|
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.
|
|
8
37
|
|
|
9
38
|
```ruby
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
20
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
|
+
}
|
|
21
57
|
```
|
|
22
58
|
|
|
23
|
-
|
|
59
|
+
## Plain Ruby
|
|
24
60
|
|
|
25
|
-
|
|
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.
|
|
26
62
|
|
|
27
|
-
|
|
28
|
-
```ruby
|
|
29
|
-
config.cache_store = :memory_store
|
|
30
|
-
```
|
|
63
|
+
### A small in-process LRU cache
|
|
31
64
|
|
|
32
|
-
|
|
33
|
-
```ruby
|
|
34
|
-
config.cache_store = :null_store
|
|
35
|
-
```
|
|
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.
|
|
36
66
|
|
|
37
|
-
3. Custom Cache Store
|
|
38
67
|
```ruby
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
42
74
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
47
96
|
```
|
|
48
97
|
|
|
49
|
-
|
|
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.
|
|
50
99
|
|
|
51
|
-
|
|
52
|
-
- `cache_prefix`: Prefix for all cache keys (default: 'cal_invite')
|
|
53
|
-
- `cache_expires_in`: Default cache expiration time in seconds
|
|
54
|
-
- `timezone`: Default timezone for cache keys (default: 'UTC')
|
|
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.
|
|
55
101
|
|
|
56
|
-
###
|
|
102
|
+
### ActiveSupport cache stores
|
|
57
103
|
|
|
58
|
-
|
|
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.
|
|
59
105
|
|
|
60
106
|
```ruby
|
|
61
|
-
|
|
62
|
-
def read(key)
|
|
63
|
-
# Implementation for retrieving cached value
|
|
64
|
-
end
|
|
107
|
+
require "active_support"
|
|
65
108
|
|
|
66
|
-
|
|
67
|
-
# Implementation for storing value in cache
|
|
68
|
-
# options may include :expires_in
|
|
69
|
-
end
|
|
109
|
+
STORE = ActiveSupport::Cache::MemoryStore.new(size: 32 * 1024 * 1024, expires_in: 3600)
|
|
70
110
|
|
|
71
|
-
|
|
72
|
-
|
|
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)
|
|
73
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
|
|
74
131
|
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
|
77
136
|
end
|
|
78
137
|
end
|
|
79
138
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
83
156
|
end
|
|
84
157
|
```
|
|
85
158
|
|
|
86
|
-
|
|
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`.
|
|
87
162
|
|
|
88
|
-
|
|
163
|
+
## Sinatra
|
|
89
164
|
|
|
90
|
-
|
|
91
|
-
2. When a new calendar URL is generated
|
|
92
|
-
3. When the configuration changes
|
|
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.
|
|
93
166
|
|
|
94
|
-
|
|
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 }
|
|
95
173
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
- Implement a custom cache store for specific requirements
|
|
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
|
|
100
177
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
```
|
|
105
183
|
|
|
106
|
-
|
|
107
|
-
- Set appropriate expiration times based on your use case
|
|
108
|
-
- Consider using shorter expiration times for frequently changing data
|
|
109
|
-
- Use `nil` expiration for permanent caching (until manual invalidation)
|
|
184
|
+
## Hanami
|
|
110
185
|
|
|
111
|
-
|
|
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.
|
|
112
187
|
|
|
113
|
-
|
|
188
|
+
`response.fresh` sets the `ETag`/`Last-Modified` headers and halts with `304` when the request is still fresh, before the `.ics` is built.
|
|
114
189
|
|
|
115
190
|
```ruby
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
126
206
|
end
|
|
127
|
-
|
|
128
|
-
config.cache_prefix = "cal_invite:#{Rails.env}"
|
|
129
|
-
config.timezone = 'UTC'
|
|
130
207
|
end
|
|
131
208
|
```
|
|
132
209
|
|
|
133
|
-
|
|
210
|
+
## Grape
|
|
134
211
|
|
|
135
|
-
|
|
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.
|
|
136
213
|
|
|
137
214
|
```ruby
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
142
237
|
end
|
|
238
|
+
```
|
|
143
239
|
|
|
144
|
-
|
|
145
|
-
def setup
|
|
146
|
-
CalInvite.configuration.cache_store.clear
|
|
147
|
-
end
|
|
148
|
-
```
|
|
240
|
+
The same API can still serve JSON from other endpoints. The `.ics` extension selects the formatter.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Cal Invite
|
|
2
2
|
|
|
3
|
+
## [v0.3.1] - 2026-10-22
|
|
4
|
+
|
|
5
|
+
No changes to the library code; this release is packaging and documentation.
|
|
6
|
+
|
|
7
|
+
- **Smaller gem.** The package no longer includes `assets/` (README images) or the unused `lib/tasks/rdoc.rake`: 52 KB instead of 973 KB.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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`).
|
|
10
|
+
- **README rewritten** for 0.3.0, with accurate examples that run in plain Ruby.
|
|
11
|
+
- **Gem metadata:** the RubyGems changelog link pointed at a non-existent `master` branch; it now points at `main`, and a documentation link was added.
|
|
12
|
+
|
|
13
|
+
## [v0.3.0] - 2026-10-22
|
|
14
|
+
|
|
15
|
+
### Upgrading from 0.2.x
|
|
16
|
+
|
|
17
|
+
These changes can break existing callers:
|
|
18
|
+
|
|
19
|
+
- **Unknown `Event` attributes raise.** `Event.new` and `Event#update_attributes` raise `ArgumentError` (`Unknown attribute(s): locaton`) for keys that aren't `Event` attributes, instead of silently ignoring them. If you pass a whole params hash, slice it first. A rejected `update_attributes` leaves the event unchanged. (#10)
|
|
20
|
+
- **`timezone` is validated.** It must be `'UTC'` or an IANA identifier known to tzinfo (e.g. `'America/New_York'`); anything else, including raw offsets like `'+01:00'`, raises `ArgumentError`. Such values previously produced invalid `.ics` (a `TZID=` with no `VTIMEZONE`, times shifted by the offset) and could inject parameters. (#7)
|
|
21
|
+
- **Time order is validated.** `ArgumentError` when `end_time` isn't after `start_time` (all-day events may end on the same date), or a `multi_day_sessions` entry is missing a time or ends before it starts. (#9)
|
|
22
|
+
- **Provider names are validated.** `generate_calendar_url` raises `ArgumentError` listing the supported providers for anything outside `CalInvite::Providers::SUPPORTED_PROVIDERS` (`:ics_content` still works as an alias of `:ics`). Previously e.g. `:string` resolved Ruby's `String` and failed confusingly. (#11)
|
|
23
|
+
- **`config.timezone` is now honored** as the default `Event#timezone` when `timezone:` isn't passed, as documented. If you set it to something other than `'UTC'`, your events now render in that zone. An explicit `timezone:` still wins. (#22)
|
|
24
|
+
- **No more blanket `active_support/core_ext`.** Requiring `cal_invite` no longer monkey-patches core classes (`Integer#hours`, `Object#blank?`, `String#squish`, ...) in non-Rails apps. Plain-Ruby code that used those only because `cal_invite` loaded them must `require "active_support/core_ext"` (or the specific file). Rails apps are unaffected. This also lets the gem load with ActiveSupport 6.0 on Ruby 4.0. (#15)
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- **Output caching.** `generate_calendar_url` builds its output fresh on every call and never touches a cache store; `update_attributes` no longer wipes every event's cache entries. The cache ignored `cache_prefix`, almost never hit, froze `.ics` `DTSTAMP`, and cost more than regenerating. Output is unchanged. To cache anyway, do it in your app; CONFIGURATION.md has an example. (#14)
|
|
29
|
+
- **Cache helpers.** `CalInvite.fetch_from_cache`, `write_to_cache`, `read_from_cache`, `clear_cache!`, `clear_event_cache!`, `clear_provider_cache!` and `CalInvite::Caching` are gone. They had never worked (every call raised `NoMethodError`). `CACHING.md` is gone too. (#32)
|
|
30
|
+
|
|
31
|
+
### Deprecated (removed in 0.4.0)
|
|
32
|
+
|
|
33
|
+
- **`config.cache_store=`, `cache_prefix=`, `cache_expires_in=`** have no effect. They print `warning: [cal_invite] Configuration#cache_store= is deprecated and has no effect ...` pointing at your initializer line, but still store the value, so code that reads them keeps working. Delete them from your initializer. (#32)
|
|
34
|
+
- **`::BaseProvider`.** The provider base class is now `CalInvite::Providers::BaseProvider`, so the gem no longer owns a top-level constant that can collide with your app's. `::BaseProvider` remains as an alias (not defined at all if your app has its own `BaseProvider`). Ruby prints `constant ::BaseProvider is deprecated` when deprecation warnings are enabled (e.g. `RUBYOPT=-W:deprecated`). (#13)
|
|
35
|
+
|
|
36
|
+
### Security
|
|
37
|
+
|
|
38
|
+
- **Fix content-line/parameter injection in `.ics` output** (`:ics`, `:ical`, `IcsContent`). A CR or LF in event attributes (title, description, attendee/organizer names and emails, `uid`, `rrule`, `visibility`, `geo`, ...), or a `"` in a display name, could add arbitrary properties to a generated invite (e.g. extra attendees, a different `METHOD:`). URL providers were not affected. (#5)
|
|
39
|
+
- TEXT values are escaped per RFC 5545 (backslashes were previously left unescaped and lone `\r` passed through).
|
|
40
|
+
- `CN=` display names are quoted and escaped per RFC 6868.
|
|
41
|
+
- `URL:` is no longer TEXT-escaped (it's a URI; commas and semicolons in links were being corrupted).
|
|
42
|
+
- Values that can't be escaped raise `ArgumentError` if they contain a line break. `visibility` must be `:public`/`:private`/`:confidential`, `geo` numeric, and `sequence` an integer.
|
|
43
|
+
- `SECURITY.md`: real policy (supported versions, private reporting via GitHub Security Advisories).
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `organizer:` accepts a plain email string (`organizer: "jane@example.com"`), like `attendees`; a string previously crashed `.ics` generation with `TypeError`. String-keyed hashes are read too. (#12)
|
|
48
|
+
- Multi-day events no longer need top-level `start_time`/`end_time`: they default to the earliest session start and latest session end, so the README's multi-day example works. `:google`/`:outlook`/`:office365` show the overall span; `:ics`/`:ical`/`:yahoo` still emit one entry per session. Explicit times win. (#24)
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- `.ics` content lines longer than 75 octets are folded (RFC 5545 §3.1), never splitting a multi-byte UTF-8 character. Long lines could be mangled or rejected by Outlook and mail gateways. (#6)
|
|
53
|
+
- `'UTC'` events emit `Z`-suffixed `DTSTART`/`DTEND` instead of `TZID=UTC` with no `VTIMEZONE`. (#7)
|
|
54
|
+
- `:ical` and `IcsContent` now produce exactly the same content as `:ics`. `IcsContent` previously emitted UTC-only times with no `VTIMEZONE`, ignored `notes`, and didn't support all-day events. `:ics` all-day events without times default to today, as `:ical` did. (#4)
|
|
55
|
+
- Non-UTC timezones under tzinfo 1.x (Rails/ActiveSupport 6.0): `:ics`/`:ical` raised `NoMethodError` (`to_local`) and silently omitted `VTIMEZONE`. (#17)
|
|
56
|
+
- `Event.new` no longer mutates the attributes hash passed in (it used to delete several keys from it, so reusing the hash dropped them). (#8)
|
|
57
|
+
|
|
58
|
+
### Docs
|
|
59
|
+
|
|
60
|
+
- README "Testing" section covers `rake test`, `bin/console`, and the Example app's local-path Gemfile workflow. `CLAUDE.md` merged into `AGENTS.md`.
|
|
61
|
+
|
|
3
62
|
## [v0.2.1] - 2026-08-10
|
|
4
63
|
|
|
5
64
|
- `:google` provider: honor `Event#attendees`/`show_attendees` (`add=`), `Event#timezone` (`ctz=`), `Event#rrule` (`recur=`), and `Event#busy` (`crm=BUSY`/`AVAILABLE`) in the generated Google Calendar URL — these were already modeled on `Event` for the ics providers but never wired into `:google`.
|
|
@@ -56,7 +115,7 @@
|
|
|
56
115
|
|
|
57
116
|
## [v0.1.2] - 2024-12-17
|
|
58
117
|
|
|
59
|
-
|
|
118
|
+
**Warning:** This version should not be used
|
|
60
119
|
|
|
61
120
|
- Add support to Microsoft office 365 calendar invite URL
|
|
62
121
|
- Update the README
|
|
@@ -65,7 +124,7 @@
|
|
|
65
124
|
|
|
66
125
|
## [v0.1.1] - 2024-12-17
|
|
67
126
|
|
|
68
|
-
|
|
127
|
+
**Warning:** This version should not be used
|
|
69
128
|
|
|
70
129
|
Fixing a bug in the gemspec file
|
|
71
130
|
|