diff --git a/_define.php b/_define.php index 138269a..d4f20f2 100644 --- a/_define.php +++ b/_define.php @@ -19,6 +19,7 @@ date: '2026-10-03', //Release date acls: [ //Permissions needed 'events_events' => 'member', + 'events_event_ics' => 'member', 'events_bookings' => 'member', 'filter-eventslist' => 'member', 'events_event_add' => 'groupmanager', diff --git a/_routes.php b/_routes.php index 81634e8..7d049c9 100644 --- a/_routes.php +++ b/_routes.php @@ -41,6 +41,11 @@ 'events_event_edit' )->add(Authenticate::class); +$app->get( + '/event/{id:\d+}/ics', + [EventsController::class, 'ics'] +)->setName('events_event_ics')->add(Authenticate::class); + $app->post( '/event/add', [EventsController::class, 'doAdd'] diff --git a/js/calendar.js b/js/calendar.js index e8937ce..e448a85 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -22,6 +22,7 @@ $(function() { var $modal = $('#calendar_event'); var $edit = $modal.find('[data-action="edit"]'); var $booking = $modal.find('[data-action="booking"]'); + var $ics = $modal.find('[data-action="ics"]'); var calendar = new Calendar(calendarEl, { plugins: [ themePlugin, interactionPlugin, dayGridPlugin, listPlugin ], @@ -46,6 +47,7 @@ $(function() { .attr('href', infos.edit_url || '#') .toggleClass('displaynone', !infos.edit_url); $booking.attr('href', infos.booking_url); + $ics.attr('href', infos.ics_url); $modal.modal('show'); }, eventMouseEnter: function(info) { diff --git a/lib/GaletteEvents/Controllers/Crud/EventsController.php b/lib/GaletteEvents/Controllers/Crud/EventsController.php index a0a7046..d3aa7fa 100644 --- a/lib/GaletteEvents/Controllers/Crud/EventsController.php +++ b/lib/GaletteEvents/Controllers/Crud/EventsController.php @@ -15,6 +15,7 @@ use Galette\Core\Pagination; use GaletteEvents\Filters\EventsList; use GaletteEvents\Event; +use GaletteEvents\Ics; use GaletteEvents\NotFoundException; use GaletteEvents\Repository\Events; use Slim\Psr7\Request; @@ -181,11 +182,37 @@ public function ajaxCalendar(Request $request, Response $response): Response $row['edit_url'] = $this->routeparser->urlFor('events_event_edit', ['id' => $id]); } $row['booking_url'] = $this->routeparser->urlFor('events_booking_add') . '?event=' . $id; + $row['ics_url'] = $this->routeparser->urlFor('events_event_ics', ['id' => $id]); } return $this->withJson($response, $list); } + /** + * Download an event as an iCalendar file + * + * @param int $id Event id + */ + public function ics(Request $request, Response $response, int $id): Response + { + try { + $event = new Event($this->zdb, $this->login, $this->history, $id); + } catch (NotFoundException) { + return $this->redirectNotFound($response, $id); + } + + //same answer as a missing event, not to disclose it exists + if (!$event->canShow($this->login)) { + return $this->redirectNotFound($response, $id); + } + + $ics = new Ics($this->preferences, $this->routeparser); + $response->getBody()->write($ics->getCalendar([$event])->serialize()); + return $response + ->withHeader('Content-Type', Ics::CONTENT_TYPE) + ->withHeader('Content-Disposition', 'attachment; filename="' . Ics::getFilename($event) . '"'); + } + // /CRUD - Read // CRUD - Update diff --git a/lib/GaletteEvents/Event.php b/lib/GaletteEvents/Event.php index a33c204..3004239 100644 --- a/lib/GaletteEvents/Event.php +++ b/lib/GaletteEvents/Event.php @@ -14,7 +14,9 @@ use Galette\Core\Db; use Galette\Core\History; use Galette\Core\Login; +use Galette\Entity\Adherent; use Galette\Entity\Group; +use GaletteEvents\Repository\Events; use Analog\Analog; use Laminas\Db\Sql\Expression; @@ -728,6 +730,28 @@ public function canEdit(Login $login): bool return $this->group !== null && $login->isGroupManager($this->group); } + /** + * Can member see event + * + * As in the calendar: events that are public or of one of their groups, + * and the ones they have booked, whatever their state. + * + * @param Login $login Login instance + */ + public function canShow(Login $login): bool + { + if (Events::isVisible($this->group, $login)) { + return true; + } + + $select = $this->zdb->select(EVENTS_PREFIX . Booking::TABLE); + $select->where([ + self::PK => $this->id, + Adherent::PK => $login->id + ]); + return $this->zdb->execute($select)->count() > 0; + } + /** * Can memebr create an event * diff --git a/lib/GaletteEvents/Ics.php b/lib/GaletteEvents/Ics.php new file mode 100644 index 0000000..894eee2 --- /dev/null +++ b/lib/GaletteEvents/Ics.php @@ -0,0 +1,216 @@ + + */ +class Ics +{ + public const string CONTENT_TYPE = 'text/calendar; charset=utf-8'; + + /** + * Default constructor + * + * @param Preferences $preferences Preferences instance + * @param RouteParserInterface $routeparser Route parser + */ + public function __construct( + private readonly Preferences $preferences, + private readonly RouteParserInterface $routeparser + ) { + } + + /** + * Build calendar + * + * @param array $events Events to include + */ + public function getCalendar(array $events): VCalendar + { + $calendar = new VCalendar([ + 'PRODID' => '-//Galette//Galette Events//EN', + 'CALSCALE' => 'GREGORIAN', + 'METHOD' => 'PUBLISH', + ]); + + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $domain = $this->getUidDomain(); + foreach ($events as $event) { + $this->addEvent($calendar, $event, $domain, $now); + } + + return $calendar; + } + + /** + * Add an event to the calendar + * + * @param VCalendar $calendar Calendar + * @param Event $event Event to add + * @param string $domain UID domain + * @param DateTimeImmutable $now Calendar generation time + */ + private function addEvent(VCalendar $calendar, Event $event, string $domain, DateTimeImmutable $now): void + { + /** @var VEvent $vevent */ + $vevent = $calendar->add('VEVENT', [ + 'UID' => 'event-' . $event->getId() . '@' . $domain, + 'DTSTAMP' => $now, + 'SUMMARY' => $event->getName(), + ]); + + if ($event->isAllDay()) { + $vevent->add('DTSTART', $this->formatDate($event->getBeginDate()), ['VALUE' => 'DATE']); + $vevent->add('DTEND', $this->formatDate($event->getEndDate(), '+1 day'), ['VALUE' => 'DATE']); + } else { + $vevent->add('DTSTART', $this->toUtc($event->getBeginDate() . ' ' . $event->getBeginTime())); + if ($event->getEndTime() !== null) { + $vevent->add('DTEND', $this->toUtc($event->getEndDate() . ' ' . $event->getEndTime())); + } elseif ($event->getEndDate() !== $event->getBeginDate()) { + //no end time: event lasts until the end of its last day + $vevent->add('DTEND', $this->toUtc($event->getEndDate() . ' 00:00', '+1 day')); + } + } + + $location = $this->getLocation($event); + if ($location !== '') { + $vevent->add('LOCATION', $location); + } + + $description = $this->getDescription($event); + if ($description !== '') { + $vevent->add('DESCRIPTION', $description); + } + + if ($event->getGroup() !== null) { + $vevent->add('CATEGORIES', $event->getGroupName()); + } + + $vevent->add( + 'URL', + rtrim($this->preferences->getURL(), '/') + . $this->routeparser->urlFor('events_booking_add') + . '?event=' . $event->getId() + ); + } + + /** + * Get event location, from its non empty address parts + * + * @param Event $event Event + */ + private function getLocation(Event $event): string + { + $town = trim($event->getZip() . ' ' . $event->getTown()); + return implode(', ', array_filter( + [trim($event->getAddress()), $town, trim($event->getCountry() ?? '')], + fn(string $part): bool => $part !== '' + )); + } + + /** + * Get event description, as plain text + * + * @param Event $event Event + */ + private function getDescription(Event $event): string + { + $parts = []; + if (trim($event->getComment()) !== '') { + $parts[] = trim($event->getComment()); + } + + //activities that are not offered are linked to the event as well + $activities = array_map( + fn(array $activity): string => '- ' . $activity['activity']->getName(), + array_filter( + $event->getActivities(), + fn(array $activity): bool => (int)$activity['status'] !== Activity::NO + ) + ); + if (count($activities)) { + $parts[] = _T('Activities', 'events') . "\n" . implode("\n", $activities); + } + + return implode("\n\n", $parts); + } + + /** + * Domain part of events UIDs, that must not change for an instance + */ + private function getUidDomain(): string + { + $uuid = $this->preferences->pref_instance_uuid; + if (empty($uuid)) { + $uuid = $this->preferences->generateUUID('instance'); + } + return $uuid; + } + + /** + * Format a date for an all day event + * + * @param string $date Date, as Y-m-d + * @param string $modify Modifier to apply, if any + */ + private function formatDate(string $date, string $modify = ''): string + { + $date = new DateTimeImmutable($date); + if ($modify !== '') { + $date = $date->modify($modify); + } + return $date->format('Ymd'); + } + + /** + * Server local date and time to UTC + * + * @param string $datetime Date and time, as Y-m-d H:i + * @param string $modify Modifier to apply, if any + */ + private function toUtc(string $datetime, string $modify = ''): DateTimeImmutable + { + $date = new DateTimeImmutable($datetime); + if ($modify !== '') { + $date = $date->modify($modify); + } + return $date->setTimezone(new DateTimeZone('UTC')); + } + + /** + * Get file name for an event + * + * @param Event $event Event + */ + public static function getFilename(Event $event): string + { + try { + $name = Text::slugify($event->getName()); + } catch (\RuntimeException) { + $name = 'event-' . $event->getId(); + } + return $name . '.ics'; + } +} diff --git a/templates/default/calendar.html.twig b/templates/default/calendar.html.twig index 2ea410b..10b5c14 100644 --- a/templates/default/calendar.html.twig +++ b/templates/default/calendar.html.twig @@ -8,7 +8,7 @@ {% block content %}
-