From e73efbd09e13f40a1ce59628131068ebb1ab2189 Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 2 Sep 2026 12:59:33 -0700 Subject: [PATCH 1/6] Collapse multi-day events to a single occurrence (#198) --- src/Types/MultiDayEvent.php | 19 ++++++++++++++++++ tests/Types/MultiDayEventsTest.php | 31 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index 9dd2a46..7ed1941 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -45,6 +45,16 @@ public function end(): CarbonImmutable return $this->days->last()->end(); } + public function nextOccurrences(int $limit = 1): Collection + { + return $this->uniqueCollapsedOccurrences(parent::nextOccurrences($limit)); + } + + public function occurrencesBetween(string|CarbonInterface $from, string|CarbonInterface $to): Collection + { + return $this->uniqueCollapsedOccurrences(parent::occurrencesBetween($from, $to)); + } + public function start(): CarbonImmutable { return $this->days->first()->start(); @@ -139,4 +149,13 @@ private function getDayFromDate(CarbonInterface $date): ?Day { return $this->days->first(fn (Day $day, int $index) => $this->collapseMultiDays ? $index == 0 : $date->isSameDay($day->start())); } + + private function uniqueCollapsedOccurrences(Collection $occurrences): Collection + { + if (! $this->collapseMultiDays) { + return $occurrences; + } + + return $occurrences->unique(fn (Entry $occurrence) => $occurrence->id())->values(); + } } diff --git a/tests/Types/MultiDayEventsTest.php b/tests/Types/MultiDayEventsTest.php index c7f4ea3..e277730 100755 --- a/tests/Types/MultiDayEventsTest.php +++ b/tests/Types/MultiDayEventsTest.php @@ -36,6 +36,7 @@ 'timezone' => 'America/Vancouver', ]); + $this->entry = $entry; $this->event = EventFactory::createFromEntry($entry); $noEndTimeEntry = Entry::make() @@ -113,6 +114,36 @@ expect($this->event->nextOccurrences()[0]->start)->toEqual(Carbon::parse('2019-11-24')->setTimeFromTimeString('11:00:00')); }); +test('can collapse a multi day event into one occurrence', function () { + $event = EventFactory::createFromEntry($this->entry, collapseMultiDays: true); + + $occurrences = $event->occurrencesBetween( + Carbon::parse('2019-11-23')->startOfDay(), + Carbon::parse('2019-11-25')->endOfDay(), + ); + + expect($occurrences)->toHaveCount(1) + ->and($occurrences->first()->start)->toEqual(Carbon::parse('2019-11-23 19:00')->shiftTimezone('America/Vancouver')) + ->and($occurrences->first()->end)->toEqual(Carbon::parse('2019-11-25 15:00')->shiftTimezone('America/Vancouver')); +}); + +test('can collapse upcoming multi day occurrences', function () { + Carbon::setTestNowAndTimezone('2019-11-22', 'America/Vancouver'); + + $event = EventFactory::createFromEntry($this->entry, collapseMultiDays: true); + + expect($event->nextOccurrences(3))->toHaveCount(1); +}); + +test('does not collapse a multi day event by default', function () { + $occurrences = $this->event->occurrencesBetween( + Carbon::parse('2019-11-23')->startOfDay(), + Carbon::parse('2019-11-25')->endOfDay(), + ); + + expect($occurrences)->toHaveCount(3); +}); + test('day is all day when no start and end time', function () { $days = $this->allDayEvent->days(); From 8274d54c2022b38c3f31dc30d5878dc8293c2b47 Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Tue, 8 Sep 2026 15:44:43 -0700 Subject: [PATCH 2/6] Reconcile the contradicting ICS field docs (#205) --- 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 961820d916757d92bb2cff672beeade0518c6b1a Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Tue, 8 Sep 2026 16:06:57 -0700 Subject: [PATCH 3/6] Fix multi-day ICS downloads dropping location fields (#206) --- src/Types/Event.php | 42 ++++++--- src/Types/MultiDayEvent.php | 51 ++++------- src/Types/RecurringEvent.php | 25 +----- tests/Http/Contollers/IcsControllerTest.php | 96 +++++++++++++++++++++ 4 files changed, 148 insertions(+), 66 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 6ca2c9c..5653473 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -105,20 +105,40 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent return null; } + return $this->decorate($this->buildICalendarEvent($date)); + } + + /** + * @return ICalendarEvent[] + */ + public function toICalendarEvents(): array + { + if (! $event = $this->toICalendarEvent($this->start())) { + return []; + } + + return [$event]; + } + + protected function buildICalendarEvent(string|CarbonInterface $date): ICalendarEvent + { $immutableDate = $this->toCarbonImmutable($date); - $iCalEvent = ICalendarEvent::create($this->event->title) + 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()) { $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 +152,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 +174,14 @@ 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) + && 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..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,47 +61,31 @@ public function start(): CarbonImmutable return $this->days->first()->start(); } - public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent + /** + * @return ICalendarEvent[] + */ + public function toICalendarEvents(): array { - if (! $this->occursOnDate($date)) { - return null; - } + 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); - $iCalEvent = ICalendarEvent::create($this->event->title) + return 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 ICalendarEvent[] - */ - public function toICalendarEvents(): array - { - return collect($this->days) - ->map(fn (Day $day, int $index) => $day->toICalendarEvent($this->event->title, $index)) - ->all(); } protected function rule(bool $useEnd = false): RRuleInterface diff --git a/src/Types/RecurringEvent.php b/src/Types/RecurringEvent.php index 69c8120..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,29 +26,11 @@ 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 (! $event = $this->toICalendarEvent($this->start())) { + return []; } - 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 [$event->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 e1c62e2371d7fbe4098891b08ed3732464c6970f Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 9 Sep 2026 14:17:48 -0700 Subject: [PATCH 4/6] Add online_url for Zoom and livestream links (#209) --- DOCUMENTATION.md | 12 +- resources/fieldsets/event.yaml | 16 ++ src/Types/Event.php | 14 +- tests/Http/Contollers/IcsControllerTest.php | 175 ++++++++++++++++++++ 4 files changed, 211 insertions(+), 6 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ec08324..c856a6d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -45,11 +45,11 @@ ICS downloads read the following entry fields when present: | 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 | +| `URL` | `online_url`, then deprecated `link`, then deprecated `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:`. +A URL-valued `location` (with no separate `address` / `online_url` / `link`) currently emits **both** `LOCATION:` and `URL:`. Prefer `online_url` for join links; `link` and the URL-valued `location` fallback are deprecated and will be removed in 7.0. The `coordinates` field must be a keyed array: @@ -79,6 +79,14 @@ Using the sample fieldset is the fastest way to get started. ## Fields +### Location & Online URL + +| Field | Description | +|-------|-------------| +| `online_url` | Join link for online or hybrid events (Zoom, livestream, etc.). Optional; can be combined with a physical place once `location` is declared. | + +`link` is deprecated in favour of `online_url` and will be removed in 7.0. + ### Single-Day Events | Field | Required | Description | diff --git a/resources/fieldsets/event.yaml b/resources/fieldsets/event.yaml index c7baee8..9ba674c 100644 --- a/resources/fieldsets/event.yaml +++ b/resources/fieldsets/event.yaml @@ -203,3 +203,19 @@ fields: field: 'events::event.all_day' config: width: 25 + - + handle: location_section + field: + type: section + display: Location + - + handle: online_url + field: + type: text + input_type: url + display: 'Online URL' + localizable: false + validate: + - nullable + - url + instructions: 'Join link for online or hybrid events (Zoom, livestream, etc.).' diff --git a/src/Types/Event.php b/src/Types/Event.php index 5653473..fcb496c 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -145,8 +145,8 @@ protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent $iCalEvent->description($description); } - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); + if (! is_null($url = $this->eventUrl())) { + $iCalEvent->url($url); } return $iCalEvent; @@ -154,13 +154,19 @@ protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent protected function eventUrl(): ?string { - if (! is_null($link = $this->event->link)) { + if (is_string($url = $this->event->get('online_url')) && $url !== '') { + return $url; + } + + // @deprecated Will be removed in 7.0. Use online_url. + if (is_string($link = $this->event->get('link')) && $link !== '') { return $link; } + // @deprecated Will be removed in 7.0. Use online_url. $location = $this->event->get('location'); - if (! is_string($location)) { + if (! is_string($location) || $location === '') { return null; } diff --git a/tests/Http/Contollers/IcsControllerTest.php b/tests/Http/Contollers/IcsControllerTest.php index 181f4d2..121c5ae 100755 --- a/tests/Http/Contollers/IcsControllerTest.php +++ b/tests/Http/Contollers/IcsControllerTest.php @@ -406,3 +406,178 @@ $this->assertStringContainsString('LOCATION:123 Main St', $content); $this->assertStringNotContainsString('GEO:', $content); }); + +test('online_url emits URL', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('online-url-event') + ->id('online-url-id') + ->data([ + 'title' => 'Online URL Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/123', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-url-id', + ]))->assertDownload('online-url-event.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://zoom.us/j/123', $content); +}); + +test('online_url wins over link when both are set', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('online-over-link-event') + ->id('online-over-link-id') + ->data([ + 'title' => 'Online Over Link Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/123', + 'link' => 'https://example.com/old-link', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-over-link-id', + ]))->assertDownload('online-over-link-event.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://zoom.us/j/123', $content); + $this->assertStringNotContainsString('URL:https://example.com/old-link', $content); +}); + +test('deprecated link alone still emits URL', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('link-only-event') + ->id('link-only-id') + ->data([ + 'title' => 'Link Only Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'link' => 'https://example.com/join', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'link-only-id', + ]))->assertDownload('link-only-event.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://example.com/join', $content); +}); + +test('deprecated URL-valued location alone still emits LOCATION and URL', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('url-location-event') + ->id('url-location-id') + ->data([ + 'title' => 'URL Location Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'location' => 'https://zoom.us/j/456', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'url-location-id', + ]))->assertDownload('url-location-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:https://zoom.us/j/456', $content); + $this->assertStringContainsString('URL:https://zoom.us/j/456', $content); +}); + +test('online_url is included on all four download routes', function () { + Carbon::setTestNow(now()); + + Entry::make() + ->collection('events') + ->slug('online-single') + ->id('online-single-id') + ->data([ + 'title' => 'Online Single', + 'start_date' => now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/single', + ])->save(); + + Entry::make() + ->collection('events') + ->slug('online-recurring') + ->id('online-recurring-id') + ->data([ + 'title' => 'Online Recurring', + 'start_date' => now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'recurrence' => 'weekly', + 'online_url' => 'https://zoom.us/j/recurring', + ])->save(); + + Entry::make() + ->collection('events') + ->slug('online-multi-day') + ->id('online-multi-day-id') + ->data([ + 'title' => 'Online Multi Day', + 'multi_day' => true, + 'online_url' => 'https://zoom.us/j/multiday', + '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(); + + $single = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-single-id', + ]))->assertDownload('online-single.ics')->streamedContent(); + + $recurringDate = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-recurring-id', + ]))->assertDownload('online-recurring.ics')->streamedContent(); + + $recurringWhole = $this->get(route('statamic.events.ics.show', [ + 'event' => 'online-recurring-id', + ]))->assertDownload('online-recurring.ics')->streamedContent(); + + $multiDate = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-multi-day-id', + ]))->assertDownload('online-multi-day.ics')->streamedContent(); + + $multiWhole = $this->get(route('statamic.events.ics.show', [ + 'event' => 'online-multi-day-id', + ]))->assertDownload('online-multi-day.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://zoom.us/j/single', $single); + $this->assertStringContainsString('URL:https://zoom.us/j/recurring', $recurringDate); + $this->assertStringContainsString('URL:https://zoom.us/j/recurring', $recurringWhole); + $this->assertStringContainsString('URL:https://zoom.us/j/multiday', $multiDate); + expect(substr_count($multiWhole, 'URL:https://zoom.us/j/multiday'))->toBe(2); +}); From 46956589546002f1f08e36e0bbbc333ffa3ef88f Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 9 Sep 2026 14:49:56 -0700 Subject: [PATCH 5/6] PR-gated release on 6.x with workflows v1.4.0 (#213) --- .github/dependabot.yml | 12 ++++ .github/pr-labeler.yml | 4 -- .github/release-drafter.yml | 42 ------------- .github/workflows/create-draft-release.yml | 15 ++--- .github/workflows/label-pr.yml | 14 ++--- .github/workflows/release-prepare.yml | 28 +++++++++ .github/workflows/release-publish.yml | 11 ++++ .github/workflows/release.yml | 68 ---------------------- 8 files changed, 63 insertions(+), 131 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .github/pr-labeler.yml delete mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-prepare.yml create mode 100644 .github/workflows/release-publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ebe594b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + labels: + - "chore" diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml deleted file mode 100644 index c3de460..0000000 --- a/.github/pr-labeler.yml +++ /dev/null @@ -1,4 +0,0 @@ -chore: 'chore/*' -feature: ['feature/*', 'feat/*'] -fix: 'fix/*' -improvement: ['improvement/*', 'improve/*'] diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 868f093..0000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,42 +0,0 @@ -name-template: 'v$RESOLVED_VERSION' -tag-template: 'v$RESOLVED_VERSION' -categories: - - title: '🚀 New' - labels: - - 'feature' - - 'enhancement' - - title: '🐛 Fixed' - labels: - - 'fix' - - 'bugfix' - - 'bug' - - title: '🔧 Improved' - labels: - - 'change' - - 'improve' - - 'improvement' - - 'sync' - - title: '🧰 Maintenance' - label: 'chore' -change-template: '- $TITLE [@$AUTHOR](https://github.com/$AUTHOR) (#$NUMBER)' -change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. -version-resolver: - major: - labels: - - 'major' - minor: - labels: - - 'feature' - - 'enhancement' - - 'change' - - 'improve' - - 'improvement' - patch: - labels: - - 'fix' - - 'bugfix' - - 'bug' - - 'sync' - default: patch -template: | - $CHANGES diff --git a/.github/workflows/create-draft-release.yml b/.github/workflows/create-draft-release.yml index 0d2362c..0954b0d 100644 --- a/.github/workflows/create-draft-release.yml +++ b/.github/workflows/create-draft-release.yml @@ -3,15 +3,10 @@ name: Release Drafter on: pull_request: types: [closed] -permissions: - contents: read jobs: - update_release_draft: - if: github.event.pull_request.merged == true - permissions: write-all - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v7.3.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + draft: + uses: edalzell/github-workflows/.github/workflows/release-draft.yml@9f72f0b77303abff9903b16da75346b1eb1c3785 # v1.4.0 + permissions: + contents: write + pull-requests: write diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 26417e0..667072b 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -1,12 +1,12 @@ name: PR Labeler + on: pull_request: - types: [opened] + types: [opened, reopened, synchronize] jobs: - pr-labeler: - runs-on: ubuntu-latest - steps: - - uses: TimonVS/pr-labeler-action@v5.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + label: + uses: edalzell/github-workflows/.github/workflows/label-pr.yml@9f72f0b77303abff9903b16da75346b1eb1c3785 # v1.4.0 + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..ca25fac --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,28 @@ +name: Release Prepare + +on: + workflow_dispatch: + +jobs: + authorize: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require transformstudios/maintainers + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + state=$(gh api "orgs/transformstudios/teams/maintainers/memberships/${GITHUB_ACTOR}" --jq .state 2>/dev/null || true) + if [ "$state" != "active" ]; then + echo "::error::Only @transformstudios/maintainers can run Release Prepare (actor: ${GITHUB_ACTOR})" + exit 1 + fi + + prepare: + needs: authorize + uses: edalzell/github-workflows/.github/workflows/release-prepare.yml@9f72f0b77303abff9903b16da75346b1eb1c3785 # v1.4.0 + permissions: + contents: write + secrets: + release_token: ${{ secrets.RELEASE_TOKEN }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..f010296 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,11 @@ +name: Release Publish + +on: + pull_request: + types: [closed] + +jobs: + publish: + uses: edalzell/github-workflows/.github/workflows/release-publish.yml@9f72f0b77303abff9903b16da75346b1eb1c3785 # v1.4.0 + permissions: + contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 36d8b9a..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Release - -on: - workflow_dispatch: -jobs: - get_draft_release: - runs-on: ubuntu-latest - outputs: - release_body: ${{ steps.latest_draft_release.outputs.body }} - release_id: ${{ steps.latest_draft_release.outputs.id }} - release_tag: ${{ steps.latest_draft_release.outputs.tag_name }} - release_upload_url: ${{ steps.latest_draft_release.outputs.upload_url }} - steps: - - name: Get Draft Release - uses: cardinalby/git-get-release-action@1.2.5 - id: latest_draft_release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - draft: true - latest: true - - upload_assets: - if: ${{ inputs.upload_assets }} - needs: get_draft_release - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6.0.2 - - name: Install dependencies - run: npm install - - name: Compile assets - run: npm run production - - name: Create zip - run: tar -czvf dist.tar.gz dist - - name: Upload zip to release - uses: actions/upload-release-asset@v1.0.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.get_draft_release.outputs.release_upload_url }} - asset_path: ./dist.tar.gz - asset_name: dist.tar.gz - asset_content_type: application/tar+gz - - release: - runs-on: ubuntu-latest - needs: get_draft_release - steps: - - name: Checkout code - uses: actions/checkout@v6.0.2 - - name: Update Changelog - id: update_changelog - uses: stefanzweifel/changelog-updater-action@v1.12.0 - with: - latest-version: ${{ needs.get_draft_release.outputs.release_tag }} - release-notes: ${{ needs.get_draft_release.outputs.release_body }} - - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v7.1.0 - with: - branch: main - commit_message: Update CHANGELOG - file_pattern: CHANGELOG.md - - uses: eregon/publish-release@v1.0.6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - release_id: ${{ needs.get_draft_release.outputs.release_id }} From 356d9cfa68dcebf4bdae88ffdf024edccb92467f Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Wed, 9 Sep 2026 14:56:53 -0700 Subject: [PATCH 6/6] Release v6.2.0 (#216) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6cbc7..ff490c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## v6.2.0 - 2026-09-09 + +### 🚀 New + +- Add online_url for Zoom and livestream links [@edalzell](https://github.com/edalzell) (#209) + +### 🐛 Fixed + +- Fix multi-day ICS downloads dropping location fields [@edalzell](https://github.com/edalzell) (#206) +- Reconcile the contradicting ICS field docs [@edalzell](https://github.com/edalzell) (#205) + +### 🧰 Maintenance + +- PR-gated release on 6.x with workflows v1.4.0 [@edalzell](https://github.com/edalzell) (#213) + +## v6.1.7 - 2026-09-06 + +### 🐛 Fixed + +- Ignore empty or invalid timezone tag params [@edalzell](https://github.com/edalzell) (#199) + +### 🧰 Maintenance + +- Check maintainers with RELEASE_TOKEN [@edalzell](https://github.com/edalzell) (#202) +- Bump the github-actions group with 3 updates [@[dependabot[bot]](https://github.com/apps/dependabot)](https://github.com/[dependabot[bot]](https://github.com/apps/dependabot)) (#201) +- Release via PR instead of pushing main [@edalzell](https://github.com/edalzell) (#200) + +## v6.1.6 - 2026-09-02 + +### 🐛 Fixed + +- Collapse multi-day events to a single occurrence [@edalzell](https://github.com/edalzell) (#198) +- Include localized events that inherit dates from origin [@edalzell](https://github.com/edalzell) (#197) + +### 🧰 Maintenance + +- Drop unused spatie/calendar-links dependency [@edalzell](https://github.com/edalzell) (#189) + ## v6.1.5 - 2026-08-21 ### 🐛 Fixed