diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 00eb7033..cd188a27 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -20,6 +20,10 @@ import { clearCalendarEvents, findEventsByUid, toLocalDateTime, + supportsSyntheticCalendarIds, + resetSyntheticIdSupport, + queryExpandedEvents, + hydrateExpandedOccurrences, } from '../calendar'; describe('toLocalDateTime', () => { @@ -38,6 +42,7 @@ const mockRequest = jmapClient.request as ReturnType; beforeEach(() => { vi.clearAllMocks(); + resetSyntheticIdSupport(); }); describe('calendar operations', () => { @@ -51,12 +56,142 @@ describe('calendar operations', () => { const result = await getCalendars(); expect(result).toEqual(calendars); expect(mockRequest).toHaveBeenCalledWith( - [['Calendar/get', { accountId: 'acc-1' }, '0']], + [ + ['Calendar/get', { accountId: 'acc-1' }, '0'], + // The synthetic-id probe rides along with the first Calendar/get. + ['CalendarEvent/set', { accountId: 'acc-1', update: { h333333: {} } }, 'synthetic-id-probe'], + ], expect.arrayContaining(['urn:ietf:params:jmap:calendars']), ); }); }); + describe('supportsSyntheticCalendarIds', () => { + it('takes the verdict from the probe that rode along with Calendar/get', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Calendar/get', { list: [] }, '0'], + ['CalendarEvent/set', { notUpdated: { h333333: { type: 'notFound' } } }, 'synthetic-id-probe'], + ], + }); + + await getCalendars(); + expect(await supportsSyntheticCalendarIds()).toBe(true); + expect(mockRequest).toHaveBeenCalledTimes(1); + + // Known now: the next Calendar/get goes out alone. + await getCalendars(); + expect(mockRequest.mock.calls[1][0]).toHaveLength(1); + }); + + it('counts a server that refuses synthetic ids as unsupported', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/set', { + notUpdated: { h333333: { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.' } }, + }, 'synthetic-id-probe']], + }); + + expect(await supportsSyntheticCalendarIds()).toBe(false); + expect(await supportsSyntheticCalendarIds()).toBe(false); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('probes again after a failed request', async () => { + mockRequest.mockRejectedValueOnce(new Error('offline')); + expect(await supportsSyntheticCalendarIds()).toBe(false); + + mockRequest.mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/set', { notUpdated: { h333333: { type: 'notFound' } } }, 'synthetic-id-probe']], + }); + expect(await supportsSyntheticCalendarIds()).toBe(true); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + }); + + describe('queryExpandedEvents', () => { + it('asks the server to expand the range with a filter of the bounds alone', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/query', { ids: ['s1', 's2'] }, '0']], + }); + + expect(await queryExpandedEvents('2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z')).toEqual(['s1', 's2']); + const args = mockRequest.mock.calls[0][0][0][1]; + expect(args.expandRecurrences).toBe(true); + expect(Object.keys(args.filter).sort()).toEqual(['after', 'before']); + }); + + it('returns null when the range expands past the server limit', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['error', { + type: 'invalidArguments', + description: 'The number of expanded recurrences exceeds the limit of 1000', + }, '0']], + }); + + expect(await queryExpandedEvents('2026-01-01T00:00:00Z', '2027-01-01T00:00:00Z')).toBeNull(); + }); + + it('throws on any other error', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['error', { type: 'forbidden' }, '0']], + }); + + await expect(queryExpandedEvents('2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z')).rejects.toThrow('forbidden'); + }); + }); + + describe('hydrateExpandedOccurrences', () => { + it('gives server occurrences their base event\'s rules, overrides and all-day flag', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/get', { + list: [{ + id: 'base', + recurrenceRule: { frequency: 'daily' }, + recurrenceOverrides: { '2026-03-03': { title: 'Moved' } }, + showWithoutTime: true, + timeZone: null, + duration: 'P1D', + }], + }, '0']], + }); + const occurrence = { + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02', start: '2026-03-02T00:00:00', + timeZone: 'Europe/Berlin', title: 'Daily', + } as any; + const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00', title: 'Once' } as any; + + const [hydrated, untouched] = await hydrateExpandedOccurrences([occurrence, single]); + + expect(mockRequest.mock.calls[0][0][0][1]).toMatchObject({ ids: ['base'] }); + expect(hydrated).toMatchObject({ + id: 's1', + recurrenceRules: [{ frequency: 'daily' }], + recurrenceOverrides: { '2026-03-03': { title: 'Moved' } }, + showWithoutTime: true, + timeZone: null, + }); + expect(untouched).toBe(single); + }); + + it('skips the round trip when nothing recurs', async () => { + const single = { id: 's9', baseEventId: 'other', start: '2026-03-02T09:00:00' } as any; + expect(await hydrateExpandedOccurrences([single])).toEqual([single]); + expect(mockRequest).not.toHaveBeenCalled(); + }); + }); + + describe('getEvents for expanded occurrences', () => { + it('asks for the base event id only then', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: [] }, '0']] }); + + await getEvents(['a'], undefined, { expanded: true }); + await getEvents(['a']); + + expect(mockRequest.mock.calls[0][0][0][1].properties).toContain('baseEventId'); + expect(mockRequest.mock.calls[1][0][0][1].properties).not.toContain('baseEventId'); + }); + }); + describe('queryEvents', () => { it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => { // Stalwart rejects the draft's plural `inCalendars` filter; calendars diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 8dc37483..195d610a 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -4,6 +4,12 @@ import type { Calendar, CalendarEvent, CalendarRights } from './types'; import { assertSetResult } from './jmap-result'; import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; +import { + RECURRENCE_BASE_PROPERTIES, + SYNTHETIC_ID_PROBE, + hydrateRecurrenceInstances, + isServerRecurrenceInstance, +} from '../lib/recurrence-instances'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; @@ -109,6 +115,19 @@ const CALENDAR_EVENT_PROPERTIES = [ 'links', 'created', 'updated', ]; +// Asked for only on server-expanded ranges: servers that expand recurrences +// for the app (see supportsSyntheticCalendarIds) know these. +const EXPANDED_EVENT_PROPERTIES = [...CALENDAR_EVENT_PROPERTIES, 'baseEventId', 'recurrenceIdTimeZone']; + +// Client-side fields that must never reach CalendarEvent/set. +const CLIENT_ONLY_EVENT_KEYS = [ + 'originalId', 'baseEventId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId', +]; + +function stripClientOnlyFields(payload: Record): void { + for (const key of CLIENT_ONLY_EVENT_KEYS) delete payload[key]; +} + /** * Normalize Stalwart's singular recurrence property names to the RFC 8984 * plural array forms the client uses internally. JSCalendar 2.0 defines @@ -233,12 +252,83 @@ function sharedCalendarAccountIds(): string[] { .map(([id]) => id); } +// ─── Synthetic ids ─────────────────────────────────────── +// Whether CalendarEvent/set accepts the synthetic ids that +// CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20), +// per server and account. Mirrors the webmail's supportsSyntheticCalendarIds. + +const SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe'; +const syntheticIdSupport = new Map>(); + +function syntheticIdProbeKey(): string | null { + try { + return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`; + } catch { + return null; + } +} + +function syntheticIdProbeCall(accountId: string): [string, Record, string] { + return ['CalendarEvent/set', { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, SYNTHETIC_ID_PROBE_CALL_ID]; +} + +/** + * Derive (and cache) the probe verdict from a response that carried the + * probe call. A server that still rejects synthetic ids answers + * `invalidProperties` before looking the event up, one that supports them + * answers `notFound`; anything else counts as unsupported. A request that + * failed outright is not remembered, so the next range load probes again. + */ +function rememberSyntheticIdProbe(key: string, pending: Promise): void { + const verdict: Promise = pending.then( + (res) => { + const entry = ((res?.methodResponses ?? []) as [string, any, string][]).find( + (r) => r[2] === SYNTHETIC_ID_PROBE_CALL_ID, + ); + if (!entry || entry[0] !== 'CalendarEvent/set') return false; + return entry[1]?.notUpdated?.[SYNTHETIC_ID_PROBE]?.type === 'notFound'; + }, + () => { + if (syntheticIdSupport.get(key) === verdict) syntheticIdSupport.delete(key); + return false; + }, + ); + syntheticIdSupport.set(key, verdict); +} + +/** + * Whether the server expands recurring events for the app: it accepts the + * synthetic occurrence ids in CalendarEvent/set, so each occurrence can be + * edited through its own id. Probed once per server and account with a + * side-effect-free update of an id that cannot exist; the first Calendar/get + * carries the probe along (getCalendars), so the usual start-up pays no + * extra round trip. + */ +export function supportsSyntheticCalendarIds(): Promise { + const key = syntheticIdProbeKey(); + if (!key) return Promise.resolve(false); + if (!syntheticIdSupport.has(key)) { + rememberSyntheticIdProbe( + key, + jmapClient.request([syntheticIdProbeCall(jmapClient.accountId)], USING), + ); + } + return syntheticIdSupport.get(key)!; +} + +export function resetSyntheticIdSupport(): void { + syntheticIdSupport.clear(); +} + export async function getCalendars(): Promise { const accountId = jmapClient.accountId; - const res = await jmapClient.request( - [['Calendar/get', { accountId }, '0']], - USING, - ); + const probeKey = syntheticIdProbeKey(); + const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey); + const methodCalls: [string, Record, string][] = [['Calendar/get', { accountId }, '0']]; + if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId)); + const pending = jmapClient.request(methodCalls, USING); + if (carriesProbe) rememberSyntheticIdProbe(probeKey!, pending); + const res = await pending; const own = methodResult<{ list: Calendar[] }>(res).list ?? []; // Calendars shared with the user live in other session accounts. Failures @@ -330,6 +420,91 @@ export async function queryEvents( return ids; } +/** + * Occurrence ids in a date window, with recurring series expanded by the + * server into one synthetic id per occurrence (CalendarEvent/query + * `expandRecurrences`). Only for servers that accept those ids in + * CalendarEvent/set (supportsSyntheticCalendarIds). The filter holds just + * the two range bounds, the form the server expands with (the webmail + * sends the same); the caller keeps the calendars it asked for. `null` when + * the server refuses to expand this range (more occurrences than its + * maxRecurrenceExpansions): the caller then expands on the device. + */ +export async function queryExpandedEvents( + after: string, + before: string, + accountId?: string, +): Promise { + const account = accountId || jmapClient.accountId; + const timeZone = getUserTimeZone(); + const filter = { + after: toLocalDateTime(after, timeZone), + before: toLocalDateTime(before, timeZone), + }; + const ids: string[] = []; + for (let page = 0; page < QUERY_MAX_PAGES; page++) { + const args: Record = { + accountId: account, + filter, + expandRecurrences: true, + limit: QUERY_PAGE_SIZE, + position: ids.length, + }; + if (timeZone) args.timeZone = timeZone; + const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING); + const entry = res?.methodResponses?.[0]; + if ( + entry?.[0] === 'error' + && entry[1]?.type === 'invalidArguments' + && /expanded recurrences exceeds/i.test(entry[1]?.description ?? '') + ) { + return null; + } + const batch = methodResult<{ ids: string[] }>(res).ids ?? []; + ids.push(...batch); + if (batch.length < QUERY_PAGE_SIZE) break; + } + return ids; +} + +/** + * Server-expanded occurrences come back without their series' recurrence + * rule / overrides (and all-day ones without showWithoutTime). Fetch those + * from the base events in one batched get and merge them in, so an + * occurrence carries the same series context the device-side expansion + * copies from its master. + */ +export async function hydrateExpandedOccurrences( + events: CalendarEvent[], + accountId?: string, +): Promise { + const baseIds = Array.from(new Set( + events + .filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId) + .map((event) => event.baseEventId as string), + )); + if (baseIds.length === 0) return events; + const account = accountId || jmapClient.accountId; + const timeZone = getUserTimeZone(); + const batchSize = jmapClient.getMaxObjectsInGet(); + const bases = new Map>(); + for (let i = 0; i < baseIds.length; i += batchSize) { + const res = await jmapClient.request( + [['CalendarEvent/get', { + accountId: account, + ids: baseIds.slice(i, i + batchSize), + properties: [...RECURRENCE_BASE_PROPERTIES], + ...(timeZone ? { timeZone } : {}), + }, '0']], + USING, + ); + for (const base of methodResult<{ list: Partial[] }>(res).list ?? []) { + if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base)); + } + } + return hydrateRecurrenceInstances(events, bases); +} + /** * Every calendar object of an account with just the properties needed to tell * tasks from events (see lib/calendar-component-detection). Used to find @@ -366,7 +541,12 @@ export async function scanCalendarObjects(accountId?: string): Promise { +export async function getEvents( + ids: string[], + accountId?: string, + // `expanded`: the ids are server-expanded occurrences (queryExpandedEvents). + options?: { expanded?: boolean }, +): Promise { if (ids.length === 0) return []; const account = accountId || jmapClient.accountId; const timeZone = getUserTimeZone(); @@ -378,7 +558,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise { const accountId = targetAccountId || jmapClient.accountId; const payload: Record = { ...event, calendarIds: { [calendarId]: true } }; + stripClientOnlyFields(payload); cleanRecurrenceRules(payload); const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { @@ -476,6 +657,7 @@ export async function batchCreateEvents( const create: Record> = {}; events.forEach((e, i) => { const payload: Record = { ...e, calendarIds: { [calendarId]: true } }; + stripClientOnlyFields(payload); cleanRecurrenceRules(payload); create[`evt-${i}`] = payload as Partial; }); @@ -503,6 +685,7 @@ export async function updateEvent( ): Promise { const accountId = targetAccountId || jmapClient.accountId; const patch: Record = { ...changes }; + stripClientOnlyFields(patch); cleanRecurrenceRules(patch); const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']], diff --git a/src/api/types.ts b/src/api/types.ts index bd73eb99..1f42506d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -506,6 +506,12 @@ export interface CalendarEvent { recurrenceOverrides?: Record> | null; excludedRecurrenceRules?: RecurrenceRule[] | null; recurrenceId?: string; + // The zone of a LocalDateTime `recurrenceId` (server-expanded occurrences). + recurrenceIdTimeZone?: string | null; + // Server-expanded occurrences (CalendarEvent/query expandRecurrences) carry + // a synthetic `id`; this is the raw id of the stored event they come from. + // A stored event fetched directly carries its own id here. + baseEventId?: string | null; originalId?: string; // Client-only: the event's server-side calendarIds (raw, unprefixed), kept // when `calendarIds` is remapped to namespaced store ids for shared events. diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts new file mode 100644 index 00000000..9888e494 --- /dev/null +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + baseEventStoreId, + hydrateRecurrenceInstances, + isServerRecurrenceInstance, + mergeOverrideParticipants, + resolveOverrideKey, + seriesIdOf, + stableOccurrenceKey, +} from '../recurrence-instances'; +import { expandRecurringEvents } from '../recurrence-expansion'; +import type { CalendarEvent } from '../../api/types'; + +function ev(partial: Partial): CalendarEvent { + return { id: 'e', uid: 'u', title: 't', start: '2026-03-02T09:00:00', calendarIds: { c: true }, ...partial } as CalendarEvent; +} + +describe('server recurrence instances', () => { + it('tells a server-expanded occurrence from a stored event', () => { + expect(isServerRecurrenceInstance(ev({ id: 's1', baseEventId: 'base' }))).toBe(true); + // Shared account: the store id is namespaced, originalId holds the raw id. + expect(isServerRecurrenceInstance(ev({ id: 'acc:s1', originalId: 's1', baseEventId: 'base' }))).toBe(true); + // A stored event fetched directly names itself. + expect(isServerRecurrenceInstance(ev({ id: 'base', baseEventId: 'base' }))).toBe(false); + expect(isServerRecurrenceInstance(ev({ id: 'acc:base', originalId: 'base', baseEventId: 'base' }))).toBe(false); + expect(isServerRecurrenceInstance(ev({ id: 'x:2026', originalId: 'x' }))).toBe(false); + }); + + it('names the base event under the store id it would have', () => { + expect(baseEventStoreId(ev({ id: 's1', baseEventId: 'base' }))).toBe('base'); + expect(baseEventStoreId(ev({ id: 'acc:s1', originalId: 's1', baseEventId: 'base' }))).toBe('acc:base'); + expect(baseEventStoreId(ev({ id: 'base' }))).toBeNull(); + }); + + it('finds the stored event behind any store event', () => { + expect(seriesIdOf(ev({ id: 's1', baseEventId: 'base' }))).toBe('base'); + expect(seriesIdOf(ev({ id: 'm:2026-03-02T09:00:00', originalId: 'm' }))).toBe('m'); + expect(seriesIdOf(ev({ id: 'plain' }))).toBe('plain'); + }); + + it('keys an occurrence by what survives a refetch', () => { + expect(stableOccurrenceKey(ev({ id: 's7', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00' }))) + .toBe('base:2026-03-02T09:00:00'); + expect(stableOccurrenceKey(ev({ id: 'acc:s7', originalId: 's7', accountId: 'acc', baseEventId: 'base' }))) + .toBe('acc:base'); + expect(stableOccurrenceKey(ev({ id: 'm:2026-03-02T09:00:00', originalId: 'm', recurrenceId: '2026-03-02T09:00:00' }))) + .toBe('m:2026-03-02T09:00:00'); + }); + + it('finds a moved occurrence\'s override by its new start on older servers', () => { + const overrides = { '2026-03-02T09:00:00': { start: '2026-03-02T11:00:00' } }; + expect(resolveOverrideKey({ recurrenceId: '2026-03-02T09:00:00', start: '2026-03-02T11:00:00' }, overrides)) + .toBe('2026-03-02T09:00:00'); + expect(resolveOverrideKey({ recurrenceId: '2026-03-02T11:00:00', start: '2026-03-02T11:00:00' }, overrides)) + .toBe('2026-03-02T09:00:00'); + expect(resolveOverrideKey({ recurrenceId: '2026-03-04T09:00:00', start: '2026-03-04T09:00:00' }, overrides)) + .toBe('2026-03-04T09:00:00'); + expect(resolveOverrideKey({ start: '2026-03-04T09:00:00' }, overrides)).toBeNull(); + }); + + it('completes an override\'s participants from the series', () => { + const series = { + org: { calendarAddress: 'mailto:org@x', roles: { owner: true }, participationStatus: 'accepted' }, + a: { calendarAddress: 'mailto:a@x', participationStatus: 'needs-action' }, + } as unknown as CalendarEvent['participants']; + const override = { + o2: { calendarAddress: 'MAILTO:org@x', participationStatus: null }, + a: { participationStatus: 'declined' }, + } as unknown as CalendarEvent['participants']; + + expect(mergeOverrideParticipants(series, override)).toEqual({ + org: { calendarAddress: 'MAILTO:org@x', roles: { owner: true }, participationStatus: 'accepted' }, + a: { calendarAddress: 'mailto:a@x', participationStatus: 'declined' }, + }); + }); +}); + +describe('hydrateRecurrenceInstances', () => { + const base = { + id: 'base', + recurrenceRules: [{ frequency: 'weekly' }], + recurrenceOverrides: { '2026-03-09T09:00:00': { title: 'Moved', start: '2026-03-09T11:00:00' } }, + showWithoutTime: false, + timeZone: 'Europe/Berlin', + duration: 'PT1H', + } as Partial; + const bases = new Map([['base', base]]); + + it('copies the series context and the base duration of an override without one', () => { + const [plain, overridden] = hydrateRecurrenceInstances([ + ev({ id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', duration: 'PT1H' }), + ev({ + id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', start: '2026-03-09T11:00:00', + title: 'Moved', duration: 'PT2H', utcStart: '2026-03-09T10:00:00Z', utcEnd: '2026-03-09T12:00:00Z', + }), + ], bases); + + expect(plain.recurrenceRules).toEqual([{ frequency: 'weekly' }]); + expect(plain.recurrenceOverrides).toBe(base.recurrenceOverrides); + expect(overridden.duration).toBe('PT1H'); + expect(overridden.utcEnd).toBe('2026-03-09T11:00:00.000Z'); + }); + + it('restores the all-day flag the expansion dropped', () => { + const allDay = new Map([['b2', { id: 'b2', showWithoutTime: true, timeZone: null, duration: 'P1D' }]]); + const [hydrated] = hydrateRecurrenceInstances( + [ev({ id: 's1', baseEventId: 'b2', recurrenceId: '2026-03-02T00:00:00', timeZone: 'Europe/Berlin' })], + allDay, + ); + expect(hydrated.showWithoutTime).toBe(true); + expect(hydrated.timeZone).toBeNull(); + }); + + it('collapses an occurrence listed twice, taking the override\'s own fields', () => { + const withReply = new Map([['base', { + ...base, + recurrenceOverrides: { '2026-03-02T09:00:00': { participants: { a: { participationStatus: 'accepted' } } } }, + }]]); + const series = ev({ + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', title: 'Standup', + participants: { a: { calendarAddress: 'mailto:a@x', participationStatus: 'needs-action' } } as any, + }); + const fromOverride = ev({ + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', title: '', + participants: { a: { participationStatus: 'accepted' } } as any, + }); + + const result = hydrateRecurrenceInstances([series, fromOverride], withReply); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Standup'); + expect(result[0].participants).toEqual({ + a: { calendarAddress: 'mailto:a@x', participationStatus: 'accepted' }, + }); + }); + + it('leaves events without a known base alone', () => { + const lone = ev({ id: 's9', baseEventId: 'unknown', recurrenceId: '2026-03-02T09:00:00' }); + expect(hydrateRecurrenceInstances([lone], bases)[0]).toBe(lone); + }); +}); + +describe('expandRecurringEvents with server occurrences', () => { + it('never expands an occurrence the server already expanded', () => { + const occurrence = ev({ + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', + recurrenceRules: [{ frequency: 'daily' } as any], + }); + const result = expandRecurringEvents([occurrence], '2026-03-01T00:00:00', '2026-03-31T00:00:00'); + expect(result).toEqual([occurrence]); + }); +}); diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index dbe5efd4..fee59fbb 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -1,6 +1,7 @@ import { parseISO } from 'date-fns'; import type { Alert, Calendar, CalendarEvent } from '../api/types'; import { getTaskDueDate, parseDuration } from './calendar-utils'; +import { seriesIdOf, stableOccurrenceKey } from './recurrence-instances'; // Pure alert maths for calendar reminders. Port of the webmail's // lib/calendar-alerts.ts (computeFireTime / getEffectiveAlerts / @@ -8,7 +9,11 @@ import { getTaskDueDate, parseDuration } from './calendar-utils'; // instead of polling for alerts that just fired. export interface ScheduledAlert { - /** `${eventId}:${alertId}:${fireTimeMs}` — stable across refetches. */ + /** + * `${event}:${alertId}:${fireTimeMs}` — stable across refetches; a + * server-expanded occurrence is named by its base event and recurrence + * id, since its synthetic id is positional. + */ key: string; eventId: string; alertId: string; @@ -147,14 +152,14 @@ export function getUpcomingAlerts( const fireTimeMs = computeFireTime(event, alert.trigger); if (fireTimeMs === null || fireTimeMs <= window.now || fireTimeMs > until) continue; out.push({ - key: buildAlertKey(event.id, alertId, fireTimeMs), + key: buildAlertKey(stableOccurrenceKey(event), alertId, fireTimeMs), eventId: event.id, alertId, fireTimeMs, title: event.title || '(No title)', body: Number.isNaN(startMs) ? '' : `Starts ${formatWhen(fireTimeMs, startMs)}`, kind: 'event', - serverId: event.originalId ?? event.id, + serverId: seriesIdOf(event), accountId: event.accountId, recurrenceId: event.recurrenceId, startMs: Number.isNaN(startMs) ? undefined : startMs, diff --git a/src/lib/calendar-reminder-open.ts b/src/lib/calendar-reminder-open.ts index 603da499..2abaa109 100644 --- a/src/lib/calendar-reminder-open.ts +++ b/src/lib/calendar-reminder-open.ts @@ -8,6 +8,7 @@ import { } from '../stores/calendar-store'; import { useLocaleStore } from '../stores/locale-store'; import { useToastStore } from '../stores/toast-store'; +import { seriesIdOf } from './recurrence-instances'; import { usePendingCalendarOpen, type CalendarReminderTarget, @@ -21,14 +22,9 @@ import { const DAY_MS = 24 * 60 * 60 * 1000; -/** The raw JMAP id of the stored event behind a store event or occurrence. */ -function serverIdOf(event: CalendarEvent): string { - return event.originalId ?? event.id; -} - function matchesTarget(event: CalendarEvent, target: CalendarReminderTarget): boolean { if (event.id === target.eventId) return true; - if (!target.serverId || serverIdOf(event) !== target.serverId) return false; + if (!target.serverId || seriesIdOf(event) !== target.serverId) return false; return (event.accountId ?? undefined) === (target.accountId ?? undefined) && (event.recurrenceId ?? undefined) === (target.recurrenceId ?? undefined); } @@ -74,7 +70,7 @@ export async function resolveReminderTask( const find = () => useCalendarStore.getState().tasks.find((task) => task.id === target.eventId || (!!target.serverId - && serverIdOf(task) === target.serverId + && seriesIdOf(task) === target.serverId && (task.accountId ?? undefined) === (target.accountId ?? undefined))); const loaded = find(); if (loaded) return loaded; diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index f7404c9c..2fea3b24 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -5,14 +5,17 @@ * recurrence rule interpretation: implicit byX addition, byX filtering, * bySetPosition. Mobile port of webmail's lib/recurrence-expansion.ts. * - * Stalwart does not yet support mutations on the synthetic IDs produced by - * CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with - * real, mutable IDs) and expand recurring series into individual occurrences - * on the client. + * Used for servers that do not accept the synthetic ids produced by + * CalendarEvent/query?expandRecurrences=true in CalendarEvent/set (Stalwart + * before 0.16.20, see lib/recurrence-instances.ts), and for a range the + * server refuses to expand: raw events (with real, mutable ids) are fetched + * and recurring series are expanded into individual occurrences on the + * device. Occurrences the server already expanded pass through unchanged. */ import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns'; import type { CalendarEvent, RecurrenceRule } from '../api/types'; +import { isServerRecurrenceInstance } from './recurrence-instances'; type NDay = { day: string; nthOfPeriod?: number }; @@ -49,7 +52,10 @@ export function expandRecurringEvents( for (const event of events) { if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) continue; - if (!event.recurrenceRules?.length) { + // An occurrence the server already expanded is a single instance even + // though it carries its series' recurrence rule (lib/recurrence-instances + // hydrates it with that); never expand it again. + if (!event.recurrenceRules?.length || isServerRecurrenceInstance(event)) { result.push(event); continue; } diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts new file mode 100644 index 00000000..0faec66a --- /dev/null +++ b/src/lib/recurrence-instances.ts @@ -0,0 +1,262 @@ +/** + * Server-side recurrence instances ("synthetic ids"). Port of the webmail's + * lib/recurrence-instances.ts. + * + * `CalendarEvent/query` with `expandRecurrences: true` returns one synthetic + * id per occurrence in the range instead of the stored base event. Since + * Stalwart 0.16.20 those ids are also accepted by `CalendarEvent/set`; older + * servers reject them, in which case the app keeps expanding recurrences on + * the device (lib/recurrence-expansion.ts) and patches the base event itself. + * + * What an expanded occurrence looks like (verified by the webmail against + * 0.16.19): + * - `id` is synthetic, `baseEventId` is the stored event's id; a + * non-recurring event in the range gets a synthetic id too. + * - `recurrenceId` / `recurrenceIdTimeZone` are set for occurrences of a + * series, `recurrenceRule` / `recurrenceOverrides` are stripped. + * - all-day occurrences lose `showWithoutTime` and are stamped with the + * request time zone. + * - the ids are positional and reshuffle whenever the series' overrides + * change, so the loaded range has to be refetched after mutating one. + */ + +import type { CalendarEvent, Participant } from '../api/types'; + +/** + * A synthetic id whose event can never exist: expansion 0 of document + * `u32::MAX` (`Id::from_parts(1, u32::MAX)` in Stalwart's base32 alphabet). + * A pre-0.16.20 server rejects an update on it as `invalidProperties` + * before looking anything up; a server that supports synthetic ids answers + * `notFound`. Either way nothing is written, which makes it a safe probe. + */ +export const SYNTHETIC_ID_PROBE = 'h333333'; + +/** Base-event properties an expanded occurrence needs back (see `hydrateRecurrenceInstances`). */ +export const RECURRENCE_BASE_PROPERTIES = [ + 'id', + 'recurrenceRule', + 'excludedRecurrenceRule', + 'recurrenceOverrides', + 'showWithoutTime', + 'timeZone', + 'duration', +] as const; + +type EventIdentity = Pick & Partial>; + +/** + * True for an occurrence handed out by server-side expansion, i.e. an event + * whose (raw) id is synthetic and differs from its base event's id. A base + * event fetched directly also carries `baseEventId`, equal to its own id. + */ +export function isServerRecurrenceInstance(event: EventIdentity | null | undefined): boolean { + if (!event?.baseEventId) return false; + return event.baseEventId !== (event.originalId ?? event.id); +} + +/** + * The store id under which the base event of `instance` would be addressed. + * Store ids end with the raw JMAP id (`:` for a shared + * calendar, or just ``), so swap the trailing raw synthetic id for the + * base id and keep whatever namespace prefix is there. + */ +export function baseEventStoreId(instance: EventIdentity): string | null { + if (!isServerRecurrenceInstance(instance) || !instance.baseEventId) return null; + const raw = instance.originalId ?? instance.id; + if (!instance.id.endsWith(raw)) return instance.baseEventId; + return instance.id.slice(0, instance.id.length - raw.length) + instance.baseEventId; +} + +/** + * The raw JMAP id of the stored event behind a store event: the base event + * of a server-expanded occurrence, the master of one expanded on the + * device, else the event's own id. + */ +export function seriesIdOf(event: EventIdentity): string { + return event.baseEventId || event.originalId || event.id; +} + +/** + * An id for an event or occurrence that survives a refetch: synthetic ids + * are positional, so a server-expanded occurrence is named by its base + * event and recurrence id instead. + */ +export function stableOccurrenceKey( + event: EventIdentity & Partial>, +): string { + if (!isServerRecurrenceInstance(event)) return event.id; + return [event.accountId, event.baseEventId, event.recurrenceId].filter(Boolean).join(':'); +} + +type OverrideMap = NonNullable; + +/** + * Given an expanded occurrence, find the key of its entry in the base + * event's `recurrenceOverrides`. Normally that is `recurrenceId`; a + * pre-0.16.20 server reports a moved occurrence's *new* start as its + * recurrenceId, in which case the entry is found by that start instead. + */ +export function resolveOverrideKey( + instance: Pick, + overrides: OverrideMap | null | undefined, +): string | null { + if (!instance.recurrenceId) return null; + if (!overrides || instance.recurrenceId in overrides) return instance.recurrenceId; + for (const [key, override] of Object.entries(overrides)) { + if (override && override.start === instance.start) return key; + } + return instance.recurrenceId; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`).join(',')}}`; + } + return JSON.stringify(value ?? null); +} + +/** + * An override's participants, each completed from the series participant + * with the same id or calendar address. The override decides who takes part; + * the series fills what it leaves out. Stalwart records an attendee's reply to + * one occurrence as an override naming the organizer under a new id and + * without a participation status, which would otherwise show the organizer as + * not having answered their own meeting. + */ +export function mergeOverrideParticipants( + series: CalendarEvent['participants'], + override: CalendarEvent['participants'], +): CalendarEvent['participants'] { + if (!override) return override; + const address = (p: Participant) => (p.calendarAddress ?? '').toLowerCase(); + const from = series ?? {}; + const merged: Record = {}; + for (const [id, participant] of Object.entries(override)) { + const match = id in from + ? id + : Object.keys(from).find((key) => address(from[key]) && address(from[key]) === address(participant)); + const defined = Object.fromEntries(Object.entries(participant).filter(([, v]) => v != null)); + merged[match ?? id] = { ...(match ? from[match] : {}), ...defined } as Participant; + } + return merged; +} + +/** + * Stalwart lists an occurrence twice under one id - once as the series + * generates it, once from its override - when the override ranks below the + * series (no or a lower SEQUENCE). It writes such overrides itself: an + * attendee's reply to one occurrence becomes, on the organizer's side, an + * override holding little more than that attendee. Collapse each pair into + * one occurrence: the fields the override defines (per the base event's + * overrides map) from the override, the rest from the series. + */ +function collapseDuplicateInstances( + instances: CalendarEvent[], + bases: ReadonlyMap>, +): CalendarEvent[] { + const groups = new Map(); + for (const instance of instances) { + const group = groups.get(instance.id); + if (group) group.push(instance); + else groups.set(instance.id, [instance]); + } + if (groups.size === instances.length) return instances; + + const collapse = (group: CalendarEvent[]): CalendarEvent => { + const first = group[0]; + const base = first.baseEventId ? bases.get(first.baseEventId) : undefined; + const key = base ? resolveOverrideKey(first, base.recurrenceOverrides) : null; + const entry = key ? base?.recurrenceOverrides?.[key] as Record | undefined : undefined; + if (!entry) { + return group.reduce((a, b) => (Object.keys(b).length > Object.keys(a).length ? b : a)); + } + const overrideKeys = Object.keys(entry).filter((k) => k !== 'updated' && !k.includes('/')); + const score = (instance: CalendarEvent) => overrideKeys + .filter((k) => stableJson((instance as unknown as Record)[k]) === stableJson(entry[k])) + .length; + const override = group.reduce((a, b) => (score(b) > score(a) ? b : a)); + const series = group.find((instance) => instance !== override) ?? first; + const merged: Record = { ...series }; + for (const k of overrideKeys) { + merged[k] = k === 'participants' + ? mergeOverrideParticipants(series.participants, override.participants) + : (override as unknown as Record)[k]; + } + return merged as unknown as CalendarEvent; + }; + + const seen = new Set(); + const result: CalendarEvent[] = []; + for (const instance of instances) { + if (seen.has(instance.id)) continue; + seen.add(instance.id); + const group = groups.get(instance.id)!; + result.push(group.length > 1 ? collapse(group) : instance); + } + return result; +} + +/** An ISO 8601 duration ("PT1H30M", "P1DT2H", "P1W") in seconds; null when unparsable. */ +function parseDurationSeconds(duration: string | null | undefined): number | null { + if (!duration) return null; + const match = /^-?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(duration); + if (!match) return null; + const [, w, d, h, m, s] = match; + return (Number(w ?? 0) * 7 + Number(d ?? 0)) * 86400 + + Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0); +} + +function shiftIso(from: string | null | undefined, seconds: number): string | null { + if (!from) return null; + const ms = Date.parse(from); + if (Number.isNaN(ms)) return null; + return new Date(ms + seconds * 1000).toISOString(); +} + +/** + * Give expanded occurrences the base-event context the rest of the app + * expects from an occurrence (and that the device-side expansion copies + * from the master): the recurrence rules and overrides, the all-day flag, + * and - for an override that does not set its own duration - the inherited + * duration (RFC 8984 §4.3.4: an override is a patch on the base event; the + * server's own computed duration for such overrides has been seen to be + * wrong). Occurrences whose base event is not in `bases` are returned + * unchanged. + */ +export function hydrateRecurrenceInstances( + instances: CalendarEvent[], + bases: ReadonlyMap>, +): CalendarEvent[] { + return collapseDuplicateInstances(instances, bases).map((instance) => { + if (!isServerRecurrenceInstance(instance) || !instance.recurrenceId) return instance; + const base = instance.baseEventId ? bases.get(instance.baseEventId) : undefined; + if (!base) return instance; + + const hydrated: CalendarEvent = { + ...instance, + recurrenceRules: base.recurrenceRules ?? null, + excludedRecurrenceRules: base.excludedRecurrenceRules ?? null, + recurrenceOverrides: base.recurrenceOverrides ?? null, + }; + if (base.showWithoutTime) { + hydrated.showWithoutTime = true; + hydrated.timeZone = base.timeZone ?? null; + } + + const overrideKey = resolveOverrideKey(instance, base.recurrenceOverrides); + if (overrideKey) hydrated.recurrenceId = overrideKey; + const override = overrideKey ? base.recurrenceOverrides?.[overrideKey] : undefined; + if (override && override.duration == null && base.duration && hydrated.duration !== base.duration) { + hydrated.duration = base.duration; + const seconds = parseDurationSeconds(base.duration); + if (seconds !== null && !hydrated.showWithoutTime) { + hydrated.utcEnd = shiftIso(hydrated.utcStart, seconds) ?? hydrated.utcEnd; + } + } + return hydrated; + }); +} diff --git a/src/lib/recurrence-overrides.ts b/src/lib/recurrence-overrides.ts index 92943327..2f0fcff1 100644 --- a/src/lib/recurrence-overrides.ts +++ b/src/lib/recurrence-overrides.ts @@ -151,6 +151,8 @@ export function buildFutureSeriesData( delete raw.utcStart; delete raw.utcEnd; delete raw.originalId; + delete raw.baseEventId; + delete raw.recurrenceIdTimeZone; delete raw.originalCalendarIds; delete raw.accountId; delete raw.isShared; diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 56434c9b..133113ea 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../../api/calendar', () => ({ getCalendars: vi.fn(), @@ -14,6 +14,10 @@ vi.mock('../../api/calendar', () => ({ batchCreateEvents: vi.fn(), setDefaultCalendar: vi.fn(), rsvpEvent: vi.fn(), + supportsSyntheticCalendarIds: vi.fn(async () => false), + queryExpandedEvents: vi.fn(), + hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), + resetSyntheticIdSupport: vi.fn(), })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -48,6 +52,9 @@ const mockDeleteEvents = calendarApi.deleteEvents as ReturnType; const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType; const mockScan = calendarApi.scanCalendarObjects as ReturnType; const mockRsvpEvent = calendarApi.rsvpEvent as ReturnType; +const mockSupportsSynthetic = calendarApi.supportsSyntheticCalendarIds as ReturnType; +const mockQueryExpanded = calendarApi.queryExpandedEvents as ReturnType; +const mockHydrate = calendarApi.hydrateExpandedOccurrences as ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -675,6 +682,132 @@ describe('calendar-store', () => { }); }); + describe('server-side recurrence expansion', () => { + const RANGE = { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' }; + const weekly = [{ frequency: 'weekly' }]; + + beforeEach(() => { + mockSupportsSynthetic.mockResolvedValue(true); + }); + + afterEach(() => { + mockSupportsSynthetic.mockResolvedValue(false); + mockHydrate.mockImplementation(async (events: unknown[]) => events); + }); + + it('lets a server that takes synthetic ids expand the range, keeping the asked-for calendars', async () => { + useCalendarStore.setState({ calendars: [{ id: 'cal-1' }, { id: 'cal-2' }] as any }); + mockQueryExpanded.mockResolvedValue(['s1', 's2', 's3']); + mockGetEvents.mockResolvedValue([ + { id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', start: '2026-03-02T09:00:00', calendarIds: { 'cal-1': true } }, + { id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', start: '2026-03-09T09:00:00', calendarIds: { 'cal-1': true } }, + { id: 's3', baseEventId: 'other', start: '2026-03-03T09:00:00', calendarIds: { 'cal-2': true } }, + ]); + mockHydrate.mockImplementation(async (events: any[]) => + events.map((e) => (e.recurrenceId ? { ...e, recurrenceRules: weekly } : e))); + + await useCalendarStore.getState().fetchEvents(['cal-1'], RANGE.after, RANGE.before); + + expect(mockQueryExpanded).toHaveBeenCalledWith(RANGE.after, RANGE.before, undefined); + expect(mockQueryEvents).not.toHaveBeenCalled(); + expect(mockGetEvents).toHaveBeenCalledWith(['s1', 's2', 's3'], undefined, { expanded: true }); + expect(mockHydrate.mock.calls[0][0].map((e: any) => e.id)).toEqual(['s1', 's2']); + // Hydrated occurrences carry the rule but are not expanded again. + expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['s1', 's2']); + }); + + it('expands on the device when the server will not expand the range', async () => { + useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as any }); + mockQueryExpanded.mockResolvedValue(null); + mockQueryEvents.mockResolvedValue(['m']); + mockGetEvents.mockResolvedValue([ + { id: 'm', uid: 'u', start: '2026-03-02T09:00:00', recurrenceRules: weekly, calendarIds: { 'cal-1': true } }, + ]); + + await useCalendarStore.getState().fetchEvents(['cal-1'], RANGE.after, RANGE.before); + + expect(mockGetEvents).toHaveBeenCalledWith(['m'], undefined); + const events = useCalendarStore.getState().events; + expect(events.length).toBeGreaterThan(1); + expect(events.every((e) => e.originalId === 'm')).toBe(true); + }); + + it('sends a change to a server occurrence to its base event and reloads the range', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-1' }] as any, + loadedRange: RANGE, + events: [ + { id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly } as any, + { id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', recurrenceRules: weekly } as any, + ], + }); + mockUpdateEvent.mockResolvedValue(undefined); + mockQueryExpanded.mockResolvedValue([]); + + await useCalendarStore.getState().updateEvent('s1', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } }); + + expect(mockUpdateEvent).toHaveBeenCalledWith( + 'base', { 'recurrenceOverrides/2026-03-02T09:00:00': { title: 'x' } }, undefined, undefined, + ); + expect(mockQueryExpanded).toHaveBeenCalled(); + }); + + it('routes a single event\'s synthetic id to the stored event on every server', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-1' }] as any, + events: [{ id: 's5', baseEventId: 'plain', title: 'Once' } as any], + }); + mockDeleteEvents.mockResolvedValue(undefined); + + await useCalendarStore.getState().deleteEvent('s5'); + + expect(mockDeleteEvents).toHaveBeenCalledWith(['plain'], undefined, undefined); + expect(useCalendarStore.getState().events).toEqual([]); + }); + + it('deletes a shared series through the master fetched for one of its occurrences', async () => { + const occurrence = { + id: 'acc-2:s1', originalId: 's1', accountId: 'acc-2', baseEventId: 'base', + recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly, calendarIds: { 'acc-2:cal-9': true }, + }; + useCalendarStore.setState({ + calendars: [{ id: 'acc-2:cal-9', originalId: 'cal-9', accountId: 'acc-2', isShared: true }] as any, + events: [ + occurrence as any, + { ...occurrence, id: 'acc-2:s2', originalId: 's2', recurrenceId: '2026-03-09T09:00:00' } as any, + { id: 'mine' } as any, + ], + }); + mockGetEvents.mockResolvedValue([ + { id: 'base', start: '2026-03-02T09:00:00', recurrenceRules: weekly, calendarIds: { 'cal-9': true } }, + ]); + mockDeleteEvents.mockResolvedValue(undefined); + + const master = await useCalendarStore.getState().getMasterEvent(occurrence as any); + expect(mockGetEvents).toHaveBeenCalledWith(['base'], 'acc-2'); + expect(master?.id).toBe('acc-2:base'); + + await useCalendarStore.getState().deleteEvent(master!.id); + + expect(mockDeleteEvents).toHaveBeenCalledWith(['base'], undefined, 'acc-2'); + expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['mine']); + }); + + it('answers an invitation for the whole series from a server occurrence', async () => { + useCalendarStore.setState({ + events: [{ + id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', recurrenceRules: weekly, + organizerCalendarAddress: 'mailto:org@x', participants: { me: { participationStatus: 'needs-action' } }, + } as any], + }); + mockRsvpEvent.mockResolvedValue(undefined); + + await useCalendarStore.getState().rsvpEvent('s1', 'me', 'accepted'); + + expect(mockRsvpEvent).toHaveBeenCalledWith('base', 'me', 'accepted', undefined, undefined); + }); + }); + describe('recurring series mutations', () => { it('updateEvent on an occurrence patches the master and refetches the range', async () => { useCalendarStore.setState({ diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index e39a966c..24efd330 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -24,10 +24,19 @@ import { rsvpEvent as apiRsvpEvent, createCalendar as apiCreateCalendar, setDefaultCalendar as apiSetDefaultCalendar, + supportsSyntheticCalendarIds, + queryExpandedEvents, + hydrateExpandedOccurrences, + resetSyntheticIdSupport, } from '../api/calendar'; import { jmapClient } from '../api/jmap-client'; import { expandRecurringEvents } from '../lib/recurrence-expansion'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; +import { + baseEventStoreId, + isServerRecurrenceInstance, + seriesIdOf, +} from '../lib/recurrence-instances'; import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; // Does the event carry attendees the server should notify over iMIP? Used to @@ -267,7 +276,9 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR /** * The events of the given calendars (store ids) that overlap [after, before], - * mapped to store ids and with recurring series expanded over the window. + * mapped to store ids and with recurring series expanded over the window: + * by the server when it accepts the synthetic occurrence ids that gives + * (Stalwart >= 0.16.20, see lib/recurrence-instances), else on the device. * Doesn't touch the store: fetchEvents keeps the result as the visible * window, the reminders load their own upcoming window with it. Throws when * the primary account fails; a failing shared account is skipped. @@ -296,16 +307,37 @@ export async function loadEventsInRange( } if (groups.size === 0) groups.set(undefined, []); + const expand = !!after && !!before + && await supportsSyntheticCalendarIds().catch(() => false); const raw: CalendarEvent[] = []; for (const [accountId, ids] of groups) { if (accountId && isCalendarAccessDenied(accountId)) continue; try { - // The window is sent as after/before so accounts with more than - // 1000 objects don't silently lose events and navigating past the - // loaded range doesn't re-download everything. - const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; - if (eventIds.length === 0) continue; - const fetched = (await fetchEvents(eventIds, accountId)) ?? []; + let fetched: CalendarEvent[] | null = null; + if (expand) { + // null: the server won't expand a range this long; the series are + // then fetched whole and expanded below. + const expandedIds = await queryExpandedEvents(after, before, accountId); + if (expandedIds) { + const occurrences = expandedIds.length > 0 + ? (await fetchEvents(expandedIds, accountId, { expanded: true })) ?? [] + : []; + // The expanded query spans the account; keep the asked-for calendars. + const wanted = new Set(ids); + const inCalendars = wanted.size === 0 + ? occurrences + : occurrences.filter((e) => Object.keys(e.calendarIds || {}).some((id) => wanted.has(id))); + fetched = await hydrateExpandedOccurrences(inCalendars, accountId); + } + } + if (!fetched) { + // The window is sent as after/before so accounts with more than + // 1000 objects don't silently lose events and navigating past the + // loaded range doesn't re-download everything. + const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; + if (eventIds.length === 0) continue; + fetched = (await fetchEvents(eventIds, accountId)) ?? []; + } raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); } catch (err) { // A failing shared account must not hide the user's own events; @@ -321,6 +353,49 @@ export async function loadEventsInRange( return expandRecurringEvents(onlyEvents, after, before); } +// Masters fetched by getMasterEvent: an expanded series has no master in +// `events`, so a mutation addressed at one looks up its account and raw id +// here. +const knownMasters = new Map(); + +export interface MutationTarget { + storeEvent?: CalendarEvent; + /** Raw JMAP id the change is sent to. */ + realId: string; + /** Owning account of a shared calendar's event. */ + accountId?: string; +} + +/** + * Where a change to the event with store id `id` goes, as a change to the + * stored event: an occurrence (expanded by the server or on the device) + * resolves to its base event, a master fetched by getMasterEvent to itself, + * and a base event that is not in the store borrows the account routing of + * a server-expanded occurrence of it in view. Mirrors the webmail's + * resolveMutationTarget for the whole-series case. + */ +export function resolveSeriesTarget(events: CalendarEvent[], id: string): MutationTarget { + const storeEvent = events.find((e) => e.id === id) ?? knownMasters.get(id); + if (storeEvent) { + return { storeEvent, realId: seriesIdOf(storeEvent), accountId: storeEvent.accountId }; + } + const instance = events.find((e) => baseEventStoreId(e) === id); + if (instance?.baseEventId) return { realId: instance.baseEventId, accountId: instance.accountId }; + return { realId: id }; +} + +/** + * Does the store show server-expanded occurrences of base event `baseId`? + * Their synthetic ids reshuffle when the series changes, so the range is + * reloaded after such a change. + */ +function hasServerOccurrencesOf(events: CalendarEvent[], baseId: string, accountId?: string): boolean { + return events.some((e) => + isServerRecurrenceInstance(e) + && e.baseEventId === baseId + && (e.accountId ?? undefined) === (accountId ?? undefined)); +} + export const useCalendarStore = create()( persist( (set, get) => ({ @@ -529,9 +604,8 @@ export const useCalendarStore = create()( }, updateEvent: async (id, changes, options) => { - // Resolve client-side expanded occurrence IDs back to the master event ID. - const storeEvent = get().events.find((e) => e.id === id); - const realId = storeEvent?.originalId || id; + // Resolve expanded occurrence ids back to the stored (base) event. + const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id); // Remap namespaced (shared-calendar) store ids in calendarIds back to the // raw server ids the owning account knows. const patch: Record = { ...changes }; @@ -552,48 +626,63 @@ export const useCalendarStore = create()( hasSchedulingParticipants(storeEvent) ? true : undefined); - await apiUpdateEvent(realId, patch, schedule, storeEvent?.accountId); + const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) + || hasServerOccurrencesOf(get().events, realId, accountId); + await apiUpdateEvent(realId, patch, schedule, accountId); set({ events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial) } : e)), }); // A series mutation (an occurrence override, a truncated/changed rule, a // master edit) touches every expanded sibling, and the optimistic merge - // above only updated the tapped one — reload the visible range like - // webmail's refetchAfterOccurrenceMutation. - if (storeEvent && isRecurringSeriesMember(storeEvent)) { + // above only updated the tapped one; server-expanded occurrences also + // get new ids. Reload the visible range like webmail's + // refetchAfterOccurrenceMutation. + if (touchesSeries) { await get().refresh(); } }, deleteEvent: async (id) => { - const storeEvent = get().events.find((e) => e.id === id); - const realId = storeEvent?.originalId || id; + const { storeEvent, realId, accountId } = resolveSeriesTarget(get().events, id); + const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) + || hasServerOccurrencesOf(get().events, realId, accountId); await apiDeleteEvents( [realId], hasSchedulingParticipants(storeEvent) ? true : undefined, - storeEvent?.accountId, + accountId, ); + knownMasters.delete(id); // Destroying a master removes every expanded occurrence of it, not just // the tapped one. - set({ events: get().events.filter((e) => e.id !== id && (e.originalId || e.id) !== realId) }); - if (storeEvent && isRecurringSeriesMember(storeEvent)) { + set({ + events: get().events.filter((e) => + e.id !== id + && !(seriesIdOf(e) === realId && (e.accountId ?? undefined) === (accountId ?? undefined))), + }); + if (touchesSeries) { await get().refresh(); } }, getMasterEvent: async (event) => { - if (event.recurrenceRules?.length && !event.recurrenceId) return event; - // Client-side expansion replaces the master with its occurrences, each - // pointing back at the master's server id through originalId. - const realId = event.originalId || event.id; + if (event.recurrenceRules?.length && !event.recurrenceId && !isServerRecurrenceInstance(event)) { + return event; + } + // Expansion replaces the master with its occurrences, each pointing back + // at the stored event: through baseEventId when the server expanded it, + // through originalId when the device did. + const realId = seriesIdOf(event); const inStore = get().events.find( - (e) => e.id === realId && !e.recurrenceId && !!e.recurrenceRules?.length, + (e) => !e.recurrenceId && !!e.recurrenceRules?.length && !isServerRecurrenceInstance(e) + && seriesIdOf(e) === realId && (e.accountId ?? undefined) === (event.accountId ?? undefined), ); if (inStore) return inStore; const fetched = (await fetchEvents([realId], event.accountId)) ?? []; const master = fetched[0]; if (!master) return null; - return mapServerEventToStoreEvent(master, get().calendars, event.accountId); + const mapped = mapServerEventToStoreEvent(master, get().calendars, event.accountId); + knownMasters.set(mapped.id, mapped); + return mapped; }, rsvpEvent: async (eventId, participantId, status, replyTo, event) => { @@ -603,16 +692,21 @@ export const useCalendarStore = create()( throw new Error('Invalid participant ID'); } // An event outside the loaded window (an invitation looked up by UID) - // isn't in the store; the caller hands it over instead. - const storeEvent = get().events.find((e) => e.id === eventId) ?? event; - const realId = storeEvent?.originalId || eventId; + // isn't in the store; the caller hands it over instead. An occurrence + // answers for its whole series: the stored event is updated. + const target = resolveSeriesTarget(get().events, eventId); + const storeEvent = target.storeEvent ?? event; + const realId = target.storeEvent || !event ? target.realId : seriesIdOf(event); + const accountId = target.storeEvent ? target.accountId : event?.accountId ?? target.accountId; + const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) + || hasServerOccurrencesOf(get().events, realId, accountId); // Repair events that are missing the organizer (e.g. imported ones) so // Stalwart can route the REPLY; never touch an existing one. const repair = replyTo?.imip && storeEvent && !storeEvent.organizerCalendarAddress ? replyTo.imip : undefined; - await apiRsvpEvent(realId, participantId, status, repair, storeEvent?.accountId); + await apiRsvpEvent(realId, participantId, status, repair, accountId); set({ events: get().events.map((e) => { if (e.id !== eventId || !e.participants?.[participantId]) return e; @@ -625,6 +719,8 @@ export const useCalendarStore = create()( }; }), }); + // The other occurrences in view still show the old answer. + if (touchesSeries) await get().refresh(); }, importEvents: async (events, calendarId) => { @@ -832,8 +928,11 @@ export const useCalendarStore = create()( }, reset: () => { - // A new session may grant access the old one lacked. + // A new session may grant access the old one lacked, or reach an + // upgraded server. resetCalendarAccessDenied(); + resetSyntheticIdSupport(); + knownMasters.clear(); set({ calendars: [], events: [],