ReminderService.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\DAV\CalDAV\Reminder;
  8. use DateTimeImmutable;
  9. use DateTimeZone;
  10. use OCA\DAV\CalDAV\CalDavBackend;
  11. use OCA\DAV\Connector\Sabre\Principal;
  12. use OCP\AppFramework\Utility\ITimeFactory;
  13. use OCP\IConfig;
  14. use OCP\IGroup;
  15. use OCP\IGroupManager;
  16. use OCP\IUser;
  17. use OCP\IUserManager;
  18. use Psr\Log\LoggerInterface;
  19. use Sabre\VObject;
  20. use Sabre\VObject\Component\VAlarm;
  21. use Sabre\VObject\Component\VEvent;
  22. use Sabre\VObject\InvalidDataException;
  23. use Sabre\VObject\ParseException;
  24. use Sabre\VObject\Recur\EventIterator;
  25. use Sabre\VObject\Recur\MaxInstancesExceededException;
  26. use Sabre\VObject\Recur\NoInstancesException;
  27. use function count;
  28. use function strcasecmp;
  29. class ReminderService {
  30. public const REMINDER_TYPE_EMAIL = 'EMAIL';
  31. public const REMINDER_TYPE_DISPLAY = 'DISPLAY';
  32. public const REMINDER_TYPE_AUDIO = 'AUDIO';
  33. /**
  34. * @var String[]
  35. *
  36. * Official RFC5545 reminder types
  37. */
  38. public const REMINDER_TYPES = [
  39. self::REMINDER_TYPE_EMAIL,
  40. self::REMINDER_TYPE_DISPLAY,
  41. self::REMINDER_TYPE_AUDIO
  42. ];
  43. public function __construct(
  44. private Backend $backend,
  45. private NotificationProviderManager $notificationProviderManager,
  46. private IUserManager $userManager,
  47. private IGroupManager $groupManager,
  48. private CalDavBackend $caldavBackend,
  49. private ITimeFactory $timeFactory,
  50. private IConfig $config,
  51. private LoggerInterface $logger,
  52. private Principal $principalConnector,
  53. ) {
  54. }
  55. /**
  56. * Process reminders to activate
  57. *
  58. * @throws NotificationProvider\ProviderNotAvailableException
  59. * @throws NotificationTypeDoesNotExistException
  60. */
  61. public function processReminders() :void {
  62. $reminders = $this->backend->getRemindersToProcess();
  63. $this->logger->debug('{numReminders} reminders to process', [
  64. 'numReminders' => count($reminders),
  65. ]);
  66. foreach ($reminders as $reminder) {
  67. $calendarData = is_resource($reminder['calendardata'])
  68. ? stream_get_contents($reminder['calendardata'])
  69. : $reminder['calendardata'];
  70. if (!$calendarData) {
  71. continue;
  72. }
  73. $vcalendar = $this->parseCalendarData($calendarData);
  74. if (!$vcalendar) {
  75. $this->logger->debug('Reminder {id} does not belong to a valid calendar', [
  76. 'id' => $reminder['id'],
  77. ]);
  78. $this->backend->removeReminder($reminder['id']);
  79. continue;
  80. }
  81. try {
  82. $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']);
  83. } catch (MaxInstancesExceededException $e) {
  84. $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]);
  85. $this->backend->removeReminder($reminder['id']);
  86. continue;
  87. }
  88. if (!$vevent) {
  89. $this->logger->debug('Reminder {id} does not belong to a valid event', [
  90. 'id' => $reminder['id'],
  91. ]);
  92. $this->backend->removeReminder($reminder['id']);
  93. continue;
  94. }
  95. if ($this->wasEventCancelled($vevent)) {
  96. $this->logger->debug('Reminder {id} belongs to a cancelled event', [
  97. 'id' => $reminder['id'],
  98. ]);
  99. $this->deleteOrProcessNext($reminder, $vevent);
  100. continue;
  101. }
  102. if (!$this->notificationProviderManager->hasProvider($reminder['type'])) {
  103. $this->logger->debug('Reminder {id} does not belong to a valid notification provider', [
  104. 'id' => $reminder['id'],
  105. ]);
  106. $this->deleteOrProcessNext($reminder, $vevent);
  107. continue;
  108. }
  109. if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') {
  110. $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']);
  111. } else {
  112. $users = [];
  113. }
  114. $user = $this->getUserFromPrincipalURI($reminder['principaluri']);
  115. if ($user) {
  116. $users[] = $user;
  117. }
  118. $userPrincipalEmailAddresses = [];
  119. $userPrincipal = $this->principalConnector->getPrincipalByPath($reminder['principaluri']);
  120. if ($userPrincipal) {
  121. $userPrincipalEmailAddresses = $this->principalConnector->getEmailAddressesOfPrincipal($userPrincipal);
  122. }
  123. $this->logger->debug('Reminder {id} will be sent to {numUsers} users', [
  124. 'id' => $reminder['id'],
  125. 'numUsers' => count($users),
  126. ]);
  127. $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']);
  128. $notificationProvider->send($vevent, $reminder['displayname'], $userPrincipalEmailAddresses, $users);
  129. $this->deleteOrProcessNext($reminder, $vevent);
  130. }
  131. }
  132. /**
  133. * @param array $objectData
  134. * @throws VObject\InvalidDataException
  135. */
  136. public function onCalendarObjectCreate(array $objectData):void {
  137. // We only support VEvents for now
  138. if (strcasecmp($objectData['component'], 'vevent') !== 0) {
  139. return;
  140. }
  141. $calendarData = is_resource($objectData['calendardata'])
  142. ? stream_get_contents($objectData['calendardata'])
  143. : $objectData['calendardata'];
  144. if (!$calendarData) {
  145. return;
  146. }
  147. $vcalendar = $this->parseCalendarData($calendarData);
  148. if (!$vcalendar) {
  149. return;
  150. }
  151. $calendarTimeZone = $this->getCalendarTimeZone((int)$objectData['calendarid']);
  152. $vevents = $this->getAllVEventsFromVCalendar($vcalendar);
  153. if (count($vevents) === 0) {
  154. return;
  155. }
  156. $uid = (string)$vevents[0]->UID;
  157. $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
  158. $masterItem = $this->getMasterItemFromListOfVEvents($vevents);
  159. $now = $this->timeFactory->getDateTime();
  160. $isRecurring = $masterItem ? $this->isRecurring($masterItem) : false;
  161. foreach ($recurrenceExceptions as $recurrenceException) {
  162. $eventHash = $this->getEventHash($recurrenceException);
  163. if (!isset($recurrenceException->VALARM)) {
  164. continue;
  165. }
  166. foreach ($recurrenceException->VALARM as $valarm) {
  167. /** @var VAlarm $valarm */
  168. $alarmHash = $this->getAlarmHash($valarm);
  169. $triggerTime = $valarm->getEffectiveTriggerTime();
  170. $diff = $now->diff($triggerTime);
  171. if ($diff->invert === 1) {
  172. continue;
  173. }
  174. $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone,
  175. $eventHash, $alarmHash, true, true);
  176. $this->writeRemindersToDatabase($alarms);
  177. }
  178. }
  179. if ($masterItem) {
  180. $processedAlarms = [];
  181. $masterAlarms = [];
  182. $masterHash = $this->getEventHash($masterItem);
  183. if (!isset($masterItem->VALARM)) {
  184. return;
  185. }
  186. foreach ($masterItem->VALARM as $valarm) {
  187. $masterAlarms[] = $this->getAlarmHash($valarm);
  188. }
  189. try {
  190. $iterator = new EventIterator($vevents, $uid);
  191. } catch (NoInstancesException $e) {
  192. // This event is recurring, but it doesn't have a single
  193. // instance. We are skipping this event from the output
  194. // entirely.
  195. return;
  196. } catch (MaxInstancesExceededException $e) {
  197. // The event has more than 3500 recurring-instances
  198. // so we can ignore it
  199. return;
  200. }
  201. while ($iterator->valid() && count($processedAlarms) < count($masterAlarms)) {
  202. $event = $iterator->getEventObject();
  203. // Recurrence-exceptions are handled separately, so just ignore them here
  204. if (\in_array($event, $recurrenceExceptions, true)) {
  205. $iterator->next();
  206. continue;
  207. }
  208. foreach ($event->VALARM as $valarm) {
  209. /** @var VAlarm $valarm */
  210. $alarmHash = $this->getAlarmHash($valarm);
  211. if (\in_array($alarmHash, $processedAlarms, true)) {
  212. continue;
  213. }
  214. if (!\in_array((string)$valarm->ACTION, self::REMINDER_TYPES, true)) {
  215. // Action allows x-name, we don't insert reminders
  216. // into the database if they are not standard
  217. $processedAlarms[] = $alarmHash;
  218. continue;
  219. }
  220. try {
  221. $triggerTime = $valarm->getEffectiveTriggerTime();
  222. /**
  223. * @psalm-suppress DocblockTypeContradiction
  224. * https://github.com/vimeo/psalm/issues/9244
  225. */
  226. if ($triggerTime->getTimezone() === false || $triggerTime->getTimezone()->getName() === 'UTC') {
  227. $triggerTime = new DateTimeImmutable(
  228. $triggerTime->format('Y-m-d H:i:s'),
  229. $calendarTimeZone
  230. );
  231. }
  232. } catch (InvalidDataException $e) {
  233. continue;
  234. }
  235. // If effective trigger time is in the past
  236. // just skip and generate for next event
  237. $diff = $now->diff($triggerTime);
  238. if ($diff->invert === 1) {
  239. // If an absolute alarm is in the past,
  240. // just add it to processedAlarms, so
  241. // we don't extend till eternity
  242. if (!$this->isAlarmRelative($valarm)) {
  243. $processedAlarms[] = $alarmHash;
  244. }
  245. continue;
  246. }
  247. $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false);
  248. $this->writeRemindersToDatabase($alarms);
  249. $processedAlarms[] = $alarmHash;
  250. }
  251. $iterator->next();
  252. }
  253. }
  254. }
  255. /**
  256. * @param array $objectData
  257. * @throws VObject\InvalidDataException
  258. */
  259. public function onCalendarObjectEdit(array $objectData):void {
  260. // TODO - this can be vastly improved
  261. // - get cached reminders
  262. // - ...
  263. $this->onCalendarObjectDelete($objectData);
  264. $this->onCalendarObjectCreate($objectData);
  265. }
  266. /**
  267. * @param array $objectData
  268. * @throws VObject\InvalidDataException
  269. */
  270. public function onCalendarObjectDelete(array $objectData):void {
  271. // We only support VEvents for now
  272. if (strcasecmp($objectData['component'], 'vevent') !== 0) {
  273. return;
  274. }
  275. $this->backend->cleanRemindersForEvent((int)$objectData['id']);
  276. }
  277. /**
  278. * @param VAlarm $valarm
  279. * @param array $objectData
  280. * @param DateTimeZone $calendarTimeZone
  281. * @param string|null $eventHash
  282. * @param string|null $alarmHash
  283. * @param bool $isRecurring
  284. * @param bool $isRecurrenceException
  285. * @return array
  286. */
  287. private function getRemindersForVAlarm(VAlarm $valarm,
  288. array $objectData,
  289. DateTimeZone $calendarTimeZone,
  290. ?string $eventHash = null,
  291. ?string $alarmHash = null,
  292. bool $isRecurring = false,
  293. bool $isRecurrenceException = false):array {
  294. if ($eventHash === null) {
  295. $eventHash = $this->getEventHash($valarm->parent);
  296. }
  297. if ($alarmHash === null) {
  298. $alarmHash = $this->getAlarmHash($valarm);
  299. }
  300. $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($valarm->parent);
  301. $isRelative = $this->isAlarmRelative($valarm);
  302. /** @var DateTimeImmutable $notificationDate */
  303. $notificationDate = $valarm->getEffectiveTriggerTime();
  304. /**
  305. * @psalm-suppress DocblockTypeContradiction
  306. * https://github.com/vimeo/psalm/issues/9244
  307. */
  308. if ($notificationDate->getTimezone() === false || $notificationDate->getTimezone()->getName() === 'UTC') {
  309. $notificationDate = new DateTimeImmutable(
  310. $notificationDate->format('Y-m-d H:i:s'),
  311. $calendarTimeZone
  312. );
  313. }
  314. $clonedNotificationDate = new \DateTime('now', $notificationDate->getTimezone());
  315. $clonedNotificationDate->setTimestamp($notificationDate->getTimestamp());
  316. $alarms = [];
  317. $alarms[] = [
  318. 'calendar_id' => $objectData['calendarid'],
  319. 'object_id' => $objectData['id'],
  320. 'uid' => (string)$valarm->parent->UID,
  321. 'is_recurring' => $isRecurring,
  322. 'recurrence_id' => $recurrenceId,
  323. 'is_recurrence_exception' => $isRecurrenceException,
  324. 'event_hash' => $eventHash,
  325. 'alarm_hash' => $alarmHash,
  326. 'type' => (string)$valarm->ACTION,
  327. 'is_relative' => $isRelative,
  328. 'notification_date' => $notificationDate->getTimestamp(),
  329. 'is_repeat_based' => false,
  330. ];
  331. $repeat = isset($valarm->REPEAT) ? (int)$valarm->REPEAT->getValue() : 0;
  332. for ($i = 0; $i < $repeat; $i++) {
  333. if ($valarm->DURATION === null) {
  334. continue;
  335. }
  336. $clonedNotificationDate->add($valarm->DURATION->getDateInterval());
  337. $alarms[] = [
  338. 'calendar_id' => $objectData['calendarid'],
  339. 'object_id' => $objectData['id'],
  340. 'uid' => (string)$valarm->parent->UID,
  341. 'is_recurring' => $isRecurring,
  342. 'recurrence_id' => $recurrenceId,
  343. 'is_recurrence_exception' => $isRecurrenceException,
  344. 'event_hash' => $eventHash,
  345. 'alarm_hash' => $alarmHash,
  346. 'type' => (string)$valarm->ACTION,
  347. 'is_relative' => $isRelative,
  348. 'notification_date' => $clonedNotificationDate->getTimestamp(),
  349. 'is_repeat_based' => true,
  350. ];
  351. }
  352. return $alarms;
  353. }
  354. /**
  355. * @param array $reminders
  356. */
  357. private function writeRemindersToDatabase(array $reminders): void {
  358. $uniqueReminders = [];
  359. foreach ($reminders as $reminder) {
  360. $key = $reminder['notification_date'] . $reminder['event_hash'] . $reminder['type'];
  361. if (!isset($uniqueReminders[$key])) {
  362. $uniqueReminders[$key] = $reminder;
  363. }
  364. }
  365. foreach (array_values($uniqueReminders) as $reminder) {
  366. $this->backend->insertReminder(
  367. (int)$reminder['calendar_id'],
  368. (int)$reminder['object_id'],
  369. $reminder['uid'],
  370. $reminder['is_recurring'],
  371. (int)$reminder['recurrence_id'],
  372. $reminder['is_recurrence_exception'],
  373. $reminder['event_hash'],
  374. $reminder['alarm_hash'],
  375. $reminder['type'],
  376. $reminder['is_relative'],
  377. (int)$reminder['notification_date'],
  378. $reminder['is_repeat_based']
  379. );
  380. }
  381. }
  382. /**
  383. * @param array $reminder
  384. * @param VEvent $vevent
  385. */
  386. private function deleteOrProcessNext(array $reminder,
  387. VObject\Component\VEvent $vevent):void {
  388. if ($reminder['is_repeat_based'] ||
  389. !$reminder['is_recurring'] ||
  390. !$reminder['is_relative'] ||
  391. $reminder['is_recurrence_exception']) {
  392. $this->backend->removeReminder($reminder['id']);
  393. return;
  394. }
  395. $vevents = $this->getAllVEventsFromVCalendar($vevent->parent);
  396. $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
  397. $now = $this->timeFactory->getDateTime();
  398. $calendarTimeZone = $this->getCalendarTimeZone((int)$reminder['calendar_id']);
  399. try {
  400. $iterator = new EventIterator($vevents, $reminder['uid']);
  401. } catch (NoInstancesException $e) {
  402. // This event is recurring, but it doesn't have a single
  403. // instance. We are skipping this event from the output
  404. // entirely.
  405. return;
  406. }
  407. try {
  408. while ($iterator->valid()) {
  409. $event = $iterator->getEventObject();
  410. // Recurrence-exceptions are handled separately, so just ignore them here
  411. if (\in_array($event, $recurrenceExceptions, true)) {
  412. $iterator->next();
  413. continue;
  414. }
  415. $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event);
  416. if ($reminder['recurrence_id'] >= $recurrenceId) {
  417. $iterator->next();
  418. continue;
  419. }
  420. foreach ($event->VALARM as $valarm) {
  421. /** @var VAlarm $valarm */
  422. $alarmHash = $this->getAlarmHash($valarm);
  423. if ($alarmHash !== $reminder['alarm_hash']) {
  424. continue;
  425. }
  426. $triggerTime = $valarm->getEffectiveTriggerTime();
  427. // If effective trigger time is in the past
  428. // just skip and generate for next event
  429. $diff = $now->diff($triggerTime);
  430. if ($diff->invert === 1) {
  431. continue;
  432. }
  433. $this->backend->removeReminder($reminder['id']);
  434. $alarms = $this->getRemindersForVAlarm($valarm, [
  435. 'calendarid' => $reminder['calendar_id'],
  436. 'id' => $reminder['object_id'],
  437. ], $calendarTimeZone, $reminder['event_hash'], $alarmHash, true, false);
  438. $this->writeRemindersToDatabase($alarms);
  439. // Abort generating reminders after creating one successfully
  440. return;
  441. }
  442. $iterator->next();
  443. }
  444. } catch (MaxInstancesExceededException $e) {
  445. // Using debug logger as this isn't really an error
  446. $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]);
  447. }
  448. $this->backend->removeReminder($reminder['id']);
  449. }
  450. /**
  451. * @param int $calendarId
  452. * @return IUser[]
  453. */
  454. private function getAllUsersWithWriteAccessToCalendar(int $calendarId):array {
  455. $shares = $this->caldavBackend->getShares($calendarId);
  456. $users = [];
  457. $userIds = [];
  458. $groups = [];
  459. foreach ($shares as $share) {
  460. // Only consider writable shares
  461. if ($share['readOnly']) {
  462. continue;
  463. }
  464. $principal = explode('/', $share['{http://owncloud.org/ns}principal']);
  465. if ($principal[1] === 'users') {
  466. $user = $this->userManager->get($principal[2]);
  467. if ($user) {
  468. $users[] = $user;
  469. $userIds[] = $principal[2];
  470. }
  471. } elseif ($principal[1] === 'groups') {
  472. $groups[] = $principal[2];
  473. }
  474. }
  475. foreach ($groups as $gid) {
  476. $group = $this->groupManager->get($gid);
  477. if ($group instanceof IGroup) {
  478. foreach ($group->getUsers() as $user) {
  479. if (!\in_array($user->getUID(), $userIds, true)) {
  480. $users[] = $user;
  481. $userIds[] = $user->getUID();
  482. }
  483. }
  484. }
  485. }
  486. return $users;
  487. }
  488. /**
  489. * Gets a hash of the event.
  490. * If the hash changes, we have to update all relative alarms.
  491. *
  492. * @param VEvent $vevent
  493. * @return string
  494. */
  495. private function getEventHash(VEvent $vevent):string {
  496. $properties = [
  497. (string)$vevent->DTSTART->serialize(),
  498. ];
  499. if ($vevent->DTEND) {
  500. $properties[] = (string)$vevent->DTEND->serialize();
  501. }
  502. if ($vevent->DURATION) {
  503. $properties[] = (string)$vevent->DURATION->serialize();
  504. }
  505. if ($vevent->{'RECURRENCE-ID'}) {
  506. $properties[] = (string)$vevent->{'RECURRENCE-ID'}->serialize();
  507. }
  508. if ($vevent->RRULE) {
  509. $properties[] = (string)$vevent->RRULE->serialize();
  510. }
  511. if ($vevent->EXDATE) {
  512. $properties[] = (string)$vevent->EXDATE->serialize();
  513. }
  514. if ($vevent->RDATE) {
  515. $properties[] = (string)$vevent->RDATE->serialize();
  516. }
  517. return md5(implode('::', $properties));
  518. }
  519. /**
  520. * Gets a hash of the alarm.
  521. * If the hash changes, we have to update oc_dav_reminders.
  522. *
  523. * @param VAlarm $valarm
  524. * @return string
  525. */
  526. private function getAlarmHash(VAlarm $valarm):string {
  527. $properties = [
  528. (string)$valarm->ACTION->serialize(),
  529. (string)$valarm->TRIGGER->serialize(),
  530. ];
  531. if ($valarm->DURATION) {
  532. $properties[] = (string)$valarm->DURATION->serialize();
  533. }
  534. if ($valarm->REPEAT) {
  535. $properties[] = (string)$valarm->REPEAT->serialize();
  536. }
  537. return md5(implode('::', $properties));
  538. }
  539. /**
  540. * @param VObject\Component\VCalendar $vcalendar
  541. * @param int $recurrenceId
  542. * @param bool $isRecurrenceException
  543. * @return VEvent|null
  544. */
  545. private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar,
  546. int $recurrenceId,
  547. bool $isRecurrenceException):?VEvent {
  548. $vevents = $this->getAllVEventsFromVCalendar($vcalendar);
  549. if (count($vevents) === 0) {
  550. return null;
  551. }
  552. $uid = (string)$vevents[0]->UID;
  553. $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
  554. $masterItem = $this->getMasterItemFromListOfVEvents($vevents);
  555. // Handle recurrence-exceptions first, because recurrence-expansion is expensive
  556. if ($isRecurrenceException) {
  557. foreach ($recurrenceExceptions as $recurrenceException) {
  558. if ($this->getEffectiveRecurrenceIdOfVEvent($recurrenceException) === $recurrenceId) {
  559. return $recurrenceException;
  560. }
  561. }
  562. return null;
  563. }
  564. if ($masterItem) {
  565. try {
  566. $iterator = new EventIterator($vevents, $uid);
  567. } catch (NoInstancesException $e) {
  568. // This event is recurring, but it doesn't have a single
  569. // instance. We are skipping this event from the output
  570. // entirely.
  571. return null;
  572. }
  573. while ($iterator->valid()) {
  574. $event = $iterator->getEventObject();
  575. // Recurrence-exceptions are handled separately, so just ignore them here
  576. if (\in_array($event, $recurrenceExceptions, true)) {
  577. $iterator->next();
  578. continue;
  579. }
  580. if ($this->getEffectiveRecurrenceIdOfVEvent($event) === $recurrenceId) {
  581. return $event;
  582. }
  583. $iterator->next();
  584. }
  585. }
  586. return null;
  587. }
  588. /**
  589. * @param VEvent $vevent
  590. * @return string
  591. */
  592. private function getStatusOfEvent(VEvent $vevent):string {
  593. if ($vevent->STATUS) {
  594. return (string)$vevent->STATUS;
  595. }
  596. // Doesn't say so in the standard,
  597. // but we consider events without a status
  598. // to be confirmed
  599. return 'CONFIRMED';
  600. }
  601. /**
  602. * @param VObject\Component\VEvent $vevent
  603. * @return bool
  604. */
  605. private function wasEventCancelled(VObject\Component\VEvent $vevent):bool {
  606. return $this->getStatusOfEvent($vevent) === 'CANCELLED';
  607. }
  608. /**
  609. * @param string $calendarData
  610. * @return VObject\Component\VCalendar|null
  611. */
  612. private function parseCalendarData(string $calendarData):?VObject\Component\VCalendar {
  613. try {
  614. return VObject\Reader::read($calendarData,
  615. VObject\Reader::OPTION_FORGIVING);
  616. } catch (ParseException $ex) {
  617. return null;
  618. }
  619. }
  620. /**
  621. * @param string $principalUri
  622. * @return IUser|null
  623. */
  624. private function getUserFromPrincipalURI(string $principalUri):?IUser {
  625. if (!$principalUri) {
  626. return null;
  627. }
  628. if (stripos($principalUri, 'principals/users/') !== 0) {
  629. return null;
  630. }
  631. $userId = substr($principalUri, 17);
  632. return $this->userManager->get($userId);
  633. }
  634. /**
  635. * @param VObject\Component\VCalendar $vcalendar
  636. * @return VObject\Component\VEvent[]
  637. */
  638. private function getAllVEventsFromVCalendar(VObject\Component\VCalendar $vcalendar):array {
  639. $vevents = [];
  640. foreach ($vcalendar->children() as $child) {
  641. if (!($child instanceof VObject\Component)) {
  642. continue;
  643. }
  644. if ($child->name !== 'VEVENT') {
  645. continue;
  646. }
  647. // Ignore invalid events with no DTSTART
  648. if ($child->DTSTART === null) {
  649. continue;
  650. }
  651. $vevents[] = $child;
  652. }
  653. return $vevents;
  654. }
  655. /**
  656. * @param array $vevents
  657. * @return VObject\Component\VEvent[]
  658. */
  659. private function getRecurrenceExceptionFromListOfVEvents(array $vevents):array {
  660. return array_values(array_filter($vevents, function (VEvent $vevent) {
  661. return $vevent->{'RECURRENCE-ID'} !== null;
  662. }));
  663. }
  664. /**
  665. * @param array $vevents
  666. * @return VEvent|null
  667. */
  668. private function getMasterItemFromListOfVEvents(array $vevents):?VEvent {
  669. $elements = array_values(array_filter($vevents, function (VEvent $vevent) {
  670. return $vevent->{'RECURRENCE-ID'} === null;
  671. }));
  672. if (count($elements) === 0) {
  673. return null;
  674. }
  675. if (count($elements) > 1) {
  676. throw new \TypeError('Multiple master objects');
  677. }
  678. return $elements[0];
  679. }
  680. /**
  681. * @param VAlarm $valarm
  682. * @return bool
  683. */
  684. private function isAlarmRelative(VAlarm $valarm):bool {
  685. $trigger = $valarm->TRIGGER;
  686. return $trigger instanceof VObject\Property\ICalendar\Duration;
  687. }
  688. /**
  689. * @param VEvent $vevent
  690. * @return int
  691. */
  692. private function getEffectiveRecurrenceIdOfVEvent(VEvent $vevent):int {
  693. if (isset($vevent->{'RECURRENCE-ID'})) {
  694. return $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp();
  695. }
  696. return $vevent->DTSTART->getDateTime()->getTimestamp();
  697. }
  698. /**
  699. * @param VEvent $vevent
  700. * @return bool
  701. */
  702. private function isRecurring(VEvent $vevent):bool {
  703. return isset($vevent->RRULE) || isset($vevent->RDATE);
  704. }
  705. /**
  706. * @param int $calendarid
  707. *
  708. * @return DateTimeZone
  709. */
  710. private function getCalendarTimeZone(int $calendarid): DateTimeZone {
  711. $calendarInfo = $this->caldavBackend->getCalendarById($calendarid);
  712. $tzProp = '{urn:ietf:params:xml:ns:caldav}calendar-timezone';
  713. if (empty($calendarInfo[$tzProp])) {
  714. // Defaulting to UTC
  715. return new DateTimeZone('UTC');
  716. }
  717. // This property contains a VCALENDAR with a single VTIMEZONE
  718. /** @var string $timezoneProp */
  719. $timezoneProp = $calendarInfo[$tzProp];
  720. /** @var VObject\Component\VCalendar $vtimezoneObj */
  721. $vtimezoneObj = VObject\Reader::read($timezoneProp);
  722. /** @var VObject\Component\VTimeZone $vtimezone */
  723. $vtimezone = $vtimezoneObj->VTIMEZONE;
  724. return $vtimezone->getTimeZone();
  725. }
  726. }