From 8918b789e96def998ca1cca731edddfa0dd4c25d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:03:48 +0200 Subject: [PATCH] fix: keep the organizer and list occurrences once in the rest of a split series Two faults in the new series "This and following events" makes, found on 0.16.23 with the app's own code against the test server (1.0 did not show them): - The new series named its organizer only when the editor wrote one, which it does only for an event that had none. Created without it, 0.16.23 makes the user who creates the event its organizer, whatever the owner participant says: a sharee who split the owner's meeting became the organizer of the rest (organizerCalendarAddress mailto:usera, isOrigin false, the invitation sent from the sharee), and a series organized under an alias would have moved to the user's main address. 1.0 takes the owner participant. The new series now carries the series' organizer address whenever it has participants. - The overrides the new series takes over kept the old series' SEQUENCE while the new series starts at its own, and 0.16.23 listed each such occurrence twice, as changed and as the series has it, for the organizer and the attendee alike. The overrides now leave the old number behind. --- .../__tests__/recurrence-scope-edit.test.ts | 79 +++++++++++++++++++ src/lib/recurrence-scope-edit.ts | 15 +++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/recurrence-scope-edit.test.ts b/src/lib/__tests__/recurrence-scope-edit.test.ts index 0dab8934..01d6c7ab 100644 --- a/src/lib/__tests__/recurrence-scope-edit.test.ts +++ b/src/lib/__tests__/recurrence-scope-edit.test.ts @@ -1380,3 +1380,82 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('a series m expect(updates.recurrenceOverrides).toBeNull(); }); }); + +// The rest of a split series is a new event, and 0.16.23 makes whoever +// creates it the organizer unless it names one: a sharee who split the +// owner's meeting became its organizer (live, organizerCalendarAddress +// mailto:usera, isOrigin false), and so would a user's main address for a +// series organized under an alias. 1.0 takes the owner participant. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the organizer of the rest of a split series on Stalwart %s', (_label, version) => { + it('is the series\' own when a sharee splits the owner\'s meeting', async () => { + const occurrence = serverOccurrence(version); + const deps = actions({ ...series(version), isOrigin: true, isShared: true, accountId: 'f' }); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence), + sendScheduling: true, userEmails: [SHAREE], + }, deps); + + expect(deps.createEvent.mock.calls[0][0].organizerCalendarAddress).toBe(`mailto:${ORGANIZER}`); + }); + + it('is the exact address a series organized under an alias has', async () => { + const alias = 'planning@example.org'; + const participants = { org: participant(version, alias, true, 'accepted'), b: participant(version, ATTENDEE, false, 'accepted') }; + const master = { ...series(version), organizerCalendarAddress: `mailto:${alias}`, participants, recurrenceOverrides: null } as CalendarEvent; + const occurrence = { ...serverOccurrence(version), organizerCalendarAddress: `mailto:${alias}`, participants } as CalendarEvent; + const deps = actions(master); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence), + sendScheduling: true, userEmails: [ORGANIZER, alias], + }, deps); + + expect(deps.createEvent.mock.calls[0][0].organizerCalendarAddress).toBe(`mailto:${alias}`); + }); + + it('is left out with the participants when the user removed every attendee', async () => { + const occurrence = serverOccurrence(version); + const deps = actions(series(version)); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { participants: null, organizerCalendarAddress: null }), + editor: editorOn(occurrence, { attendees: [] }), sendScheduling: false, userEmails: [ORGANIZER], + }, deps); + + const created = deps.createEvent.mock.calls[0][0]; + expect(created.participants).toBeNull(); + expect(created.organizerCalendarAddress).toBeNull(); + }); +}); + +// The new series of a split starts at a SEQUENCE of its own. An override +// that brought the old series' number along made 0.16.23 list its +// occurrence twice in the new series - changed, and as the series has it +// (live, organizer and attendee alike). +describe('the overrides a split gives the new series (Stalwart 0.16.23)', () => { + it('leave the old series\' SEQUENCE behind', async () => { + const participants = { org: participant('0.16', ORGANIZER, true, 'accepted'), b: participant('0.16', ATTENDEE, false, 'accepted') }; + const master = { + ...series('0.16'), sequence: 3, start: '2026-12-07T10:00:00', utcStart: '2026-12-07T09:00:00Z', utcEnd: '2026-12-07T10:00:00Z', + participants, recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 6 }], + recurrenceOverrides: { + '2027-01-04T10:00:00': { updated: '2026-11-20T09:00:00Z', start: '2027-01-04T10:00:00', duration: 'PT1H', sequence: 3, title: 'Planning, special', participants }, + }, + } as unknown as CalendarEvent; + const occurrence = { + ...master, id: 'maaaaae', baseEventId: 'b', start: '2026-12-21T10:00:00', recurrenceId: '2026-12-21T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', utcStart: berlinUtc('2026-12-21T10:00:00'), utcEnd: berlinUtc('2026-12-21T10:00:00', 1), + } as CalendarEvent; + const deps = actions(master); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + const created = deps.createEvent.mock.calls[0][0]; + expect(created).not.toHaveProperty('sequence'); + expect(created.recurrenceOverrides!['2027-01-04T10:00:00']).toMatchObject({ title: 'Planning, special' }); + expect(created.recurrenceOverrides!['2027-01-04T10:00:00']).not.toHaveProperty('sequence'); + }); +}); diff --git a/src/lib/recurrence-scope-edit.ts b/src/lib/recurrence-scope-edit.ts index 08a7d0de..a96def8f 100644 --- a/src/lib/recurrence-scope-edit.ts +++ b/src/lib/recurrence-scope-edit.ts @@ -785,7 +785,11 @@ function followingOverrides( const result: OverrideMap = {}; for (const { key, target, override } of rekeyed(overrides, (key) => overrideKeyIn(key, from, to, splitKey))) { const opened = key === splitKey; - const { updated: _updated, ...rest } = override as OverrideEntry; + // The new series starts at a SEQUENCE of its own; 0.16 lists an + // occurrence whose override holds another number twice - the old + // series' number came along, and the new series showed that occurrence + // as changed and as the series has it (live on 0.16.23). + const { updated: _updated, sequence: _sequence, ...rest } = override as OverrideEntry; let entry = withParticipantsOf(rest, to.participants); if (!entry.excluded) { if (turned) { @@ -880,9 +884,16 @@ export function followingSeriesData( delete raw.recurrenceId; delete raw.recurrenceOverrides; if (data.participants) { + // The series' organizer, by the exact address it is organized under: + // the editor writes one only for an event that had none. Created + // without it, 0.16.23 made the user who created the rest the organizer + // (a sharee changing the owner's meeting, a series organized under an + // alias), whatever the owner participant says; 1.0 takes the owner + // participant (live). + data.organizerCalendarAddress ??= master.organizerCalendarAddress; data.participants = participantsAskedAgain({ participants: data.participants, - organizerCalendarAddress: data.organizerCalendarAddress ?? master.organizerCalendarAddress, + organizerCalendarAddress: data.organizerCalendarAddress, }) ?? data.participants; } const overrides = followingOverrides(