ReminderService.php 23 KB

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