diff options
Diffstat (limited to 'apps/dav/lib/CalDAV/Schedule')
-rw-r--r-- | apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 216 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Schedule/IMipService.php | 921 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/Schedule/Plugin.php | 142 |
3 files changed, 966 insertions, 313 deletions
diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index fcc2ae1e166..2af6b162d8d 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -1,38 +1,10 @@ <?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/). - * @copyright 2022 Anna Larch <anna.larch@gmx.net> - * - * @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> - * @author Anna Larch <anna.larch@gmx.net> - * - * @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; @@ -40,9 +12,13 @@ use OCA\DAV\CalDAV\CalendarObject; use OCA\DAV\CalDAV\EventComparisonService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Defaults; -use OCP\IConfig; -use OCP\IUserManager; +use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Mail\IMailer; +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; @@ -69,41 +45,26 @@ use Sabre\VObject\Reader; * @license http://sabre.io/license/ Modified BSD License */ class IMipPlugin extends SabreIMipPlugin { - private ?string $userId; - private IConfig $config; - private IMailer $mailer; - private LoggerInterface $logger; - private ITimeFactory $timeFactory; - private Defaults $defaults; - private IUserManager $userManager; + private ?VCalendar $vCalendar = null; - private IMipService $imipService; public const MAX_DATE = '2038-01-01'; public const METHOD_REQUEST = 'request'; public const METHOD_REPLY = 'reply'; public const METHOD_CANCEL = 'cancel'; - public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages - private EventComparisonService $eventComparisonService; - - public function __construct(IConfig $config, - IMailer $mailer, - LoggerInterface $logger, - ITimeFactory $timeFactory, - Defaults $defaults, - IUserManager $userManager, - $userId, - IMipService $imipService, - EventComparisonService $eventComparisonService) { + 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(''); - $this->userId = $userId; - $this->config = $config; - $this->mailer = $mailer; - $this->logger = $logger; - $this->timeFactory = $timeFactory; - $this->defaults = $defaults; - $this->userManager = $userManager; - $this->imipService = $imipService; - $this->eventComparisonService = $eventComparisonService; } public function initialize(DAV\Server $server): void { @@ -120,7 +81,7 @@ class IMipPlugin extends SabreIMipPlugin { * @param bool $modified modified */ public function beforeWriteContent($uri, INode $node, $data, $modified): void { - if(!$node instanceof CalendarObject) { + if (!$node instanceof CalendarObject) { return; } /** @var VCalendar $vCalendar */ @@ -135,8 +96,8 @@ class IMipPlugin extends SabreIMipPlugin { * @return void */ 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'; @@ -177,7 +138,7 @@ class IMipPlugin extends SabreIMipPlugin { // 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)) { + 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; @@ -189,15 +150,16 @@ class IMipPlugin extends SabreIMipPlugin { // 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) { + 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 things - if($this->imipService->isRoomOrResource($attendee)) { - $this->logger->debug('No invitation sent as recipient is room or resource', [ + // 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'; @@ -206,17 +168,16 @@ class IMipPlugin extends SabreIMipPlugin { $this->imipService->setL10n($attendee); // Build the sender name. - // Due to a bug in sabre, the senderName property for an iTIP message - // can actually also be a VObject Property - /** @var Parameter|string|null $senderName */ - $senderName = $iTipMessage->senderName ?: null; - if($senderName instanceof Parameter) { - $senderName = $senderName->getValue() ?? null; - } - - // Try to get the sender name from the current user id if available. - if ($this->userId !== null && ($senderName === null || empty(trim($senderName)))) { - $senderName = $this->userManager->getDisplayName($this->userId); + // 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 = ''; } $sender = substr($iTipMessage->sender, 7); @@ -225,7 +186,7 @@ class IMipPlugin extends SabreIMipPlugin { switch (strtolower($iTipMessage->method)) { case self::METHOD_REPLY: $method = self::METHOD_REPLY; - $data = $this->imipService->buildBodyData($vEvent, $oldVevent); + $data = $this->imipService->buildReplyBodyData($vEvent); $replyingAttendee = $this->imipService->getReplyingAttendee($iTipMessage); break; case self::METHOD_CANCEL: @@ -244,21 +205,6 @@ class IMipPlugin extends SabreIMipPlugin { $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); $fromName = $this->imipService->getFrom($senderName, $this->defaults->getName()); - $message = $this->mailer->createMessage() - ->setFrom([$fromEMail => $fromName]); - - if ($recipientName !== null) { - $message->setTo([$recipient => $recipientName]); - } else { - $message->setTo([$recipient]); - } - - if ($senderName !== null) { - $message->setReplyTo([$sender => $senderName]); - } else { - $message->setReplyTo([$sender]); - } - $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); $template->addHeader(); @@ -288,7 +234,7 @@ class IMipPlugin extends SabreIMipPlugin { */ $recipientDomain = substr(strrchr($recipient, '@'), 1); - $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes')))); + $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) @@ -300,18 +246,70 @@ class IMipPlugin extends SabreIMipPlugin { } $template->addFooter(); - - $message->useTemplate($template); - + // convert iTip Message to string $itip_msg = $iTipMessage->message->serialize(); - $message->attachInline( - $itip_msg, - 'event.ics', - 'text/calendar; method=' . $iTipMessage->method, - ); + + $mailService = null; try { - $failed = $this->mailer->send($message); + 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); + } + } + + // 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); + } + $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)]); diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php index 4cd859d79ac..54c0bc31849 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipService.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -1,31 +1,16 @@ <?php declare(strict_types=1); -/* - * DAV App - * - * @copyright 2022 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU AFFERO GENERAL PUBLIC LICENSE for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with this library. If not, see <http://www.gnu.org/licenses/>. +/** + * 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; @@ -42,11 +27,6 @@ use Sabre\VObject\Recur\EventIterator; class IMipService { - private URLGenerator $urlGenerator; - private IConfig $config; - private IDBConnection $db; - private ISecureRandom $random; - private L10NFactory $l10nFactory; private IL10N $l10n; /** @var string[] */ @@ -57,18 +37,17 @@ class IMipService { 'meeting_location' => 'LOCATION' ]; - public function __construct(URLGenerator $urlGenerator, - IConfig $config, - IDBConnection $db, - ISecureRandom $random, - L10NFactory $l10nFactory) { - $this->urlGenerator = $urlGenerator; - $this->config = $config; - $this->db = $db; - $this->random = $random; - $this->l10nFactory = $l10nFactory; - $default = $this->l10nFactory->findGenericLanguage(); - $this->l10n = $this->l10nFactory->get('dav', $default); + 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); } /** @@ -100,7 +79,7 @@ class IMipService { return $default; } $newstring = $vevent->$property->getValue(); - if(isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) { + if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) { $oldstring = $oldVEvent->$property->getValue(); return sprintf($strikethrough, $oldstring, $newstring); } @@ -147,11 +126,15 @@ class IMipService { * @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($vEvent); + $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent); - foreach(self::STRING_DIFF as $key => $property) { + foreach (self::STRING_DIFF as $key => $property) { $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); } @@ -161,8 +144,8 @@ class IMipService { $data['meeting_location_html'] = $locationHtml; } - if(!empty($oldVEvent)) { - $oldMeetingWhen = $this->generateWhenString($oldVEvent); + 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']); @@ -170,107 +153,635 @@ class IMipService { $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'] && $oldMeetingWhen !== null) - ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) - : $data['meeting_when']; + $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 IL10N $this->l10n - * @param VEvent $vevent - * @return false|int|string + * @param VEvent $vEvent + * @return array */ - public function generateWhenString(VEvent $vevent) { - /** @var Property\ICalendar\DateTime $dtstart */ - $dtstart = $vevent->DTSTART; - if (isset($vevent->DTEND)) { - /** @var Property\ICalendar\DateTime $dtend */ - $dtend = $vevent->DTEND; - } elseif (isset($vevent->DURATION)) { - $isFloating = $dtstart->isFloating(); - $dtend = clone $dtstart; - $endDateTime = $dtend->getDateTime(); - $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); - $dtend->setDateTime($endDateTime, $isFloating); - } elseif (!$dtstart->hasTime()) { - $isFloating = $dtstart->isFloating(); - $dtend = clone $dtstart; - $endDateTime = $dtend->getDateTime(); - $endDateTime = $endDateTime->modify('+1 day'); - $dtend->setDateTime($endDateTime, $isFloating); - } else { - $dtend = clone $dtstart; + 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); } - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ - /** @var \DateTimeImmutable $dtstartDt */ - $dtstartDt = $dtstart->getDateTime(); + if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) { + $data['meeting_location_html'] = $locationHtml; + } - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ - /** @var \DateTimeImmutable $dtendDt */ - $dtendDt = $dtend->getDateTime(); + $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : ''; - $diff = $dtstartDt->diff($dtendDt); + // generate occurring next string + if ($eventReader->recurs()) { + $data['meeting_occurring'] = $this->generateOccurringString($eventReader); + } - $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); - $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); + return $data; + } - if ($dtstart instanceof Property\ICalendar\Date) { - // One day event - if ($diff->days === 1) { - return $this->l10n->l('date', $dtstartDt, ['width' => 'medium']); - } + /** + * 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) + }; + } - // 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'); + /** + * 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') + }; + } - //event that spans over multiple days - $localeStart = $this->l10n->l('date', $dtstartDt, ['width' => 'medium']); - $localeEnd = $this->l10n->l('date', $dtendDt, ['width' => 'medium']); + /** + * 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), + }; + } - return $localeStart . ' - ' . $localeEnd; + /** + * 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') + }; - /** @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(); - } + /** + * 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') + }; - $localeStart = $this->l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . - $this->l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); - - // always show full date with timezone if timezones are different - if ($startTimezone !== $endTimezone) { - $localeEnd = $this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); + } - return $localeStart . ' (' . $startTimezone . ') - ' . - $localeEnd . ' (' . $endTimezone . ')'; + /** + * 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') + }; + } - // show only end time if date is the same - if ($dtstartDt->format('Y-m-d') === $dtendDt->format('Y-m-d')) { - $localeEnd = $this->l10n->l('time', $dtendDt, ['width' => 'short']); + /** + * 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 { - $localeEnd = $this->l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . - $this->l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); + $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') + }; - return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; } /** @@ -278,12 +789,13 @@ class IMipService { * @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($vEvent); + $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; @@ -337,7 +849,7 @@ class IMipService { return $dtEnd->getDateTime()->getTimeStamp(); } - if(isset($component->DURATION)) { + if (isset($component->DURATION)) { /** @var \DateTime $endDate */ $endDate = clone $dtStart->getDateTime(); // $component->DTEND->getDateTime() returns DateTimeImmutable @@ -345,7 +857,7 @@ class IMipService { return $endDate->getTimestamp(); } - if(!$dtStart->hasTime()) { + if (!$dtStart->hasTime()) { /** @var \DateTime $endDate */ // $component->DTSTART->getDateTime() returns DateTimeImmutable $endDate = clone $dtStart->getDateTime(); @@ -361,7 +873,7 @@ class IMipService { * @param Property|null $attendee */ public function setL10n(?Property $attendee = null) { - if($attendee === null) { + if ($attendee === null) { return; } @@ -377,7 +889,7 @@ class IMipService { * @return bool */ public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) { - if($attendee === null) { + if ($attendee === null) { return false; } @@ -485,10 +997,10 @@ class IMipService { htmlspecialchars($organizer->getNormalizedValue()), htmlspecialchars($organizerName ?: $organizerEmail)); $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); - if(isset($organizer['PARTSTAT'])) { + if (isset($organizer['PARTSTAT'])) { /** @var Parameter $partstat */ $partstat = $organizer['PARTSTAT']; - if(strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { + if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { $organizerHTML .= ' ✔︎'; $organizerText .= ' ✔︎'; } @@ -539,7 +1051,7 @@ class IMipService { $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('Time:'), + $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'] !== '') { @@ -550,6 +1062,10 @@ class IMipService { $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); @@ -569,8 +1085,11 @@ class IMipService { $vevent = $iTipMessage->message->VEVENT; $attendees = $vevent->select('ATTENDEE'); foreach ($attendees as $attendee) { - /** @var Property $attendee */ - if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { + 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; } } @@ -589,9 +1108,9 @@ class IMipService { $attendee = $iTipMessage->recipient; $organizer = $iTipMessage->sender; $sequence = $iTipMessage->sequence; - $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? - $vevent->{'RECURRENCE-ID'}->serialize() : null; - $uid = $vevent->{'UID'}; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) + ? $vevent->{'RECURRENCE-ID'}->serialize() : null; + $uid = $vevent->{'UID'}?->getValue(); $query = $this->db->getQueryBuilder(); $query->insert('calendar_invitations') @@ -604,37 +1123,12 @@ class IMipService { 'expiration' => $query->createNamedParameter($lastOccurrence), 'uid' => $query->createNamedParameter($uid) ]) - ->execute(); + ->executeStatement(); return $token; } /** - * Create a valid VCalendar object out of the details of - * a VEvent and its associated iTip Message - * - * We do this to filter out all unchanged VEvents - * This is especially important in iTip Messages with recurrences - * and recurrence exceptions - * - * @param Message $iTipMessage - * @param VEvent $vEvent - * @return VCalendar - */ - public function generateVCalendar(Message $iTipMessage, VEvent $vEvent): VCalendar { - $vCalendar = new VCalendar(); - $vCalendar->add('METHOD', $iTipMessage->method); - foreach ($iTipMessage->message->getComponents() as $component) { - if ($component instanceof VEvent) { - continue; - } - $vCalendar->add(clone $component); - } - $vCalendar->add($vEvent); - return $vCalendar; - } - - /** * @param IEMailTemplate $template * @param $token */ @@ -678,14 +1172,123 @@ class IMipService { public function isRoomOrResource(Property $attendee): bool { $cuType = $attendee->offsetGet('CUTYPE'); - if(!$cuType instanceof Parameter) { + if (!$cuType instanceof Parameter) { return false; } $type = $cuType->getValue() ?? 'INDIVIDUAL'; - if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM', 'UNKNOWN'], true)) { + 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 a1297fd2cf1..a001df8b2a8 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -1,31 +1,8 @@ <?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> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * - * @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; @@ -33,11 +10,15 @@ 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; @@ -61,11 +42,6 @@ use function Sabre\Uri\split; class Plugin extends \Sabre\CalDAV\Schedule\Plugin { - /** - * @var IConfig - */ - private $config; - /** @var ITip\Message[] */ private $schedulingResponses = []; @@ -74,14 +50,15 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type'; public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL'; - private LoggerInterface $logger; /** * @param IConfig $config */ - public function __construct(IConfig $config, LoggerInterface $logger) { - $this->config = $config; - $this->logger = $logger; + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + private DefaultCalendarValidator $defaultCalendarValidator, + ) { } /** @@ -105,6 +82,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { } /** + * Returns an instance of the iTip\Broker. + */ + protected function createITipBroker(): TipBroker { + return new TipBroker(); + } + + /** * Allow manual setting of the object change URL * to support public write * @@ -155,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; } @@ -173,7 +162,47 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { } try { - parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew); + + // 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); } @@ -232,7 +261,7 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $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 room or resource, not processing further'); + $this->logger->debug('Calendar user type is neither room nor resource, not processing further'); return; } @@ -343,8 +372,8 @@ EOF; return null; } - $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources') || - str_starts_with($principalUrl, 'principals/calendar-rooms'); + $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); @@ -379,11 +408,20 @@ EOF; * - 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 && !$node->isSubscription() && $node->canWrite() && !$node->isShared() && !$node->isDeleted()) { - $userCalendars[] = $node; + if (!($node instanceof Calendar)) { + continue; } + + try { + $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node); + } catch (DavException $e) { + continue; + } + + $userCalendars[] = $node; } if (count($userCalendars) > 0) { @@ -392,12 +430,20 @@ EOF; } else { // Otherwise if we have really nothing, create a new calendar if ($currentCalendarDeleted) { - // If the calendar exists but is deleted, we need to purge it first - // This may cause some issues in a non synchronous database setup + // 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) { - $calendar->disableTrashbin(); - $calendar->delete(); + $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); @@ -532,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; } @@ -664,6 +712,10 @@ EOF; ]); } + 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. * |