diff --git a/lib/Service/Appointments/BookingCalendarWriter.php b/lib/Service/Appointments/BookingCalendarWriter.php index 274ecbe8b5..7b46b76e2a 100644 --- a/lib/Service/Appointments/BookingCalendarWriter.php +++ b/lib/Service/Appointments/BookingCalendarWriter.php @@ -55,16 +55,20 @@ class BookingCalendarWriter { /** @var IL10N */ private $l10n; + private TimezoneGenerator $timezoneGenerator; + public function __construct(IConfig $config, IManager $manager, IUserManager $userManager, ISecureRandom $random, - IL10N $l10n) { + IL10N $l10n, + TimezoneGenerator $timezoneGenerator) { $this->config = $config; $this->manager = $manager; $this->userManager = $userManager; $this->random = $random; $this->l10n = $l10n; + $this->timezoneGenerator = $timezoneGenerator; } private function secondsToIso8601Duration(int $secs): string { @@ -97,7 +101,7 @@ public function write(AppointmentConfig $config, DateTimeImmutable $start, string $displayName, string $email, - ?string $description = null, + string $timezone, ?string $description = null, ?string $location = null) : string { $calendar = current($this->manager->getCalendarsForPrincipal($config->getPrincipalUri(), [$config->getTargetCalendarUri()])); if (!($calendar instanceof ICreateFromString)) { @@ -121,6 +125,12 @@ public function write(AppointmentConfig $config, ] ]); + $end = $start->getTimestamp() + $config->getLength(); + $tz = $this->timezoneGenerator->generateVTimezone($timezone, $start->getTimestamp(), $end); + if($tz) { + $vcalendar->add($tz); + } + if (!empty($description)) { $vcalendar->VEVENT->add('DESCRIPTION', $description); } @@ -171,7 +181,6 @@ public function write(AppointmentConfig $config, $vcalendar->VEVENT->add($alarm); } - if ($config->getLocation() !== null) { $vcalendar->VEVENT->add('LOCATION', $config->getLocation()); } @@ -199,6 +208,10 @@ public function write(AppointmentConfig $config, 'DTEND' => $start ] ]); + $tz = $this->timezoneGenerator->generateVTimezone($timezone, $prepStart->getTimestamp(), $start->getTimestamp()); + if($tz) { + $prepCalendar->add($tz); + } $prepCalendar->VEVENT->add('RELATED-TO', $vcalendar->VEVENT->{'UID'}); $prepCalendar->VEVENT->add('RELTYPE', 'PARENT'); @@ -228,6 +241,11 @@ public function write(AppointmentConfig $config, ] ]); + $tz = $this->timezoneGenerator->generateVTimezone($timezone, $followupStart->getTimestamp(), $followUpEnd->getTimestamp()); + if($tz) { + $followUpCalendar->add($tz); + } + $followUpCalendar->VEVENT->add('RELATED-TO', $vcalendar->VEVENT->{'UID'}); $followUpCalendar->VEVENT->add('RELTYPE', 'PARENT'); $followUpCalendar->VEVENT->add('X-NC-POST-APPOINTMENT', $config->getToken()); diff --git a/lib/Service/Appointments/BookingService.php b/lib/Service/Appointments/BookingService.php index 7af7781df0..e57120766d 100644 --- a/lib/Service/Appointments/BookingService.php +++ b/lib/Service/Appointments/BookingService.php @@ -131,13 +131,13 @@ public function confirmBooking(Booking $booking, AppointmentConfig $config): Boo $startObj, $booking->getDisplayName(), $booking->getEmail(), + $booking->getTimezone(), $booking->getDescription(), $config->getCreateTalkRoom() ? $booking->getTalkUrl() : $config->getLocation(), ); $booking->setConfirmed(true); $this->bookingMapper->update($booking); - try { $this->mailService->sendBookingInformationEmail($booking, $config, $calendar); $this->mailService->sendOrganizerBookingInformationEmail($booking, $config, $calendar); diff --git a/lib/Service/Appointments/TimezoneGenerator.php b/lib/Service/Appointments/TimezoneGenerator.php new file mode 100644 index 0000000000..3989121779 --- /dev/null +++ b/lib/Service/Appointments/TimezoneGenerator.php @@ -0,0 +1,143 @@ + + * * + * * @author Anna Larch + * * + * * This library is free software; you can redistribute it and/or + * * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * * License as published by the Free Software Foundation; either + * * version 3 of the License, or any later version. + * * + * * This library is distributed in the hope that it will be useful, + * * but WITHOUT ANY WARRANTY; without even the implied warranty of + * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * * GNU AFFERO GENERAL PUBLIC LICENSE for more details. + * * + * * You should have received a copy of the GNU Affero General Public + * * License along with this library. If not, see . + * * + * + */ + +declare(strict_types=1); + +/* + * @copyright 2023 Anna Larch + * + * @author 2023 Anna Larch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Calendar\Service\Appointments; + +use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\TimeZoneUtil; +use function max; +use function min; + +class TimezoneGenerator { + /** + * Returns a VTIMEZONE component for a Olson timezone identifier + * with daylight transitions covering the given date range. + * + * @link https://gist.github.com/thomascube/47ff7d530244c669825736b10877a200 + * + * @param string $timezone Timezone + * @param integer $from Unix timestamp with first date/time in this timezone + * @param integer $to Unix timestap with last date/time in this timezone + * @psalm-suppress NoValue + * + * @return null|VTimeZone A Sabre\VObject\Component object representing a VTIMEZONE definition + * or null if no timezone information is available + */ + public function generateVtimezone(string $timezone, int $from, int $to): ?VTimeZone { + try { + $tz = new \DateTimeZone($timezone); + } catch (\Exception $e) { + return null; + } + + // get all transitions for one year back/ahead + $year = 86400 * 360; + $transitions = $tz->getTransitions($from - $year, $to + $year); + + $vcalendar = new VCalendar(); + $vtimezone = $vcalendar->createComponent('VTIMEZONE'); + $vtimezone->TZID = $timezone; + + $standard = $daylightStart = null; + foreach ($transitions as $i => $trans) { + $component = null; + + // skip the first entry... + if ($i === 0) { + // ... but remember the offset for the next TZOFFSETFROM value + $tzfrom = $trans['offset'] / 3600; + continue; + } + + // daylight saving time definition + if ($trans['isdst']) { + $daylightDefinition = $trans['ts']; + $daylightStart = $vcalendar->createComponent('DAYLIGHT'); + $component = $daylightStart; + } + // standard time definition + else { + $standardDefinition = $trans['ts']; + $standard = $vcalendar->createComponent('STANDARD'); + $component = $standard; + } + + if ($component) { + $date = new \DateTime($trans['time']); + $offset = $trans['offset'] / 3600; + + $component->DTSTART = $date->format('Ymd\THis'); + $component->TZOFFSETFROM = sprintf('%s%02d%02d', $tzfrom >= 0 ? '+' : '-', abs(floor($tzfrom)), ($tzfrom - floor($tzfrom)) * 60); + $component->TZOFFSETTO = sprintf('%s%02d%02d', $offset >= 0 ? '+' : '-', abs(floor($offset)), ($offset - floor($offset)) * 60); + + // add abbreviated timezone name if available + if (!empty($trans['abbr'])) { + $component->TZNAME = $trans['abbr']; + } + + $tzfrom = $offset; + $vtimezone->add($component); + } + + // we covered the entire date range + if ($standard && $daylightStart && min($standardDefinition, $daylightDefinition) < $from && max($standardDefinition, $daylightDefinition) > $to) { + break; + } + } + + // add X-MICROSOFT-CDO-TZID if available + $microsoftExchangeMap = array_flip(TimeZoneUtil::$microsoftExchangeMap); + if (!empty($microsoftExchangeMap) && array_key_exists($tz->getName(), $microsoftExchangeMap)) { + $vtimezone->add('X-MICROSOFT-CDO-TZID', $microsoftExchangeMap[$tz->getName()]); + } + + return $vtimezone; + } +} diff --git a/psalm.xml b/psalm.xml index 7f70f8020a..46aa449516 100644 --- a/psalm.xml +++ b/psalm.xml @@ -29,6 +29,8 @@ + + @@ -45,6 +47,7 @@ + diff --git a/tests/php/unit/Service/Appointments/TimezoneGeneratorTest.php b/tests/php/unit/Service/Appointments/TimezoneGeneratorTest.php new file mode 100644 index 0000000000..cc80e8ebbf --- /dev/null +++ b/tests/php/unit/Service/Appointments/TimezoneGeneratorTest.php @@ -0,0 +1,91 @@ + + * @copyright 2023 Anna Larch + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU AFFERO GENERAL PUBLIC LICENSE for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with this library. If not, see . + * + */ +namespace OCA\Calendar\Tests\Unit\Service\Appointments; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Calendar\Service\Appointments\TimezoneGenerator; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\TimeZoneUtil; + +class TimezoneGeneratorTest extends TestCase { + + protected TimezoneGenerator $timezoneGenerator; + protected function setUp(): void { + $this->timezoneGenerator = new TimezoneGenerator(); + } + + /** + * @dataProvider providerDaylightSaving + */ + public function testWithDaylightSaving($timezone, $daytime, $standard, $msTimezoneId): void { + /** @var VTimeZone $generated */ + $generated = $this->timezoneGenerator->generateVtimezone($timezone, 1640991600, 1672527600); + + $this->assertEquals($timezone, $generated->TZID->getValue()); + $this->assertNotNull($generated->DAYLIGHT); + $this->assertCount($daytime, $generated->DAYLIGHT->getIterator()); + $this->assertNotNull($generated->STANDARD); + $this->assertCount($standard, $generated->STANDARD->getIterator()); + $this->assertEquals($generated->{'X-MICROSOFT-CDO-TZID'}->getValue(), $msTimezoneId); + } + + /** + * @dataProvider providerNoDaylightSaving + */ + public function testNoDaylightSaving($timezone, $daytime, $standard, $msTimezoneId): void { + /** @var VTimeZone $generated */ + $generated = $this->timezoneGenerator->generateVtimezone($timezone, 1640991600, 1672527600); + + $this->assertEquals($timezone, $generated->TZID->getValue()); + $this->assertNull($generated->DAYLIGHT); + $this->assertNull($generated->STANDARD); + $this->assertEquals($generated->{'X-MICROSOFT-CDO-TZID'}->getValue(), $msTimezoneId); + } + + public function testInvalid(): void { + /** @var VTimeZone $generated */ + $generated = $this->timezoneGenerator->generateVtimezone('Nonsense', 1640991600, 1672527600); + + $this->assertNull($generated); + } + + public function providerDaylightSaving(): array { + $microsoftExchangeMap = array_flip(TimeZoneUtil::$microsoftExchangeMap); + return [ + ['Europe/Berlin', 3, 3, $microsoftExchangeMap['Europe/Berlin']], + ['Europe/London', 3, 3, $microsoftExchangeMap['Europe/London']], + ['Australia/Adelaide', 3, 3, $microsoftExchangeMap['Australia/Adelaide']], + ]; + } + + public function providerNoDaylightSaving(): array { + $microsoftExchangeMap = array_flip(TimeZoneUtil::$microsoftExchangeMap); + return [ + ['Pacific/Midway', null, null, $microsoftExchangeMap['Pacific/Midway']], + ['Asia/Singapore', null, null, $microsoftExchangeMap['Asia/Singapore']], + ]; + } + + +}