diff --git a/lib/Controller/ObjectController.php b/lib/Controller/ObjectController.php new file mode 100644 index 0000000000..a266587724 --- /dev/null +++ b/lib/Controller/ObjectController.php @@ -0,0 +1,69 @@ +userId !== null + ? $this->objectResolverService->findByUid($this->userId, $uid) + : null; + + if ($resolved !== null) { + $davPath = '/remote.php/dav/calendars/' . $this->userId . '/' . $resolved['calendarUri'] . '/' . $resolved['objectUri']; + $objectId = base64_encode($davPath); + + return new RedirectResponse( + $this->urlGenerator->linkToRoute('calendar.view.indexdirect.edit.recurrenceId', [ + 'objectId' => $objectId, + 'recurrenceId' => $recurrenceId ?? 'next', + ]) + ); + } + + // Object not found (no access or deleted) — redirect to a non-existent object so + // the frontend 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("/object-not-found/$uid"), + 'recurrenceId' => $recurrenceId ?? 'next', + ]) + ); + } +} diff --git a/lib/Service/ObjectResolverService.php b/lib/Service/ObjectResolverService.php new file mode 100644 index 0000000000..97ca13fdad --- /dev/null +++ b/lib/Service/ObjectResolverService.php @@ -0,0 +1,45 @@ +calendarManager->getCalendarsForPrincipal($principalUri); + + foreach ($calendars as $calendar) { + if ($calendar->isDeleted()) { + continue; + } + + $results = $calendar->search('', [], ['uid' => $uid], 1); + if (!empty($results)) { + return [ + 'calendarUri' => $calendar->getUri(), + 'objectUri' => $results[0]['uri'], + ]; + } + } + + return null; + } +} diff --git a/src/mixins/EditorMixin.js b/src/mixins/EditorMixin.js index 6d325343f4..9f21e643be 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/object/{uid}/{recurrenceId}', { uid, recurrenceId }) + } + + return window.location.origin + generateUrl('/apps/calendar/object/{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 9395acf131..982300cfb6 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') }} + @@ -279,6 +285,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' @@ -322,6 +329,7 @@ export default { Close, Download, ContentDuplicate, + ContentCopy, Delete, InvitationResponseButtons, CalendarPickerHeader,