Skip to content

Commit

Permalink
Merge pull request #5339 from nextcloud/fix/add-tz-to-appointment-ics
Browse files Browse the repository at this point in the history
fix: add VTIMEZONE to Appointments
  • Loading branch information
ChristophWurst committed Nov 7, 2023
2 parents 279c9ec + 496896a commit 27c4402
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 4 deletions.
24 changes: 21 additions & 3 deletions lib/Service/Appointments/BookingCalendarWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand All @@ -120,6 +124,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);
}
Expand Down Expand Up @@ -170,7 +180,6 @@ public function write(AppointmentConfig $config,
$vcalendar->VEVENT->add($alarm);
}


if ($config->getLocation() !== null) {
$vcalendar->VEVENT->add('LOCATION', $config->getLocation());
}
Expand Down Expand Up @@ -198,6 +207,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');
Expand Down Expand Up @@ -227,6 +240,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());
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/Appointments/BookingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
143 changes: 143 additions & 0 deletions lib/Service/Appointments/TimezoneGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php
/*
* *
* * calendar App
* *
* * @copyright 2023 Anna Larch <[email protected]>
* *
* * @author Anna Larch <[email protected]>
* *
* * 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 <http://www.gnu.org/licenses/>.
* *
*
*/

declare(strict_types=1);

/*
* @copyright 2023 Anna Larch <[email protected]>
*
* @author 2023 Anna Larch <[email protected]>
*
* @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 <http://www.gnu.org/licenses/>.
*/

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;
}
}
3 changes: 3 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
<referencedClass name="OC\Security\CSP\ContentSecurityPolicyNonceManager" />
<referencedClass name="Psr\Http\Client\ClientExceptionInterface" />
<referencedClass name="Sabre\VObject\Component\VCalendar" />
<referencedClass name="Sabre\VObject\Component\VTimezone" />
<referencedClass name="Sabre\VObject\TimeZoneUtil" />
<referencedClass name="Symfony\Component\HttpFoundation\IpUtils" />
<referencedClass name="Symfony\Component\Console\Command\Command" />
<referencedClass name="Symfony\Component\Console\Input\InputArgument" />
Expand All @@ -45,6 +47,7 @@
<referencedClass name="Doctrine\DBAL\Schema\SchemaException" />
<referencedClass name="Doctrine\DBAL\Schema\Table" />
<referencedClass name="OC\Security\CSP\ContentSecurityPolicyNonceManager" />
<referencedClass name="Sabre\VObject\Component\VTimezone" />
<referencedClass name="Symfony\Component\Console\Output\OutputInterface" />
</errorLevel>
</UndefinedDocblockClass>
Expand Down
91 changes: 91 additions & 0 deletions tests/php/unit/Service/Appointments/TimezoneGeneratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);
/**
* Calendar App
*
* @author 2023 Anna Larch <[email protected]>
* @copyright 2023 Anna Larch <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
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']],
];
}


}

0 comments on commit 27c4402

Please sign in to comment.