aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/CalDAV/Schedule
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/CalDAV/Schedule')
-rw-r--r--apps/dav/lib/CalDAV/Schedule/IMipPlugin.php791
-rw-r--r--apps/dav/lib/CalDAV/Schedule/IMipService.php1294
-rw-r--r--apps/dav/lib/CalDAV/Schedule/Plugin.php301
3 files changed, 1740 insertions, 646 deletions
diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
index 8aacc33bb46..2af6b162d8d 100644
--- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
+++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
@@ -1,60 +1,34 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @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/).
- *
- * @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 Leon Klingele <leon@struktur.de>
- * @author Nick Sweeting <git@sweeting.me>
- * @author rakekniven <mark.ziegler@rakekniven.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @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/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-FileCopyrightText: 2007-2015 fruux GmbH (https://fruux.com/)
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
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;
-use OCP\IDBConnection;
-use OCP\IL10N;
-use OCP\ILogger;
-use OCP\IURLGenerator;
-use OCP\IUserManager;
-use OCP\L10N\IFactory as L10NFactory;
-use OCP\Mail\IEMailTemplate;
+use OCP\IAppConfig;
+use OCP\IUserSession;
use OCP\Mail\IMailer;
-use OCP\Security\ISecureRandom;
+use OCP\Mail\Provider\Address;
+use OCP\Mail\Provider\Attachment;
+use OCP\Mail\Provider\IManager as IMailManager;
+use OCP\Mail\Provider\IMessageSend;
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\DateTimeParser;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Parameter;
-use Sabre\VObject\Property;
-use Sabre\VObject\Recur\EventIterator;
+use Sabre\VObject\Reader;
/**
* iMIP handler.
@@ -72,75 +46,47 @@ use Sabre\VObject\Recur\EventIterator;
*/
class IMipPlugin extends SabreIMipPlugin {
- /** @var string */
- private $userId;
-
- /** @var IConfig */
- private $config;
-
- /** @var IMailer */
- private $mailer;
-
- /** @var ILogger */
- private $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 ?VCalendar $vCalendar = null;
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
+ public const IMIP_INDENT = 15;
+
+ public function __construct(
+ private IAppConfig $config,
+ private IMailer $mailer,
+ private LoggerInterface $logger,
+ private ITimeFactory $timeFactory,
+ private Defaults $defaults,
+ private IUserSession $userSession,
+ private IMipService $imipService,
+ private EventComparisonService $eventComparisonService,
+ private IMailManager $mailManager,
+ ) {
+ parent::__construct('');
+ }
+
+ public function initialize(DAV\Server $server): void {
+ parent::initialize($server);
+ $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10);
+ }
/**
- * @param IConfig $config
- * @param IMailer $mailer
- * @param ILogger $logger
- * @param ITimeFactory $timeFactory
- * @param L10NFactory $l10nFactory
- * @param IUrlGenerator $urlGenerator
- * @param Defaults $defaults
- * @param ISecureRandom $random
- * @param IDBConnection $db
- * @param string $userId
+ * 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 __construct(IConfig $config, IMailer $mailer, ILogger $logger,
- ITimeFactory $timeFactory, L10NFactory $l10nFactory,
- IURLGenerator $urlGenerator, Defaults $defaults,
- ISecureRandom $random, IDBConnection $db, IUserManager $userManager,
- $userId) {
- 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;
+ 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);
}
/**
@@ -151,8 +97,7 @@ class IMipPlugin extends SabreIMipPlugin {
*/
public function schedule(Message $iTipMessage) {
- // Not sending any emails if the system considers the update
- // insignificant.
+ // Not sending any emails if the system considers the update insignificant
if (!$iTipMessage->significantChange) {
if (!$iTipMessage->scheduleStatus) {
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email';
@@ -160,103 +105,114 @@ 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 ($recipient === false || !$this->mailer->validateMailAddress($recipient)) {
+ 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;
}
-
- $senderName = $iTipMessage->senderName ?: null;
- $recipientName = $iTipMessage->recipientName ?: null;
-
- if ($senderName === null || empty(trim($senderName))) {
- $user = $this->userManager->get($this->userId);
- if ($user) {
- // getDisplayName automatically uses the uid
- // if no display-name is set
- $senderName = $user->getDisplayName();
- }
+ $recipientName = $iTipMessage->recipientName ? (string)$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;
+ $isModified = isset($oldVevent);
+
+ // 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;
}
- /** @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;
+ // 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);
+ if ($attendee === null) {
+ $uid = $vEvent->UID ?? 'no UID found';
+ $this->logger->debug('Could not find recipient ' . $recipient . ' as attendee for event with UID ' . $uid);
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
+ return;
+ }
+ // Don't send emails to rooms, resources and circles
+ if ($this->imipService->isRoomOrResource($attendee)
+ || $this->imipService->isCircle($attendee)) {
+ $this->logger->debug('No invitation sent as recipient is room, resource or circle', [
+ 'attendee' => $recipient,
+ ]);
+ $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email';
+ return;
+ }
+ $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
+ // If the iTIP message senderName is null or empty use the user session name as the senderName
+ if (($iTipMessage->senderName instanceof Parameter) && !empty(trim($iTipMessage->senderName->getValue()))) {
+ $senderName = trim($iTipMessage->senderName->getValue());
+ } elseif (is_string($iTipMessage->senderName) && !empty(trim($iTipMessage->senderName))) {
+ $senderName = trim($iTipMessage->senderName);
+ } elseif ($this->userSession->getUser() !== null) {
+ $senderName = trim($this->userSession->getUser()->getDisplayName());
+ } else {
+ $senderName = '';
+ }
- $defaultVal = '--';
+ $sender = substr($iTipMessage->sender, 7);
- $method = self::METHOD_REQUEST;
+ $replyingAttendee = null;
switch (strtolower($iTipMessage->method)) {
case self::METHOD_REPLY:
$method = self::METHOD_REPLY;
+ $data = $this->imipService->buildReplyBodyData($vEvent);
+ $replyingAttendee = $this->imipService->getReplyingAttendee($iTipMessage);
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->defaults->getName()]);
-
- $message = $this->mailer->createMessage()
- ->setFrom([$fromEMail => $fromName])
- ->setTo([$recipient => $recipientName]);
-
- if ($sender !== false) {
- $message->setReplyTo([$sender => $senderName]);
- }
+ $fromName = $this->imipService->getFrom($senderName, $this->defaults->getName());
$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'], $isModified, $replyingAttendee);
+ $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
@@ -277,453 +233,106 @@ class IMipPlugin extends SabreIMipPlugin {
** To suppress URLs entirely, set invitation_link_recipients to boolean "no".
*/
- $recipientDomain = substr(strrchr($recipient, "@"), 1);
- $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes'))));
+ $recipientDomain = substr(strrchr($recipient, '@'), 1);
+ $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getValueString('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);
}
}
$template->addFooter();
+ // convert iTip Message to string
+ $itip_msg = $iTipMessage->message->serialize();
- $message->useTemplate($template);
-
- $attachment = $this->mailer->createAttachment(
- $iTipMessage->message->serialize(),
- 'event.ics',// TODO(leon): Make file name unique, e.g. add event id
- 'text/calendar; method=' . $iTipMessage->method
- );
- $message->attach($attachment);
+ $mailService = null;
try {
- $failed = $this->mailer->send($message);
- $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip';
- if ($failed) {
- $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
- $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
- }
- } catch (\Exception $ex) {
- $this->logger->logException($ex, ['app' => 'dav']);
- $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
- }
- }
-
- /**
- * 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();
+ if ($this->config->getValueBool('core', 'mail_providers_enabled', true)) {
+ // retrieve user object
+ $user = $this->userSession->getUser();
+ if ($user !== null) {
+ // retrieve appropriate service with the same address as sender
+ $mailService = $this->mailManager->findServiceByAddress($user->getUID(), $sender);
}
- $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
- */
- private function getAttendeeLangOrDefault($default, Property $attendee = null) {
- if ($attendee !== null) {
- $lang = $attendee->offsetGet('LANGUAGE');
- if ($lang instanceof Parameter) {
- return $lang->getValue();
- }
- }
- return $default;
- }
-
- /**
- * @param Property|null $attendee
- * @return bool
- */
- 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;
- }
-
- /**
- * @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 .= ' ✔︎';
- }
+ // The display name in Nextcloud can use utf-8.
+ // As the default charset for text/* is us-ascii, it's important to explicitly define it.
+ // See https://www.rfc-editor.org/rfc/rfc6047.html#section-2.4.
+ $contentType = 'text/calendar; method=' . $iTipMessage->method . '; charset="utf-8"';
+
+ // evaluate if a mail service was found and has sending capabilities
+ if ($mailService instanceof IMessageSend) {
+ // construct mail message and set required parameters
+ $message = $mailService->initiateMessage();
+ $message->setFrom(
+ (new Address($sender, $fromName))
+ );
+ $message->setTo(
+ (new Address($recipient, $recipientName))
+ );
+ $message->setSubject($template->renderSubject());
+ $message->setBodyPlain($template->renderText());
+ $message->setBodyHtml($template->renderHtml());
+ // Adding name=event.ics is a trick to make the invitation also appear
+ // as a file attachment in mail clients like Thunderbird or Evolution.
+ $message->setAttachments((new Attachment(
+ $itip_msg,
+ null,
+ $contentType . '; name=event.ics',
+ true
+ )));
+ // send message
+ $mailService->sendMessage($message);
+ } else {
+ // construct symfony mailer message and set required parameters
+ $message = $this->mailer->createMessage();
+ $message->setFrom([$fromEMail => $fromName]);
+ $message->setTo(
+ (($recipientName !== null) ? [$recipient => $recipientName] : [$recipient])
+ );
+ $message->setReplyTo(
+ (($senderName !== null) ? [$sender => $senderName] : [$sender])
+ );
+ $message->useTemplate($template);
+ // Using a different content type because Symfony Mailer/Mime will append the name to
+ // the content type header and attachInline does not allow null.
+ $message->attachInline(
+ $itip_msg,
+ 'event.ics',
+ $contentType,
+ );
+ $failed = $this->mailer->send($message);
}
- $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 .= ' ✔︎';
+ $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip';
+ if (!empty($failed)) {
+ $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
}
- array_push($attendeesHTML, $attendeeHTML);
- array_push($attendeesText, $attendeeText);
+ } catch (\Exception $ex) {
+ $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]);
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
}
-
- $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
+ * @return ?VCalendar
*/
- 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);
+ public function getVCalendar(): ?VCalendar {
+ return $this->vCalendar;
}
/**
- * @param string $path
- * @return string
+ * @param ?VCalendar $vCalendar
*/
- private function getAbsoluteImagePath($path) {
- return $this->urlGenerator->getAbsoluteURL(
- $this->urlGenerator->imagePath('core', $path)
- );
+ public function setVCalendar(?VCalendar $vCalendar): void {
+ $this->vCalendar = $vCalendar;
}
- /**
- * @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;
- }
}
diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php
new file mode 100644
index 00000000000..54c0bc31849
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php
@@ -0,0 +1,1294 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\Schedule;
+
+use OC\URLGenerator;
+use OCA\DAV\CalDAV\EventReader;
+use OCP\AppFramework\Utility\ITimeFactory;
+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 IL10N $l10n;
+
+ /** @var string[] */
+ private const STRING_DIFF = [
+ 'meeting_title' => 'SUMMARY',
+ 'meeting_description' => 'DESCRIPTION',
+ 'meeting_url' => 'URL',
+ 'meeting_location' => 'LOCATION'
+ ];
+
+ public function __construct(
+ private URLGenerator $urlGenerator,
+ private IConfig $config,
+ private IDBConnection $db,
+ private ISecureRandom $random,
+ private L10NFactory $l10nFactory,
+ private ITimeFactory $timeFactory,
+ ) {
+ $language = $this->l10nFactory->findGenericLanguage();
+ $locale = $this->l10nFactory->findLocale($language);
+ $this->l10n = $this->l10nFactory->get('dav', $language, $locale);
+ }
+
+ /**
+ * @param string|null $senderName
+ * @param string $default
+ * @return string
+ */
+ public function getFrom(?string $senderName, string $default): string {
+ if ($senderName === null) {
+ return $default;
+ }
+
+ 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) && $oldVEvent->$property->getValue() !== $newstring) {
+ $oldstring = $oldVEvent->$property->getValue();
+ return sprintf($strikethrough, $oldstring, $newstring);
+ }
+ return $newstring;
+ }
+
+ /**
+ * Like generateDiffString() but linkifies the property values if they are urls.
+ */
+ private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
+ if (!isset($vevent->$property)) {
+ return $default;
+ }
+ /** @var string|null $newString */
+ $newString = $vevent->$property->getValue();
+ $oldString = isset($oldVEvent->$property) ? $oldVEvent->$property->getValue() : null;
+ if ($oldString !== $newString) {
+ return sprintf(
+ "<span style='text-decoration: line-through'>%s</span><br />%s",
+ $this->linkify($oldString) ?? $oldString ?? '',
+ $this->linkify($newString) ?? $newString ?? ''
+ );
+ }
+ return $this->linkify($newString) ?? $newString;
+ }
+
+ /**
+ * Convert a given url to a html link element or return null otherwise.
+ */
+ private function linkify(?string $url): ?string {
+ if ($url === null) {
+ return null;
+ }
+ if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
+ return null;
+ }
+
+ return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url));
+ }
+
+ /**
+ * @param VEvent $vEvent
+ * @param VEvent|null $oldVEvent
+ * @return array
+ */
+ public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
+
+ // construct event reader
+ $eventReaderCurrent = new EventReader($vEvent);
+ $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
+ $defaultVal = '';
+ $data = [];
+ $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent);
+
+ foreach (self::STRING_DIFF as $key => $property) {
+ $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
+ }
+
+ $data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
+
+ if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
+ $data['meeting_location_html'] = $locationHtml;
+ }
+
+ if (!empty($oldVEvent)) {
+ $oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
+ $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->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
+
+ $oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
+ $data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
+
+ $data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when'];
+ }
+ // generate occurring next string
+ if ($eventReaderCurrent->recurs()) {
+ $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent);
+ }
+ return $data;
+ }
+
+ /**
+ * @param VEvent $vEvent
+ * @return array
+ */
+ public function buildReplyBodyData(VEvent $vEvent): array {
+ // construct event reader
+ $eventReader = new EventReader($vEvent);
+ $defaultVal = '';
+ $data = [];
+ $data['meeting_when'] = $this->generateWhenString($eventReader);
+
+ foreach (self::STRING_DIFF as $key => $property) {
+ $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
+ }
+
+ if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
+ $data['meeting_location_html'] = $locationHtml;
+ }
+
+ $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
+
+ // generate occurring next string
+ if ($eventReader->recurs()) {
+ $data['meeting_occurring'] = $this->generateOccurringString($eventReader);
+ }
+
+ return $data;
+ }
+
+ /**
+ * generates a when string based on if a event has an recurrence or not
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenString(EventReader $er): string {
+ return match ($er->recurs()) {
+ true => $this->generateWhenStringRecurring($er),
+ false => $this->generateWhenStringSingular($er)
+ };
+ }
+
+ /**
+ * generates a when string for a non recurring event
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringSingular(EventReader $er): string {
+ // initialize
+ $startTime = null;
+ $endTime = null;
+ // calculate time difference from now to start of event
+ $occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
+ // extract start date
+ $startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']);
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // In a minute/hour/day/week/month/year on July 1, 2024 for the entire day
+ // In a minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
+ return match ([$occurring['scale'], $endTime !== null]) {
+ ['past', false] => $this->l10n->t(
+ 'In the past on %1$s for the entire day',
+ [$startDate]
+ ),
+ ['minute', false] => $this->l10n->n(
+ 'In a minute on %1$s for the entire day',
+ 'In %n minutes on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['hour', false] => $this->l10n->n(
+ 'In a hour on %1$s for the entire day',
+ 'In %n hours on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['day', false] => $this->l10n->n(
+ 'In a day on %1$s for the entire day',
+ 'In %n days on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['week', false] => $this->l10n->n(
+ 'In a week on %1$s for the entire day',
+ 'In %n weeks on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['month', false] => $this->l10n->n(
+ 'In a month on %1$s for the entire day',
+ 'In %n months on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['year', false] => $this->l10n->n(
+ 'In a year on %1$s for the entire day',
+ 'In %n years on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['past', true] => $this->l10n->t(
+ 'In the past on %1$s between %2$s - %3$s',
+ [$startDate, $startTime, $endTime]
+ ),
+ ['minute', true] => $this->l10n->n(
+ 'In a minute on %1$s between %2$s - %3$s',
+ 'In %n minutes on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['hour', true] => $this->l10n->n(
+ 'In a hour on %1$s between %2$s - %3$s',
+ 'In %n hours on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['day', true] => $this->l10n->n(
+ 'In a day on %1$s between %2$s - %3$s',
+ 'In %n days on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['week', true] => $this->l10n->n(
+ 'In a week on %1$s between %2$s - %3$s',
+ 'In %n weeks on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['month', true] => $this->l10n->n(
+ 'In a month on %1$s between %2$s - %3$s',
+ 'In %n months on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['year', true] => $this->l10n->n(
+ 'In a year on %1$s between %2$s - %3$s',
+ 'In %n years on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ default => $this->l10n->t('Could not generate when statement')
+ };
+ }
+
+ /**
+ * generates a when string based on recurrence precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurring(EventReader $er): string {
+ return match ($er->recurringPrecision()) {
+ 'daily' => $this->generateWhenStringRecurringDaily($er),
+ 'weekly' => $this->generateWhenStringRecurringWeekly($er),
+ 'monthly' => $this->generateWhenStringRecurringMonthly($er),
+ 'yearly' => $this->generateWhenStringRecurringYearly($er),
+ 'fixed' => $this->generateWhenStringRecurringFixed($er),
+ };
+ }
+
+ /**
+ * generates a when string for a daily precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringDaily(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // Every Day for the entire day
+ // Every Day for the entire day until July 13, 2024
+ // Every Day between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ // Every 3 Days for the entire day
+ // Every 3 Days for the entire day until July 13, 2024
+ // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Day for the entire day'),
+ [false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]),
+ [false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]),
+ [true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+
+ }
+
+ /**
+ * generates a when string for a weekly precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringWeekly(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // days of the week
+ $days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // Every Week on Monday, Wednesday, Friday for the entire day
+ // Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024
+ // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ // Every 2 Weeks on Monday, Wednesday, Friday for the entire day
+ // Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024
+ // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]),
+ [false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]),
+ [false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]),
+ [true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+
+ }
+
+ /**
+ * generates a when string for a monthly precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringMonthly(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // days of month
+ if ($er->recurringPattern() === 'R') {
+ $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
+ . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
+ } else {
+ $days = implode(', ', $er->recurringDaysOfMonth());
+ }
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order, output varies depending on if the event is absolute or releative:
+ // Absolute: Every Month on the 1, 8 for the entire day
+ // Relative: Every Month on the First Sunday, Saturday for the entire day
+ // Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024
+ // Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024
+ // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ // Absolute: Every 2 Months on the 1, 8 for the entire day
+ // Relative: Every 2 Months on the First Sunday, Saturday for the entire day
+ // Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024
+ // Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024
+ // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]),
+ [false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]),
+ [false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]),
+ [true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+ }
+
+ /**
+ * generates a when string for a yearly precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringYearly(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // months of year
+ $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed()));
+ // days of month
+ if ($er->recurringPattern() === 'R') {
+ $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
+ . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
+ } else {
+ $days = $er->startDateTime()->format('jS');
+ }
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order, output varies depending on if the event is absolute or releative:
+ // Absolute: Every Year in July on the 1st for the entire day
+ // Relative: Every Year in July on the First Sunday, Saturday for the entire day
+ // Absolute: Every Year in July on the 1st for the entire day until July 31, 2026
+ // Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026
+ // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ // Absolute: Every 2 Years in July on the 1st for the entire day
+ // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day
+ // Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026
+ // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026
+ // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]),
+ [false, false, true] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day until %3$s', [$months, $days, $conclusion]),
+ [false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', [$months, $days, $startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]),
+ [true, false, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [$interval, $months, $days, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [$interval, $months, $days, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [$interval, $months, $days, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+ }
+
+ /**
+ * generates a when string for a fixed precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringFixed(EventReader $er): string {
+ // initialize
+ $startTime = null;
+ $conclusion = null;
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // On specific dates for the entire day until July 13, 2024
+ // On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ return match ($startTime !== null) {
+ false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]),
+ true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
+ };
+ }
+
+ /**
+ * generates a occurring next string for a recurring event
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateOccurringString(EventReader $er): string {
+
+ // initialize
+ $occurrence = null;
+ $occurrence2 = null;
+ $occurrence3 = null;
+ // reset to initial occurrence
+ $er->recurrenceRewind();
+ // forward to current date
+ $er->recurrenceAdvanceTo($this->timeFactory->getDateTime());
+ // calculate time difference from now to start of next event occurrence and minimize it
+ $occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
+ // store next occurrence value
+ $occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
+ // forward one occurrence
+ $er->recurrenceAdvance();
+ // evaluate if occurrence is valid
+ if ($er->recurrenceDate() !== null) {
+ // store following occurrence value
+ $occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
+ // forward one occurrence
+ $er->recurrenceAdvance();
+ // evaluate if occurrence is valid
+ if ($er->recurrenceDate()) {
+ // store following occurrence value
+ $occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
+ }
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // In a minute/hour/day/week/month/year on July 1, 2024
+ // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024
+ // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024
+ return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) {
+ ['past', false, false] => $this->l10n->t(
+ 'In the past on %1$s',
+ [$occurrence]
+ ),
+ ['minute', false, false] => $this->l10n->n(
+ 'In a minute on %1$s',
+ 'In %n minutes on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['hour', false, false] => $this->l10n->n(
+ 'In a hour on %1$s',
+ 'In %n hours on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['day', false, false] => $this->l10n->n(
+ 'In a day on %1$s',
+ 'In %n days on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['week', false, false] => $this->l10n->n(
+ 'In a week on %1$s',
+ 'In %n weeks on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['month', false, false] => $this->l10n->n(
+ 'In a month on %1$s',
+ 'In %n months on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['year', false, false] => $this->l10n->n(
+ 'In a year on %1$s',
+ 'In %n years on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['past', true, false] => $this->l10n->t(
+ 'In the past on %1$s then on %2$s',
+ [$occurrence, $occurrence2]
+ ),
+ ['minute', true, false] => $this->l10n->n(
+ 'In a minute on %1$s then on %2$s',
+ 'In %n minutes on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['hour', true, false] => $this->l10n->n(
+ 'In a hour on %1$s then on %2$s',
+ 'In %n hours on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['day', true, false] => $this->l10n->n(
+ 'In a day on %1$s then on %2$s',
+ 'In %n days on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['week', true, false] => $this->l10n->n(
+ 'In a week on %1$s then on %2$s',
+ 'In %n weeks on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['month', true, false] => $this->l10n->n(
+ 'In a month on %1$s then on %2$s',
+ 'In %n months on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['year', true, false] => $this->l10n->n(
+ 'In a year on %1$s then on %2$s',
+ 'In %n years on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['past', true, true] => $this->l10n->t(
+ 'In the past on %1$s then on %2$s and %3$s',
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['minute', true, true] => $this->l10n->n(
+ 'In a minute on %1$s then on %2$s and %3$s',
+ 'In %n minutes on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['hour', true, true] => $this->l10n->n(
+ 'In a hour on %1$s then on %2$s and %3$s',
+ 'In %n hours on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['day', true, true] => $this->l10n->n(
+ 'In a day on %1$s then on %2$s and %3$s',
+ 'In %n days on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['week', true, true] => $this->l10n->n(
+ 'In a week on %1$s then on %2$s and %3$s',
+ 'In %n weeks on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['month', true, true] => $this->l10n->n(
+ 'In a month on %1$s then on %2$s and %3$s',
+ 'In %n months on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['year', true, true] => $this->l10n->n(
+ 'In a year on %1$s then on %2$s and %3$s',
+ 'In %n years on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ default => $this->l10n->t('Could not generate next recurrence statement')
+ };
+
+ }
+
+ /**
+ * @param VEvent $vEvent
+ * @return array
+ */
+ public function buildCancelledBodyData(VEvent $vEvent): array {
+ // construct event reader
+ $eventReaderCurrent = new EventReader($vEvent);
+ $defaultVal = '';
+ $strikethrough = "<span style='text-decoration: line-through'>%s</span>";
+
+ $newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
+ $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;
+ $newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
+
+ $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'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
+ $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
+ * @param bool $isModified
+ */
+ public function addSubjectAndHeading(IEMailTemplate $template,
+ string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): 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]));
+ // Build the strings
+ $partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null;
+ $partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null;
+ switch ($partstat) {
+ case 'ACCEPTED':
+ $template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender]));
+ break;
+ case 'TENTATIVE':
+ $template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender]));
+ break;
+ case 'DECLINED':
+ $template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender]));
+ break;
+ case null:
+ default:
+ $template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender]));
+ break;
+ }
+ } elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) {
+ // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}"
+ $template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary]));
+ $template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary]));
+ } 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_html'] ?? $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('When:'),
+ $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);
+ }
+ if (isset($data['meeting_occurring'])) {
+ $template->addBodyListItem($data['meeting_occurring_html'] ?? $data['meeting_occurring'], $this->l10n->t('Occurring:'),
+ $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', 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) {
+ if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
+ /** @var Property $attendee */
+ return $attendee;
+ } elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
+ /** @var Property $attendee */
+ 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'}?->getValue();
+
+ $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)
+ ])
+ ->executeStatement();
+
+ return $token;
+ }
+
+ /**
+ * @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);
+ }
+
+ public function getReplyingAttendee(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->sender) === 0) {
+ return $attendee;
+ }
+ }
+ return null;
+ }
+
+ public function isRoomOrResource(Property $attendee): bool {
+ $cuType = $attendee->offsetGet('CUTYPE');
+ if (!$cuType instanceof Parameter) {
+ return false;
+ }
+ $type = $cuType->getValue() ?? 'INDIVIDUAL';
+ if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) {
+ // Don't send emails to things
+ return true;
+ }
+ return false;
+ }
+
+ public function isCircle(Property $attendee): bool {
+ $cuType = $attendee->offsetGet('CUTYPE');
+ if (!$cuType instanceof Parameter) {
+ return false;
+ }
+
+ $uri = $attendee->getValue();
+ if (!$uri) {
+ return false;
+ }
+
+ $cuTypeValue = $cuType->getValue();
+ return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+');
+ }
+
+ public function minimizeInterval(\DateInterval $dateInterval): array {
+ // evaluate if time interval is in the past
+ if ($dateInterval->invert == 1) {
+ return ['interval' => 1, 'scale' => 'past'];
+ }
+ // evaluate interval parts and return smallest time period
+ if ($dateInterval->y > 0) {
+ $interval = $dateInterval->y;
+ $scale = 'year';
+ } elseif ($dateInterval->m > 0) {
+ $interval = $dateInterval->m;
+ $scale = 'month';
+ } elseif ($dateInterval->d >= 7) {
+ $interval = (int)($dateInterval->d / 7);
+ $scale = 'week';
+ } elseif ($dateInterval->d > 0) {
+ $interval = $dateInterval->d;
+ $scale = 'day';
+ } elseif ($dateInterval->h > 0) {
+ $interval = $dateInterval->h;
+ $scale = 'hour';
+ } else {
+ $interval = $dateInterval->i;
+ $scale = 'minute';
+ }
+
+ return ['interval' => $interval, 'scale' => $scale];
+ }
+
+ /**
+ * Localizes week day names to another language
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function localizeDayName(string $value): string {
+ return match ($value) {
+ 'Monday' => $this->l10n->t('Monday'),
+ 'Tuesday' => $this->l10n->t('Tuesday'),
+ 'Wednesday' => $this->l10n->t('Wednesday'),
+ 'Thursday' => $this->l10n->t('Thursday'),
+ 'Friday' => $this->l10n->t('Friday'),
+ 'Saturday' => $this->l10n->t('Saturday'),
+ 'Sunday' => $this->l10n->t('Sunday'),
+ };
+ }
+
+ /**
+ * Localizes month names to another language
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function localizeMonthName(string $value): string {
+ return match ($value) {
+ 'January' => $this->l10n->t('January'),
+ 'February' => $this->l10n->t('February'),
+ 'March' => $this->l10n->t('March'),
+ 'April' => $this->l10n->t('April'),
+ 'May' => $this->l10n->t('May'),
+ 'June' => $this->l10n->t('June'),
+ 'July' => $this->l10n->t('July'),
+ 'August' => $this->l10n->t('August'),
+ 'September' => $this->l10n->t('September'),
+ 'October' => $this->l10n->t('October'),
+ 'November' => $this->l10n->t('November'),
+ 'December' => $this->l10n->t('December'),
+ };
+ }
+
+ /**
+ * Localizes relative position names to another language
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function localizeRelativePositionName(string $value): string {
+ return match ($value) {
+ 'First' => $this->l10n->t('First'),
+ 'Second' => $this->l10n->t('Second'),
+ 'Third' => $this->l10n->t('Third'),
+ 'Fourth' => $this->l10n->t('Fourth'),
+ 'Fifth' => $this->l10n->t('Fifth'),
+ 'Last' => $this->l10n->t('Last'),
+ 'Second Last' => $this->l10n->t('Second Last'),
+ 'Third Last' => $this->l10n->t('Third Last'),
+ 'Fourth Last' => $this->l10n->t('Fourth Last'),
+ 'Fifth Last' => $this->l10n->t('Fifth Last'),
+ };
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php
index 96bacce4454..a001df8b2a8 100644
--- a/apps/dav/lib/CalDAV/Schedule/Plugin.php
+++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php
@@ -1,43 +1,30 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl>
- * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Citharel <nextcloud@tcit.fr>
- *
- * @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/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Schedule;
use DateTimeZone;
use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\CalendarHome;
+use OCA\DAV\CalDAV\CalendarObject;
+use OCA\DAV\CalDAV\DefaultCalendarValidator;
+use OCA\DAV\CalDAV\TipBroker;
use OCP\IConfig;
+use Psr\Log\LoggerInterface;
use Sabre\CalDAV\ICalendar;
+use Sabre\CalDAV\ICalendarObject;
+use Sabre\CalDAV\Schedule\ISchedulingObject;
+use Sabre\DAV\Exception as DavException;
use Sabre\DAV\INode;
use Sabre\DAV\IProperties;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\Xml\Property\LocalHref;
+use Sabre\DAVACL\IACL;
use Sabre\DAVACL\IPrincipal;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
@@ -47,18 +34,14 @@ use Sabre\VObject\Component\VEvent;
use Sabre\VObject\DateTimeParser;
use Sabre\VObject\FreeBusyGenerator;
use Sabre\VObject\ITip;
+use Sabre\VObject\ITip\SameOrganizerForAllComponentsException;
use Sabre\VObject\Parameter;
use Sabre\VObject\Property;
use Sabre\VObject\Reader;
-use function \Sabre\Uri\split;
+use function Sabre\Uri\split;
class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
- /**
- * @var IConfig
- */
- private $config;
-
/** @var ITip\Message[] */
private $schedulingResponses = [];
@@ -71,8 +54,11 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
/**
* @param IConfig $config
*/
- public function __construct(IConfig $config) {
- $this->config = $config;
+ public function __construct(
+ private IConfig $config,
+ private LoggerInterface $logger,
+ private DefaultCalendarValidator $defaultCalendarValidator,
+ ) {
}
/**
@@ -86,6 +72,20 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
$server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90);
$server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']);
$server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
+
+ // We allow mutating the default calendar URL through the CustomPropertiesBackend
+ // (oc_properties table)
+ $server->protectedProperties = array_filter(
+ $server->protectedProperties,
+ static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL,
+ );
+ }
+
+ /**
+ * Returns an instance of the iTip\Broker.
+ */
+ protected function createITipBroker(): TipBroker {
+ return new TipBroker();
}
/**
@@ -139,6 +139,11 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
$result = [];
}
+ // iterate through items and html decode values
+ foreach ($result as $key => $value) {
+ $result[$key] = urldecode($value);
+ }
+
return $result;
}
@@ -156,20 +161,91 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
$this->pathOfCalendarObjectChange = $request->getPath();
}
- parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew);
+ try {
+
+ // Do not generate iTip and iMip messages if scheduling is disabled for this message
+ if ($request->getHeader('x-nc-scheduling') === 'false') {
+ return;
+ }
+
+ if (!$this->scheduleReply($this->server->httpRequest)) {
+ return;
+ }
+
+ /** @var Calendar $calendarNode */
+ $calendarNode = $this->server->tree->getNodeForPath($calendarPath);
+ // extract addresses for owner
+ $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner());
+ // determine if request is from a sharee
+ if ($calendarNode->isShared()) {
+ // extract addresses for sharee and add to address collection
+ $addresses = array_merge(
+ $addresses,
+ $this->getAddressesForPrincipal($calendarNode->getPrincipalURI())
+ );
+ }
+ // determine if we are updating a calendar event
+ if (!$isNew) {
+ // retrieve current calendar event node
+ /** @var CalendarObject $currentNode */
+ $currentNode = $this->server->tree->getNodeForPath($request->getPath());
+ // convert calendar event string data to VCalendar object
+ /** @var \Sabre\VObject\Component\VCalendar $currentObject */
+ $currentObject = Reader::read($currentNode->get());
+ } else {
+ $currentObject = null;
+ }
+ // process request
+ $this->processICalendarChange($currentObject, $vCal, $addresses, [], $modified);
+
+ if ($currentObject) {
+ // Destroy circular references so PHP will GC the object.
+ $currentObject->destroy();
+ }
+
+ } catch (SameOrganizerForAllComponentsException $e) {
+ $this->handleSameOrganizerException($e, $vCal, $calendarPath);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function beforeUnbind($path): void {
+ try {
+ parent::beforeUnbind($path);
+ } catch (SameOrganizerForAllComponentsException $e) {
+ $node = $this->server->tree->getNodeForPath($path);
+ if (!$node instanceof ICalendarObject || $node instanceof ISchedulingObject) {
+ throw $e;
+ }
+
+ /** @var VCalendar $vCal */
+ $vCal = Reader::read($node->get());
+ $this->handleSameOrganizerException($e, $vCal, $path);
+ }
}
/**
* @inheritDoc
*/
public function scheduleLocalDelivery(ITip\Message $iTipMessage):void {
- parent::scheduleLocalDelivery($iTipMessage);
+ /** @var VEvent|null $vevent */
+ $vevent = $iTipMessage->message->VEVENT ?? null;
+
+ // Strip VALARMs from incoming VEVENT
+ if ($vevent && isset($vevent->VALARM)) {
+ $vevent->remove('VALARM');
+ }
+ parent::scheduleLocalDelivery($iTipMessage);
// We only care when the message was successfully delivered locally
+ // Log all possible codes returned from the parent method that mean something went wrong
+ // 3.7, 3.8, 5.0, 5.2
if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') {
+ $this->logger->debug('Message not delivered locally with status: ' . $iTipMessage->scheduleStatus);
return;
}
-
// We only care about request. reply and cancel are properly handled
// by parent::scheduleLocalDelivery already
if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) {
@@ -178,41 +254,38 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
// If parent::scheduleLocalDelivery set scheduleStatus to 1.2,
// it means that it was successfully delivered locally.
- // Meaning that the ACL plugin is loaded and that a principial
+ // Meaning that the ACL plugin is loaded and that a principal
// exists for the given recipient id, no need to double check
/** @var \Sabre\DAVACL\Plugin $aclPlugin */
$aclPlugin = $this->server->getPlugin('acl');
$principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient);
$calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri);
if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) {
+ $this->logger->debug('Calendar user type is neither room nor resource, not processing further');
return;
}
$attendee = $this->getCurrentAttendee($iTipMessage);
if (!$attendee) {
+ $this->logger->debug('No attendee set for scheduling message');
return;
}
// We only respond when a response was actually requested
$rsvp = $this->getAttendeeRSVP($attendee);
if (!$rsvp) {
+ $this->logger->debug('No RSVP requested for attendee ' . $attendee->getValue());
return;
}
- if (!isset($iTipMessage->message)) {
- return;
- }
-
- $vcalendar = $iTipMessage->message;
- if (!isset($vcalendar->VEVENT)) {
+ if (!$vevent) {
+ $this->logger->debug('No VEVENT set to process on scheduling message');
return;
}
- /** @var Component $vevent */
- $vevent = $vcalendar->VEVENT;
-
// We don't support autoresponses for recurrencing events for now
if (isset($vevent->RRULE) || isset($vevent->RDATE)) {
+ $this->logger->debug('VEVENT is a recurring event, autoresponding not supported');
return;
}
@@ -299,12 +372,14 @@ EOF;
return null;
}
- if (strpos($principalUrl, 'principals/users') === 0) {
+ $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources')
+ || str_starts_with($principalUrl, 'principals/calendar-rooms');
+
+ if (str_starts_with($principalUrl, 'principals/users')) {
[, $userId] = split($principalUrl);
$uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI);
$displayName = CalDavBackend::PERSONAL_CALENDAR_NAME;
- } elseif (strpos($principalUrl, 'principals/calendar-resources') === 0 ||
- strpos($principalUrl, 'principals/calendar-rooms') === 0) {
+ } elseif ($isResourceOrRoom) {
$uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI;
$displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME;
} else {
@@ -315,10 +390,65 @@ EOF;
/** @var CalendarHome $calendarHome */
$calendarHome = $this->server->tree->getNodeForPath($calendarHomePath);
- if (!$calendarHome->childExists($uri)) {
- $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, $uri, [
- '{DAV:}displayname' => $displayName,
- ]);
+ $currentCalendarDeleted = false;
+ if (!$calendarHome->childExists($uri) || $currentCalendarDeleted = $this->isCalendarDeleted($calendarHome, $uri)) {
+ // If the default calendar doesn't exist
+ if ($isResourceOrRoom) {
+ // Resources or rooms can't be in the trashbin, so we're fine
+ $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName);
+ } else {
+ // And we're not handling scheduling on resource/room booking
+ $userCalendars = [];
+ /**
+ * If the default calendar of the user isn't set and the
+ * fallback doesn't match any of the user's calendar
+ * try to find the first "personal" calendar we can write to
+ * instead of creating a new one.
+ * A appropriate personal calendar to receive invites:
+ * - isn't a calendar subscription
+ * - user can write to it (no virtual/3rd-party calendars)
+ * - calendar isn't a share
+ * - calendar supports VEVENTs
+ */
+ foreach ($calendarHome->getChildren() as $node) {
+ if (!($node instanceof Calendar)) {
+ continue;
+ }
+
+ try {
+ $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
+ } catch (DavException $e) {
+ continue;
+ }
+
+ $userCalendars[] = $node;
+ }
+
+ if (count($userCalendars) > 0) {
+ // Calendar backend returns calendar by calendarorder property
+ $uri = $userCalendars[0]->getName();
+ } else {
+ // Otherwise if we have really nothing, create a new calendar
+ if ($currentCalendarDeleted) {
+ // If the calendar exists but is in the trash bin, we try to rename its uri
+ // so that we can create the new one and still restore the previous one
+ // otherwise we just purge the calendar by removing it before recreating it
+ $calendar = $this->getCalendar($calendarHome, $uri);
+ if ($calendar instanceof Calendar) {
+ $backend = $calendarHome->getCalDAVBackend();
+ if ($backend instanceof CalDavBackend) {
+ // If the CalDAV backend supports moving calendars
+ $this->moveCalendar($backend, $principalUrl, $uri, $uri . '-back-' . time());
+ } else {
+ // Otherwise just purge the calendar
+ $calendar->disableTrashbin();
+ $calendar->delete();
+ }
+ }
+ }
+ $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName);
+ }
+ }
}
$result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1);
@@ -373,7 +503,7 @@ EOF;
* @param Property|null $attendee
* @return bool
*/
- private function getAttendeeRSVP(Property $attendee = null):bool {
+ private function getAttendeeRSVP(?Property $attendee = null):bool {
if ($attendee !== null) {
$rsvp = $attendee->offsetGet('RSVP');
if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
@@ -448,7 +578,9 @@ EOF;
$calendarTimeZone = new DateTimeZone('UTC');
$homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref();
+ /** @var Calendar $node */
foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) {
+
if (!$node instanceof ICalendar) {
continue;
}
@@ -533,7 +665,7 @@ EOF;
}
// If more than one Free-Busy property was returned, it means that an event
- // starts or ends inside this time-range, so it's not availabe and we return false
+ // starts or ends inside this time-range, so it's not available and we return false
if (count($freeBusyProperties) > 1) {
return false;
}
@@ -564,4 +696,63 @@ EOF;
return $email;
}
+
+ private function getCalendar(CalendarHome $calendarHome, string $uri): INode {
+ return $calendarHome->getChild($uri);
+ }
+
+ private function isCalendarDeleted(CalendarHome $calendarHome, string $uri): bool {
+ $calendar = $this->getCalendar($calendarHome, $uri);
+ return $calendar instanceof Calendar && $calendar->isDeleted();
+ }
+
+ private function createCalendar(CalendarHome $calendarHome, string $principalUri, string $uri, string $displayName): void {
+ $calendarHome->getCalDAVBackend()->createCalendar($principalUri, $uri, [
+ '{DAV:}displayname' => $displayName,
+ ]);
+ }
+
+ private function moveCalendar(CalDavBackend $calDavBackend, string $principalUri, string $oldUri, string $newUri): void {
+ $calDavBackend->moveCalendar($oldUri, $principalUri, $principalUri, $newUri);
+ }
+
+ /**
+ * Try to handle the given exception gracefully or throw it if necessary.
+ *
+ * @throws SameOrganizerForAllComponentsException If the exception should not be ignored
+ */
+ private function handleSameOrganizerException(
+ SameOrganizerForAllComponentsException $e,
+ VCalendar $vCal,
+ string $calendarPath,
+ ): void {
+ // This is very hacky! However, we want to allow saving events with multiple
+ // organizers. Those events are not RFC compliant, but sometimes imported from major
+ // external calendar services (e.g. Google). If the current user is not an organizer of
+ // the event we ignore the exception as no scheduling messages will be sent anyway.
+
+ // It would be cleaner to patch Sabre to validate organizers *after* checking if
+ // scheduling messages are necessary. Currently, organizers are validated first and
+ // afterwards the broker checks if messages should be scheduled. So the code will throw
+ // even if the organizers are not relevant. This is to ensure compliance with RFCs but
+ // a bit too strict for real world usage.
+
+ if (!isset($vCal->VEVENT)) {
+ throw $e;
+ }
+
+ $calendarNode = $this->server->tree->getNodeForPath($calendarPath);
+ if (!($calendarNode instanceof IACL)) {
+ // Should always be an instance of IACL but just to be sure
+ throw $e;
+ }
+
+ $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner());
+ foreach ($vCal->VEVENT as $vevent) {
+ if (in_array($vevent->ORGANIZER->getNormalizedValue(), $addresses, true)) {
+ // User is an organizer => throw the exception
+ throw $e;
+ }
+ }
+ }
}