Skip to content

Commit ae2ecc3

Browse files
committed
feat: add event reference provider and widget
Fixes: #7104 Signed-off-by: Jonas <jonas@freesources.org>
1 parent b433f28 commit ae2ecc3

File tree

5 files changed

+388
-0
lines changed

5 files changed

+388
-0
lines changed

lib/AppInfo/Application.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use OCA\Calendar\Listener\UserDeletedListener;
1616
use OCA\Calendar\Notification\Notifier;
1717
use OCA\Calendar\Profile\AppointmentsAction;
18+
use OCA\Calendar\Reference\EventReferenceProvider;
1819
use OCA\Calendar\Reference\ReferenceProvider;
1920
use OCA\Calendar\UserMigration\Migrator;
2021
use OCP\AppFramework\App;
@@ -52,6 +53,8 @@ public function register(IRegistrationContext $context): void {
5253

5354
$context->registerProfileLinkAction(AppointmentsAction::class);
5455

56+
$context->registerReferenceProvider(EventReferenceProvider::class);
57+
5558
$context->registerReferenceProvider(ReferenceProvider::class);
5659

5760
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Reference;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCA\DAV\CalDAV\CalDavBackend;
14+
use OCP\Calendar\IManager;
15+
use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
16+
use OCP\Collaboration\Reference\IReference;
17+
use OCP\Collaboration\Reference\Reference;
18+
use OCP\IDateTimeFormatter;
19+
use OCP\IL10N;
20+
use OCP\IURLGenerator;
21+
use Sabre\VObject\Reader;
22+
23+
class EventReferenceProvider extends ADiscoverableReferenceProvider {
24+
public function __construct(
25+
private readonly IL10N $l10n,
26+
private readonly IURLGenerator $urlGenerator,
27+
private readonly IManager $calendarManager,
28+
private CalDavBackend $calDavBackend,
29+
private readonly IDateTimeFormatter $dateTimeFormatter,
30+
private readonly ?string $userId,
31+
) {
32+
}
33+
34+
#[\Override]
35+
public function getId(): string {
36+
return 'calendar-event';
37+
}
38+
39+
#[\Override]
40+
public function getTitle(): string {
41+
return $this->l10n->t('Calendar event');
42+
}
43+
44+
#[\Override]
45+
public function getOrder(): int {
46+
return 21;
47+
}
48+
49+
#[\Override]
50+
public function getIconUrl(): string {
51+
return $this->urlGenerator->getAbsoluteURL(
52+
$this->urlGenerator->imagePath(Application::APP_ID, 'calendar-dark.svg')
53+
);
54+
}
55+
56+
#[\Override]
57+
public function matchReference(string $referenceText): bool {
58+
$start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_ID);
59+
$startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID);
60+
61+
foreach ([$start, $startIndex] as $base) {
62+
$quoted = preg_quote($base, '/');
63+
64+
// URL pattern 1: .../apps/calendar/edit/{objectId}
65+
// URL pattern 2: .../apps/calendar/edit/{objectId}/{recurrenceId}
66+
if (preg_match('/^' . $quoted . '\/edit\/[^\/?#]+$/i', $referenceText) === 1) {
67+
return true;
68+
}
69+
70+
// URL pattern 3: .../apps/calender/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId}
71+
$views = 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth';
72+
if (preg_match('/^' . $quoted . '\/(?:' . $views . ')\/[^\/]+\/edit\/(?:popover|full)\/[^\/?#]+/i', $referenceText) === 1) {
73+
return true;
74+
}
75+
}
76+
77+
return false;
78+
}
79+
80+
#[\Override]
81+
public function resolveReference(string $referenceText): ?IReference {
82+
if ($this->userId === null || !$this->matchReference($referenceText)) {
83+
return null;
84+
}
85+
86+
$objectId = $this->getObjectIdFromUrl($referenceText);
87+
if ($objectId === null) {
88+
return null;
89+
}
90+
91+
// objectId is base64(davUrl)
92+
$davUrl = base64_decode($objectId, true);
93+
if ($davUrl === false) {
94+
return null;
95+
}
96+
97+
// DAV URL format: /remote.php/dav/calendars/{userid}/{calendarUri}/{eventFile}.ics
98+
$parts = explode('/', trim($davUrl, '/'));
99+
if (count($parts) < 2) {
100+
return null;
101+
}
102+
$eventFile = array_pop($parts); // e.g. 'event.ics'
103+
$calendarUri = array_pop($parts); // e.g. 'personal'
104+
if (empty($calendarUri) || empty($eventFile)) {
105+
return null;
106+
}
107+
108+
$calendar = $this->getCalendar($calendarUri);
109+
if ($calendar === null) {
110+
return null;
111+
}
112+
113+
$eventData = $this->getEventData($calendar['id'], $eventFile);
114+
if ($eventData === null) {
115+
return null;
116+
}
117+
118+
$reference = new Reference($referenceText);
119+
$reference->setTitle($eventData['title']);
120+
$reference->setDescription($eventData['date'] ?? $calendar['{DAV:}displayname'] ?? '');
121+
$reference->setRichObject(
122+
'calendar_event',
123+
[
124+
'title' => $eventData['title'],
125+
'calendarName' => $calendar['{DAV:}displayname'] ?? '',
126+
'calendarColor' => $calendar['{http://apple.com/ns/ical/}calendar-color'] ?? null,
127+
'date' => $eventData['date'],
128+
'startTimestamp' => $eventData['startTimestamp'],
129+
'endTimestamp' => $eventData['endTimestamp'],
130+
'url' => $referenceText,
131+
]
132+
);
133+
return $reference;
134+
}
135+
136+
private function getObjectIdFromUrl(string $url): ?string {
137+
// URL pattern 1+2: .../apps/calendar/edit/{objectId}[/{recurrenceId}]
138+
if (preg_match('/\/edit\/([^\/?#]+)/i', $url, $matches) === 1) {
139+
if (in_array($matches[1], ['popover', 'full'], true)) {
140+
// URL pattern 3: .../apps/calender/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId}
141+
if (preg_match('/\/edit\/(?:popover|full)\/([^\/?#]+)/i', $url, $m2)) {
142+
return $m2[1];
143+
}
144+
return null;
145+
}
146+
return $matches[1];
147+
}
148+
return null;
149+
}
150+
151+
private function getCalendar(string $calendarUri): ?array {
152+
$principalUri = 'principals/users/' . $this->userId;
153+
$calendar = $this->calDavBackend->getCalendarByUri($principalUri, $calendarUri);
154+
if ($calendar === null || $calendar['{http://nextcloud.com/ns}deleted_at'] !== null) {
155+
return null;
156+
}
157+
return $calendar;
158+
}
159+
160+
private function getEventData(int $calendarId, string $eventFile): ?array {
161+
$object = $this->calDavBackend->getCalendarObject($calendarId, $eventFile);
162+
if ($object === null) {
163+
return null;
164+
}
165+
166+
$vObject = Reader::read($object['calendardata']);
167+
$vEvent = $vObject->VEVENT ?? null;
168+
if ($vEvent === null) {
169+
return null;
170+
}
171+
172+
$date = null;
173+
$startTimestamp = null;
174+
if (isset($vEvent->DTSTART)) {
175+
$dt = $vEvent->DTSTART->getDateTime();
176+
$date = $this->dateTimeFormatter->formatTimeSpan(\DateTime::createFromInterface($dt));
177+
$startTimestamp = $dt->getTimestamp();
178+
}
179+
180+
$endTimestamp = null;
181+
if (isset($vEvent->DTEND)) {
182+
$dt = $vEvent->DTEND->getDateTime();
183+
$endTimestamp = $dt->getTimestamp();
184+
} elseif (isset($vEvent->DURATION) && $startTimestamp !== null) {
185+
$duration = $vEvent->DURATION->getDateInterval();
186+
$endTimestamp = (new \DateTime())->setTimestamp($startTimestamp)->add($duration)->getTimestamp();
187+
}
188+
189+
return [
190+
'title' => isset($vEvent->SUMMARY) ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'),
191+
'date' => $date,
192+
'startTimestamp' => $startTimestamp,
193+
'endTimestamp' => $endTimestamp,
194+
'location' => isset($vEvent->LOCATION) ? (string)$vEvent->LOCATION : null,
195+
];
196+
}
197+
198+
#[\Override]
199+
public function getCachePrefix(string $referenceId): string {
200+
return $this->userId ?? '';
201+
}
202+
203+
#[\Override]
204+
public function getCacheKey(string $referenceId): string {
205+
return $referenceId;
206+
}
207+
}

psalm.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<referencedClass name="OCA\Circles\Api\v1\Circles" />
3838
<referencedClass name="OCA\Circles\Exceptions\CircleNotFoundException" />
3939
<referencedClass name="OCA\Calendar\Controller\Exception" />
40+
<referencedClass name="OCA\DAV\CalDAV\CalDavBackend" />
4041
<referencedClass name="OCA\DAV\CalDAV\Calendar" />
4142
<referencedClass name="OCA\DAV\CalDAV\CalendarHome" />
4243
<referencedClass name="OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer" />
@@ -45,6 +46,7 @@
4546
<referencedClass name="Sabre\VObject\Component\VCalendar" />
4647
<referencedClass name="Sabre\VObject\Component\VEvent" />
4748
<referencedClass name="Sabre\VObject\Component\VTimezone" />
49+
<referencedClass name="Sabre\VObject\Reader" />
4850
<referencedClass name="Sabre\VObject\TimeZoneUtil" />
4951
<referencedClass name="Symfony\Component\HttpFoundation\IpUtils" />
5052
<referencedClass name="Symfony\Component\Console\Command\Command" />

src/reference.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,16 @@ registerWidget('calendar_widget', async (el, { richObjectType, richObject, acces
3939
}, (el, renderResult) => {
4040
renderResult.object.$destroy()
4141
}, true)
42+
43+
registerWidget('calendar_event', async (el, { richObject }) => {
44+
const { createApp } = await import('vue')
45+
const { default: EventReferenceWidget } = await import('./views/EventReferenceWidget.vue')
46+
47+
const app = createApp(EventReferenceWidget, {
48+
richObject,
49+
})
50+
app.mount(el)
51+
return new NcCustomPickerRenderResult(el, app)
52+
}, (el, renderResult) => {
53+
renderResult.object.$destroy()
54+
})

0 commit comments

Comments
 (0)