Skip to content
Merged
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
22 changes: 17 additions & 5 deletions src/store/calendarObjectInstance.js
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@ export default defineStore('calendarObjectInstance', {
// we might be editing an instance or fork, not the base component itself. Both properties
// eventComponent already shared with the base component AND ones it didn't (e.g. a LOCATION
// added for the first time) need to end up on the base component.
const excludedPropertyNames = ['UID', 'RECURRENCE-ID', 'DTSTART', 'DTEND']
const excludedPropertyNames = ['UID', 'RECURRENCE-ID', 'DTSTART', 'DTEND', 'RRULE', 'RDATE', 'EXDATE']
for (const property of baseComponent.getPropertyIterator()) {
if (excludedPropertyNames.includes(property.name)) {
continue
Expand All @@ -1511,13 +1511,25 @@ export default defineStore('calendarObjectInstance', {
}
baseComponent.addProperty(property.clone())
}
// DTSTART and DTEND need to be cloned separately so that internal logic of ical.js
// can adjust all the recurrence rules and exceptions accordingly. Only do so when
// editing the base occurrence - otherwise we risk changing the date/time of the whole
// series when the user only intended to change a single occurrence.
// DTSTART, DTEND, and the recurrence-rule properties need to be handled separately, and
// only when editing the base occurrence - otherwise we risk changing the date/time or
// recurrence pattern of the whole series when the user only intended to change a single
// occurrence. DTSTART/DTEND need cloning separately so ical.js can adjust recurrence
// rules/exceptions accordingly; forkItem()'s COUNT adjustment above is a no-op for the
// primary occurrence itself, so its own RRULE/RDATE/EXDATE are safe to copy as-is here.
if (isPrimaryOccurrence) {
baseComponent.startDate = eventComponent.startDate.clone()
baseComponent.endDate = eventComponent.endDate.clone()

const recurrencePropertyNames = ['RRULE', 'RDATE', 'EXDATE']
for (const propertyName of recurrencePropertyNames) {
baseComponent.deleteAllProperties(propertyName)
}
for (const property of eventComponent.getPropertyIterator()) {
if (recurrencePropertyNames.includes(property.name)) {
baseComponent.addProperty(property.clone())
}
}
}
// Only VALARM is copied here because it's the only sub-component the
// editor currently lets users change; other sub-components (e.g.
Expand Down
60 changes: 60 additions & 0 deletions tests/javascript/unit/store/calendarObjectInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,66 @@ describe('store/calendarObjectInstance test suite', () => {
expect(calendarObjectsStore.createCalendarObjectFromFork).not.toHaveBeenCalled()
expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject })
})

it('preserves the original RRULE when saving series scope from a non-primary occurrence (real calendar-js)', async () => {
// Regression test for a real bug: forkItem() adjusts a forked occurrence's
// own RRULE COUNT down to "occurrences remaining from this point" (needed
// for the "this and future" truncate flow) - but the same fork is also
// used for ordinary editing. Saving with series scope from anything but
// the primary occurrence used to blindly copy that locally-adjusted RRULE
// onto the master, silently truncating the whole series - even when the
// user never touched the recurrence rule at all.
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Nextcloud//calendar-js tests//EN',
'BEGIN:VEVENT',
'UID:series-rrule-preserved-test',
'DTSTART:20260907T100000Z',
'DTEND:20260907T110000Z',
'DTSTAMP:20260901T000000Z',
'SUMMARY:Original title',
'RRULE:FREQ=DAILY;COUNT=5',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')

const parser = getParserManager().getParserForFileType('text/calendar')
parser.parse(ics)
const calendarComponent = parser.getItemIterator().next().value

let masterComponent = null
for (const component of calendarComponent.getComponentIterator()) {
if (component.name === 'VEVENT' && !component.hasProperty('RECURRENCE-ID')) {
masterComponent = component
}
}
const rangeEnd = masterComponent.startDate.clone()
rangeEnd.year += 1
// The 3rd occurrence - not the primary
const thirdOccurrence = masterComponent.recurrenceManager.getAllOccurrencesBetween(masterComponent.startDate, rangeEnd)[2]

// The user only edits an unrelated property, never touching the recurrence rule
thirdOccurrence.updatePropertyWithValue('SUMMARY', 'Edited title')
thirdOccurrence.markDirty()

const calendarObject = { calendarId: 'personal', calendarComponent: markRaw(calendarComponent) }

const store = useCalendarObjectInstanceStore()
const calendarObjectsStore = useCalendarObjectsStore()
store.calendarObject = calendarObject
store.calendarObjectInstance = { eventComponent: markRaw(thirdOccurrence) }
mockedisBaseOccurrence.mockReturnValue(false)
vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue()

await store.saveCalendarObjectInstance({
scope: 'series',
calendarId: 'personal',
})

expect(masterComponent.getFirstPropertyFirstValue('SUMMARY')).toBe('Edited title')
expect(masterComponent.getFirstPropertyFirstValue('RRULE').count).toBe(5)
})
})

describe('deleteCalendarObjectInstance', () => {
Expand Down
Loading