Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/store/calendarObjectInstance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 })
Expand Down
107 changes: 107 additions & 0 deletions tests/javascript/unit/store/calendarObjectInstance.test.js
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading