Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _define.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions _routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
2 changes: 2 additions & 0 deletions js/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ],
Expand All @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions lib/GaletteEvents/Controllers/Crud/EventsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions lib/GaletteEvents/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
*
Expand Down
216 changes: 216 additions & 0 deletions lib/GaletteEvents/Ics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<?php

/**
* This file is part of Galette Events plugin (https://galette.eu).
* SPDX-FileCopyrightText: Copyright © 2018-2026 The Galette Team
* SPDX-License-Identifier: GPL-3.0-or-later
*/

declare(strict_types=1);

namespace GaletteEvents;

use DateTimeImmutable;
use DateTimeZone;
use Galette\Core\Preferences;
use Galette\Util\Text;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Slim\Interfaces\RouteParserInterface;

/**
* Events as iCalendar (RFC 5545) data
*
* Events without times last all day long, with an exclusive end date.
* Times are local to the server, they are given in UTC.
*
* @author Johan Cwiklinski <johan@x-tnd.be>
*/
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<Event> $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';
}
}
6 changes: 5 additions & 1 deletion templates/default/calendar.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

{% block content %}
<div id='calendar'></div>
<div class="ui tiny modal" id="calendar_event">
<div class="ui small modal" id="calendar_event">
<div class="header"></div>
<div class="content"></div>
<div class="actions">
Expand All @@ -17,6 +17,10 @@
<i class="edit icon" aria-hidden="true"></i>
{{ _T("Edit") }}
</a>
<a href="#" class="ui labeled icon button" data-action="ics">
<i class="calendar plus icon" aria-hidden="true"></i>
{{ _T("Download", "events") }}
</a>
<a href="#" class="ui labeled icon green button" data-action="booking">
<i class="plus circle icon" aria-hidden="true"></i>
{{ _T("New booking", "events") }}
Expand Down
8 changes: 8 additions & 0 deletions templates/default/events.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@
'args': {'event': eid}
},
'icon': 'eye grey'
},
{
'label': _T("%1$s: download for calendar applications", "events")|replace({"%1$s": event.getName()}),
'route': {
'name': 'events_event_ics',
'args': {'id': eid}
},
'icon': 'calendar plus grey'
}
]) %}

Expand Down
Loading
Loading