From e406f778e2be40d2d042970d326c155f40e9eb61 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sat, 7 Mar 2026 00:58:39 +0100 Subject: [PATCH 1/2] feat: add event reference provider and widget Fixes: #7104 Signed-off-by: Jonas --- lib/AppInfo/Application.php | 3 + lib/Reference/EventReferenceProvider.php | 207 +++++++++++++++++++++++ psalm.xml | 2 + src/reference.js | 13 ++ src/views/EventReferenceWidget.vue | 163 ++++++++++++++++++ 5 files changed, 388 insertions(+) create mode 100644 lib/Reference/EventReferenceProvider.php create mode 100644 src/views/EventReferenceWidget.vue diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b0409bc4c7..082a84949d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -15,6 +15,7 @@ use OCA\Calendar\Listener\UserDeletedListener; use OCA\Calendar\Notification\Notifier; use OCA\Calendar\Profile\AppointmentsAction; +use OCA\Calendar\Reference\EventReferenceProvider; use OCA\Calendar\Reference\ReferenceProvider; use OCA\Calendar\UserMigration\Migrator; use OCP\AppFramework\App; @@ -52,6 +53,8 @@ public function register(IRegistrationContext $context): void { $context->registerProfileLinkAction(AppointmentsAction::class); + $context->registerReferenceProvider(EventReferenceProvider::class); + $context->registerReferenceProvider(ReferenceProvider::class); $context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class); diff --git a/lib/Reference/EventReferenceProvider.php b/lib/Reference/EventReferenceProvider.php new file mode 100644 index 0000000000..d5d32d164f --- /dev/null +++ b/lib/Reference/EventReferenceProvider.php @@ -0,0 +1,207 @@ +l10n->t('Calendar event'); + } + + #[\Override] + public function getOrder(): int { + return 21; + } + + #[\Override] + public function getIconUrl(): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath(Application::APP_ID, 'calendar-dark.svg') + ); + } + + #[\Override] + public function matchReference(string $referenceText): bool { + $start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID); + $startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID); + + foreach ([$start, $startIndex] as $base) { + $quoted = preg_quote($base, '/'); + + // URL pattern 1: .../apps/calendar/edit/{objectId} + // URL pattern 2: .../apps/calendar/edit/{objectId}/{recurrenceId} + if (preg_match('/^' . $quoted . '\/edit\/[^\/?#]+$/i', $referenceText) === 1) { + return true; + } + + // URL pattern 3: .../apps/calender/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId} + $views = 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'; + if (preg_match('/^' . $quoted . '\/(?:' . $views . ')\/[^\/]+\/edit\/(?:popover|full)\/[^\/?#]+/i', $referenceText) === 1) { + return true; + } + } + + return false; + } + + #[\Override] + public function resolveReference(string $referenceText): ?IReference { + if ($this->userId === null || !$this->matchReference($referenceText)) { + return null; + } + + $objectId = $this->getObjectIdFromUrl($referenceText); + if ($objectId === null) { + return null; + } + + // objectId is base64(davUrl) + $davUrl = base64_decode($objectId, true); + if ($davUrl === false) { + return null; + } + + // DAV URL format: /remote.php/dav/calendars/{userid}/{calendarUri}/{eventFile}.ics + $parts = explode('/', trim($davUrl, '/')); + if (count($parts) < 2) { + return null; + } + $eventFile = array_pop($parts); // e.g. 'event.ics' + $calendarUri = array_pop($parts); // e.g. 'personal' + if (empty($calendarUri) || empty($eventFile)) { + return null; + } + + $calendar = $this->getCalendar($calendarUri); + if ($calendar === null) { + return null; + } + + $eventData = $this->getEventData($calendar['id'], $eventFile); + if ($eventData === null) { + return null; + } + + $reference = new Reference($referenceText); + $reference->setTitle($eventData['title']); + $reference->setDescription($eventData['date'] ?? $calendar['{DAV:}displayname'] ?? ''); + $reference->setRichObject( + 'calendar_event', + [ + 'title' => $eventData['title'], + 'calendarName' => $calendar['{DAV:}displayname'] ?? '', + 'calendarColor' => $calendar['{http://apple.com/ns/ical/}calendar-color'] ?? null, + 'date' => $eventData['date'], + 'startTimestamp' => $eventData['startTimestamp'], + 'endTimestamp' => $eventData['endTimestamp'], + 'url' => $referenceText, + ] + ); + return $reference; + } + + private function getObjectIdFromUrl(string $url): ?string { + // URL pattern 1+2: .../apps/calendar/edit/{objectId}[/{recurrenceId}] + if (preg_match('/\/edit\/([^\/?#]+)/i', $url, $matches) === 1) { + if (in_array($matches[1], ['popover', 'full'], true)) { + // URL pattern 3: .../apps/calender/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId} + if (preg_match('/\/edit\/(?:popover|full)\/([^\/?#]+)/i', $url, $m2)) { + return $m2[1]; + } + return null; + } + return $matches[1]; + } + return null; + } + + private function getCalendar(string $calendarUri): ?array { + $principalUri = 'principals/users/' . $this->userId; + $calendar = $this->calDavBackend->getCalendarByUri($principalUri, $calendarUri); + if ($calendar === null || $calendar['{http://nextcloud.com/ns}deleted_at'] !== null) { + return null; + } + return $calendar; + } + + private function getEventData(int $calendarId, string $eventFile): ?array { + $object = $this->calDavBackend->getCalendarObject($calendarId, $eventFile); + if ($object === null) { + return null; + } + + $vObject = Reader::read($object['calendardata']); + $vEvent = $vObject->VEVENT ?? null; + if ($vEvent === null) { + return null; + } + + $date = null; + $startTimestamp = null; + if (isset($vEvent->DTSTART)) { + $dt = $vEvent->DTSTART->getDateTime(); + $date = $this->dateTimeFormatter->formatTimeSpan(\DateTime::createFromInterface($dt)); + $startTimestamp = $dt->getTimestamp(); + } + + $endTimestamp = null; + if (isset($vEvent->DTEND)) { + $dt = $vEvent->DTEND->getDateTime(); + $endTimestamp = $dt->getTimestamp(); + } elseif (isset($vEvent->DURATION) && $startTimestamp !== null) { + $duration = $vEvent->DURATION->getDateInterval(); + $endTimestamp = (new \DateTime())->setTimestamp($startTimestamp)->add($duration)->getTimestamp(); + } + + return [ + 'title' => isset($vEvent->SUMMARY) ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'), + 'date' => $date, + 'startTimestamp' => $startTimestamp, + 'endTimestamp' => $endTimestamp, + 'location' => isset($vEvent->LOCATION) ? (string)$vEvent->LOCATION : null, + ]; + } + + #[\Override] + public function getCachePrefix(string $referenceId): string { + return $this->userId ?? ''; + } + + #[\Override] + public function getCacheKey(string $referenceId): string { + return $referenceId; + } +} diff --git a/psalm.xml b/psalm.xml index 5080548c66..ec0ab1de0b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -37,6 +37,7 @@ + @@ -45,6 +46,7 @@ + diff --git a/src/reference.js b/src/reference.js index 23b70af59c..be8fcf1715 100644 --- a/src/reference.js +++ b/src/reference.js @@ -39,3 +39,16 @@ registerWidget('calendar_widget', async (el, { richObjectType, richObject, acces }, (el, renderResult) => { renderResult.object.$destroy() }, true) + +registerWidget('calendar_event', async (el, { richObject }) => { + const { createApp } = await import('vue') + const { default: EventReferenceWidget } = await import('./views/EventReferenceWidget.vue') + + const app = createApp(EventReferenceWidget, { + richObject, + }) + app.mount(el) + return new NcCustomPickerRenderResult(el, app) +}, (el, renderResult) => { + renderResult.object.$destroy() +}) diff --git a/src/views/EventReferenceWidget.vue b/src/views/EventReferenceWidget.vue new file mode 100644 index 0000000000..f4876185ae --- /dev/null +++ b/src/views/EventReferenceWidget.vue @@ -0,0 +1,163 @@ + + + + + + + From fc255c2aa6609eba04fe85b8c1f97935f9453fc3 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 19 Mar 2026 18:50:13 +0100 Subject: [PATCH 2/2] chore(EventReferenceProvider): use IManager instead of CalDavBackend Signed-off-by: Jonas --- lib/Reference/EventReferenceProvider.php | 69 +++++++++++++----------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/lib/Reference/EventReferenceProvider.php b/lib/Reference/EventReferenceProvider.php index d5d32d164f..ba2b96fb81 100644 --- a/lib/Reference/EventReferenceProvider.php +++ b/lib/Reference/EventReferenceProvider.php @@ -10,7 +10,7 @@ namespace OCA\Calendar\Reference; use OCA\Calendar\AppInfo\Application; -use OCA\DAV\CalDAV\CalDavBackend; +use OCP\Calendar\ICalendar; use OCP\Calendar\IManager; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; use OCP\Collaboration\Reference\IReference; @@ -18,14 +18,12 @@ use OCP\IDateTimeFormatter; use OCP\IL10N; use OCP\IURLGenerator; -use Sabre\VObject\Reader; class EventReferenceProvider extends ADiscoverableReferenceProvider { public function __construct( private readonly IL10N $l10n, private readonly IURLGenerator $urlGenerator, private readonly IManager $calendarManager, - private CalDavBackend $calDavBackend, private readonly IDateTimeFormatter $dateTimeFormatter, private readonly ?string $userId, ) { @@ -106,24 +104,24 @@ public function resolveReference(string $referenceText): ?IReference { } $calendar = $this->getCalendar($calendarUri); - if ($calendar === null) { + if ($calendar->isDeleted()) { return null; } - $eventData = $this->getEventData($calendar['id'], $eventFile); + $eventData = $this->getEventData($calendar, $eventFile); if ($eventData === null) { return null; } $reference = new Reference($referenceText); $reference->setTitle($eventData['title']); - $reference->setDescription($eventData['date'] ?? $calendar['{DAV:}displayname'] ?? ''); + $reference->setDescription($eventData['date'] ?? $calendar->getDisplayName()); $reference->setRichObject( 'calendar_event', [ 'title' => $eventData['title'], - 'calendarName' => $calendar['{DAV:}displayname'] ?? '', - 'calendarColor' => $calendar['{http://apple.com/ns/ical/}calendar-color'] ?? null, + 'calendarName' => $calendar->getDisplayName(), + 'calendarColor' => $calendar->getDisplayColor(), 'date' => $eventData['date'], 'startTimestamp' => $eventData['startTimestamp'], 'endTimestamp' => $eventData['endTimestamp'], @@ -148,50 +146,57 @@ private function getObjectIdFromUrl(string $url): ?string { return null; } - private function getCalendar(string $calendarUri): ?array { + private function getCalendar(string $calendarUri): ICalendar { $principalUri = 'principals/users/' . $this->userId; - $calendar = $this->calDavBackend->getCalendarByUri($principalUri, $calendarUri); - if ($calendar === null || $calendar['{http://nextcloud.com/ns}deleted_at'] !== null) { - return null; - } - return $calendar; + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$calendarUri]); + return $calendars[0]; } - private function getEventData(int $calendarId, string $eventFile): ?array { - $object = $this->calDavBackend->getCalendarObject($calendarId, $eventFile); - if ($object === null) { + private function getEventData(ICalendar $calendar, string $eventFile): ?array { + $event = null; + foreach ($calendar->search('') as $result) { + if (($result['uri'] ?? null) === $eventFile) { + $event = $result; + break; + } + } + if ($event === null) { return null; } - $vObject = Reader::read($object['calendardata']); - $vEvent = $vObject->VEVENT ?? null; - if ($vEvent === null) { + $object = $event['objects'][0] ?? null; + if ($object === null) { return null; + } $date = null; $startTimestamp = null; - if (isset($vEvent->DTSTART)) { - $dt = $vEvent->DTSTART->getDateTime(); - $date = $this->dateTimeFormatter->formatTimeSpan(\DateTime::createFromInterface($dt)); - $startTimestamp = $dt->getTimestamp(); + /** @var \DateTimeInterface|null $dtStart */ + $dtStart = $object['DTSTART'][0] ?? null; + if ($dtStart instanceof \DateTimeInterface) { + $date = $this->dateTimeFormatter->formatTimeSpan(\DateTime::createFromInterface($dtStart)); + $startTimestamp = $dtStart->getTimestamp(); } $endTimestamp = null; - if (isset($vEvent->DTEND)) { - $dt = $vEvent->DTEND->getDateTime(); - $endTimestamp = $dt->getTimestamp(); - } elseif (isset($vEvent->DURATION) && $startTimestamp !== null) { - $duration = $vEvent->DURATION->getDateInterval(); - $endTimestamp = (new \DateTime())->setTimestamp($startTimestamp)->add($duration)->getTimestamp(); + /** @var \DateTimeInterface|null $dtEnd */ + $dtEnd = $object['DTEND'][0] ?? null; + if ($dtEnd instanceof \DateTimeInterface) { + $endTimestamp = $dtEnd->getTimestamp(); + } elseif ($startTimestamp !== null) { + $duration = $object['DURATION'][0] ?? null; + if ($duration instanceof \DateInterval) { + $endTimestamp = (new \DateTime())->setTimestamp($startTimestamp)->add($duration)->getTimestamp(); + } } return [ - 'title' => isset($vEvent->SUMMARY) ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'), + 'title' => $object['SUMMARY'][0] ?? $this->l10n->t('Untitled event'), 'date' => $date, 'startTimestamp' => $startTimestamp, 'endTimestamp' => $endTimestamp, - 'location' => isset($vEvent->LOCATION) ? (string)$vEvent->LOCATION : null, + 'location' => $object['LOCATION'][0] ?? null, ]; }