From 156b9948e2d08600b65374df0d3ec18ba20e2193 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 28 Apr 2026 20:53:23 +0200 Subject: [PATCH] feat: Allow or disallow invitation forwarding AI-assisted: OpenCode (gpt-5.4) Signed-off-by: Daniel Kesselberg --- src/mixins/PropertyMixin.js | 2 + src/models/event.js | 10 ++++ src/store/calendarObjectInstance.js | 12 ++++ src/views/EditFull.vue | 41 +++++++++++++ tests/javascript/unit/models/event.test.js | 70 +++++++++++++++++++++- 5 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/mixins/PropertyMixin.js b/src/mixins/PropertyMixin.js index ae8060e760..2f927af790 100644 --- a/src/mixins/PropertyMixin.js +++ b/src/mixins/PropertyMixin.js @@ -10,6 +10,7 @@ * See inline for more documentation */ +import AccountPlusOutline from 'vue-material-design-icons/AccountPlusOutline.vue' import Bell from 'vue-material-design-icons/BellOutline.vue' import Briefcase from 'vue-material-design-icons/BriefcaseOutline.vue' import Check from 'vue-material-design-icons/Check.vue' @@ -21,6 +22,7 @@ import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue' export default { components: { + AccountPlusOutline, Briefcase, Check, Eye, diff --git a/src/models/event.js b/src/models/event.js index c460b0b9ae..6156478808 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -71,6 +71,8 @@ function getDefaultEventObject(props = {}) { categories: [], // Attachments of this event attachments: [], + // Invitation forwarding + invitationForwarding: 'TRUE', ...props, } } @@ -172,6 +174,10 @@ function mapEventComponentToEventObject(eventComponent) { } } + if (eventComponent.hasProperty('X-NC-INVITATION-FORWARDING')) { + eventObject.invitationForwarding = eventComponent.getFirstPropertyFirstValue('X-NC-INVITATION-FORWARDING') + } + return eventObject } @@ -221,6 +227,10 @@ function copyCalendarObjectInstanceIntoEventComponent(eventObject, eventComponen eventComponent.addProperty(rule) } + if (eventObject.eventComponent.hasProperty('X-NC-INVITATION-FORWARDING')) { + eventComponent.updatePropertyWithValue('X-NC-INVITATION-FORWARDING', eventObject.invitationForwarding) + } + if (eventObject.customColor) { eventComponent.color = getClosestCSS3ColorNameForHex(eventObject.customColor) } diff --git a/src/store/calendarObjectInstance.js b/src/store/calendarObjectInstance.js index ab366418af..e6299a505c 100644 --- a/src/store/calendarObjectInstance.js +++ b/src/store/calendarObjectInstance.js @@ -385,6 +385,18 @@ export default defineStore('calendarObjectInstance', { calendarObjectInstance.timeTransparency = timeTransparency }, + /** + * Change the invitation-forwarding property of an event + * + * @param {object} data The destructuring object + * @param {object} data.calendarObjectInstance The calendarObjectInstance object + * @param {string} data.invitationForwarding Invitation forwarding value + */ + changeInvitationForwarding({ calendarObjectInstance, invitationForwarding }) { + calendarObjectInstance.eventComponent.updatePropertyWithValue('X-NC-INVITATION-FORWARDING', invitationForwarding) + calendarObjectInstance.invitationForwarding = invitationForwarding + }, + /** * Change the customized color of an event * diff --git a/src/views/EditFull.vue b/src/views/EditFull.vue index 084b97e009..9395acf131 100644 --- a/src/views/EditFull.vue +++ b/src/views/EditFull.vue @@ -243,6 +243,12 @@ :propModel="rfcProps.color" :value="color" @update:value="updateColor" /> + @@ -333,6 +339,7 @@ import IconCancel from '@mdi/svg/svg/cancel.svg?raw' import IconDelete from '@mdi/svg/svg/delete.svg?raw' import { Parameter } from '@nextcloud/calendar-js' +import { translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { generateUrl } from '@nextcloud/router' import { @@ -378,6 +385,7 @@ import useCalendarObjectInstanceStore from '../store/calendarObjectInstance.js' import usePrincipalsStore from '../store/principals.js' import useSettingsStore from '../store/settings.js' import logger from '../utils/logger.js' +import { isAfterVersion } from '../utils/nextcloudVersion.ts' export default { name: 'EditFull', @@ -447,6 +455,19 @@ export default { showCancelDialog: false, showFullModal: true, + + propInvitationForwarding: { + readableName: t('calendar', 'Allow forwarding'), + icon: 'AccountPlusOutline', + options: [ + { value: 'TRUE', label: t('calendar', 'Anyone with the invitation can respond') }, + { value: 'FALSE', label: t('calendar', 'Only invited attendees can respond') }, + ], + + multiple: false, + info: t('calendar', 'Choose "Only invited attendees can respond" to prevent attendees from forwarding the invitation to others.'), + defaultValue: 'TRUE', + }, } }, @@ -476,6 +497,10 @@ export default { return this.calendarObjectInstance?.timeTransparency || null }, + invitationForwarding() { + return this.calendarObjectInstance?.invitationForwarding ?? null + }, + subTitle() { if (!this.calendarObjectInstance) { return '' @@ -506,6 +531,10 @@ export default { return ['ROOM', 'RESOURCE'].includes(attendee.attendeeProperty.userType) }) }, + + showInvitationForwarding() { + return isAfterVersion(34) + }, }, mounted() { @@ -585,6 +614,18 @@ export default { }) }, + /** + * Allow or disallow forwarding of this invitation + * + * @param {string} invitationForwarding Invitation forwarding value + */ + updateInvitationForwarding(invitationForwarding) { + this.calendarObjectInstanceStore.changeInvitationForwarding({ + calendarObjectInstance: this.calendarObjectInstance, + invitationForwarding, + }) + }, + /** * Adds a category to the event * diff --git a/tests/javascript/unit/models/event.test.js b/tests/javascript/unit/models/event.test.js index 2fe7f60115..84670df797 100644 --- a/tests/javascript/unit/models/event.test.js +++ b/tests/javascript/unit/models/event.test.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getDefaultEventObject, mapEventComponentToEventObject } from "../../../../src/models/event.js"; +import { copyCalendarObjectInstanceIntoEventComponent, getDefaultEventObject, mapEventComponentToEventObject } from '../../../../src/models/event.js' import { getDateFromDateTimeValue } from '../../../../src/utils/date.js' import { getHexForColorName } from '../../../../src/utils/color.js' import { mapAlarmComponentToAlarmObject } from '../../../../src/models/alarm.js' @@ -62,6 +62,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDefaultRecurrenceRuleObject).toHaveBeenCalledTimes(1) @@ -104,6 +105,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', otherProp: 'foo', }) @@ -154,6 +156,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -221,7 +224,8 @@ describe('Test suite: Event model (models/event.js)', () => { alarms: [], customColor: null, categories: [], - attachments: [] + attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -289,6 +293,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) const alarms = eventComponent.getAlarmList() @@ -343,6 +348,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: ['BUSINESS', 'HUMAN RESOURCES'], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -399,6 +405,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: '#eeffee', categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -458,6 +465,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -514,6 +522,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -567,6 +576,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -620,6 +630,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -677,6 +688,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -733,6 +745,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -787,6 +800,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -844,6 +858,7 @@ describe('Test suite: Event model (models/event.js)', () => { customColor: null, categories: [], attachments: [], + invitationForwarding: 'TRUE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -858,4 +873,55 @@ describe('Test suite: Event model (models/event.js)', () => { expect(getDefaultRecurrenceRuleObject).toHaveBeenCalledTimes(1) }) + + it('should default invitation forwarding to TRUE', () => { + getDefaultRecurrenceRuleObject + .mockReturnValueOnce({ + defaultRecurrenceObject: true, + }) + + expect(getDefaultEventObject().invitationForwarding).toEqual('TRUE') + }) + + it('should map an event component custom invitation forwarding property', () => { + const recurrenceId = DateTimeValue.fromJSDate(new Date(Date.UTC(2016, 7, 16, 7, 0, 0)), true) + const eventComponent = getEventComponentFromAsset('vcalendars/vcalendar-event-timed', recurrenceId) + eventComponent.updatePropertyWithValue('X-NC-INVITATION-FORWARDING', 'FALSE') + + const mockDate1 = new Date() + const mockDate2 = new Date() + getDateFromDateTimeValue + .mockReturnValueOnce(mockDate1) + .mockReturnValueOnce(mockDate2) + + getDefaultRecurrenceRuleObject + .mockReturnValueOnce({ + defaultRecurrenceObject: true, + }) + + expect(mapEventComponentToEventObject(eventComponent).invitationForwarding).toEqual('FALSE') + }) + + it('should copy the custom invitation forwarding property into a new event component', () => { + const recurrenceId = DateTimeValue.fromJSDate(new Date(Date.UTC(2016, 7, 16, 7, 0, 0)), true) + const sourceEventComponent = getEventComponentFromAsset('vcalendars/vcalendar-event-timed', recurrenceId) + const targetEventComponent = getEventComponentFromAsset('vcalendars/vcalendar-event-timed', recurrenceId) + sourceEventComponent.updatePropertyWithValue('X-NC-INVITATION-FORWARDING', 'FALSE') + + const eventObject = getDefaultEventObject({ + eventComponent: sourceEventComponent, + title: sourceEventComponent.title, + location: sourceEventComponent.location, + description: sourceEventComponent.description, + accessClass: sourceEventComponent.accessClass, + status: sourceEventComponent.status, + timeTransparency: sourceEventComponent.timeTransparency, + invitationForwarding: 'FALSE', + }) + + copyCalendarObjectInstanceIntoEventComponent(eventObject, targetEventComponent) + + expect(targetEventComponent.hasProperty('X-NC-INVITATION-FORWARDING')).toBe(true) + expect(targetEventComponent.getFirstPropertyFirstValue('X-NC-INVITATION-FORWARDING')).toBe('FALSE') + }) })