diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index b80271483..05f7289f7 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -744,8 +744,12 @@ describe('saveOccurrenceEdit for every occurrence', () => { expect(saved).toBe(true); expect(deps.findMasterEvent).toHaveBeenCalledWith(occurrence); // What the user changed, and only that: the calendar, the description - // and the rule the editor wrote as they were stay the series' own. - expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { title: 'Planning, room 2' }, true); + // and the rule the editor wrote as they were stay the series' own. The + // attendees are told, and 19 October has an override: the SEQUENCE goes + // up (see below). + expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { + title: 'Planning, room 2', sequence: 1, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 1, + }, true, { keepSequence: true }); }); it('reports a series it cannot find', async () => { @@ -762,6 +766,64 @@ describe('saveOccurrenceEdit for every occurrence', () => { }); +// 0.16 sends the change of one occurrence with its SEQUENCE raised, without +// keeping the raise itself. A later rename of every occurrence carried the +// lower number for it, and the attendee's server refused the whole change: +// the mail arrived, the copy stayed as it was (live on 0.16.23). 1.0 raises +// every occurrence's SEQUENCE the same way itself for a change it sends. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the SEQUENCE of a changed series on Stalwart %s', (_label, version) => { + // Five Mondays from 5 October, 19 October with an override: on 0.16 it + // holds a sequence of its own (copied when it got the override), on 1.0 + // the PatchObject leaves it out while it is the series'. + const withSequences = (overrides: CalendarEvent['recurrenceOverrides'] = series(version).recurrenceOverrides) => ({ + ...series(version), sequence: 2, + recurrenceOverrides: version === '1.0' + ? overrides + : Object.fromEntries(Object.entries(overrides ?? {}).map(([key, entry]) => [key, { ...entry, sequence: 2 }])), + }) as CalendarEvent; + const save = (master: CalendarEvent, change: Partial, extra: Partial[1]> = {}, editor = {}) => { + const occurrence = { ...serverOccurrence(version), id: 'maaaaad', start: '2026-10-26T10:00:00', recurrenceId: '2026-10-26T10:00:00', utcStart: '2026-10-26T09:00:00Z', utcEnd: '2026-10-26T10:00:00Z' } as CalendarEvent; + const deps = actions(master); + return saveOccurrenceEdit('all', { + event: occurrence, updates: editorUpdates(occurrence, change), editor: editorOn(occurrence, editor), + sendScheduling: true, userEmails: [ORGANIZER], ...extra, + }, deps).then(() => deps.updateEvent.mock.calls[0]); + }; + + it('raises the series\' and each changed occurrence\'s by one on a new title', async () => { + const [, id, patch, send, options] = await save(withSequences(), { title: 'Planning, room 2' }); + + expect([id, send, options]).toEqual(['b', true, { keepSequence: true }]); + expect(patch).toEqual({ title: 'Planning, room 2', sequence: 3, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 3 }); + }); + + it('raises them inside the overrides when the series moves', async () => { + const occurrenceStart = getEventStartDate({ ...serverOccurrence(version), start: '2026-10-26T10:00:00', utcStart: '2026-10-26T09:00:00Z' } as CalendarEvent); + const retitled = withSequences({ '2026-10-19T10:00:00': { title: 'Planning, special' } } as never); + const [, , patch, , options] = await save(retitled, {}, {}, { + when: { allDay: false, start: addHours(occurrenceStart, 1), end: addHours(occurrenceStart, 2) }, + }); + + expect(options).toEqual({ keepSequence: true }); + expect(patch.sequence).toBe(3); + expect(patch.recurrenceOverrides['2026-10-19T11:00:00'].sequence).toBe(3); + expect(Object.keys(patch).some((key: string) => key.startsWith('recurrenceOverrides/'))).toBe(false); + }); + + it('leaves them alone when nothing goes out, nothing the attendees see changes, or no occurrence has an override', async () => { + const quiet = await save(withSequences(), { title: 'Planning, room 2' }, { sendScheduling: false }); + const alerts = await save(withSequences(), { + alerts: { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } } as never, + }); + const plain = await save({ ...withSequences(), recurrenceOverrides: null } as CalendarEvent, { title: 'Planning, room 2' }); + + for (const [, , patch, , options] of [quiet, alerts, plain]) { + expect(patch).not.toHaveProperty('sequence'); + expect(options).toBeUndefined(); + } + }); +}); + // "This and the following occurrences" ends the series before the // occurrence and makes a new series of the rest. The new series kept the // answers, and Stalwart invites to a new event only attendees without one: diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 0b0389d22..efd602b56 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -52,7 +52,8 @@ export interface CalendarEventUpdateOptions { /** * Send `sequence` instead of stripping it with the other server-managed * fields. Only for a patch that creates a recurrence override and must carry - * the series' sequence (see buildOccurrenceRsvpPatch). + * the series' sequence (see buildOccurrenceRsvpPatch), or a change of a + * whole series that raises it (see withRaisedSequences). */ keepSequence?: boolean; } diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index a3f17d163..bedf5eb3a 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -506,6 +506,63 @@ export interface EventUpdateOptions { * (see the calendar store's updateEvent). */ asOrganizer?: boolean; + /** The update sets `sequence` itself (withRaisedSequences); it is not stripped. */ + keepSequence?: boolean; +} + +/** Changes of a series its attendees see - Stalwart announces them, and 1.0 raises the SEQUENCE for them. */ +const ANNOUNCED_KEYS = [ + 'title', 'description', 'locations', 'virtualLocations', 'status', 'participants', 'recurrenceRules', + 'excludedRecurrenceRules', 'recurrenceOverrides', ...WHEN_KEYS, +] as const; + +/** + * `series`, a change to the series `master` its attendees are told of, with + * the SEQUENCE of the series and of each of its changed occurrences one up. + * + * 0.16 (0.16.6 to 0.16.23) sends the change of one occurrence with that + * occurrence's SEQUENCE raised, but does not keep the raise itself (in + * organizer.rs the list of components to raise is shadowed for such + * changes). A later change of every occurrence that does not raise the + * number again - a new title - then carried a lower SEQUENCE for that + * occurrence than the attendee's copy has, and the attendee's server + * refused the whole change as out of sequence: the mail arrived, the copy + * stayed as it was (live on 0.16.23). One up from what the organizer's + * copy holds is at least what the attendees got. 1.0 raises every + * occurrence's SEQUENCE the same way itself for a change it announces, and + * keeps a number it would have given (live: the same result with and + * without). + */ +function withRaisedSequences(series: Partial, master: CalendarEvent): Partial { + const base = master.sequence ?? 0; + const raised: Record = { ...series, sequence: base + 1 }; + const raise = (entry: OverrideEntry) => (entry.excluded ? entry : { ...entry, sequence: Number(entry.sequence ?? base) + 1 }); + if (series.recurrenceOverrides !== undefined) { + if (series.recurrenceOverrides) { + raised.recurrenceOverrides = Object.fromEntries(Object.entries(series.recurrenceOverrides) + .map(([key, entry]) => [key, raise(entry as OverrideEntry)])); + } + } else { + for (const [key, entry] of Object.entries(master.recurrenceOverrides ?? {})) { + if (!(entry as OverrideEntry).excluded) raised[`recurrenceOverrides/${key}/sequence`] = raise(entry as OverrideEntry).sequence; + } + } + return raised as Partial; +} + +/** + * Whether saving `series` for every occurrence of `master` raises their + * SEQUENCE (withRaisedSequences): the user organizes the series and tells + * its attendees of a change they see, and it has occurrences of its own a + * change of one of them may have left behind. + */ +function raisesSequences(series: Partial, master: CalendarEvent, edit: OccurrenceEdit): boolean { + return !!edit.sendScheduling + && !!edit.editor + && Object.keys(master.participants ?? {}).length > 0 + && userOrganizes(master, edit.userEmails) + && Object.values(master.recurrenceOverrides ?? {}).some((entry) => !(entry as OverrideEntry).excluded) + && ANNOUNCED_KEYS.some((key) => key in series); } /** What saving an occurrence edit works with: the calendar store's actions and the app's series lookup. */ @@ -915,6 +972,10 @@ export async function saveOccurrenceEdit( seriesUpdates = { ...updates }; delete seriesUpdates.recurrenceId; } + if (raisesSequences(seriesUpdates, master, edit)) { + await updateEvent(client, master.id, withRaisedSequences(seriesUpdates, master), sendScheduling, { keepSequence: true }); + return true; + } await updateEvent(client, master.id, seriesUpdates, sendScheduling); return true; } diff --git a/stores/__tests__/calendar-store-synthetic-ids.test.ts b/stores/__tests__/calendar-store-synthetic-ids.test.ts index ffa4247a1..e490fbd25 100644 --- a/stores/__tests__/calendar-store-synthetic-ids.test.ts +++ b/stores/__tests__/calendar-store-synthetic-ids.test.ts @@ -191,6 +191,18 @@ describe('updateEvent on an expanded occurrence', () => { expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2); }); + // A change of every occurrence that raises their SEQUENCE sends it (see + // withRaisedSequences in lib/recurrence-scope-edit.ts). + it('keeps the sequence of a change to the whole series when asked to', async () => { + const client = fakeClient(); + client.queryAllCalendarEvents.mockResolvedValue([occurrence()]); + await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]); + + await useCalendarStore.getState().updateEvent(client, 'b', { title: 'Whole series', sequence: 3 }, true, { keepSequence: true }); + + expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { title: 'Whole series', sequence: 3 }, true, undefined, { keepSequence: true }); + }); + it('does not reload after changing an ordinary event', async () => { const client = fakeClient(); const plain = occurrence({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceId: null, recurrenceRules: null }); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index e90520a4a..78a009f74 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -165,19 +165,23 @@ async function updateBaseEvent( updates: Partial, sendSchedulingMessages: boolean | undefined, targetAccountId: string | undefined, + options?: CalendarEventUpdateOptions, ): Promise { + const write = (patch: Partial) => (options + ? client.updateCalendarEvent(id, patch, sendSchedulingMessages, targetAccountId, options) + : client.updateCalendarEvent(id, patch, sendSchedulingMessages, targetAccountId)); const writesOverrides = Object.keys(updates).some((key) => key.split('/', 1)[0] === 'recurrenceOverrides'); if (writesOverrides && overrideSeriesKeysRejected.has(client)) { - await client.updateCalendarEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, targetAccountId); + await write(withoutOverrideSeriesKeys(updates)); return; } try { - await client.updateCalendarEvent(id, updates, sendSchedulingMessages, targetAccountId); + await write(updates); } catch (error) { if (!writesOverrides || !isOverrideSeriesKeyRejection(error)) throw error; overrideSeriesKeysRejected.add(client); debug.log('calendar', 'Server refuses series keys inside recurrence overrides; writing them without'); - await client.updateCalendarEvent(id, withoutOverrideSeriesKeys(updates), sendSchedulingMessages, targetAccountId); + await write(withoutOverrideSeriesKeys(updates)); } } @@ -1044,7 +1048,10 @@ export const useCalendarStore = create()( await updateBrowserOccurrence(client, target, cleanUpdates, sendSchedulingMessages); } } else { - await updateBaseEvent(client, realId, cleanUpdates, sendSchedulingMessages, targetAccountId); + await updateBaseEvent( + client, realId, cleanUpdates, sendSchedulingMessages, targetAccountId, + options?.keepSequence ? { keepSequence: true } : undefined, + ); } set((state) => ({ events: state.events.map(e => {