From ccf830a78b96a4be479c1b4f4295b798fdebbf8f Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 15:41:10 -0700 Subject: [PATCH 1/5] Reconcile contradicting ICS field documentation. Document the actual 6.x LOCATION/URL/GEO/DESCRIPTION resolution in one table and cross-reference it from events:download_link. --- DOCUMENTATION.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b1f2579..ec08324 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -40,11 +40,16 @@ If you are using a different Statamic collection, update it in the addon setting ### ICS Downloads -ICS downloads use the following fields if they exist: +ICS downloads read the following entry fields when present: -- `address` -- `coordinates` -- `description` +| ICS property | Source | +|---|---| +| `LOCATION` | `address`, falling back to `location` — only when the value is a string | +| `URL` | `link`, falling back to `location` when that string is a URL | +| `GEO` | `coordinates` (`latitude` / `longitude`) | +| `DESCRIPTION` | `description` | + +A URL-valued `location` (with no separate `address` / `link`) currently emits **both** `LOCATION:` and `URL:`. The `coordinates` field must be a keyed array: @@ -262,7 +267,4 @@ Generates an ICS download link. **Parameters:** - `date` date get occurrences to download -Includes: -- `location` -- `description` -- `link` +Includes the fields documented under [ICS Downloads](#ics-downloads). From c690b49d7d549c1cd389d9572d565efc3de6f1c5 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 15:44:51 -0700 Subject: [PATCH 2/5] Fix multi-day ICS downloads missing location fields. Share decorate() across event types and guard coordinates so whole-event downloads keep LOCATION/URL/DESCRIPTION/GEO. --- src/Types/Event.php | 42 +++++---- src/Types/MultiDayEvent.php | 32 ++----- src/Types/RecurringEvent.php | 32 ++----- tests/Http/Contollers/IcsControllerTest.php | 96 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 61 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 6ca2c9c..7c1d09c 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -107,18 +107,31 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent $immutableDate = $this->toCarbonImmutable($date); - $iCalEvent = ICalendarEvent::create($this->event->title) - ->withoutTimezone() - ->uniqueIdentifier($this->event->id()) - ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) - ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())); + return $this->decorate( + ICalendarEvent::create($this->event->title) + ->withoutTimezone() + ->uniqueIdentifier($this->event->id()) + ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) + ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())) + ); + } + /** + * @return ICalendarEvent[] + */ + public function toICalendarEvents(): array + { + return Arr::wrap($this->toICalendarEvent($this->start())); + } + + protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent + { if ($address = $this->icsAddress()) { $iCalEvent->address($address); } - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); + if ($this->hasValidCoordinates($coords = $this->event->get('coordinates'))) { + $iCalEvent->coordinates((float) $coords['latitude'], (float) $coords['longitude']); } if (! is_null($description = $this->event->description)) { @@ -132,14 +145,6 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent return $iCalEvent; } - /** - * @return ICalendarEvent[] - */ - public function toICalendarEvents(): array - { - return Arr::wrap($this->toICalendarEvent($this->start())); - } - protected function eventUrl(): ?string { if (! is_null($link = $this->event->link)) { @@ -162,6 +167,13 @@ protected function icsAddress(): ?string return is_string($address) && $address !== '' ? $address : null; } + protected function hasValidCoordinates(mixed $coords): bool + { + return is_array($coords) + && is_numeric($coords['latitude'] ?? null) + && is_numeric($coords['longitude'] ?? null); + } + protected function supplement(CarbonInterface $date): ?Entry { return unserialize(serialize($this->event)) diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index 7ed1941..a992e5c 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -69,28 +69,12 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent $immutableDate = $this->toCarbonImmutable($date); $day = $this->getDayFromDate($immutableDate); - $iCalEvent = ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($immutableDate->setTimeFromTimeString($day->start())) - ->endsAt($immutableDate->setTimeFromTimeString($day->end())); - - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); - } - - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); - } - - if (! is_null($description = $this->event->description)) { - $iCalEvent->description($description); - } - - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); - } - - return $iCalEvent; + return $this->decorate( + ICalendarEvent::create($this->event->title) + ->uniqueIdentifier($this->event->id()) + ->startsAt($immutableDate->setTimeFromTimeString($day->start())) + ->endsAt($immutableDate->setTimeFromTimeString($day->end())) + ); } /** @@ -99,7 +83,9 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent public function toICalendarEvents(): array { return collect($this->days) - ->map(fn (Day $day, int $index) => $day->toICalendarEvent($this->event->title, $index)) + ->map(fn (Day $day, int $index) => $this->decorate( + $day->toICalendarEvent($this->event->title, $index) + )) ->all(); } diff --git a/src/Types/RecurringEvent.php b/src/Types/RecurringEvent.php index 69c8120..6969481 100644 --- a/src/Types/RecurringEvent.php +++ b/src/Types/RecurringEvent.php @@ -27,29 +27,15 @@ public function interval(): int */ public function toICalendarEvents(): array { - $iCalEvent = ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($this->start()) - ->endsAt($this->end()) - ->rrule($this->spatieRule()); - - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); - } - - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); - } - - if (! is_null($description = $this->event->description)) { - $iCalEvent->description($description); - } - - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); - } - - return [$iCalEvent]; + return [ + $this->decorate( + ICalendarEvent::create($this->event->title) + ->uniqueIdentifier($this->event->id()) + ->startsAt($this->start()) + ->endsAt($this->end()) + ->rrule($this->spatieRule()) + ), + ]; } protected function rule(bool $useEnd = false): RRuleInterface diff --git a/tests/Http/Contollers/IcsControllerTest.php b/tests/Http/Contollers/IcsControllerTest.php index 1f66697..181f4d2 100755 --- a/tests/Http/Contollers/IcsControllerTest.php +++ b/tests/Http/Contollers/IcsControllerTest.php @@ -310,3 +310,99 @@ $this->assertStringNotContainsString('LOCATION:', $response->streamedContent()); }); + +test('multi-day whole-event download includes location url description and geo on every day', function () { + Carbon::setTestNow(now()); + + Entry::make() + ->slug('multi-day-whole-event') + ->collection('events') + ->id('the-multi-day-whole-event') + ->data([ + 'title' => 'Multi-day Whole Event', + 'multi_day' => true, + 'address' => '123 Main St', + 'link' => 'https://example.com/join', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + 'description' => 'The description', + 'days' => [ + [ + 'date' => now()->toDateString(), + 'start_time' => '19:00', + 'end_time' => '21:00', + ], + [ + 'date' => now()->addDay()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '15:00', + ], + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'event' => 'the-multi-day-whole-event', + ]))->assertDownload('multi-day-whole-event.ics')->streamedContent(); + + expect(substr_count($content, 'LOCATION:123 Main St'))->toBe(2) + ->and(substr_count($content, 'URL:https://example.com/join'))->toBe(2) + ->and(substr_count($content, 'DESCRIPTION:The description'))->toBe(2) + ->and(substr_count($content, 'GEO:40;50'))->toBe(2); +}); + +test('partial coordinates do not fatal and omit geo', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('partial-coords-event') + ->id('partial-coords-id') + ->data([ + 'title' => 'Partial Coords Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'address' => '123 Main St', + 'coordinates' => [ + 'latitude' => 40, + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'partial-coords-id', + ]))->assertDownload('partial-coords-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:123 Main St', $content); + $this->assertStringNotContainsString('GEO:', $content); +}); + +test('non-numeric coordinates do not fatal and omit geo', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('bad-coords-event') + ->id('bad-coords-id') + ->data([ + 'title' => 'Bad Coords Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'address' => '123 Main St', + 'coordinates' => [ + 'latitude' => 'north', + 'longitude' => 'west', + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'bad-coords-id', + ]))->assertDownload('bad-coords-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:123 Main St', $content); + $this->assertStringNotContainsString('GEO:', $content); +}); From 278c8d4575c004cb3a3b7a085f31236a35e6a092 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 15:55:25 -0700 Subject: [PATCH 3/5] Route ICS exports through toICalendarEvent for decorate. Subclasses build the bare event; only the base toICalendarEvent path applies location fields. --- src/Types/Event.php | 21 ++++++++++-------- src/Types/MultiDayEvent.php | 41 ++++++++++++++++++------------------ src/Types/RecurringEvent.php | 15 +++++-------- 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 7c1d09c..bd89a55 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -105,15 +105,7 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent return null; } - $immutableDate = $this->toCarbonImmutable($date); - - return $this->decorate( - ICalendarEvent::create($this->event->title) - ->withoutTimezone() - ->uniqueIdentifier($this->event->id()) - ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) - ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())) - ); + return $this->decorate($this->buildICalendarEvent($date)); } /** @@ -124,6 +116,17 @@ public function toICalendarEvents(): array return Arr::wrap($this->toICalendarEvent($this->start())); } + protected function buildICalendarEvent(string|CarbonInterface $date): ICalendarEvent + { + $immutableDate = $this->toCarbonImmutable($date); + + return ICalendarEvent::create($this->event->title) + ->withoutTimezone() + ->uniqueIdentifier($this->event->id()) + ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) + ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())); + } + protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent { if ($address = $this->icsAddress()) { diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index a992e5c..bc3eeca 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -11,6 +11,7 @@ use Spatie\IcalendarGenerator\Components\Event as ICalendarEvent; use Statamic\Entries\Entry; use Statamic\Fields\Values; +use Statamic\Support\Str; use TransformStudios\Events\Day; class MultiDayEvent extends Event @@ -60,35 +61,33 @@ public function start(): CarbonImmutable return $this->days->first()->start(); } - public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent - { - if (! $this->occursOnDate($date)) { - return null; - } - - $immutableDate = $this->toCarbonImmutable($date); - $day = $this->getDayFromDate($immutableDate); - - return $this->decorate( - ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($immutableDate->setTimeFromTimeString($day->start())) - ->endsAt($immutableDate->setTimeFromTimeString($day->end())) - ); - } - /** * @return ICalendarEvent[] */ public function toICalendarEvents(): array { - return collect($this->days) - ->map(fn (Day $day, int $index) => $this->decorate( - $day->toICalendarEvent($this->event->title, $index) - )) + return $this->days + ->values() + ->map(function (Day $day, int $index) { + $event = $this->toICalendarEvent($day->start()); + + return $event?->uniqueIdentifier(Str::slug($this->event->title).'-'.$index); + }) + ->filter() ->all(); } + protected function buildICalendarEvent(string|CarbonInterface $date): ICalendarEvent + { + $immutableDate = $this->toCarbonImmutable($date); + $day = $this->getDayFromDate($immutableDate); + + return ICalendarEvent::create($this->event->title) + ->uniqueIdentifier($this->event->id()) + ->startsAt($immutableDate->setTimeFromTimeString($day->start())) + ->endsAt($immutableDate->setTimeFromTimeString($day->end())); + } + protected function rule(bool $useEnd = false): RRuleInterface { return tap( diff --git a/src/Types/RecurringEvent.php b/src/Types/RecurringEvent.php index 6969481..fb204b7 100644 --- a/src/Types/RecurringEvent.php +++ b/src/Types/RecurringEvent.php @@ -6,7 +6,6 @@ use Illuminate\Support\Arr; use RRule\RRule; use RRule\RRuleInterface; -use Spatie\IcalendarGenerator\Components\Event as ICalendarEvent; use Spatie\IcalendarGenerator\Enums\RecurrenceFrequency; use Spatie\IcalendarGenerator\ValueObjects\RRule as ICalendarRule; @@ -27,15 +26,11 @@ public function interval(): int */ public function toICalendarEvents(): array { - return [ - $this->decorate( - ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($this->start()) - ->endsAt($this->end()) - ->rrule($this->spatieRule()) - ), - ]; + if (! $event = $this->toICalendarEvent($this->start())) { + return []; + } + + return [$event->rrule($this->spatieRule())]; } protected function rule(bool $useEnd = false): RRuleInterface From 4f97275f1f1190b10c7f55a4ebcebb2a5e74e968 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 15:59:45 -0700 Subject: [PATCH 4/5] Unify toICalendarEvents return shape. Use the same null-check and single-element array form as RecurringEvent. --- src/Types/Event.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index bd89a55..9da9837 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -113,7 +113,11 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent */ public function toICalendarEvents(): array { - return Arr::wrap($this->toICalendarEvent($this->start())); + if (! $event = $this->toICalendarEvent($this->start())) { + return []; + } + + return [$event]; } protected function buildICalendarEvent(string|CarbonInterface $date): ICalendarEvent From eb8f69dfdecb8a4f59a55052d7c1ff775368db33 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 16:04:11 -0700 Subject: [PATCH 5/5] Explain why hasValidCoordinates accepts mixed. Entry::get() is untyped; mixed avoids a TypeError on the public ICS route. --- src/Types/Event.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Types/Event.php b/src/Types/Event.php index 9da9837..5653473 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -174,6 +174,7 @@ protected function icsAddress(): ?string return is_string($address) && $address !== '' ? $address : null; } + // Entry::get() is untyped; keep mixed so a bad value can't TypeError the public ICS route. protected function hasValidCoordinates(mixed $coords): bool { return is_array($coords)