Include more information in iMIP email and show diff information on updating an eventtags/v26.0.0beta2
@@ -51,6 +51,7 @@ return array( | |||
'OCA\\DAV\\CalDAV\\CalendarObject' => $baseDir . '/../lib/CalDAV/CalendarObject.php', | |||
'OCA\\DAV\\CalDAV\\CalendarProvider' => $baseDir . '/../lib/CalDAV/CalendarProvider.php', | |||
'OCA\\DAV\\CalDAV\\CalendarRoot' => $baseDir . '/../lib/CalDAV/CalendarRoot.php', | |||
'OCA\\DAV\\CalDAV\\EventComparisonService' => $baseDir . '/../lib/CalDAV/EventComparisonService.php', | |||
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php', | |||
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php', | |||
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php', | |||
@@ -83,6 +84,7 @@ return array( | |||
'OCA\\DAV\\CalDAV\\ResourceBooking\\RoomPrincipalBackend' => $baseDir . '/../lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php', | |||
'OCA\\DAV\\CalDAV\\RetentionService' => $baseDir . '/../lib/CalDAV/RetentionService.php', | |||
'OCA\\DAV\\CalDAV\\Schedule\\IMipPlugin' => $baseDir . '/../lib/CalDAV/Schedule/IMipPlugin.php', | |||
'OCA\\DAV\\CalDAV\\Schedule\\IMipService' => $baseDir . '/../lib/CalDAV/Schedule/IMipService.php', | |||
'OCA\\DAV\\CalDAV\\Schedule\\Plugin' => $baseDir . '/../lib/CalDAV/Schedule/Plugin.php', | |||
'OCA\\DAV\\CalDAV\\Search\\SearchPlugin' => $baseDir . '/../lib/CalDAV/Search/SearchPlugin.php', | |||
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/CompFilter.php', |
@@ -66,6 +66,7 @@ class ComposerStaticInitDAV | |||
'OCA\\DAV\\CalDAV\\CalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarObject.php', | |||
'OCA\\DAV\\CalDAV\\CalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarProvider.php', | |||
'OCA\\DAV\\CalDAV\\CalendarRoot' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarRoot.php', | |||
'OCA\\DAV\\CalDAV\\EventComparisonService' => __DIR__ . '/..' . '/../lib/CalDAV/EventComparisonService.php', | |||
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php', | |||
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php', | |||
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php', | |||
@@ -98,6 +99,7 @@ class ComposerStaticInitDAV | |||
'OCA\\DAV\\CalDAV\\ResourceBooking\\RoomPrincipalBackend' => __DIR__ . '/..' . '/../lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php', | |||
'OCA\\DAV\\CalDAV\\RetentionService' => __DIR__ . '/..' . '/../lib/CalDAV/RetentionService.php', | |||
'OCA\\DAV\\CalDAV\\Schedule\\IMipPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/IMipPlugin.php', | |||
'OCA\\DAV\\CalDAV\\Schedule\\IMipService' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/IMipService.php', | |||
'OCA\\DAV\\CalDAV\\Schedule\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/Plugin.php', | |||
'OCA\\DAV\\CalDAV\\Search\\SearchPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Search/SearchPlugin.php', | |||
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/CompFilter.php', |
@@ -0,0 +1,123 @@ | |||
<?php | |||
declare(strict_types=1); | |||
/** | |||
* @copyright 2022 Anna Larch <anna.larch@gmx.net> | |||
* | |||
* @author 2022 Anna Larch <anna.larch@gmx.net> | |||
* | |||
* @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\DAV\CalDAV; | |||
use OCA\DAV\AppInfo\Application; | |||
use OCA\DAV\CalDAV\Schedule\IMipService; | |||
use OCP\AppFramework\Utility\ITimeFactory; | |||
use OCP\IConfig; | |||
use Sabre\VObject\Component\VCalendar; | |||
use Sabre\VObject\Component\VEvent; | |||
use Sabre\VObject\Component\VTimeZone; | |||
use Sabre\VObject\Component\VTodo; | |||
use function max; | |||
class EventComparisonService { | |||
/** @var string[] */ | |||
private const EVENT_DIFF = [ | |||
'RECURRENCE-ID', | |||
'RRULE', | |||
'SEQUENCE', | |||
'LAST-MODIFIED' | |||
]; | |||
/** | |||
* If found, remove the event from $eventsToFilter that | |||
* is identical to the passed $filterEvent | |||
* and return whether an identical event was found | |||
* | |||
* This function takes into account the SEQUENCE, | |||
* RRULE, RECURRENCE-ID and LAST-MODIFIED parameters | |||
* | |||
* @param VEvent $filterEvent | |||
* @param array $eventsToFilter | |||
* @return bool true if there was an identical event found and removed, false if there wasn't | |||
*/ | |||
private function removeIfUnchanged(VEvent $filterEvent, array &$eventsToFilter): bool { | |||
$filterEventData = []; | |||
foreach(self::EVENT_DIFF as $eventDiff) { | |||
$filterEventData[] = IMipService::readPropertyWithDefault($filterEvent, $eventDiff, ''); | |||
} | |||
/** @var VEvent $component */ | |||
foreach ($eventsToFilter as $k => $eventToFilter) { | |||
$eventToFilterData = []; | |||
foreach(self::EVENT_DIFF as $eventDiff) { | |||
$eventToFilterData[] = IMipService::readPropertyWithDefault($eventToFilter, $eventDiff, ''); | |||
} | |||
// events are identical and can be removed | |||
if (empty(array_diff($filterEventData, $eventToFilterData))) { | |||
unset($eventsToFilter[$k]); | |||
return true; | |||
} | |||
} | |||
return false; | |||
} | |||
/** | |||
* Compare two VCalendars with each other and find all changed elements | |||
* | |||
* Returns an array of old and new events | |||
* | |||
* Old events are only detected if they are also changed | |||
* If there is no corresponding old event for a VEvent, it | |||
* has been newly created | |||
* | |||
* @param VCalendar $new | |||
* @param VCalendar|null $old | |||
* @return array<string, VEvent[]> | |||
*/ | |||
public function findModified(VCalendar $new, ?VCalendar $old): array { | |||
$newEventComponents = $new->getComponents(); | |||
foreach ($newEventComponents as $k => $event) { | |||
if(!$event instanceof VEvent) { | |||
unset($newEventComponents[$k]); | |||
} | |||
} | |||
if(empty($old)) { | |||
return ['old' => null, 'new' => $newEventComponents]; | |||
} | |||
$oldEventComponents = $old->getComponents(); | |||
if(is_array($oldEventComponents) && !empty($oldEventComponents)) { | |||
foreach ($oldEventComponents as $k => $event) { | |||
if(!$event instanceof VEvent) { | |||
unset($oldEventComponents[$k]); | |||
continue; | |||
} | |||
if($this->removeIfUnchanged($event, $newEventComponents)) { | |||
unset($oldEventComponents[$k]); | |||
} | |||
} | |||
} | |||
return ['old' => array_values($oldEventComponents), 'new' => array_values($newEventComponents)]; | |||
} | |||
} |
@@ -4,6 +4,7 @@ | |||
* @copyright Copyright (c) 2017, Georg Ehrke | |||
* @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). | |||
* @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). | |||
* @copyright 2022 Anna Larch <anna.larch@gmx.net> | |||
* | |||
* @author brad2014 <brad2014@users.noreply.github.com> | |||
* @author Brad Rubenstein <brad@wbr.tech> | |||
@@ -16,6 +17,7 @@ | |||
* @author Roeland Jago Douma <roeland@famdouma.nl> | |||
* @author Thomas Citharel <nextcloud@tcit.fr> | |||
* @author Thomas Müller <thomas.mueller@tmit.eu> | |||
* @author Anna Larch <anna.larch@gmx.net> | |||
* | |||
* @license AGPL-3.0 | |||
* | |||
@@ -34,6 +36,8 @@ | |||
*/ | |||
namespace OCA\DAV\CalDAV\Schedule; | |||
use OCA\DAV\CalDAV\CalendarObject; | |||
use OCA\DAV\CalDAV\EventComparisonService; | |||
use OCP\AppFramework\Utility\ITimeFactory; | |||
use OCP\Defaults; | |||
use OCP\IConfig; | |||
@@ -48,12 +52,16 @@ use OCP\Security\ISecureRandom; | |||
use OCP\Util; | |||
use Psr\Log\LoggerInterface; | |||
use Sabre\CalDAV\Schedule\IMipPlugin as SabreIMipPlugin; | |||
use Sabre\DAV; | |||
use Sabre\DAV\INode; | |||
use Sabre\VObject\Component\VCalendar; | |||
use Sabre\VObject\Component\VEvent; | |||
use Sabre\VObject\Component\VTimeZone; | |||
use Sabre\VObject\DateTimeParser; | |||
use Sabre\VObject\ITip\Message; | |||
use Sabre\VObject\Parameter; | |||
use Sabre\VObject\Property; | |||
use Sabre\VObject\Reader; | |||
use Sabre\VObject\Recur\EventIterator; | |||
/** | |||
@@ -71,63 +79,63 @@ use Sabre\VObject\Recur\EventIterator; | |||
* @license http://sabre.io/license/ Modified BSD License | |||
*/ | |||
class IMipPlugin extends SabreIMipPlugin { | |||
/** @var string */ | |||
private $userId; | |||
/** @var IConfig */ | |||
private $config; | |||
/** @var IMailer */ | |||
private $mailer; | |||
private ?string $userId; | |||
private IConfig $config; | |||
private IMailer $mailer; | |||
private LoggerInterface $logger; | |||
/** @var ITimeFactory */ | |||
private $timeFactory; | |||
/** @var L10NFactory */ | |||
private $l10nFactory; | |||
/** @var IURLGenerator */ | |||
private $urlGenerator; | |||
/** @var ISecureRandom */ | |||
private $random; | |||
/** @var IDBConnection */ | |||
private $db; | |||
/** @var Defaults */ | |||
private $defaults; | |||
/** @var IUserManager */ | |||
private $userManager; | |||
private ITimeFactory $timeFactory; | |||
private Defaults $defaults; | |||
private IUserManager $userManager; | |||
private ?VCalendar $vCalendar = null; | |||
private IMipService $imipService; | |||
public const MAX_DATE = '2038-01-01'; | |||
public const METHOD_REQUEST = 'request'; | |||
public const METHOD_REPLY = 'reply'; | |||
public const METHOD_CANCEL = 'cancel'; | |||
public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages | |||
private EventComparisonService $eventComparisonService; | |||
public function __construct(IConfig $config, IMailer $mailer, | |||
public function __construct(IConfig $config, | |||
IMailer $mailer, | |||
LoggerInterface $logger, | |||
ITimeFactory $timeFactory, L10NFactory $l10nFactory, | |||
IURLGenerator $urlGenerator, Defaults $defaults, | |||
ISecureRandom $random, IDBConnection $db, IUserManager $userManager, | |||
$userId) { | |||
ITimeFactory $timeFactory, | |||
Defaults $defaults, | |||
IUserManager $userManager, | |||
$userId, | |||
IMipService $imipService, | |||
EventComparisonService $eventComparisonService) { | |||
parent::__construct(''); | |||
$this->userId = $userId; | |||
$this->config = $config; | |||
$this->mailer = $mailer; | |||
$this->logger = $logger; | |||
$this->timeFactory = $timeFactory; | |||
$this->l10nFactory = $l10nFactory; | |||
$this->urlGenerator = $urlGenerator; | |||
$this->random = $random; | |||
$this->db = $db; | |||
$this->defaults = $defaults; | |||
$this->userManager = $userManager; | |||
$this->imipService = $imipService; | |||
$this->eventComparisonService = $eventComparisonService; | |||
} | |||
public function initialize(DAV\Server $server): void { | |||
parent::initialize($server); | |||
$server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); | |||
} | |||
/** | |||
* Check quota before writing content | |||
* | |||
* @param string $uri target file URI | |||
* @param INode $node Sabre Node | |||
* @param resource $data data | |||
* @param bool $modified modified | |||
*/ | |||
public function beforeWriteContent($uri, INode $node, $data, $modified): void { | |||
if(!$node instanceof CalendarObject) { | |||
return; | |||
} | |||
/** @var VCalendar $vCalendar */ | |||
$vCalendar = Reader::read($node->get()); | |||
$this->setVCalendar($vCalendar); | |||
} | |||
/** | |||
@@ -146,34 +154,55 @@ class IMipPlugin extends SabreIMipPlugin { | |||
return; | |||
} | |||
$summary = $iTipMessage->message->VEVENT->SUMMARY; | |||
if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto') { | |||
return; | |||
} | |||
if (parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { | |||
if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto' | |||
|| parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { | |||
return; | |||
} | |||
// don't send out mails for events that already took place | |||
$lastOccurrence = $this->getLastOccurrence($iTipMessage->message); | |||
$lastOccurrence = $this->imipService->getLastOccurrence($iTipMessage->message); | |||
$currentTime = $this->timeFactory->getTime(); | |||
if ($lastOccurrence < $currentTime) { | |||
return; | |||
} | |||
// Strip off mailto: | |||
$sender = substr($iTipMessage->sender, 7); | |||
$recipient = substr($iTipMessage->recipient, 7); | |||
if (!$this->mailer->validateMailAddress($recipient)) { | |||
// Nothing to send if the recipient doesn't have a valid email address | |||
$iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; | |||
return; | |||
} | |||
$recipientName = $iTipMessage->recipientName ?: null; | |||
$newEvents = $iTipMessage->message; | |||
$oldEvents = $this->getVCalendar(); | |||
$modified = $this->eventComparisonService->findModified($newEvents, $oldEvents); | |||
/** @var VEvent $vEvent */ | |||
$vEvent = array_pop($modified['new']); | |||
/** @var VEvent $oldVevent */ | |||
$oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null; | |||
// No changed events after all - this shouldn't happen if there is significant change yet here we are | |||
// The scheduling status is debatable | |||
if(empty($vEvent)) { | |||
$this->logger->warning('iTip message said the change was significant but comparison did not detect any updated VEvents'); | |||
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; | |||
return; | |||
} | |||
// we (should) have one event component left | |||
// as the ITip\Broker creates one iTip message per change | |||
// and triggers the "schedule" event once per message | |||
// we also might not have an old event as this could be a new | |||
// invitation, or a new recurrence exception | |||
$attendee = $this->imipService->getCurrentAttendee($iTipMessage); | |||
$this->imipService->setL10n($attendee); | |||
// Build the sender name. | |||
// Due to a bug in sabre, the senderName property for an iTIP message | |||
// can actually also be a VObject Property | |||
/** @var Parameter|string|null $senderName */ | |||
$senderName = $iTipMessage->senderName ?: null; | |||
if($senderName instanceof Parameter) { | |||
@@ -183,47 +212,29 @@ class IMipPlugin extends SabreIMipPlugin { | |||
if ($senderName === null || empty(trim($senderName))) { | |||
$senderName = $this->userManager->getDisplayName($this->userId); | |||
} | |||
$sender = substr($iTipMessage->sender, 7); | |||
/** @var VEvent $vevent */ | |||
$vevent = $iTipMessage->message->VEVENT; | |||
$attendee = $this->getCurrentAttendee($iTipMessage); | |||
$defaultLang = $this->l10nFactory->findGenericLanguage(); | |||
$lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee); | |||
$l10n = $this->l10nFactory->get('dav', $lang); | |||
$meetingAttendeeName = $recipientName ?: $recipient; | |||
$meetingInviteeName = $senderName ?: $sender; | |||
$meetingTitle = $vevent->SUMMARY; | |||
$meetingDescription = $vevent->DESCRIPTION; | |||
$meetingUrl = $vevent->URL; | |||
$meetingLocation = $vevent->LOCATION; | |||
$defaultVal = '--'; | |||
$method = self::METHOD_REQUEST; | |||
switch (strtolower($iTipMessage->method)) { | |||
case self::METHOD_REPLY: | |||
$method = self::METHOD_REPLY; | |||
$data = $this->imipService->buildBodyData($vEvent, $oldVevent); | |||
break; | |||
case self::METHOD_CANCEL: | |||
$method = self::METHOD_CANCEL; | |||
$data = $this->imipService->buildCancelledBodyData($vEvent); | |||
break; | |||
default: | |||
$method = self::METHOD_REQUEST; | |||
$data = $this->imipService->buildBodyData($vEvent, $oldVevent); | |||
break; | |||
} | |||
$data = [ | |||
'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, | |||
'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, | |||
'meeting_title' => (string)$meetingTitle ?: $defaultVal, | |||
'meeting_description' => (string)$meetingDescription ?: $defaultVal, | |||
'meeting_url' => (string)$meetingUrl ?: $defaultVal, | |||
]; | |||
$data['attendee_name'] = ($recipientName ?: $recipient); | |||
$data['invitee_name'] = ($senderName ?: $sender); | |||
$fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); | |||
$fromName = $l10n->t('%1$s via %2$s', [$senderName ?? $this->userId, $this->defaults->getName()]); | |||
$fromName = $this->imipService->getFrom($senderName, $this->defaults->getName()); | |||
$message = $this->mailer->createMessage() | |||
->setFrom([$fromEMail => $fromName]) | |||
@@ -233,13 +244,12 @@ class IMipPlugin extends SabreIMipPlugin { | |||
$template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); | |||
$template->addHeader(); | |||
$summary = ((string) $summary !== '') ? (string) $summary : $l10n->t('Untitled event'); | |||
$this->addSubjectAndHeading($template, $l10n, $method, $summary); | |||
$this->addBulletList($template, $l10n, $vevent); | |||
$this->imipService->addSubjectAndHeading($template, $method, $data['invitee_name'], $data['meeting_title']); | |||
$this->imipService->addBulletList($template, $vEvent, $data); | |||
// Only add response buttons to invitation requests: Fix Issue #11230 | |||
if (($method == self::METHOD_REQUEST) && $this->getAttendeeRsvpOrReqForParticipant($attendee)) { | |||
if (strcasecmp($method, self::METHOD_REQUEST) === 0 && $this->imipService->getAttendeeRsvpOrReqForParticipant($attendee)) { | |||
/* | |||
** Only offer invitation accept/reject buttons, which link back to the | |||
** nextcloud server, to recipients who can access the nextcloud server via | |||
@@ -259,13 +269,15 @@ class IMipPlugin extends SabreIMipPlugin { | |||
** To suppress URLs entirely, set invitation_link_recipients to boolean "no". | |||
*/ | |||
$recipientDomain = substr(strrchr($recipient, "@"), 1); | |||
$recipientDomain = substr(strrchr($recipient, '@'), 1); | |||
$invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes')))); | |||
if (strcmp('yes', $invitationLinkRecipients[0]) === 0 | |||
|| in_array(strtolower($recipient), $invitationLinkRecipients) | |||
|| in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { | |||
$this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); | |||
|| in_array(strtolower($recipient), $invitationLinkRecipients) | |||
|| in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { | |||
$token = $this->imipService->createInvitationToken($iTipMessage, $vEvent, $lastOccurrence); | |||
$this->imipService->addResponseButtons($template, $token); | |||
$this->imipService->addMoreOptionsButton($template, $token); | |||
} | |||
} | |||
@@ -273,9 +285,11 @@ class IMipPlugin extends SabreIMipPlugin { | |||
$message->useTemplate($template); | |||
$vCalendar = $this->imipService->generateVCalendar($iTipMessage, $vEvent); | |||
$attachment = $this->mailer->createAttachment( | |||
$iTipMessage->message->serialize(), | |||
'event.ics',// TODO(leon): Make file name unique, e.g. add event id | |||
$vCalendar->serialize(), | |||
'event.ics', | |||
'text/calendar; method=' . $iTipMessage->method | |||
); | |||
$message->attach($attachment); | |||
@@ -283,7 +297,7 @@ class IMipPlugin extends SabreIMipPlugin { | |||
try { | |||
$failed = $this->mailer->send($message); | |||
$iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; | |||
if ($failed) { | |||
if (!empty($failed)) { | |||
$this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); | |||
$iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; | |||
} | |||
@@ -294,418 +308,17 @@ class IMipPlugin extends SabreIMipPlugin { | |||
} | |||
/** | |||
* check if event took place in the past already | |||
* @param VCalendar $vObject | |||
* @return int | |||
*/ | |||
private function getLastOccurrence(VCalendar $vObject) { | |||
/** @var VEvent $component */ | |||
$component = $vObject->VEVENT; | |||
$firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); | |||
// Finding the last occurrence is a bit harder | |||
if (!isset($component->RRULE)) { | |||
if (isset($component->DTEND)) { | |||
$lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); | |||
} elseif (isset($component->DURATION)) { | |||
/** @var \DateTime $endDate */ | |||
$endDate = clone $component->DTSTART->getDateTime(); | |||
// $component->DTEND->getDateTime() returns DateTimeImmutable | |||
$endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); | |||
$lastOccurrence = $endDate->getTimestamp(); | |||
} elseif (!$component->DTSTART->hasTime()) { | |||
/** @var \DateTime $endDate */ | |||
$endDate = clone $component->DTSTART->getDateTime(); | |||
// $component->DTSTART->getDateTime() returns DateTimeImmutable | |||
$endDate = $endDate->modify('+1 day'); | |||
$lastOccurrence = $endDate->getTimestamp(); | |||
} else { | |||
$lastOccurrence = $firstOccurrence; | |||
} | |||
} else { | |||
$it = new EventIterator($vObject, (string)$component->UID); | |||
$maxDate = new \DateTime(self::MAX_DATE); | |||
if ($it->isInfinite()) { | |||
$lastOccurrence = $maxDate->getTimestamp(); | |||
} else { | |||
$end = $it->getDtEnd(); | |||
while ($it->valid() && $end < $maxDate) { | |||
$end = $it->getDtEnd(); | |||
$it->next(); | |||
} | |||
$lastOccurrence = $end->getTimestamp(); | |||
} | |||
} | |||
return $lastOccurrence; | |||
} | |||
/** | |||
* @param Message $iTipMessage | |||
* @return null|Property | |||
*/ | |||
private function getCurrentAttendee(Message $iTipMessage) { | |||
/** @var VEvent $vevent */ | |||
$vevent = $iTipMessage->message->VEVENT; | |||
$attendees = $vevent->select('ATTENDEE'); | |||
foreach ($attendees as $attendee) { | |||
/** @var Property $attendee */ | |||
if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { | |||
return $attendee; | |||
} | |||
} | |||
return null; | |||
} | |||
/** | |||
* @param string $default | |||
* @param Property|null $attendee | |||
* @return string | |||
* @return ?VCalendar | |||
*/ | |||
private function getAttendeeLangOrDefault($default, Property $attendee = null) { | |||
if ($attendee !== null) { | |||
$lang = $attendee->offsetGet('LANGUAGE'); | |||
if ($lang instanceof Parameter) { | |||
return $lang->getValue(); | |||
} | |||
} | |||
return $default; | |||
public function getVCalendar(): ?VCalendar { | |||
return $this->vCalendar; | |||
} | |||
/** | |||
* @param Property|null $attendee | |||
* @return bool | |||
* @param ?VCalendar $vCalendar | |||
*/ | |||
private function getAttendeeRsvpOrReqForParticipant(Property $attendee = null) { | |||
if ($attendee !== null) { | |||
$rsvp = $attendee->offsetGet('RSVP'); | |||
if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { | |||
return true; | |||
} | |||
$role = $attendee->offsetGet('ROLE'); | |||
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 | |||
// Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set | |||
if ($role === null | |||
|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) | |||
|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) | |||
) { | |||
return true; | |||
} | |||
} | |||
// RFC 5545 3.2.17: default RSVP is false | |||
return false; | |||
public function setVCalendar(?VCalendar $vCalendar): void { | |||
$this->vCalendar = $vCalendar; | |||
} | |||
/** | |||
* @param IL10N $l10n | |||
* @param VEvent $vevent | |||
*/ | |||
private function generateWhenString(IL10N $l10n, VEvent $vevent) { | |||
$dtstart = $vevent->DTSTART; | |||
if (isset($vevent->DTEND)) { | |||
$dtend = $vevent->DTEND; | |||
} elseif (isset($vevent->DURATION)) { | |||
$isFloating = $vevent->DTSTART->isFloating(); | |||
$dtend = clone $vevent->DTSTART; | |||
$endDateTime = $dtend->getDateTime(); | |||
$endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); | |||
$dtend->setDateTime($endDateTime, $isFloating); | |||
} elseif (!$vevent->DTSTART->hasTime()) { | |||
$isFloating = $vevent->DTSTART->isFloating(); | |||
$dtend = clone $vevent->DTSTART; | |||
$endDateTime = $dtend->getDateTime(); | |||
$endDateTime = $endDateTime->modify('+1 day'); | |||
$dtend->setDateTime($endDateTime, $isFloating); | |||
} else { | |||
$dtend = clone $vevent->DTSTART; | |||
} | |||
$isAllDay = $dtstart instanceof Property\ICalendar\Date; | |||
/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ | |||
/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ | |||
/** @var \DateTimeImmutable $dtstartDt */ | |||
$dtstartDt = $dtstart->getDateTime(); | |||
/** @var \DateTimeImmutable $dtendDt */ | |||
$dtendDt = $dtend->getDateTime(); | |||
$diff = $dtstartDt->diff($dtendDt); | |||
$dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); | |||
$dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); | |||
if ($isAllDay) { | |||
// One day event | |||
if ($diff->days === 1) { | |||
return $l10n->l('date', $dtstartDt, ['width' => 'medium']); | |||
} | |||
// DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05, | |||
// the email should show 2020-01-01 to 2020-01-04. | |||
$dtendDt->modify('-1 day'); | |||
//event that spans over multiple days | |||
$localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']); | |||
$localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']); | |||
return $localeStart . ' - ' . $localeEnd; | |||
} | |||
/** @var Property\ICalendar\DateTime $dtstart */ | |||
/** @var Property\ICalendar\DateTime $dtend */ | |||
$isFloating = $dtstart->isFloating(); | |||
$startTimezone = $endTimezone = null; | |||
if (!$isFloating) { | |||
$prop = $dtstart->offsetGet('TZID'); | |||
if ($prop instanceof Parameter) { | |||
$startTimezone = $prop->getValue(); | |||
} | |||
$prop = $dtend->offsetGet('TZID'); | |||
if ($prop instanceof Parameter) { | |||
$endTimezone = $prop->getValue(); | |||
} | |||
} | |||
$localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . | |||
$l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); | |||
// always show full date with timezone if timezones are different | |||
if ($startTimezone !== $endTimezone) { | |||
$localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); | |||
return $localeStart . ' (' . $startTimezone . ') - ' . | |||
$localeEnd . ' (' . $endTimezone . ')'; | |||
} | |||
// show only end time if date is the same | |||
if ($this->isDayEqual($dtstartDt, $dtendDt)) { | |||
$localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']); | |||
} else { | |||
$localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . | |||
$l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); | |||
} | |||
return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; | |||
} | |||
/** | |||
* @param \DateTime $dtStart | |||
* @param \DateTime $dtEnd | |||
* @return bool | |||
*/ | |||
private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { | |||
return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); | |||
} | |||
/** | |||
* @param IEMailTemplate $template | |||
* @param IL10N $l10n | |||
* @param string $method | |||
* @param string $summary | |||
*/ | |||
private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, | |||
$method, $summary) { | |||
if ($method === self::METHOD_CANCEL) { | |||
// TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" | |||
$template->setSubject($l10n->t('Cancelled: %1$s', [$summary])); | |||
$template->addHeading($l10n->t('Invitation canceled')); | |||
} elseif ($method === self::METHOD_REPLY) { | |||
// TRANSLATORS Subject for email, when an invitation is updated. Ex: "Re: {{Event Name}}" | |||
$template->setSubject($l10n->t('Re: %1$s', [$summary])); | |||
$template->addHeading($l10n->t('Invitation updated')); | |||
} else { | |||
// TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" | |||
$template->setSubject($l10n->t('Invitation: %1$s', [$summary])); | |||
$template->addHeading($l10n->t('Invitation')); | |||
} | |||
} | |||
/** | |||
* @param IEMailTemplate $template | |||
* @param IL10N $l10n | |||
* @param VEVENT $vevent | |||
*/ | |||
private function addBulletList(IEMailTemplate $template, IL10N $l10n, $vevent) { | |||
if ($vevent->SUMMARY) { | |||
$template->addBodyListItem($vevent->SUMMARY, $l10n->t('Title:'), | |||
$this->getAbsoluteImagePath('caldav/title.png'), '', '', self::IMIP_INDENT); | |||
} | |||
$meetingWhen = $this->generateWhenString($l10n, $vevent); | |||
if ($meetingWhen) { | |||
$template->addBodyListItem($meetingWhen, $l10n->t('Time:'), | |||
$this->getAbsoluteImagePath('caldav/time.png'), '', '', self::IMIP_INDENT); | |||
} | |||
if ($vevent->LOCATION) { | |||
$template->addBodyListItem($vevent->LOCATION, $l10n->t('Location:'), | |||
$this->getAbsoluteImagePath('caldav/location.png'), '', '', self::IMIP_INDENT); | |||
} | |||
if ($vevent->URL) { | |||
$url = $vevent->URL->getValue(); | |||
$template->addBodyListItem(sprintf('<a href="%s">%s</a>', | |||
htmlspecialchars($url), | |||
htmlspecialchars($url)), | |||
$l10n->t('Link:'), | |||
$this->getAbsoluteImagePath('caldav/link.png'), | |||
$url, '', self::IMIP_INDENT); | |||
} | |||
$this->addAttendees($template, $l10n, $vevent); | |||
/* Put description last, like an email body, since it can be arbitrarily long */ | |||
if ($vevent->DESCRIPTION) { | |||
$template->addBodyListItem($vevent->DESCRIPTION->getValue(), $l10n->t('Description:'), | |||
$this->getAbsoluteImagePath('caldav/description.png'), '', '', self::IMIP_INDENT); | |||
} | |||
} | |||
/** | |||
* addAttendees: add organizer and attendee names/emails to iMip mail. | |||
* | |||
* Enable with DAV setting: invitation_list_attendees (default: no) | |||
* | |||
* The default is 'no', which matches old behavior, and is privacy preserving. | |||
* | |||
* To enable including attendees in invitation emails: | |||
* % php occ config:app:set dav invitation_list_attendees --value yes | |||
* | |||
* @param IEMailTemplate $template | |||
* @param IL10N $l10n | |||
* @param Message $iTipMessage | |||
* @param int $lastOccurrence | |||
* @author brad2014 on github.com | |||
*/ | |||
private function addAttendees(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { | |||
if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { | |||
return; | |||
} | |||
if (isset($vevent->ORGANIZER)) { | |||
/** @var Property\ICalendar\CalAddress $organizer */ | |||
$organizer = $vevent->ORGANIZER; | |||
$organizerURI = $organizer->getNormalizedValue(); | |||
[$scheme,$organizerEmail] = explode(':', $organizerURI, 2); # strip off scheme mailto: | |||
/** @var string|null $organizerName */ | |||
$organizerName = isset($organizer['CN']) ? $organizer['CN'] : null; | |||
$organizerHTML = sprintf('<a href="%s">%s</a>', | |||
htmlspecialchars($organizerURI), | |||
htmlspecialchars($organizerName ?: $organizerEmail)); | |||
$organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); | |||
if (isset($organizer['PARTSTAT'])) { | |||
/** @var Parameter $partstat */ | |||
$partstat = $organizer['PARTSTAT']; | |||
if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { | |||
$organizerHTML .= ' ✔︎'; | |||
$organizerText .= ' ✔︎'; | |||
} | |||
} | |||
$template->addBodyListItem($organizerHTML, $l10n->t('Organizer:'), | |||
$this->getAbsoluteImagePath('caldav/organizer.png'), | |||
$organizerText, '', self::IMIP_INDENT); | |||
} | |||
$attendees = $vevent->select('ATTENDEE'); | |||
if (count($attendees) === 0) { | |||
return; | |||
} | |||
$attendeesHTML = []; | |||
$attendeesText = []; | |||
foreach ($attendees as $attendee) { | |||
$attendeeURI = $attendee->getNormalizedValue(); | |||
[$scheme,$attendeeEmail] = explode(':', $attendeeURI, 2); # strip off scheme mailto: | |||
$attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null; | |||
$attendeeHTML = sprintf('<a href="%s">%s</a>', | |||
htmlspecialchars($attendeeURI), | |||
htmlspecialchars($attendeeName ?: $attendeeEmail)); | |||
$attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); | |||
if (isset($attendee['PARTSTAT']) | |||
&& strcasecmp($attendee['PARTSTAT'], 'ACCEPTED') === 0) { | |||
$attendeeHTML .= ' ✔︎'; | |||
$attendeeText .= ' ✔︎'; | |||
} | |||
array_push($attendeesHTML, $attendeeHTML); | |||
array_push($attendeesText, $attendeeText); | |||
} | |||
$template->addBodyListItem(implode('<br/>', $attendeesHTML), $l10n->t('Attendees:'), | |||
$this->getAbsoluteImagePath('caldav/attendees.png'), | |||
implode("\n", $attendeesText), '', self::IMIP_INDENT); | |||
} | |||
/** | |||
* @param IEMailTemplate $template | |||
* @param IL10N $l10n | |||
* @param Message $iTipMessage | |||
* @param int $lastOccurrence | |||
*/ | |||
private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, | |||
Message $iTipMessage, $lastOccurrence) { | |||
$token = $this->createInvitationToken($iTipMessage, $lastOccurrence); | |||
$template->addBodyButtonGroup( | |||
$l10n->t('Accept'), | |||
$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ | |||
'token' => $token, | |||
]), | |||
$l10n->t('Decline'), | |||
$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ | |||
'token' => $token, | |||
]) | |||
); | |||
$moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ | |||
'token' => $token, | |||
]); | |||
$html = vsprintf('<small><a href="%s">%s</a></small>', [ | |||
$moreOptionsURL, $l10n->t('More options …') | |||
]); | |||
$text = $l10n->t('More options at %s', [$moreOptionsURL]); | |||
$template->addBodyText($html, $text); | |||
} | |||
/** | |||
* @param string $path | |||
* @return string | |||
*/ | |||
private function getAbsoluteImagePath($path) { | |||
return $this->urlGenerator->getAbsoluteURL( | |||
$this->urlGenerator->imagePath('core', $path) | |||
); | |||
} | |||
/** | |||
* @param Message $iTipMessage | |||
* @param int $lastOccurrence | |||
* @return string | |||
*/ | |||
private function createInvitationToken(Message $iTipMessage, $lastOccurrence):string { | |||
$token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); | |||
/** @var VEvent $vevent */ | |||
$vevent = $iTipMessage->message->VEVENT; | |||
$attendee = $iTipMessage->recipient; | |||
$organizer = $iTipMessage->sender; | |||
$sequence = $iTipMessage->sequence; | |||
$recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? | |||
$vevent->{'RECURRENCE-ID'}->serialize() : null; | |||
$uid = $vevent->{'UID'}; | |||
$query = $this->db->getQueryBuilder(); | |||
$query->insert('calendar_invitations') | |||
->values([ | |||
'token' => $query->createNamedParameter($token), | |||
'attendee' => $query->createNamedParameter($attendee), | |||
'organizer' => $query->createNamedParameter($organizer), | |||
'sequence' => $query->createNamedParameter($sequence), | |||
'recurrenceid' => $query->createNamedParameter($recurrenceId), | |||
'expiration' => $query->createNamedParameter($lastOccurrence), | |||
'uid' => $query->createNamedParameter($uid) | |||
]) | |||
->execute(); | |||
return $token; | |||
} | |||
} |
@@ -0,0 +1,597 @@ | |||
<?php | |||
declare(strict_types=1); | |||
/* | |||
* DAV App | |||
* | |||
* @copyright 2022 Anna Larch <anna.larch@gmx.net> | |||
* | |||
* @author Anna Larch <anna.larch@gmx.net> | |||
* | |||
* 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\DAV\CalDAV\Schedule; | |||
use OC\URLGenerator; | |||
use OCP\IConfig; | |||
use OCP\IDBConnection; | |||
use OCP\IL10N; | |||
use OCP\L10N\IFactory as L10NFactory; | |||
use OCP\Mail\IEMailTemplate; | |||
use OCP\Security\ISecureRandom; | |||
use Sabre\VObject\Component\VCalendar; | |||
use Sabre\VObject\Component\VEvent; | |||
use Sabre\VObject\DateTimeParser; | |||
use Sabre\VObject\ITip\Message; | |||
use Sabre\VObject\Parameter; | |||
use Sabre\VObject\Property; | |||
use Sabre\VObject\Recur\EventIterator; | |||
class IMipService { | |||
private URLGenerator $urlGenerator; | |||
private IConfig $config; | |||
private IDBConnection $db; | |||
private ISecureRandom $random; | |||
private L10NFactory $l10nFactory; | |||
private IL10N $l10n; | |||
/** @var string[] */ | |||
private const STRING_DIFF = [ | |||
'meeting_title' => 'SUMMARY', | |||
'meeting_description' => 'DESCRIPTION', | |||
'meeting_url' => 'URL', | |||
'meeting_location' => 'LOCATION' | |||
]; | |||
public function __construct(URLGenerator $urlGenerator, | |||
IConfig $config, | |||
IDBConnection $db, | |||
ISecureRandom $random, | |||
L10NFactory $l10nFactory) { | |||
$this->urlGenerator = $urlGenerator; | |||
$this->config = $config; | |||
$this->db = $db; | |||
$this->random = $random; | |||
$this->l10nFactory = $l10nFactory; | |||
$default = $this->l10nFactory->findGenericLanguage(); | |||
$this->l10n = $this->l10nFactory->get('dav', $default); | |||
} | |||
/** | |||
* @param string $senderName | |||
* @param $default | |||
* @return string | |||
*/ | |||
public function getFrom(string $senderName, $default): string { | |||
return $this->l10n->t('%1$s via %2$s', [$senderName, $default]); | |||
} | |||
public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) { | |||
if (isset($vevent->$property)) { | |||
$value = $vevent->$property->getValue(); | |||
if (!empty($value)) { | |||
return $value; | |||
} | |||
} | |||
return $default; | |||
} | |||
private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string { | |||
$strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s"; | |||
if (!isset($vevent->$property)) { | |||
return $default; | |||
} | |||
$newstring = $vevent->$property->getValue(); | |||
if(isset($oldVEvent->$property)) { | |||
$oldstring = $oldVEvent->$property->getValue(); | |||
return sprintf($strikethrough, $oldstring, $newstring); | |||
} | |||
return $newstring; | |||
} | |||
/** | |||
* @param VEvent $vEvent | |||
* @param VEvent|null $oldVEvent | |||
* @return array | |||
*/ | |||
public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { | |||
$defaultVal = ''; | |||
$data = []; | |||
$data['meeting_when'] = $this->generateWhenString($vEvent); | |||
foreach(self::STRING_DIFF as $key => $property) { | |||
$data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); | |||
} | |||
$data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal); | |||
if(!empty($oldVEvent)) { | |||
$oldMeetingWhen = $this->generateWhenString($oldVEvent); | |||
$data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']); | |||
$data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']); | |||
$data['meeting_location_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']); | |||
$oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal); | |||
$data['meeting_url_html'] = !empty($oldUrl) ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url']; | |||
$data['meeting_when_html'] = | |||
($oldMeetingWhen !== $data['meeting_when'] && $oldMeetingWhen !== null) | |||
? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) | |||
: $data['meeting_when']; | |||
} | |||
return $data; | |||
} | |||
/** | |||
* @param IL10N $this->l10n | |||
* @param VEvent $vevent | |||
* @return false|int|string | |||
*/ | |||
public function generateWhenString(VEvent $vevent) { | |||
/** @var Property\ICalendar\DateTime $dtstart */ | |||
$dtstart = $vevent->DTSTART; | |||
if (isset($vevent->DTEND)) { | |||
/** @var Property\ICalendar\DateTime $dtend */ | |||
$dtend = $vevent->DTEND; | |||
} elseif (isset($vevent->DURATION)) { | |||
$isFloating = $dtstart->isFloating(); | |||
$dtend = clone $dtstart; | |||
$endDateTime = $dtend->getDateTime(); | |||
$endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); | |||
$dtend->setDateTime($endDateTime, $isFloating); | |||
} elseif (!$dtstart->hasTime()) { | |||
$isFloating = $dtstart->isFloating(); | |||
$dtend = clone $dtstart; | |||
$endDateTime = $dtend->getDateTime(); | |||
$endDateTime = $endDateTime->modify('+1 day'); | |||
$dtend->setDateTime($endDateTime, $isFloating); | |||
} else { | |||
$dtend = clone $dtstart; | |||
} | |||
/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ | |||
/** @var \DateTimeImmutable $dtstartDt */ | |||
$dtstartDt = $dtstart->getDateTime(); | |||
/** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ | |||
/** @var \DateTimeImmutable $dtendDt */ | |||
$dtendDt = $dtend->getDateTime(); | |||
$diff = $dtstartDt->diff($dtendDt); | |||
$dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); | |||
$dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); | |||
if ($dtstart instanceof Property\ICalendar\Date) { | |||
// One day event | |||
if ($diff->days === 1) { | |||
return $this->l10n->l('date', $dtstartDt, ['width' => 'medium']); | |||
} | |||
// DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05, | |||
// the email should show 2020-01-01 to 2020-01-04. | |||
$dtendDt->modify('-1 day'); | |||
//event that spans over multiple days | |||
$localeStart = $this->l10n->l('date', $dtstartDt, ['width' => 'medium']); | |||
$localeEnd = $this->l10n->l('date', $dtendDt, ['width' => 'medium']); | |||
return $localeStart . ' - ' . $localeEnd; | |||
} | |||
/** @var Property\ICalendar\DateTime $dtstart */ | |||
/** @var Property\ICalendar\DateTime $dtend */ | |||
$isFloating = $dtstart->isFloating(); | |||
$startTimezone = $endTimezone = null; | |||
if (!$isFloating) { | |||
$prop = $dtstart->offsetGet('TZID'); | |||
if ($prop instanceof Parameter) { | |||
$startTimezone = $prop->getValue(); | |||
} | |||
$prop = $dtend->offsetGet('TZID'); | |||
if ($prop instanceof Parameter) { | |||
$endTimezone = $prop->getValue(); | |||
} | |||
} | |||
$localeStart = $this->l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . | |||
$this->l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); | |||
// always show full date with timezone if timezones are different | |||
if ($startTimezone !== $endTimezone) { | |||
$localeEnd = $this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); | |||
return $localeStart . ' (' . $startTimezone . ') - ' . | |||
$localeEnd . ' (' . $endTimezone . ')'; | |||
} | |||
// show only end time if date is the same | |||
if ($dtstartDt->format('Y-m-d') === $dtendDt->format('Y-m-d')) { | |||
$localeEnd = $this->l10n->l('time', $dtendDt, ['width' => 'short']); | |||
} else { | |||
$localeEnd = $this->l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . | |||
$this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); | |||
} | |||
return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; | |||
} | |||
/** | |||
* @param VEvent $vEvent | |||
* @return array | |||
*/ | |||
public function buildCancelledBodyData(VEvent $vEvent): array { | |||
$defaultVal = ''; | |||
$strikethrough = "<span style='text-decoration: line-through'>%s</span>"; | |||
$newMeetingWhen = $this->generateWhenString($vEvent); | |||
$newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event');; | |||
$newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal; | |||
$newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal; | |||
$newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal; | |||
$data = []; | |||
$data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen); | |||
$data['meeting_when'] = $newMeetingWhen; | |||
$data['meeting_title_html'] = sprintf($strikethrough, $newSummary); | |||
$data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event'); | |||
$data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : ''; | |||
$data['meeting_description'] = $newDescription; | |||
$data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : ''; | |||
$data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : ''; | |||
$data['meeting_location_html'] = $newLocation !== '' ? sprintf($strikethrough, $newLocation) : ''; | |||
$data['meeting_location'] = $newLocation; | |||
return $data; | |||
} | |||
/** | |||
* Check if event took place in the past | |||
* | |||
* @param VCalendar $vObject | |||
* @return int | |||
*/ | |||
public function getLastOccurrence(VCalendar $vObject) { | |||
/** @var VEvent $component */ | |||
$component = $vObject->VEVENT; | |||
if (isset($component->RRULE)) { | |||
$it = new EventIterator($vObject, (string)$component->UID); | |||
$maxDate = new \DateTime(IMipPlugin::MAX_DATE); | |||
if ($it->isInfinite()) { | |||
return $maxDate->getTimestamp(); | |||
} | |||
$end = $it->getDtEnd(); | |||
while ($it->valid() && $end < $maxDate) { | |||
$end = $it->getDtEnd(); | |||
$it->next(); | |||
} | |||
return $end->getTimestamp(); | |||
} | |||
/** @var Property\ICalendar\DateTime $dtStart */ | |||
$dtStart = $component->DTSTART; | |||
if (isset($component->DTEND)) { | |||
/** @var Property\ICalendar\DateTime $dtEnd */ | |||
$dtEnd = $component->DTEND; | |||
return $dtEnd->getDateTime()->getTimeStamp(); | |||
} | |||
if(isset($component->DURATION)) { | |||
/** @var \DateTime $endDate */ | |||
$endDate = clone $dtStart->getDateTime(); | |||
// $component->DTEND->getDateTime() returns DateTimeImmutable | |||
$endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); | |||
return $endDate->getTimestamp(); | |||
} | |||
if(!$dtStart->hasTime()) { | |||
/** @var \DateTime $endDate */ | |||
// $component->DTSTART->getDateTime() returns DateTimeImmutable | |||
$endDate = clone $dtStart->getDateTime(); | |||
$endDate = $endDate->modify('+1 day'); | |||
return $endDate->getTimestamp(); | |||
} | |||
// No computation of end time possible - return start date | |||
return $dtStart->getDateTime()->getTimeStamp(); | |||
} | |||
/** | |||
* @param Property|null $attendee | |||
*/ | |||
public function setL10n(?Property $attendee = null) { | |||
if($attendee === null) { | |||
return; | |||
} | |||
$lang = $attendee->offsetGet('LANGUAGE'); | |||
if ($lang instanceof Parameter) { | |||
$lang = $lang->getValue(); | |||
$this->l10n = $this->l10nFactory->get('dav', $lang); | |||
} | |||
} | |||
/** | |||
* @param Property|null $attendee | |||
* @return bool | |||
*/ | |||
public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) { | |||
if($attendee === null) { | |||
return false; | |||
} | |||
$rsvp = $attendee->offsetGet('RSVP'); | |||
if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { | |||
return true; | |||
} | |||
$role = $attendee->offsetGet('ROLE'); | |||
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 | |||
// Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set | |||
if ($role === null | |||
|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) | |||
|| (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) | |||
) { | |||
return true; | |||
} | |||
// RFC 5545 3.2.17: default RSVP is false | |||
return false; | |||
} | |||
/** | |||
* @param IEMailTemplate $template | |||
* @param string $method | |||
* @param string $sender | |||
* @param string $summary | |||
* @param string|null $partstat | |||
*/ | |||
public function addSubjectAndHeading(IEMailTemplate $template, | |||
string $method, string $sender, string $summary): void { | |||
if ($method === IMipPlugin::METHOD_CANCEL) { | |||
// TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" | |||
$template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary])); | |||
$template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary])); | |||
} elseif ($method === IMipPlugin::METHOD_REPLY) { | |||
// TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}" | |||
$template->setSubject($this->l10n->t('Re: %1$s', [$summary])); | |||
$template->addHeading($this->l10n->t('%1$s has responded your invitation', [$sender])); | |||
} else { | |||
// TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" | |||
$template->setSubject($this->l10n->t('Invitation: %1$s', [$summary])); | |||
$template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary])); | |||
} | |||
} | |||
/** | |||
* @param string $path | |||
* @return string | |||
*/ | |||
public function getAbsoluteImagePath($path): string { | |||
return $this->urlGenerator->getAbsoluteURL( | |||
$this->urlGenerator->imagePath('core', $path) | |||
); | |||
} | |||
/** | |||
* addAttendees: add organizer and attendee names/emails to iMip mail. | |||
* | |||
* Enable with DAV setting: invitation_list_attendees (default: no) | |||
* | |||
* The default is 'no', which matches old behavior, and is privacy preserving. | |||
* | |||
* To enable including attendees in invitation emails: | |||
* % php occ config:app:set dav invitation_list_attendees --value yes | |||
* | |||
* @param IEMailTemplate $template | |||
* @param IL10N $this->l10n | |||
* @param VEvent $vevent | |||
* @author brad2014 on github.com | |||
*/ | |||
public function addAttendees(IEMailTemplate $template, VEvent $vevent) { | |||
if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { | |||
return; | |||
} | |||
if (isset($vevent->ORGANIZER)) { | |||
/** @var Property | Property\ICalendar\CalAddress $organizer */ | |||
$organizer = $vevent->ORGANIZER; | |||
$organizerEmail = substr($organizer->getNormalizedValue(), 7); | |||
/** @var string|null $organizerName */ | |||
$organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null; | |||
$organizerHTML = sprintf('<a href="%s">%s</a>', | |||
htmlspecialchars($organizer->getNormalizedValue()), | |||
htmlspecialchars($organizerName ?: $organizerEmail)); | |||
$organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); | |||
if(isset($organizer['PARTSTAT']) ) { | |||
/** @var Parameter $partstat */ | |||
$partstat = $organizer['PARTSTAT']; | |||
if(strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { | |||
$organizerHTML .= ' ✔︎'; | |||
$organizerText .= ' ✔︎'; | |||
} | |||
} | |||
$template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'), | |||
$this->getAbsoluteImagePath('caldav/organizer.png'), | |||
$organizerText, '', IMipPlugin::IMIP_INDENT); | |||
} | |||
$attendees = $vevent->select('ATTENDEE'); | |||
if (count($attendees) === 0) { | |||
return; | |||
} | |||
$attendeesHTML = []; | |||
$attendeesText = []; | |||
foreach ($attendees as $attendee) { | |||
$attendeeEmail = substr($attendee->getNormalizedValue(), 7); | |||
$attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null; | |||
$attendeeHTML = sprintf('<a href="%s">%s</a>', | |||
htmlspecialchars($attendee->getNormalizedValue()), | |||
htmlspecialchars($attendeeName ?: $attendeeEmail)); | |||
$attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); | |||
if (isset($attendee['PARTSTAT'])) { | |||
/** @var Parameter $partstat */ | |||
$partstat = $attendee['PARTSTAT']; | |||
if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { | |||
$attendeeHTML .= ' ✔︎'; | |||
$attendeeText .= ' ✔︎'; | |||
} | |||
} | |||
$attendeesHTML[] = $attendeeHTML; | |||
$attendeesText[] = $attendeeText; | |||
} | |||
$template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'), | |||
$this->getAbsoluteImagePath('caldav/attendees.png'), | |||
implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT); | |||
} | |||
/** | |||
* @param IEMailTemplate $template | |||
* @param VEVENT $vevent | |||
* @param $data | |||
*/ | |||
public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) { | |||
$template->addBodyListItem( | |||
$data['meeting_title'], $this->l10n->t('Title:'), | |||
$this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT); | |||
if ($data['meeting_when'] !== '') { | |||
$template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('Time:'), | |||
$this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT); | |||
} | |||
if ($data['meeting_location'] !== '') { | |||
$template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'), | |||
$this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT); | |||
} | |||
if ($data['meeting_url'] !== '') { | |||
$template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'), | |||
$this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT); | |||
} | |||
$this->addAttendees($template, $vevent); | |||
/* Put description last, like an email body, since it can be arbitrarily long */ | |||
if ($data['meeting_description']) { | |||
$template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'), | |||
$this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT); | |||
} | |||
} | |||
/** | |||
* @param Message $iTipMessage | |||
* @return null|Property | |||
*/ | |||
public function getCurrentAttendee(Message $iTipMessage): ?Property { | |||
/** @var VEvent $vevent */ | |||
$vevent = $iTipMessage->message->VEVENT; | |||
$attendees = $vevent->select('ATTENDEE'); | |||
foreach ($attendees as $attendee) { | |||
/** @var Property $attendee */ | |||
if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { | |||
return $attendee; | |||
} | |||
} | |||
return null; | |||
} | |||
/** | |||
* @param Message $iTipMessage | |||
* @param VEvent $vevent | |||
* @param int $lastOccurrence | |||
* @return string | |||
*/ | |||
public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string { | |||
$token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); | |||
$attendee = $iTipMessage->recipient; | |||
$organizer = $iTipMessage->sender; | |||
$sequence = $iTipMessage->sequence; | |||
$recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? | |||
$vevent->{'RECURRENCE-ID'}->serialize() : null; | |||
$uid = $vevent->{'UID'}; | |||
$query = $this->db->getQueryBuilder(); | |||
$query->insert('calendar_invitations') | |||
->values([ | |||
'token' => $query->createNamedParameter($token), | |||
'attendee' => $query->createNamedParameter($attendee), | |||
'organizer' => $query->createNamedParameter($organizer), | |||
'sequence' => $query->createNamedParameter($sequence), | |||
'recurrenceid' => $query->createNamedParameter($recurrenceId), | |||
'expiration' => $query->createNamedParameter($lastOccurrence), | |||
'uid' => $query->createNamedParameter($uid) | |||
]) | |||
->execute(); | |||
return $token; | |||
} | |||
/** | |||
* Create a valid VCalendar object out of the details of | |||
* a VEvent and its associated iTip Message | |||
* | |||
* We do this to filter out all unchanged VEvents | |||
* This is especially important in iTip Messages with recurrences | |||
* and recurrence exceptions | |||
* | |||
* @param Message $iTipMessage | |||
* @param VEvent $vEvent | |||
* @return VCalendar | |||
*/ | |||
public function generateVCalendar(Message $iTipMessage, VEvent $vEvent): VCalendar { | |||
$vCalendar = new VCalendar(); | |||
$vCalendar->add('METHOD', $iTipMessage->method); | |||
foreach ($iTipMessage->message->getComponents() as $component) { | |||
if ($component instanceof VEvent) { | |||
continue; | |||
} | |||
$vCalendar->add(clone $component); | |||
} | |||
$vCalendar->add($vEvent); | |||
return $vCalendar; | |||
} | |||
/** | |||
* @param IEMailTemplate $template | |||
* @param $token | |||
*/ | |||
public function addResponseButtons(IEMailTemplate $template, $token) { | |||
$template->addBodyButtonGroup( | |||
$this->l10n->t('Accept'), | |||
$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ | |||
'token' => $token, | |||
]), | |||
$this->l10n->t('Decline'), | |||
$this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ | |||
'token' => $token, | |||
]) | |||
); | |||
} | |||
public function addMoreOptionsButton(IEMailTemplate $template, $token) { | |||
$moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ | |||
'token' => $token, | |||
]); | |||
$html = vsprintf('<small><a href="%s">%s</a></small>', [ | |||
$moreOptionsURL, $this->l10n->t('More options …') | |||
]); | |||
$text = $this->l10n->t('More options at %s', [$moreOptionsURL]); | |||
$template->addBodyText($html, $text); | |||
} | |||
} |
@@ -0,0 +1,146 @@ | |||
<?php | |||
declare(strict_types=1); | |||
/** | |||
* @copyright 2023 Daniel Kesselberg <mail@danielkesselberg.de> | |||
* | |||
* @author 2023 Daniel Kesselberg <mail@danielkesselberg.de> | |||
* | |||
* @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\DAV\Tests\unit\CalDAV; | |||
use OCA\DAV\CalDAV\EventComparisonService; | |||
use Sabre\VObject\Component\VCalendar; | |||
use Sabre\VObject\Component\VEvent; | |||
use Test\TestCase; | |||
class EventComparisonServiceTest extends TestCase | |||
{ | |||
/** @var EventComparisonService */ | |||
private $eventComparisonService; | |||
protected function setUp(): void | |||
{ | |||
$this->eventComparisonService = new EventComparisonService(); | |||
} | |||
public function testNoModifiedEvent(): void | |||
{ | |||
$vCalendarOld = new VCalendar(); | |||
$vCalendarNew = new VCalendar(); | |||
$vEventOld = $vCalendarOld->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', | |||
]); | |||
$vEventOld->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventOld->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$vEventNew = $vCalendarNew->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', | |||
]); | |||
$vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); | |||
$this->assertEmpty($result['old']); | |||
$this->assertEmpty($result['new']); | |||
} | |||
public function testNewEvent(): void | |||
{ | |||
$vCalendarOld = null; | |||
$vCalendarNew = new VCalendar(); | |||
$vEventNew = $vCalendarNew->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', | |||
]); | |||
$vEventNew->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventNew->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); | |||
$this->assertNull($result['old']); | |||
$this->assertEquals([$vEventNew], $result['new']); | |||
} | |||
public function testModifiedUnmodifiedEvent(): void | |||
{ | |||
$vCalendarOld = new VCalendar(); | |||
$vCalendarNew = new VCalendar(); | |||
$vEventOld1 = $vCalendarOld->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
]); | |||
$vEventOld1->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventOld1->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$vEventOld2 = $vCalendarOld->add('VEVENT', [ | |||
'UID' => 'uid-1235', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
]); | |||
$vEventOld2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventOld2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$vEventNew1 = $vCalendarNew->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
]); | |||
$vEventNew1->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventNew1->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$vEventNew2 = $vCalendarNew->add('VEVENT', [ | |||
'UID' => 'uid-1235', | |||
'LAST-MODIFIED' => 123457, | |||
'SEQUENCE' => 3, | |||
'SUMMARY' => 'Fellowship meeting 2', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
]); | |||
$vEventNew2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$vEventNew2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$result = $this->eventComparisonService->findModified($vCalendarNew, $vCalendarOld); | |||
$this->assertEquals([$vEventOld2], $result['old']); | |||
$this->assertEquals([$vEventNew2], $result['new']); | |||
} | |||
} |
@@ -29,28 +29,27 @@ | |||
*/ | |||
namespace OCA\DAV\Tests\unit\CalDAV\Schedule; | |||
use OCA\DAV\CalDAV\EventComparisonService; | |||
use OCA\DAV\CalDAV\Schedule\IMipPlugin; | |||
use OCA\DAV\CalDAV\Schedule\IMipService; | |||
use OCP\AppFramework\Utility\ITimeFactory; | |||
use OCP\DB\QueryBuilder\IQueryBuilder; | |||
use OCP\Defaults; | |||
use OCP\IConfig; | |||
use OCP\IDBConnection; | |||
use OCP\IL10N; | |||
use OCP\IURLGenerator; | |||
use OCP\IUserManager; | |||
use OCP\L10N\IFactory; | |||
use OCP\Mail\IAttachment; | |||
use OCP\Mail\IEMailTemplate; | |||
use OCP\Mail\IMailer; | |||
use OCP\Mail\IMessage; | |||
use OCP\Security\ISecureRandom; | |||
use PHPUnit\Framework\MockObject\MockObject; | |||
use Psr\Log\LoggerInterface; | |||
use Sabre\VObject\Component\VCalendar; | |||
use Sabre\VObject\Component\VEvent; | |||
use Sabre\VObject\ITip\Message; | |||
use Test\TestCase; | |||
use function array_merge; | |||
class IMipPluginTest extends TestCase { | |||
/** @var IMessage|MockObject */ | |||
private $mailMessage; | |||
@@ -72,19 +71,28 @@ class IMipPluginTest extends TestCase { | |||
/** @var IUserManager|MockObject */ | |||
private $userManager; | |||
/** @var IQueryBuilder|MockObject */ | |||
private $queryBuilder; | |||
/** @var IMipPlugin */ | |||
private $plugin; | |||
/** @var IMipService|MockObject */ | |||
private $service; | |||
/** @var Defaults|MockObject */ | |||
private $defaults; | |||
/** @var LoggerInterface|MockObject */ | |||
private $logger; | |||
/** @var EventComparisonService|MockObject */ | |||
private $eventComparisonService; | |||
protected function setUp(): void { | |||
$this->mailMessage = $this->createMock(IMessage::class); | |||
$this->mailMessage->method('setFrom')->willReturn($this->mailMessage); | |||
$this->mailMessage->method('setReplyTo')->willReturn($this->mailMessage); | |||
$this->mailMessage->method('setTo')->willReturn($this->mailMessage); | |||
$this->mailer = $this->getMockBuilder(IMailer::class)->disableOriginalConstructor()->getMock(); | |||
$this->mailer = $this->createMock(IMailer::class); | |||
$this->mailer->method('createMessage')->willReturn($this->mailMessage); | |||
$this->emailTemplate = $this->createMock(IEMailTemplate::class); | |||
@@ -93,249 +101,482 @@ class IMipPluginTest extends TestCase { | |||
$this->emailAttachment = $this->createMock(IAttachment::class); | |||
$this->mailer->method('createAttachment')->willReturn($this->emailAttachment); | |||
/** @var LoggerInterface|MockObject $logger */ | |||
$logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock(); | |||
$this->logger = $this->createMock(LoggerInterface::class); | |||
$this->timeFactory = $this->getMockBuilder(ITimeFactory::class)->disableOriginalConstructor()->getMock(); | |||
$this->timeFactory = $this->createMock(ITimeFactory::class); | |||
$this->timeFactory->method('getTime')->willReturn(1496912528); // 2017-01-01 | |||
$this->config = $this->createMock(IConfig::class); | |||
$this->userManager = $this->createMock(IUserManager::class); | |||
$l10n = $this->createMock(IL10N::class); | |||
$l10n->method('t') | |||
->willReturnCallback(function ($text, $parameters = []) { | |||
return vsprintf($text, $parameters); | |||
}); | |||
$l10nFactory = $this->createMock(IFactory::class); | |||
$l10nFactory->method('get')->willReturn($l10n); | |||
$urlGenerator = $this->createMock(IURLGenerator::class); | |||
$this->queryBuilder = $this->createMock(IQueryBuilder::class); | |||
$db = $this->createMock(IDBConnection::class); | |||
$db->method('getQueryBuilder') | |||
->with() | |||
->willReturn($this->queryBuilder); | |||
$random = $this->createMock(ISecureRandom::class); | |||
$random->method('generate') | |||
->with(60, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') | |||
->willReturn('random_token'); | |||
$defaults = $this->createMock(Defaults::class); | |||
$defaults->method('getName') | |||
$this->defaults = $this->createMock(Defaults::class); | |||
$this->defaults->method('getName') | |||
->willReturn('Instance Name 123'); | |||
$this->plugin = new IMipPlugin($this->config, $this->mailer, $logger, $this->timeFactory, $l10nFactory, $urlGenerator, $defaults, $random, $db, $this->userManager, 'user123'); | |||
$this->service = $this->createMock(IMipService::class); | |||
$this->eventComparisonService = $this->createMock(EventComparisonService::class); | |||
$this->plugin = new IMipPlugin( | |||
$this->config, | |||
$this->mailer, | |||
$this->logger, | |||
$this->timeFactory, | |||
$this->defaults, | |||
$this->userManager, | |||
'user123', | |||
$this->service, | |||
$this->eventComparisonService | |||
); | |||
} | |||
public function testDelivery(): void { | |||
$this->config | |||
->expects($this->any()) | |||
->method('getAppValue') | |||
->willReturnMap([ | |||
['dav', 'invitation_link_recipients', 'yes', 'yes'], | |||
]); | |||
$this->mailer->method('validateMailAddress')->willReturn(true); | |||
$message = $this->_testMessage(); | |||
$this->_expectSend(); | |||
public function testDeliveryNoSignificantChange(): void { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$message->message = new VCalendar(); | |||
$message->message->add('VEVENT', array_merge([ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 0, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
], [])); | |||
$message->message->VEVENT->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$message->message->VEVENT->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']); | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->senderName = 'Mr. Wizard'; | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
$message->significantChange = false; | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
$this->assertEquals('1.0', $message->getScheduleStatus()); | |||
} | |||
public function testFailedDelivery(): void { | |||
$this->config | |||
->expects($this->any()) | |||
public function testParsingSingle(): void { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$newVCalendar = new VCalendar(); | |||
$newVevent = new VEvent($newVCalendar, 'one', array_merge([ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 1, | |||
'SUMMARY' => 'Fellowship meeting without (!) Boromir', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
], [])); | |||
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$message->message = $newVCalendar; | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->senderName = 'Mr. Wizard'; | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
// save the old copy in the plugin | |||
$oldVCalendar = new VCalendar(); | |||
$oldVEvent = new VEvent($oldVCalendar, 'one', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 0, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
]); | |||
$oldVEvent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$oldVEvent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$oldVEvent->add('ATTENDEE', 'mailto:' . 'boromir@tra.it.or', ['RSVP' => 'TRUE']); | |||
$oldVCalendar->add($oldVEvent); | |||
$data = ['invitee_name' => 'Mr. Wizard', | |||
'meeting_title' => 'Fellowship meeting without (!) Boromir', | |||
'attendee_name' => 'frodo@hobb.it' | |||
]; | |||
$this->plugin->setVCalendar($oldVCalendar); | |||
$this->service->expects(self::once()) | |||
->method('getLastOccurrence') | |||
->willReturn('1496912700'); | |||
$this->mailer->expects(self::once()) | |||
->method('validateMailAddress') | |||
->with('frodo@hobb.it') | |||
->willReturn(true); | |||
$this->eventComparisonService->expects(self::once()) | |||
->method('findModified') | |||
->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); | |||
$this->service->expects(self::once()) | |||
->method('buildBodyData') | |||
->with($newVevent, $oldVEvent) | |||
->willReturn($data); | |||
$this->userManager->expects(self::never()) | |||
->method('getDisplayName'); | |||
$this->service->expects(self::once()) | |||
->method('getFrom'); | |||
$this->service->expects(self::once()) | |||
->method('addSubjectAndHeading') | |||
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting without (!) Boromir'); | |||
$this->service->expects(self::once()) | |||
->method('addBulletList') | |||
->with($this->emailTemplate, $newVevent, $data); | |||
$this->service->expects(self::once()) | |||
->method('getAttendeeRsvpOrReqForParticipant') | |||
->willReturn(true); | |||
$this->config->expects(self::once()) | |||
->method('getAppValue') | |||
->willReturnMap([ | |||
['dav', 'invitation_link_recipients', 'yes', 'yes'], | |||
]); | |||
$this->mailer->method('validateMailAddress')->willReturn(true); | |||
$message = $this->_testMessage(); | |||
$this->mailer | |||
->with('dav', 'invitation_link_recipients', 'yes') | |||
->willReturn('yes'); | |||
$this->service->expects(self::once()) | |||
->method('createInvitationToken') | |||
->with($message,$newVevent, '1496912700') | |||
->willReturn('token'); | |||
$this->service->expects(self::once()) | |||
->method('addResponseButtons') | |||
->with($this->emailTemplate, 'token'); | |||
$this->service->expects(self::once()) | |||
->method('addMoreOptionsButton') | |||
->with($this->emailTemplate, 'token'); | |||
$this->mailer->expects(self::once()) | |||
->method('send') | |||
->willThrowException(new \Exception()); | |||
$this->_expectSend(); | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('5.0', $message->getScheduleStatus()); | |||
} | |||
public function testInvalidEmailDelivery(): void { | |||
$this->mailer->method('validateMailAddress')->willReturn(false); | |||
$message = $this->_testMessage(); | |||
->willReturn([]); | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('5.0', $message->getScheduleStatus()); | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
} | |||
public function testDeliveryWithNoCommonName(): void { | |||
$this->config | |||
->expects($this->any()) | |||
->method('getAppValue') | |||
->willReturnMap([ | |||
['dav', 'invitation_link_recipients', 'yes', 'yes'], | |||
]); | |||
$this->mailer->method('validateMailAddress')->willReturn(true); | |||
$message = $this->_testMessage(); | |||
$message->senderName = null; | |||
$this->userManager->expects($this->once()) | |||
public function testParsingRecurrence(): void { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$newVCalendar = new VCalendar(); | |||
$newVevent = new VEvent($newVCalendar, 'one', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z' | |||
]); | |||
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$newvEvent2 = new VEvent($newVCalendar, 'two', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 1, | |||
'SUMMARY' => 'Elevenses', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RECURRENCE-ID' => new \DateTime('2016-01-01 00:00:00') | |||
]); | |||
$newvEvent2->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$newvEvent2->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$message->message = $newVCalendar; | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
// save the old copy in the plugin | |||
$oldVCalendar = new VCalendar(); | |||
$oldVEvent = new VEvent($oldVCalendar, 'one', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z' | |||
]); | |||
$oldVEvent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$oldVEvent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$data = ['invitee_name' => 'Mr. Wizard', | |||
'meeting_title' => 'Elevenses', | |||
'attendee_name' => 'frodo@hobb.it' | |||
]; | |||
$this->plugin->setVCalendar($oldVCalendar); | |||
$this->service->expects(self::once()) | |||
->method('getLastOccurrence') | |||
->willReturn('1496912700'); | |||
$this->mailer->expects(self::once()) | |||
->method('validateMailAddress') | |||
->with('frodo@hobb.it') | |||
->willReturn(true); | |||
$this->eventComparisonService->expects(self::once()) | |||
->method('findModified') | |||
->willReturn(['old' => [] ,'new' => [$newVevent]]); | |||
$this->service->expects(self::once()) | |||
->method('buildBodyData') | |||
->with($newVevent, null) | |||
->willReturn($data); | |||
$this->userManager->expects(self::once()) | |||
->method('getDisplayName') | |||
->with('user123') | |||
->willReturn('Mr. Wizard'); | |||
$this->_expectSend(); | |||
$this->service->expects(self::once()) | |||
->method('getFrom'); | |||
$this->service->expects(self::once()) | |||
->method('addSubjectAndHeading') | |||
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Elevenses'); | |||
$this->service->expects(self::once()) | |||
->method('addBulletList') | |||
->with($this->emailTemplate, $newVevent, $data); | |||
$this->service->expects(self::once()) | |||
->method('getAttendeeRsvpOrReqForParticipant') | |||
->willReturn(true); | |||
$this->config->expects(self::once()) | |||
->method('getAppValue') | |||
->with('dav', 'invitation_link_recipients', 'yes') | |||
->willReturn('yes'); | |||
$this->service->expects(self::once()) | |||
->method('createInvitationToken') | |||
->with($message, $newVevent, '1496912700') | |||
->willReturn('token'); | |||
$this->service->expects(self::once()) | |||
->method('addResponseButtons') | |||
->with($this->emailTemplate, 'token'); | |||
$this->service->expects(self::once()) | |||
->method('addMoreOptionsButton') | |||
->with($this->emailTemplate, 'token'); | |||
$this->mailer->expects(self::once()) | |||
->method('send') | |||
->willReturn([]); | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
} | |||
/** | |||
* @dataProvider dataNoMessageSendForPastEvents | |||
*/ | |||
public function testNoMessageSendForPastEvents(array $veventParams, bool $expectsMail): void { | |||
$this->config | |||
->method('getAppValue') | |||
->willReturn('yes'); | |||
$this->mailer->method('validateMailAddress')->willReturn(true); | |||
$message = $this->_testMessage($veventParams); | |||
public function testEmailValidationFailed() { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$message->message = new VCalendar(); | |||
$message->message->add('VEVENT', array_merge([ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 0, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
], [])); | |||
$message->message->VEVENT->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$message->message->VEVENT->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']); | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->senderName = 'Mr. Wizard'; | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
$this->_expectSend('frodo@hobb.it', $expectsMail, $expectsMail); | |||
$this->service->expects(self::once()) | |||
->method('getLastOccurrence') | |||
->willReturn('1496912700'); | |||
$this->mailer->expects(self::once()) | |||
->method('validateMailAddress') | |||
->with('frodo@hobb.it') | |||
->willReturn(false); | |||
$this->plugin->schedule($message); | |||
if ($expectsMail) { | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
} else { | |||
$this->assertEquals(false, $message->getScheduleStatus()); | |||
} | |||
$this->assertEquals('5.0', $message->getScheduleStatus()); | |||
} | |||
public function dataNoMessageSendForPastEvents() { | |||
return [ | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00')], false], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-01-01 00:00:00')], false], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-12-31 00:00:00')], true], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DURATION' => 'P1D'], false], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DURATION' => 'P52W'], true], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-01-01 00:00:00'), 'RRULE' => 'FREQ=WEEKLY'], true], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-01-01 00:00:00'), 'RRULE' => 'FREQ=WEEKLY;COUNT=3'], false], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-01-01 00:00:00'), 'RRULE' => 'FREQ=WEEKLY;UNTIL=20170301T000000Z'], false], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-01-01 00:00:00'), 'RRULE' => 'FREQ=WEEKLY;COUNT=33'], true], | |||
[['DTSTART' => new \DateTime('2017-01-01 00:00:00'), 'DTEND' => new \DateTime('2017-01-01 00:00:00'), 'RRULE' => 'FREQ=WEEKLY;UNTIL=20171001T000000Z'], true], | |||
public function testFailedDelivery(): void { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$newVcalendar = new VCalendar(); | |||
$newVevent = new VEvent($newVcalendar, 'one', array_merge([ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 1, | |||
'SUMMARY' => 'Fellowship meeting without (!) Boromir', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
], [])); | |||
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$message->message = $newVcalendar; | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->senderName = 'Mr. Wizard'; | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
// save the old copy in the plugin | |||
$oldVcalendar = new VCalendar(); | |||
$oldVevent = new VEvent($oldVcalendar, 'one', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 0, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
]); | |||
$oldVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$oldVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$oldVevent->add('ATTENDEE', 'mailto:' . 'boromir@tra.it.or', ['RSVP' => 'TRUE']); | |||
$oldVcalendar->add($oldVevent); | |||
$data = ['invitee_name' => 'Mr. Wizard', | |||
'meeting_title' => 'Fellowship meeting without (!) Boromir', | |||
'attendee_name' => 'frodo@hobb.it' | |||
]; | |||
} | |||
/** | |||
* @dataProvider dataIncludeResponseButtons | |||
*/ | |||
public function testIncludeResponseButtons(string $config_setting, string $recipient, bool $has_buttons): void { | |||
$message = $this->_testMessage([], $recipient); | |||
$this->mailer->method('validateMailAddress')->willReturn(true); | |||
$this->_expectSend($recipient, true, $has_buttons); | |||
$this->config | |||
->expects($this->any()) | |||
$this->plugin->setVCalendar($oldVcalendar); | |||
$this->service->expects(self::once()) | |||
->method('getLastOccurrence') | |||
->willReturn('1496912700'); | |||
$this->mailer->expects(self::once()) | |||
->method('validateMailAddress') | |||
->with('frodo@hobb.it') | |||
->willReturn(true); | |||
$this->eventComparisonService->expects(self::once()) | |||
->method('findModified') | |||
->willReturn(['old' => [] ,'new' => [$newVevent]]); | |||
$this->service->expects(self::once()) | |||
->method('buildBodyData') | |||
->with($newVevent, null) | |||
->willReturn($data); | |||
$this->userManager->expects(self::never()) | |||
->method('getDisplayName'); | |||
$this->service->expects(self::once()) | |||
->method('getFrom'); | |||
$this->service->expects(self::once()) | |||
->method('addSubjectAndHeading') | |||
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting without (!) Boromir'); | |||
$this->service->expects(self::once()) | |||
->method('addBulletList') | |||
->with($this->emailTemplate, $newVevent, $data); | |||
$this->service->expects(self::once()) | |||
->method('getAttendeeRsvpOrReqForParticipant') | |||
->willReturn(true); | |||
$this->config->expects(self::once()) | |||
->method('getAppValue') | |||
->willReturnMap([ | |||
['dav', 'invitation_link_recipients', 'yes', $config_setting], | |||
]); | |||
->with('dav', 'invitation_link_recipients', 'yes') | |||
->willReturn('yes'); | |||
$this->service->expects(self::once()) | |||
->method('createInvitationToken') | |||
->with($message, $newVevent, '1496912700') | |||
->willReturn('token'); | |||
$this->service->expects(self::once()) | |||
->method('addResponseButtons') | |||
->with($this->emailTemplate, 'token'); | |||
$this->service->expects(self::once()) | |||
->method('addMoreOptionsButton') | |||
->with($this->emailTemplate, 'token'); | |||
$this->mailer->expects(self::once()) | |||
->method('send') | |||
->willReturn([]); | |||
$this->mailer | |||
->method('send') | |||
->willThrowException(new \Exception()); | |||
$this->logger->expects(self::once()) | |||
->method('error'); | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
$this->assertEquals('5.0', $message->getScheduleStatus()); | |||
} | |||
public function dataIncludeResponseButtons() { | |||
return [ | |||
// dav.invitation_link_recipients, recipient, $has_buttons | |||
[ 'yes', 'joe@internal.com', true], | |||
[ 'joe@internal.com', 'joe@internal.com', true], | |||
[ 'internal.com', 'joe@internal.com', true], | |||
[ 'pete@otherinternal.com,internal.com', 'joe@internal.com', true], | |||
[ 'no', 'joe@internal.com', false], | |||
[ 'internal.com', 'joe@external.com', false], | |||
[ 'jane@otherinternal.com,internal.com', 'joe@otherinternal.com', false], | |||
public function testNoOldEvent(): void { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$newVCalendar = new VCalendar(); | |||
$newVevent = new VEvent($newVCalendar, 'VEVENT', array_merge([ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 1, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
], [])); | |||
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$message->message = $newVCalendar; | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->senderName = 'Mr. Wizard'; | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
$data = ['invitee_name' => 'Mr. Wizard', | |||
'meeting_title' => 'Fellowship meeting', | |||
'attendee_name' => 'frodo@hobb.it' | |||
]; | |||
} | |||
public function testMessageSendWhenEventWithoutName(): void { | |||
$this->config | |||
$this->service->expects(self::once()) | |||
->method('getLastOccurrence') | |||
->willReturn('1496912700'); | |||
$this->mailer->expects(self::once()) | |||
->method('validateMailAddress') | |||
->with('frodo@hobb.it') | |||
->willReturn(true); | |||
$this->eventComparisonService->expects(self::once()) | |||
->method('findModified') | |||
->with($newVCalendar, null) | |||
->willReturn(['old' => [] ,'new' => [$newVevent]]); | |||
$this->service->expects(self::once()) | |||
->method('buildBodyData') | |||
->with($newVevent, null) | |||
->willReturn($data); | |||
$this->userManager->expects(self::never()) | |||
->method('getDisplayName'); | |||
$this->service->expects(self::once()) | |||
->method('getFrom'); | |||
$this->service->expects(self::once()) | |||
->method('addSubjectAndHeading') | |||
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting'); | |||
$this->service->expects(self::once()) | |||
->method('addBulletList') | |||
->with($this->emailTemplate, $newVevent, $data); | |||
$this->service->expects(self::once()) | |||
->method('getAttendeeRsvpOrReqForParticipant') | |||
->willReturn(true); | |||
$this->config->expects(self::once()) | |||
->method('getAppValue') | |||
->with('dav', 'invitation_link_recipients', 'yes') | |||
->willReturn('yes'); | |||
$this->mailer->method('validateMailAddress')->willReturn(true); | |||
$message = $this->_testMessage(['SUMMARY' => '']); | |||
$this->_expectSend('frodo@hobb.it', true, true, 'Invitation: Untitled event'); | |||
$this->emailTemplate->expects($this->once()) | |||
->method('addHeading') | |||
->with('Invitation'); | |||
$this->service->expects(self::once()) | |||
->method('createInvitationToken') | |||
->with($message, $newVevent, '1496912700') | |||
->willReturn('token'); | |||
$this->service->expects(self::once()) | |||
->method('addResponseButtons') | |||
->with($this->emailTemplate, 'token'); | |||
$this->service->expects(self::once()) | |||
->method('addMoreOptionsButton') | |||
->with($this->emailTemplate, 'token'); | |||
$this->mailer->expects(self::once()) | |||
->method('send') | |||
->willReturn([]); | |||
$this->mailer | |||
->method('send') | |||
->willReturn([]); | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
} | |||
private function _testMessage(array $attrs = [], string $recipient = 'frodo@hobb.it') { | |||
public function testNoButtons(): void { | |||
$message = new Message(); | |||
$message->method = 'REQUEST'; | |||
$message->message = new VCalendar(); | |||
$message->message->add('VEVENT', array_merge([ | |||
$newVCalendar = new VCalendar(); | |||
$newVevent = new VEvent($newVCalendar, 'VEVENT', array_merge([ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 0, | |||
'SEQUENCE' => 1, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2018-01-01 00:00:00') | |||
], $attrs)); | |||
$message->message->VEVENT->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$message->message->VEVENT->add('ATTENDEE', 'mailto:'.$recipient, [ 'RSVP' => 'TRUE' ]); | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00') | |||
], [])); | |||
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$message->message = $newVCalendar; | |||
$message->sender = 'mailto:gandalf@wiz.ard'; | |||
$message->senderName = 'Mr. Wizard'; | |||
$message->recipient = 'mailto:'.$recipient; | |||
return $message; | |||
} | |||
$message->recipient = 'mailto:' . 'frodo@hobb.it'; | |||
$data = ['invitee_name' => 'Mr. Wizard', | |||
'meeting_title' => 'Fellowship meeting', | |||
'attendee_name' => 'frodo@hobb.it' | |||
]; | |||
private function _expectSend(string $recipient = 'frodo@hobb.it', bool $expectSend = true, bool $expectButtons = true, string $subject = 'Invitation: Fellowship meeting'): void { | |||
// if the event is in the past, we skip out | |||
if (!$expectSend) { | |||
$this->mailer | |||
->expects($this->never()) | |||
->method('send'); | |||
return; | |||
} | |||
$this->emailTemplate->expects($this->once()) | |||
->method('setSubject') | |||
->with($subject); | |||
$this->mailMessage->expects($this->once()) | |||
->method('setTo') | |||
->with([$recipient => null]); | |||
$this->mailMessage->expects($this->once()) | |||
->method('setReplyTo') | |||
->with(['gandalf@wiz.ard' => 'Mr. Wizard']); | |||
$this->mailMessage->expects($this->once()) | |||
->method('setFrom') | |||
->with(['invitations-noreply@localhost' => 'Mr. Wizard via Instance Name 123']); | |||
$this->service->expects(self::once()) | |||
->method('getLastOccurrence') | |||
->willReturn('1496912700'); | |||
$this->mailer->expects(self::once()) | |||
->method('validateMailAddress') | |||
->with('frodo@hobb.it') | |||
->willReturn(true); | |||
$this->eventComparisonService->expects(self::once()) | |||
->method('findModified') | |||
->with($newVCalendar, null) | |||
->willReturn(['old' => [] ,'new' => [$newVevent]]); | |||
$this->service->expects(self::once()) | |||
->method('buildBodyData') | |||
->with($newVevent, null) | |||
->willReturn($data); | |||
$this->userManager->expects(self::once()) | |||
->method('getDisplayName') | |||
->willReturn('Mr. Wizard'); | |||
$this->service->expects(self::once()) | |||
->method('getFrom'); | |||
$this->service->expects(self::once()) | |||
->method('addSubjectAndHeading') | |||
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting'); | |||
$this->service->expects(self::once()) | |||
->method('addBulletList') | |||
->with($this->emailTemplate, $newVevent, $data); | |||
$this->service->expects(self::once()) | |||
->method('getAttendeeRsvpOrReqForParticipant') | |||
->willReturn(true); | |||
$this->config->expects(self::once()) | |||
->method('getAppValue') | |||
->with('dav', 'invitation_link_recipients', 'yes') | |||
->willReturn('no'); | |||
$this->service->expects(self::never()) | |||
->method('createInvitationToken'); | |||
$this->service->expects(self::never()) | |||
->method('addResponseButtons'); | |||
$this->service->expects(self::never()) | |||
->method('addMoreOptionsButton'); | |||
$this->mailer->expects(self::once()) | |||
->method('send') | |||
->willReturn([]); | |||
$this->mailer | |||
->expects($this->once()) | |||
->method('send'); | |||
if ($expectButtons) { | |||
$this->queryBuilder->expects($this->once()) | |||
->method('insert') | |||
->with('calendar_invitations') | |||
->willReturn($this->queryBuilder); | |||
$this->queryBuilder->expects($this->once()) | |||
->method('values') | |||
->willReturn($this->queryBuilder); | |||
$this->queryBuilder->expects($this->once()) | |||
->method('execute'); | |||
} else { | |||
$this->queryBuilder->expects($this->never()) | |||
->method('insert') | |||
->with('calendar_invitations'); | |||
} | |||
->method('send') | |||
->willReturn([]); | |||
$this->plugin->schedule($message); | |||
$this->assertEquals('1.1', $message->getScheduleStatus()); | |||
} | |||
} |
@@ -0,0 +1,284 @@ | |||
<?php | |||
/** | |||
* @copyright Copyright (c) 2016, ownCloud, Inc. | |||
* @copyright Copyright (c) 2017, Georg Ehrke | |||
* | |||
* @author brad2014 <brad2014@users.noreply.github.com> | |||
* @author Brad Rubenstein <brad@wbr.tech> | |||
* @author Christoph Wurst <christoph@winzerhof-wurst.at> | |||
* @author Georg Ehrke <oc.list@georgehrke.com> | |||
* @author Joas Schilling <coding@schilljs.com> | |||
* @author Morris Jobke <hey@morrisjobke.de> | |||
* @author Thomas Citharel <nextcloud@tcit.fr> | |||
* @author Thomas Müller <thomas.mueller@tmit.eu> | |||
* | |||
* @license AGPL-3.0 | |||
* | |||
* This code is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License, version 3, | |||
* as published by the Free Software Foundation. | |||
* | |||
* 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, version 3, | |||
* along with this program. If not, see <http://www.gnu.org/licenses/> | |||
* | |||
*/ | |||
namespace OCA\DAV\Tests\unit\CalDAV\Schedule; | |||
use OC\L10N\L10N; | |||
use OC\L10N\LazyL10N; | |||
use OC\URLGenerator; | |||
use OCA\DAV\CalDAV\Schedule\IMipService; | |||
use OCP\IConfig; | |||
use OCP\IDBConnection; | |||
use OCP\L10N\IFactory as L10NFactory; | |||
use OCP\Security\ISecureRandom; | |||
use PHPUnit\Framework\MockObject\MockObject; | |||
use Sabre\VObject\Component\VCalendar; | |||
use Sabre\VObject\Component\VEvent; | |||
use Sabre\VObject\Property\ICalendar\DateTime; | |||
use Test\TestCase; | |||
class IMipServiceTest extends TestCase | |||
{ | |||
/** @var URLGenerator|MockObject */ | |||
private $urlGenerator; | |||
/** @var IConfig|MockObject */ | |||
private $config; | |||
/** @var IDBConnection|MockObject */ | |||
private $db; | |||
/** @var ISecureRandom|MockObject */ | |||
private $random; | |||
/** @var L10NFactory|MockObject */ | |||
private $l10nFactory; | |||
/** @var L10N|MockObject */ | |||
private $l10n; | |||
/** @var IMipService */ | |||
private $service; | |||
protected function setUp(): void | |||
{ | |||
$this->urlGenerator = $this->createMock(URLGenerator::class); | |||
$this->config = $this->createMock(IConfig::class); | |||
$this->db = $this->createMock(IDBConnection::class); | |||
$this->random = $this->createMock(ISecureRandom::class); | |||
$this->l10nFactory = $this->createMock(L10NFactory::class); | |||
$this->l10n = $this->createMock(LazyL10N::class); | |||
$this->l10nFactory->expects(self::once()) | |||
->method('findGenericLanguage') | |||
->willReturn('en'); | |||
$this->l10nFactory->expects(self::once()) | |||
->method('get') | |||
->with('dav', 'en') | |||
->willReturn($this->l10n); | |||
$this->service = new IMipService( | |||
$this->urlGenerator, | |||
$this->config, | |||
$this->db, | |||
$this->random, | |||
$this->l10nFactory | |||
); | |||
} | |||
public function testGetFrom(): void | |||
{ | |||
$senderName = "Detective McQueen"; | |||
$default = "Twin Lakes Police Department - Darkside Division"; | |||
$expected = "Detective McQueen via Twin Lakes Police Department - Darkside Division"; | |||
$this->l10n->expects(self::once()) | |||
->method('t') | |||
->willReturn($expected); | |||
$actual = $this->service->getFrom($senderName, $default); | |||
$this->assertEquals($expected, $actual); | |||
} | |||
public function testBuildBodyDataCreated(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$oldVevent = null; | |||
$newVevent = new VEvent($vCalendar, 'two', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 3, | |||
'LAST-MODIFIED' => 789456, | |||
'SUMMARY' => 'Second Breakfast', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RECURRENCE-ID' => new \DateTime('2016-01-01 00:00:00') | |||
]); | |||
$expected = [ | |||
'meeting_when' => $this->service->generateWhenString($newVevent), | |||
'meeting_description' => '', | |||
'meeting_title' => 'Second Breakfast', | |||
'meeting_location' => '', | |||
'meeting_url' => '', | |||
'meeting_url_html' => '', | |||
]; | |||
$actual = $this->service->buildBodyData($newVevent, $oldVevent); | |||
$this->assertEquals($expected, $actual); | |||
} | |||
public function testBuildBodyDataUpdate(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$oldVevent = new VEvent($vCalendar, 'two', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 1, | |||
'LAST-MODIFIED' => 456789, | |||
'SUMMARY' => 'Elevenses', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RECURRENCE-ID' => new \DateTime('2016-01-01 00:00:00') | |||
]); | |||
$oldVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); | |||
$oldVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); | |||
$newVevent = new VEvent($vCalendar, 'two', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 3, | |||
'LAST-MODIFIED' => 789456, | |||
'SUMMARY' => 'Second Breakfast', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RECURRENCE-ID' => new \DateTime('2016-01-01 00:00:00') | |||
]); | |||
$expected = [ | |||
'meeting_when' => $this->service->generateWhenString($newVevent), | |||
'meeting_description' => '', | |||
'meeting_title' => 'Second Breakfast', | |||
'meeting_location' => '', | |||
'meeting_url' => '', | |||
'meeting_url_html' => '', | |||
'meeting_when_html' => $this->service->generateWhenString($newVevent), | |||
'meeting_title_html' => sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", 'Elevenses', 'Second Breakfast'), | |||
'meeting_description_html' => '', | |||
'meeting_location_html' => '' | |||
]; | |||
$actual = $this->service->buildBodyData($newVevent, $oldVevent); | |||
$this->assertEquals($expected, $actual); | |||
} | |||
public function testGenerateWhenStringHourlyEvent(): void { | |||
$vCalendar = new VCalendar(); | |||
$vevent = new VEvent($vCalendar, 'two', [ | |||
'UID' => 'uid-1234', | |||
'SEQUENCE' => 1, | |||
'LAST-MODIFIED' => 456789, | |||
'SUMMARY' => 'Elevenses', | |||
'TZID' => 'Europe/Vienna', | |||
'DTSTART' => (new \DateTime('2016-01-01 08:00:00'))->setTimezone(new \DateTimeZone('Europe/Vienna')), | |||
'DTEND' => (new \DateTime('2016-01-01 09:00:00'))->setTimezone(new \DateTimeZone('Europe/Vienna')), | |||
]); | |||
$this->l10n->expects(self::exactly(3)) | |||
->method('l') | |||
->withConsecutive( | |||
['weekdayName', (new \DateTime('2016-01-01 08:00:00'))->setTimezone(new \DateTimeZone('Europe/Vienna')), ['width' => 'abbreviated']], | |||
['datetime', (new \DateTime('2016-01-01 08:00:00'))->setTimezone(new \DateTimeZone('Europe/Vienna')), ['width' => 'medium|short']], | |||
['time', (new \DateTime('2016-01-01 09:00:00'))->setTimezone(new \DateTimeZone('Europe/Vienna')), ['width' => 'short']] | |||
)->willReturnOnConsecutiveCalls( | |||
'Fr.', | |||
'01.01. 08:00', | |||
'09:00' | |||
); | |||
$expected = 'Fr., 01.01. 08:00 - 09:00 (Europe/Vienna)'; | |||
$actual = $this->service->generateWhenString($vevent); | |||
$this->assertEquals($expected, $actual); | |||
} | |||
public function testGetLastOccurrenceRRULE(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$vCalendar->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'RRULE' => 'FREQ=DAILY;INTERVAL=1;UNTIL=20160201T000000Z', | |||
]); | |||
$occurrence = $this->service->getLastOccurrence($vCalendar); | |||
$this->assertEquals(1454284800, $occurrence); | |||
} | |||
public function testGetLastOccurrenceEndDate(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$vCalendar->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'DTEND' => new \DateTime('2017-01-01 00:00:00'), | |||
]); | |||
$occurrence = $this->service->getLastOccurrence($vCalendar); | |||
$this->assertEquals(1483228800, $occurrence); | |||
} | |||
public function testGetLastOccurrenceDuration(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$vCalendar->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
'DURATION' => 'P12W', | |||
]); | |||
$occurrence = $this->service->getLastOccurrence($vCalendar); | |||
$this->assertEquals(1458864000, $occurrence); | |||
} | |||
public function testGetLastOccurrenceAllDay(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$vEvent = $vCalendar->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
]); | |||
// rewrite from DateTime to Date | |||
$vEvent->DTSTART['VALUE'] = 'DATE'; | |||
$occurrence = $this->service->getLastOccurrence($vCalendar); | |||
$this->assertEquals(1451692800, $occurrence); | |||
} | |||
public function testGetLastOccurrenceFallback(): void | |||
{ | |||
$vCalendar = new VCalendar(); | |||
$vCalendar->add('VEVENT', [ | |||
'UID' => 'uid-1234', | |||
'LAST-MODIFIED' => 123456, | |||
'SEQUENCE' => 2, | |||
'SUMMARY' => 'Fellowship meeting', | |||
'DTSTART' => new \DateTime('2016-01-01 00:00:00'), | |||
]); | |||
$occurrence = $this->service->getLastOccurrence($vCalendar); | |||
$this->assertEquals(1451606400, $occurrence); | |||
} | |||
} |