Skip to content

Commit c9ede5b

Browse files
authored
Merge pull request #8123 from nextcloud/fix/event-link
fix: add event link
2 parents dce825a + 4428857 commit c9ede5b

6 files changed

Lines changed: 175 additions & 3 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
namespace OCA\Calendar\Controller;
10+
11+
use OCA\Calendar\Service\ObjectResolverService;
12+
use OCP\AppFramework\Controller;
13+
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
14+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
15+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
16+
use OCP\AppFramework\Http\RedirectResponse;
17+
use OCP\IRequest;
18+
use OCP\IURLGenerator;
19+
20+
class ObjectController extends Controller {
21+
22+
public function __construct(
23+
string $appName,
24+
IRequest $request,
25+
private ObjectResolverService $objectResolverService,
26+
private IURLGenerator $urlGenerator,
27+
private ?string $userId,
28+
) {
29+
parent::__construct($appName, $request);
30+
}
31+
32+
/**
33+
* Resolve a permanent calendar object deep link.
34+
*
35+
* @param string $uid The iCalendar UID of the object
36+
* @param string|null $recurrenceId Unix timestamp of the recurrence instance, or null for 'next'
37+
*/
38+
#[NoAdminRequired]
39+
#[NoCSRFRequired]
40+
#[FrontpageRoute(verb: 'GET', url: '/object/{uid}', postfix: 'uid')]
41+
#[FrontpageRoute(verb: 'GET', url: '/object/{uid}/{recurrenceId}', postfix: 'uid.recurrenceId')]
42+
public function index(string $uid, ?string $recurrenceId = null): RedirectResponse {
43+
44+
$resolved = $this->userId !== null
45+
? $this->objectResolverService->findByUid($this->userId, $uid)
46+
: null;
47+
48+
if ($resolved !== null) {
49+
$davPath = '/remote.php/dav/calendars/' . $this->userId . '/' . $resolved['calendarUri'] . '/' . $resolved['objectUri'];
50+
$objectId = base64_encode($davPath);
51+
52+
return new RedirectResponse(
53+
$this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [
54+
'objectId' => $objectId,
55+
'recurrenceId' => $recurrenceId ?? 'next',
56+
])
57+
);
58+
}
59+
60+
// Object not found (no access or deleted) — redirect to a non-existent object so
61+
// the frontend error handling displays "Event does not exist" instead of a blank page.
62+
return new RedirectResponse(
63+
$this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [
64+
'objectId' => base64_encode("/object-not-found/$uid"),
65+
'recurrenceId' => $recurrenceId ?? 'next',
66+
])
67+
);
68+
}
69+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
namespace OCA\Calendar\Service;
10+
11+
use OCP\Calendar\IManager;
12+
13+
class ObjectResolverService {
14+
15+
public function __construct(
16+
private IManager $calendarManager,
17+
) {
18+
}
19+
20+
/**
21+
* Locate a calendar object by UID across all calendars of the given user.
22+
*
23+
* @return array{calendarUri: string, objectUri: string}|null
24+
*/
25+
public function findByUid(string $userId, string $uid): ?array {
26+
$principalUri = "principals/users/$userId";
27+
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri);
28+
29+
foreach ($calendars as $calendar) {
30+
if ($calendar->isDeleted()) {
31+
continue;
32+
}
33+
34+
$results = $calendar->search('', [], ['uid' => $uid], 1);
35+
if (!empty($results)) {
36+
return [
37+
'calendarUri' => $calendar->getUri(),
38+
'objectUri' => $results[0]['uri'],
39+
];
40+
}
41+
}
42+
43+
return null;
44+
}
45+
}

src/mixins/EditorMixin.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { showError } from '@nextcloud/dialogs'
6+
import { showError, showSuccess } from '@nextcloud/dialogs'
77
import { translate as t } from '@nextcloud/l10n'
8+
import { generateUrl } from '@nextcloud/router'
89
import { mapState, mapStores } from 'pinia'
910
import { getRFCProperties } from '../models/rfcProps.js'
1011
import { containsRoomUrl } from '../services/talkService.ts'
@@ -367,6 +368,28 @@ export default {
367368

368369
return this.calendarObject.dav.url + '?export'
369370
},
371+
/**
372+
* Returns the permanent deep link URL for this event, or null if the event is new
373+
*
374+
* @return {string|null}
375+
*/
376+
eventLink() {
377+
if (!this.calendarObject) {
378+
return null
379+
}
380+
381+
const uid = this.calendarObject.uid
382+
if (!uid) {
383+
return null
384+
}
385+
386+
const recurrenceId = this.$route?.params?.recurrenceId
387+
if (recurrenceId && recurrenceId !== 'next') {
388+
return window.location.origin + generateUrl('/apps/calendar/object/{uid}/{recurrenceId}', { uid, recurrenceId })
389+
}
390+
391+
return window.location.origin + generateUrl('/apps/calendar/object/{uid}', { uid })
392+
},
370393
/**
371394
* Returns whether or not this is a new event
372395
*
@@ -645,6 +668,25 @@ export default {
645668
await this.calendarObjectInstanceStore.duplicateCalendarObjectInstance()
646669
},
647670

671+
/**
672+
* Copies the permanent event deep link to the clipboard
673+
*
674+
* @return {Promise<void>}
675+
*/
676+
async copyEventLink() {
677+
if (!this.eventLink) {
678+
return
679+
}
680+
681+
try {
682+
await navigator.clipboard.writeText(this.eventLink)
683+
showSuccess(t('calendar', 'Event link copied to clipboard'))
684+
} catch (error) {
685+
logger.error('Failed to copy event link to clipboard', { error })
686+
showError(t('calendar', 'Failed to copy event link'))
687+
}
688+
},
689+
648690
/**
649691
* Deletes a calendar-object
650692
*

src/router.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ const router = createRouter({
9191
},
9292
{
9393
path: '/edit/:object',
94-
redirect: () => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/:object/next`,
94+
redirect: (to) => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/${to.params.object}/next`,
9595
},
9696
{
9797
path: '/edit/:object/:recurrenceId',
98-
redirect: () => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/:object/:recurrenceId`,
98+
redirect: (to) => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/${to.params.object}/${to.params.recurrenceId}`,
9999
},
100100
/**
101101
* This is the main route that contains the current view and viewed day

src/views/EditFull.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@
5858
@saveThisAndAllFuture="prepareAccessForAttachments(true)" />
5959
<div class="app-full__actions__inner" :class="[{ 'app-full__actions__inner__readonly': isReadOnly }]">
6060
<NcActions>
61+
<NcActionButton v-if="eventLink && !isNew" @click="copyEventLink()">
62+
<template #icon>
63+
<ContentCopy :size="20" decorative />
64+
</template>
65+
{{ $t('calendar', 'Copy link') }}
66+
</NcActionButton>
6167
<NcActionLink v-if="!hideEventExport && hasDownloadURL && !isNew" :href="downloadURL">
6268
<template #icon>
6369
<Download :size="20" decorative />
@@ -358,6 +364,7 @@ import {
358364
import { mapState, mapStores } from 'pinia'
359365
import CalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
360366
import Close from 'vue-material-design-icons/Close.vue'
367+
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
361368
import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
362369
import HelpCircleIcon from 'vue-material-design-icons/HelpCircleOutline.vue'
363370
import Delete from 'vue-material-design-icons/TrashCanOutline.vue'
@@ -414,6 +421,7 @@ export default {
414421
Delete,
415422
Download,
416423
ContentDuplicate,
424+
ContentCopy,
417425
InvitationResponseButtons,
418426
AttachmentsList,
419427
CalendarPickerHeader,

src/views/EditSimple.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@
7070
</template>
7171
</NcPopover>
7272
<Actions v-if="!isLoading && !isError && !isNew" :forceMenu="true">
73+
<ActionButton v-if="eventLink" @click="copyEventLink()">
74+
<template #icon>
75+
<ContentCopy :size="20" decorative />
76+
</template>
77+
{{ $t('calendar', 'Copy link') }}
78+
</ActionButton>
7379
<ActionLink
7480
v-if="!hideEventExport && hasDownloadURL"
7581
:href="downloadURL">
@@ -279,6 +285,7 @@ import { mapState, mapStores } from 'pinia'
279285
import Bell from 'vue-material-design-icons/BellOutline.vue'
280286
import CalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue'
281287
import Close from 'vue-material-design-icons/Close.vue'
288+
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
282289
import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
283290
import HelpCircleIcon from 'vue-material-design-icons/HelpCircleOutline.vue'
284291
import EditIcon from 'vue-material-design-icons/PencilOutline.vue'
@@ -322,6 +329,7 @@ export default {
322329
Close,
323330
Download,
324331
ContentDuplicate,
332+
ContentCopy,
325333
Delete,
326334
InvitationResponseButtons,
327335
CalendarPickerHeader,

0 commit comments

Comments
 (0)