diff --git a/src/recurrence/recurrenceManager.js b/src/recurrence/recurrenceManager.js index 38ecdcd0..3b45759f 100644 --- a/src/recurrence/recurrenceManager.js +++ b/src/recurrence/recurrenceManager.js @@ -812,14 +812,17 @@ export default class RecurrenceManager { exdate.addDuration(difference) } - for (const recurrenceException of this.getRecurrenceExceptionIterator()) { - // We don't edit RDATES, so don't update recurrence-ids if they - // are based on an RDATE - if (this.hasRecurrenceDate(false, recurrenceException.recurrenceId)) { - continue - } - + // Snapshot the exceptions first because mutating the live Map while iterating it makes + // the iterator revisit reinserted items, causing an infinite loop. + const recurrenceExceptions = this.getRecurrenceExceptionList() + // Remove all rule-based exceptions before reinserting any shifted keys. This + // prevents adjacent exceptions from overwriting entries at still-occupied keys. + for (const recurrenceException of recurrenceExceptions.filter((recurrenceException) => !this.hasRecurrenceDate(false, recurrenceException.recurrenceId))) { this.removeRecurrenceException(recurrenceException) + } + // Shift and relate the exceptions again under their new recurrence IDs, rebuilding + // the regular and range-exception indexes in the process. + for (const recurrenceException of recurrenceExceptions.filter((recurrenceException) => !this.hasRecurrenceDate(false, recurrenceException.recurrenceId))) { recurrenceException.recurrenceId.addDuration(difference) this.relateRecurrenceException(recurrenceException) } diff --git a/tests/integration/editing/updateMasterStartDate.test.js b/tests/integration/editing/updateMasterStartDate.test.js new file mode 100644 index 00000000..9954cab2 --- /dev/null +++ b/tests/integration/editing/updateMasterStartDate.test.js @@ -0,0 +1,190 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getParserManager } from '../../../src/index.js' +import DurationValue from '../../../src/values/durationValue.js' + +jest.mock('../../../src/factories/dateFactory.js') + +/** + * Parse a calendar and return its recurring master item. + * + * @param {string} ics iCalendar data + * @return {AbstractRecurringComponent} + */ +function getMasterItem(ics) { + const parser = getParserManager().getParserForFileType('text/calendar') + parser.parse(ics) + const calendarComponent = parser.getItemIterator().next().value + + return Array.from(calendarComponent.getComponentIterator()) + .find(component => component.name === 'VEVENT' && !component.hasProperty('RECURRENCE-ID')) +} + +/** + * Build a minimal recurring calendar from VEVENT property arrays. + * + * @param {string[]} masterProperties Properties of the master VEVENT + * @param {string[][]} exceptionProperties Properties of exception VEVENTs + * @return {string} + */ +function buildCalendar(masterProperties, exceptionProperties = []) { + const event = properties => ['BEGIN:VEVENT', 'UID:update-start-test', ...properties, 'END:VEVENT'] + + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud Tests//calendar-js//EN', + ...event(masterProperties), + ...exceptionProperties.flatMap(event), + 'END:VCALENDAR', + ].join('\r\n') +} + +/** + * Shift a master item by a number of seconds through its public setter. + * + * @param {AbstractRecurringComponent} masterItem Master event + * @param {number} seconds Number of seconds to shift + */ +function shiftMaster(masterItem, seconds) { + const shiftedStart = masterItem.startDate.clone() + shiftedStart.addDuration(DurationValue.fromSeconds(seconds)) + masterItem.startDate = shiftedStart +} + +const asICALString = value => value.toICALJs().toString() + +describe('updateStartDateOfMasterItem', () => { + it('shifts an existing recurrence exception once without hanging', () => { + const masterItem = getMasterItem(getAsset('weekly-recurring-with-exception')) + const recurrenceManager = masterItem.recurrenceManager + const originalExceptionRecurrenceId = recurrenceManager.getRecurrenceExceptionList()[0].recurrenceId.clone() + + shiftMaster(masterItem, 24 * 60 * 60) + + const exceptionsAfter = recurrenceManager.getRecurrenceExceptionList() + expect(exceptionsAfter).toHaveLength(1) + + const expectedShiftedRecurrenceId = originalExceptionRecurrenceId.clone() + expectedShiftedRecurrenceId.addDuration(DurationValue.fromSeconds(24 * 60 * 60)) + expect(exceptionsAfter[0].recurrenceId.compare(expectedShiftedRecurrenceId)).toEqual(0) + }) + + it('preserves every exception when adjacent recurrence IDs shift onto occupied map keys', () => { + const masterItem = getMasterItem(buildCalendar( + ['DTSTART:20240101T090000Z', 'RRULE:FREQ=DAILY;COUNT=10'], + [ + ['DTSTART:20240102T100000Z', 'RECURRENCE-ID:20240102T090000Z'], + ['DTSTART:20240103T100000Z', 'RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T090000Z'], + ['DTSTART:20240104T100000Z', 'RECURRENCE-ID:20240104T090000Z'], + ], + )) + const recurrenceManager = masterItem.recurrenceManager + + shiftMaster(masterItem, 24 * 60 * 60) + + expect(recurrenceManager.getRecurrenceExceptionList() + .map(exception => asICALString(exception.recurrenceId)) + .sort()).toEqual([ + '2024-01-03T09:00:00Z', + '2024-01-04T09:00:00Z', + '2024-01-05T09:00:00Z', + ]) + const rangeException = recurrenceManager.getRecurrenceExceptionList() + .find(exception => exception.modifiesFuture()) + expect(recurrenceManager.getRangeRecurrenceExceptionForId( + recurrenceManager.getRecurrenceExceptionList().at(-1).recurrenceId, + )).toEqual(rangeException) + }) + + it.each([ + ['forward across a month boundary', 36 * 60 * 60, '2024-02-02T21:00:00Z'], + ['backward across a month boundary', -12 * 60 * 60, '2024-01-31T21:00:00Z'], + ['by a zero-length duration', 0, '2024-02-01T09:00:00Z'], + ])('shifts recurrence IDs %s', (_scenario, seconds, expectedRecurrenceId) => { + const masterItem = getMasterItem(buildCalendar( + ['DTSTART:20240131T090000Z', 'RRULE:FREQ=DAILY;COUNT=5'], + [['DTSTART:20240201T100000Z', 'RECURRENCE-ID:20240201T090000Z']], + )) + + shiftMaster(masterItem, seconds) + + expect(asICALString(masterItem.recurrenceManager.getRecurrenceExceptionList()[0].recurrenceId)) + .toEqual(expectedRecurrenceId) + }) + + it('shifts EXDATE values except those that also match an unchanged RDATE', () => { + const masterItem = getMasterItem(buildCalendar([ + 'DTSTART:20240101T090000Z', + 'RRULE:FREQ=DAILY;COUNT=10', + 'RDATE:20240105T090000Z', + 'EXDATE:20240103T090000Z,20240105T090000Z', + ])) + + shiftMaster(masterItem, 24 * 60 * 60) + + expect(masterItem.recurrenceManager.listAllRecurrenceDates(false).map(asICALString)) + .toEqual(['2024-01-05T09:00:00Z']) + expect(masterItem.recurrenceManager.listAllRecurrenceDates(true).map(asICALString).sort()) + .toEqual(['2024-01-04T09:00:00Z', '2024-01-05T09:00:00Z']) + }) + + it('leaves an RDATE-based exception unchanged while shifting a rule-based exception', () => { + const masterItem = getMasterItem(buildCalendar( + ['DTSTART:20240101T090000Z', 'RRULE:FREQ=DAILY;COUNT=10', 'RDATE:20240110T090000Z'], + [ + ['DTSTART:20240103T100000Z', 'RECURRENCE-ID:20240103T090000Z'], + ['DTSTART:20240110T100000Z', 'RECURRENCE-ID:20240110T090000Z'], + ], + )) + + shiftMaster(masterItem, 24 * 60 * 60) + + expect(masterItem.recurrenceManager.getRecurrenceExceptionList() + .map(exception => asICALString(exception.recurrenceId)) + .sort()).toEqual(['2024-01-04T09:00:00Z', '2024-01-10T09:00:00Z']) + }) + + it('shifts RRULE UNTIL while leaving a COUNT-based rule unchanged', () => { + const untilMaster = getMasterItem(buildCalendar([ + 'DTSTART:20240101T090000Z', + 'RRULE:FREQ=DAILY;UNTIL=20240131T090000Z', + ])) + const countMaster = getMasterItem(buildCalendar([ + 'DTSTART:20240101T090000Z', + 'RRULE:FREQ=DAILY;COUNT=10', + ])) + + shiftMaster(untilMaster, 2 * 24 * 60 * 60) + shiftMaster(countMaster, 2 * 24 * 60 * 60) + + const untilRule = untilMaster.recurrenceManager.getRecurrenceRuleList()[0] + const countRule = countMaster.recurrenceManager.getRecurrenceRuleList()[0] + expect(asICALString(untilRule.until)).toEqual('2024-02-02T09:00:00Z') + expect(countRule.until).toBeNull() + expect(countRule.count).toEqual(10) + }) + + it('shifts all-day recurrence metadata by whole days', () => { + const masterItem = getMasterItem(buildCalendar( + [ + 'DTSTART;VALUE=DATE:20240228', + 'RRULE:FREQ=DAILY;UNTIL=20240305', + 'EXDATE;VALUE=DATE:20240301', + ], + [['DTSTART;VALUE=DATE:20240303', 'RECURRENCE-ID;VALUE=DATE:20240302']], + )) + + shiftMaster(masterItem, 2 * 24 * 60 * 60) + + expect(asICALString(masterItem.recurrenceManager.listAllRecurrenceDates(true)[0])) + .toEqual('2024-03-03') + expect(asICALString(masterItem.recurrenceManager.getRecurrenceExceptionList()[0].recurrenceId)) + .toEqual('2024-03-04') + expect(asICALString(masterItem.recurrenceManager.getRecurrenceRuleList()[0].until)) + .toEqual('2024-03-07') + }) +}) diff --git a/tests/integration/parser/parser.test.js b/tests/integration/parser/parser.test.js index a363d4e8..670cb133 100644 --- a/tests/integration/parser/parser.test.js +++ b/tests/integration/parser/parser.test.js @@ -2252,7 +2252,7 @@ it ('edit first occurrence - this and future', () => { 'DTSTAMP:20190703T172836Z\r\n' + 'DTSTART;TZID=Europe/Berlin:20190717T090000\r\n' + 'SEQUENCE:0\r\n' + - 'RECURRENCE-ID;TZID=Europe/Berlin:20190719T080000\r\n' + + 'RECURRENCE-ID;TZID=Europe/Berlin:20190715T100000\r\n' + 'END:VEVENT\r\n' + 'BEGIN:VEVENT\r\n' + 'CREATED:20190703T172822Z\r\n' + @@ -2265,7 +2265,7 @@ it ('edit first occurrence - this and future', () => { 'DTSTAMP:20190703T172836Z\r\n' + 'DTSTART;TZID=Europe/Berlin:20190731T090000\r\n' + 'SEQUENCE:0\r\n' + - 'RECURRENCE-ID;TZID=Europe/Berlin:20190801T090000\r\n' + + 'RECURRENCE-ID;TZID=Europe/Berlin:20190729T100000\r\n' + 'END:VEVENT\r\n' + 'BEGIN:VEVENT\r\n' + 'CREATED:20190703T172822Z\r\n' + @@ -2279,7 +2279,7 @@ it ('edit first occurrence - this and future', () => { 'DTSTAMP:20190703T172836Z\r\n' + 'DTSTART;TZID=Europe/Berlin:20190714T090000\r\n' + 'SEQUENCE:0\r\n' + - 'RECURRENCE-ID;TZID=Europe/Berlin:20190719T080000\r\n' + + 'RECURRENCE-ID;TZID=Europe/Berlin:20190703T100000\r\n' + 'END:VEVENT\r\n' + 'BEGIN:VEVENT\r\n' + 'CREATED:20190703T172822Z\r\n' +