From 942297c359257949ee0f5660ccf227c6e0faa929 Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 15:44:51 -0700 Subject: [PATCH] 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 9dd2a46..9c5db39 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -59,28 +59,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())) + ); } /** @@ -89,7 +73,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); +});