diff --git a/appinfo/routes.php b/appinfo/routes.php index ad4d168801..8bfaff072d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -14,6 +14,9 @@ ['name' => 'view#index', 'url' => '/new/{isAllDay}/{dtStart}/{dtEnd}', 'verb' => 'GET', 'postfix' => 'direct.new.timerange'], ['name' => 'view#index', 'url' => '/edit/{objectId}', 'verb' => 'GET', 'postfix' => 'direct.edit'], ['name' => 'view#index', 'url' => '/edit/{objectId}/{recurrenceId}', 'verb' => 'GET', 'postfix' => 'direct.edit.recurrenceId'], + // Events + ['name' => 'event#index', 'url' => '/event/{uid}', 'verb' => 'GET', 'postfix' => 'event.uid'], + ['name' => 'event#index', 'url' => '/event/{uid}/{recurrenceId}', 'verb' => 'GET', 'postfix' => 'event.uid.recurrenceId'], ['name' => 'view#index', 'url' => '/{view}/{timeRange}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'], 'postfix' => 'view.timerange'], ['name' => 'view#index', 'url' => '/{view}/{timeRange}/new/{mode}/{isAllDay}/{dtStart}/{dtEnd}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'], 'postfix' => 'view.timerange.new'], ['name' => 'view#index', 'url' => '/{view}/{timeRange}/edit/{mode}/{objectId}/{recurrenceId}', 'verb' => 'GET', 'requirements' => ['view' => 'timeGridDay|timeGridWeek|dayGridMonth|multiMonthYear|listMonth'], 'postfix' => 'view.timerange.edit'], diff --git a/lib/Controller/EventController.php b/lib/Controller/EventController.php new file mode 100644 index 0000000000..71713579fa --- /dev/null +++ b/lib/Controller/EventController.php @@ -0,0 +1,97 @@ +userId === null) { + $this->calendarInitialStateService->run(); + return new TemplateResponse($this->appName, 'main'); + } + + $principalUri = "principals/users/{$this->userId}"; + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri); + + foreach ($calendars as $calendar) { + if (method_exists($calendar, 'isDeleted') && $calendar->isDeleted()) { + continue; + } + + $results = $calendar->search('', [], ['uid' => $uid], 1); + if (!empty($results)) { + $result = $results[0]; + $objectUri = $result['uri']; + $calendarUri = $calendar->getUri(); + + $davPath = '/remote.php/dav/calendars/' . $this->userId . '/' . $calendarUri . '/' . $objectUri; + $objectId = base64_encode($davPath); + + $editRecurrenceId = $recurrenceId ?? 'next'; + + return new RedirectResponse( + $this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [ + 'objectId' => $objectId, + 'recurrenceId' => $editRecurrenceId, + ]) + ); + } + } + + // Event not found (no access or deleted) — redirect to a non-existent object so + // the SPA's error handling displays "Event does not exist" instead of a blank page. + return new RedirectResponse( + $this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [ + 'objectId' => base64_encode("/event-not-found/$uid"), + 'recurrenceId' => $recurrenceId ?? 'next', + ]) + ); + } +} diff --git a/src/mixins/EditorMixin.js b/src/mixins/EditorMixin.js index 6d325343f4..f6d18a8008 100644 --- a/src/mixins/EditorMixin.js +++ b/src/mixins/EditorMixin.js @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { showError } from '@nextcloud/dialogs' +import { showError, showSuccess } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' import { mapState, mapStores } from 'pinia' import { getRFCProperties } from '../models/rfcProps.js' import { containsRoomUrl } from '../services/talkService.ts' @@ -367,6 +368,28 @@ export default { return this.calendarObject.dav.url + '?export' }, + /** + * Returns the permanent deep link URL for this event, or null if the event is new + * + * @return {string|null} + */ + eventLink() { + if (!this.calendarObject) { + return null + } + + const uid = this.calendarObject.uid + if (!uid) { + return null + } + + const recurrenceId = this.$route?.params?.recurrenceId + if (recurrenceId && recurrenceId !== 'next') { + return window.location.origin + generateUrl('/apps/calendar/event/{uid}/{recurrenceId}', { uid, recurrenceId }) + } + + return window.location.origin + generateUrl('/apps/calendar/event/{uid}', { uid }) + }, /** * Returns whether or not this is a new event * @@ -645,6 +668,25 @@ export default { await this.calendarObjectInstanceStore.duplicateCalendarObjectInstance() }, + /** + * Copies the permanent event deep link to the clipboard + * + * @return {Promise} + */ + async copyEventLink() { + if (!this.eventLink) { + return + } + + try { + await navigator.clipboard.writeText(this.eventLink) + showSuccess(t('calendar', 'Event link copied to clipboard')) + } catch (error) { + logger.error('Failed to copy event link to clipboard', { error }) + showError(t('calendar', 'Failed to copy event link')) + } + }, + /** * Deletes a calendar-object * diff --git a/src/router.js b/src/router.js index 9402f01b7f..2f114a3312 100644 --- a/src/router.js +++ b/src/router.js @@ -91,11 +91,11 @@ const router = createRouter({ }, { path: '/edit/:object', - redirect: () => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/:object/next`, + redirect: (to) => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/${to.params.object}/next`, }, { path: '/edit/:object/:recurrenceId', - redirect: () => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/:object/:recurrenceId`, + redirect: (to) => `/${getInitialView()}/now/edit/${getPreferredEditorRoute()}/${to.params.object}/${to.params.recurrenceId}`, }, /** * This is the main route that contains the current view and viewed day diff --git a/src/views/EditFull.vue b/src/views/EditFull.vue index 975ef471b5..7841439e3e 100644 --- a/src/views/EditFull.vue +++ b/src/views/EditFull.vue @@ -58,6 +58,12 @@ @saveThisAndAllFuture="prepareAccessForAttachments(true)" />
+ + + {{ $t('calendar', 'Copy link') }} + + + + {{ $t('calendar', 'Copy link') }} + @@ -277,6 +283,7 @@ import { mapState, mapStores } from 'pinia' import Bell from 'vue-material-design-icons/BellOutline.vue' import CalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue' import Close from 'vue-material-design-icons/Close.vue' +import ContentCopy from 'vue-material-design-icons/ContentCopy.vue' import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue' import HelpCircleIcon from 'vue-material-design-icons/HelpCircleOutline.vue' import EditIcon from 'vue-material-design-icons/PencilOutline.vue' @@ -319,6 +326,7 @@ export default { Close, Download, ContentDuplicate, + ContentCopy, Delete, InvitationResponseButtons, CalendarPickerHeader,