From 3952345caf4ed1b16344c8bfb9dff7eefcab0b2c Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Tue, 9 Jun 2026 14:35:18 +0000 Subject: [PATCH] fix(store): propagate organizer/attendees to recurrence-exceptions on master in-place update Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nico Donath --- src/store/calendarObjectInstance.js | 34 ++++++ .../unit/store/calendarObjectInstance.test.js | 107 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 tests/javascript/unit/store/calendarObjectInstance.test.js diff --git a/src/store/calendarObjectInstance.js b/src/store/calendarObjectInstance.js index 8fdf1c9614..4390938aa5 100644 --- a/src/store/calendarObjectInstance.js +++ b/src/store/calendarObjectInstance.js @@ -34,6 +34,35 @@ import useCalendarsStore from './calendars.js' import useSettingsStore from './settings.js' import { updateRoomParticipantsFromEvent } from '@/services/talkService' +/** + * Overwrites the ATTENDEE/ORGANIZER of the master's current-and-future exceptions + * with clones of the master's own, after an in-place "this and all future" master update. + * + * @param {object} eventComponent Forked event component being saved. + */ +export function syncMasterParticipantsOntoFutureExceptions(eventComponent) { + if (!eventComponent.isExactForkOfPrimary || !eventComponent.primaryItem.isMasterItem()) { + return + } + const master = eventComponent.primaryItem + const ref = eventComponent.getReferenceRecurrenceId() + const futureExceptions = master.recurrenceManager + .getRecurrenceExceptionList() + .filter((exception) => ref.compare(exception.recurrenceId) <= 0) + const masterAttendees = master.getAttendeeList() + const masterOrganizer = master.getFirstProperty('ORGANIZER') + for (const exception of futureExceptions) { + exception.deleteAllProperties('ATTENDEE') + for (const att of masterAttendees) { + exception.addProperty(att.clone()) + } + if (masterOrganizer) { + exception.deleteAllProperties('ORGANIZER') + exception.addProperty(masterOrganizer.clone()) + } + } +} + export default defineStore('calendarObjectInstance', { state: () => { return { @@ -1518,6 +1547,11 @@ export default defineStore('calendarObjectInstance', { // - eventComponent.canCreateRecurrenceExceptions() - Can we create a recurrence-exception for this item if (isForkedItem && eventComponent.canCreateRecurrenceExceptions()) { [original, fork] = eventComponent.createRecurrenceException(thisAndAllFuture) + + // calendar-js leaves existing exceptions untouched on in-place master overrides. + if (thisAndAllFuture) { + syncMasterParticipantsOntoFutureExceptions(eventComponent) + } } await calendarObjectsStore.updateCalendarObject({ calendarObject }) diff --git a/tests/javascript/unit/store/calendarObjectInstance.test.js b/tests/javascript/unit/store/calendarObjectInstance.test.js new file mode 100644 index 0000000000..db59fca1e7 --- /dev/null +++ b/tests/javascript/unit/store/calendarObjectInstance.test.js @@ -0,0 +1,107 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { setActivePinia, createPinia } from 'pinia' +import { syncMasterParticipantsOntoFutureExceptions } from '../../../../src/store/calendarObjectInstance.js' + +describe('store/calendarObjectInstance syncMasterParticipantsOntoFutureExceptions', () => { + + beforeEach(() => { + setActivePinia(createPinia()) + }) + + const makeProperty = (tag, id) => { + const property = { tag, id } + property.clone = () => ({ ...property, cloned: true }) + return property + } + + const makeException = (recurrenceId) => ({ + recurrenceId, + deleted: [], + added: [], + deleteAllProperties(name) { + this.deleted.push(name) + }, + addProperty(property) { + this.added.push(property) + }, + }) + + const makeMaster = ({ attendees, organizer, exceptions }) => ({ + isMasterItem: () => true, + getAttendeeList: () => attendees, + getFirstProperty: (name) => (name === 'ORGANIZER' ? organizer : null), + recurrenceManager: { + getRecurrenceExceptionList: () => exceptions, + }, + }) + + const makeEventComponent = (master, referenceRecurrenceId) => ({ + isExactForkOfPrimary: true, + primaryItem: master, + getReferenceRecurrenceId: () => referenceRecurrenceId, + }) + + it('does nothing when the component is not an exact fork of its primary', () => { + const exception = makeException({ compare: () => 0 }) + const master = makeMaster({ attendees: [makeProperty('ATTENDEE', 'a')], organizer: makeProperty('ORGANIZER', 'o'), exceptions: [exception] }) + const eventComponent = makeEventComponent(master, { compare: () => 0 }) + eventComponent.isExactForkOfPrimary = false + + syncMasterParticipantsOntoFutureExceptions(eventComponent) + + expect(exception.deleted).toEqual([]) + expect(exception.added).toEqual([]) + }) + + it('does nothing when the primary item is not the master', () => { + const exception = makeException({}) + const master = makeMaster({ attendees: [], organizer: null, exceptions: [exception] }) + master.isMasterItem = () => false + const eventComponent = makeEventComponent(master, { compare: () => 0 }) + + syncMasterParticipantsOntoFutureExceptions(eventComponent) + + expect(exception.deleted).toEqual([]) + expect(exception.added).toEqual([]) + }) + + it('overwrites attendees and organizer on current-and-future exceptions only', () => { + // ref.compare(exceptionId): +1 when ref is after the exception (past, skipped), + // 0 when equal, -1 when ref is before it (future). Filter keeps <= 0. + const ref = { compare: (recurrenceId) => recurrenceId.cmp } + const past = makeException({ cmp: 1 }) + const current = makeException({ cmp: 0 }) + const future = makeException({ cmp: -1 }) + const attendees = [makeProperty('ATTENDEE', 'a1'), makeProperty('ATTENDEE', 'a2')] + const organizer = makeProperty('ORGANIZER', 'o') + const master = makeMaster({ attendees, organizer, exceptions: [past, current, future] }) + const eventComponent = makeEventComponent(master, ref) + + syncMasterParticipantsOntoFutureExceptions(eventComponent) + + expect(past.deleted).toEqual([]) + expect(past.added).toEqual([]) + + for (const exception of [current, future]) { + expect(exception.deleted).toEqual(['ATTENDEE', 'ORGANIZER']) + expect(exception.added.map((p) => p.id)).toEqual(['a1', 'a2', 'o']) + expect(exception.added.every((p) => p.cloned)).toBe(true) + } + }) + + it('overwrites only attendees when the master has no organizer', () => { + const ref = { compare: () => 0 } + const exception = makeException({}) + const attendees = [makeProperty('ATTENDEE', 'a1')] + const master = makeMaster({ attendees, organizer: null, exceptions: [exception] }) + const eventComponent = makeEventComponent(master, ref) + + syncMasterParticipantsOntoFutureExceptions(eventComponent) + + expect(exception.deleted).toEqual(['ATTENDEE']) + expect(exception.added.map((p) => p.id)).toEqual(['a1']) + }) +})