Skip to content

Commit c8421d3

Browse files
committed
fix: add event link
Signed-off-by: Hamza <hamzamahjoubi221@gmail.com>
1 parent e2c07d4 commit c8421d3

6 files changed

Lines changed: 161 additions & 3 deletions

File tree

appinfo/routes.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
['name' => 'view#index', 'url' => '/new/{isAllDay}/{dtStart}/{dtEnd}', 'verb' => 'GET', 'postfix' => 'direct.new.timerange'],
1515
['name' => 'view#index', 'url' => '/edit/{objectId}', 'verb' => 'GET', 'postfix' => 'direct.edit'],
1616
['name' => 'view#index', 'url' => '/edit/{objectId}/{recurrenceId}', 'verb' => 'GET', 'postfix' => 'direct.edit.recurrenceId'],
17+
// Events
18+
['name' => 'event#index', 'url' => '/event/{uid}', 'verb' => 'GET', 'postfix' => 'event.uid'],
19+
['name' => 'event#index', 'url' => '/event/{uid}/{recurrenceId}', 'verb' => 'GET', 'postfix' => 'event.uid.recurrenceId'],
1720
['name' => 'view#index', 'url' => '/{view}/{timeRange}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'], 'postfix' => 'view.timerange'],
1821
['name' => 'view#index', 'url' => '/{view}/{timeRange}/new/{mode}/{isAllDay}/{dtStart}/{dtEnd}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'], 'postfix' => 'view.timerange.new'],
1922
['name' => 'view#index', 'url' => '/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'], 'postfix' => 'view.timerange.edit'],

lib/Controller/EventController.php

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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\CalendarInitialStateService;
12+
use OCP\AppFramework\Controller;
13+
use OCP\AppFramework\Http\RedirectResponse;
14+
use OCP\AppFramework\Http\Response;
15+
use OCP\AppFramework\Http\TemplateResponse;
16+
use OCP\Calendar\IManager;
17+
use OCP\IRequest;
18+
use OCP\IURLGenerator;
19+
20+
/**
21+
* Controller for permanent event deep links.
22+
*
23+
* Routes like /apps/calendar/event/{uid} and /apps/calendar/event/{uid}/{recurrenceId}
24+
* resolve the UID to the appropriate calendar object and redirect to the standard
25+
* edit route
26+
*/
27+
class EventController extends Controller {
28+
29+
public function __construct(
30+
string $appName,
31+
IRequest $request,
32+
private CalendarInitialStateService $calendarInitialStateService,
33+
private IManager $calendarManager,
34+
private IURLGenerator $urlGenerator,
35+
private ?string $userId,
36+
) {
37+
parent::__construct($appName, $request);
38+
}
39+
40+
/**
41+
* Resolve a permanent event deep link.
42+
*
43+
* Searches all calendars for an event with the given UID and redirects
44+
* to the standard edit route. Falls back to serving the SPA if the
45+
* event cannot be resolved server-side.
46+
*
47+
* @NoAdminRequired
48+
* @NoCSRFRequired
49+
*
50+
* @param string $uid The iCalendar UID of the event
51+
* @param string|null $recurrenceId Unix timestamp of the recurrence instance, or null for 'next'
52+
* @return Response
53+
*/
54+
public function index(string $uid, ?string $recurrenceId = null): Response {
55+
if ($this->userId === null) {
56+
$this->calendarInitialStateService->run();
57+
return new TemplateResponse($this->appName, 'main');
58+
}
59+
60+
$principalUri = "principals/users/{$this->userId}";
61+
$calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri);
62+
63+
foreach ($calendars as $calendar) {
64+
if (method_exists($calendar, 'isDeleted') && $calendar->isDeleted()) {
65+
continue;
66+
}
67+
68+
$results = $calendar->search('', [], ['uid' => $uid], 1);
69+
if (!empty($results)) {
70+
$result = $results[0];
71+
$objectUri = $result['uri'];
72+
$calendarUri = $calendar->getUri();
73+
74+
$davPath = '/remote.php/dav/calendars/' . $this->userId . '/' . $calendarUri . '/' . $objectUri;
75+
$objectId = base64_encode($davPath);
76+
77+
$editRecurrenceId = $recurrenceId ?? 'next';
78+
79+
return new RedirectResponse(
80+
$this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [
81+
'objectId' => $objectId,
82+
'recurrenceId' => $editRecurrenceId,
83+
])
84+
);
85+
}
86+
}
87+
88+
// Event not found (no access or deleted) — redirect to a non-existent object so
89+
// the SPA's error handling displays "Event does not exist" instead of a blank page.
90+
return new RedirectResponse(
91+
$this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [
92+
'objectId' => base64_encode("/event-not-found/$uid"),
93+
'recurrenceId' => $recurrenceId ?? 'next',
94+
])
95+
);
96+
}
97+
}

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/event/{uid}/{recurrenceId}', { uid, recurrenceId })
389+
}
390+
391+
return window.location.origin + generateUrl('/apps/calendar/event/{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 />
@@ -349,6 +355,7 @@ import {
349355
import { mapState, mapStores } from 'pinia'
350356
import CalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
351357
import Close from 'vue-material-design-icons/Close.vue'
358+
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
352359
import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
353360
import HelpCircleIcon from 'vue-material-design-icons/HelpCircleOutline.vue'
354361
import Delete from 'vue-material-design-icons/TrashCanOutline.vue'
@@ -404,6 +411,7 @@ export default {
404411
Delete,
405412
Download,
406413
ContentDuplicate,
414+
ContentCopy,
407415
InvitationResponseButtons,
408416
AttachmentsList,
409417
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">
@@ -277,6 +283,7 @@ import { mapState, mapStores } from 'pinia'
277283
import Bell from 'vue-material-design-icons/BellOutline.vue'
278284
import CalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue'
279285
import Close from 'vue-material-design-icons/Close.vue'
286+
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
280287
import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
281288
import HelpCircleIcon from 'vue-material-design-icons/HelpCircleOutline.vue'
282289
import EditIcon from 'vue-material-design-icons/PencilOutline.vue'
@@ -319,6 +326,7 @@ export default {
319326
Close,
320327
Download,
321328
ContentDuplicate,
329+
ContentCopy,
322330
Delete,
323331
InvitationResponseButtons,
324332
CalendarPickerHeader,

0 commit comments

Comments
 (0)