mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: use Stalwart synthetic ids for recurring occurrences #140
This commit is contained in:
@@ -53,6 +53,8 @@ import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calen
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { buildRecurrenceOverridePatch } from "@/lib/recurrence-overrides";
|
||||
import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
|
||||
import { getClientByLocalAccountId } from "@/stores/client-registry";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
@@ -676,6 +678,27 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
|
||||
if ((occurrence.recurrenceRules?.length ?? 0) > 0 && !occurrence.recurrenceId) {
|
||||
return occurrence;
|
||||
}
|
||||
// An occurrence expanded by the server names its base event outright.
|
||||
// Fetch it through the occurrence's own account and hand it back under
|
||||
// the store id the base would have, so the store routes mutations on it
|
||||
// through the same account (see resolveMutationTarget).
|
||||
if (isServerRecurrenceInstance(occurrence) && occurrence.baseEventId) {
|
||||
const accountClient = (occurrence.localAccountId && getClientByLocalAccountId(occurrence.localAccountId)) || client;
|
||||
if (!accountClient) return null;
|
||||
const master = await accountClient.getCalendarEvent(occurrence.baseEventId, occurrence.accountId);
|
||||
if (!master) return null;
|
||||
return {
|
||||
...master,
|
||||
id: baseEventStoreId(occurrence) ?? master.id,
|
||||
originalId: master.id,
|
||||
originalCalendarIds: master.calendarIds,
|
||||
calendarIds: occurrence.calendarIds,
|
||||
accountId: occurrence.accountId,
|
||||
accountName: occurrence.accountName,
|
||||
localAccountId: occurrence.localAccountId,
|
||||
isShared: occurrence.isShared,
|
||||
};
|
||||
}
|
||||
const master = events.find(e =>
|
||||
e.uid === occurrence.uid && !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0
|
||||
);
|
||||
@@ -848,8 +871,14 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
|
||||
if (type === "edit" && updates) {
|
||||
switch (scope) {
|
||||
case "this": {
|
||||
// Synthetic IDs (from expandRecurrences) can't be updated directly.
|
||||
// Patch the master event's recurrenceOverrides instead.
|
||||
if (isServerRecurrenceInstance(event)) {
|
||||
// A server-expanded occurrence is written through its own
|
||||
// (synthetic) id; the store handles the older-server fallback.
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
break;
|
||||
}
|
||||
// Client-side expanded occurrence: patch the master event's
|
||||
// recurrenceOverrides instead.
|
||||
const master = await findMasterEvent(event);
|
||||
if (master && event.recurrenceId) {
|
||||
const patchUpdates = buildRecurrenceOverridePatch(updates, event.recurrenceId);
|
||||
@@ -917,8 +946,12 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
|
||||
} else {
|
||||
switch (scope) {
|
||||
case "this": {
|
||||
// Synthetic IDs (from expandRecurrences) can't be destroyed directly.
|
||||
// Exclude the instance via recurrenceOverrides on the master event.
|
||||
if (isServerRecurrenceInstance(event)) {
|
||||
await deleteEvent(client, event.id, sendScheduling);
|
||||
break;
|
||||
}
|
||||
// Client-side expanded occurrence: exclude the instance via
|
||||
// recurrenceOverrides on the master event.
|
||||
const delMaster = await findMasterEvent(event);
|
||||
if (delMaster && event.recurrenceId) {
|
||||
await updateEvent(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { SYNTHETIC_ID_PROBE } from '../recurrence-instances';
|
||||
|
||||
// CalendarEvent/query?expandRecurrences=true hands out one synthetic id per
|
||||
// occurrence. Stalwart accepts those ids in CalendarEvent/set from 0.16.20
|
||||
// on; older releases reject them. The client probes that once (an update on
|
||||
// a synthetic id that cannot exist: `notFound` on a supporting server,
|
||||
// `invalidProperties` before) and only asks for server-side expansion when
|
||||
// the ids are usable, then pulls the series' recurrence rule / overrides
|
||||
// from the base events so an occurrence looks like the client-expanded ones.
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:calendars': {} },
|
||||
accounts: {
|
||||
'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1', 'urn:ietf:params:jmap:calendars': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const RANGE = { after: '2026-09-01T00:00:00', before: '2026-09-30T00:00:00' };
|
||||
|
||||
// What 0.16.19 returned for the probe series (see lib/recurrence-instances.ts).
|
||||
const OCCURRENCE = {
|
||||
id: 'maaaaab', baseEventId: 'b', uid: 'series@example.com', title: 'Daily standup',
|
||||
start: '2026-09-08T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-08T08:00:00Z', utcEnd: '2026-09-08T09:00:00Z',
|
||||
recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
calendarIds: { f: true }, isDraft: false, isOrigin: true, useDefaultAlerts: true,
|
||||
};
|
||||
const SINGLE = {
|
||||
id: 'eaaaaad', baseEventId: 'd', uid: 'single@example.com', title: 'One off',
|
||||
start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z',
|
||||
calendarIds: { f: true }, isDraft: false, isOrigin: true, useDefaultAlerts: true,
|
||||
};
|
||||
const BASE = {
|
||||
id: 'b', duration: 'PT1H', timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } },
|
||||
};
|
||||
const MASTER = {
|
||||
...BASE, baseEventId: 'b', uid: 'series@example.com', title: 'Daily standup',
|
||||
start: '2026-09-07T10:00:00', utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z',
|
||||
calendarIds: { f: true }, isDraft: false, isOrigin: true, useDefaultAlerts: true,
|
||||
};
|
||||
|
||||
type Call = { method: string; args: Record<string, unknown> };
|
||||
|
||||
describe('server-side recurrence expansion with synthetic ids', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
let client: JMAPClient;
|
||||
let calls: Call[];
|
||||
let serverSupportsSyntheticIds: boolean;
|
||||
let probeFails: boolean;
|
||||
|
||||
beforeEach(async () => {
|
||||
calls = [];
|
||||
serverSupportsSyntheticIds = true;
|
||||
probeFails = false;
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
|
||||
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}'));
|
||||
const methodResponses: unknown[] = [];
|
||||
for (const [method, args, callId] of body.methodCalls ?? []) {
|
||||
calls.push({ method, args });
|
||||
methodResponses.push([...answer(method, args), callId]);
|
||||
}
|
||||
return jsonResponse({ methodResponses });
|
||||
});
|
||||
|
||||
function answer(method: string, args: Record<string, unknown>): [string, unknown] {
|
||||
const update = args.update as Record<string, unknown> | undefined;
|
||||
if (method === 'CalendarEvent/set' && update?.[SYNTHETIC_ID_PROBE]) {
|
||||
if (probeFails) throw new Error('network down');
|
||||
const failure = serverSupportsSyntheticIds
|
||||
? { type: 'notFound' }
|
||||
: { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] };
|
||||
return ['CalendarEvent/set', { notUpdated: { [SYNTHETIC_ID_PROBE]: failure } }];
|
||||
}
|
||||
if (method === 'CalendarEvent/set') {
|
||||
return ['CalendarEvent/set', { updated: Object.fromEntries(Object.keys(update ?? {}).map((id) => [id, null])) }];
|
||||
}
|
||||
if (method === 'CalendarEvent/query') {
|
||||
const ids = args.expandRecurrences ? ['maaaaab', 'eaaaaad'] : ['b', 'd'];
|
||||
return ['CalendarEvent/query', { ids }];
|
||||
}
|
||||
if (method === 'CalendarEvent/get') {
|
||||
const ids = args.ids as string[];
|
||||
const byId: Record<string, unknown> = { maaaaab: OCCURRENCE, eaaaaad: SINGLE, d: { ...SINGLE, id: 'd' } };
|
||||
// The base-event hydration get asks only for recurrence properties.
|
||||
byId.b = (args.properties as string[]).includes('title') ? MASTER : BASE;
|
||||
return ['CalendarEvent/get', { list: ids.map((id) => byId[id]).filter(Boolean) }];
|
||||
}
|
||||
return ['Calendar/get', { list: [] }];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
const probes = () => calls.filter((c) => c.method === 'CalendarEvent/set' && c.args.update && SYNTHETIC_ID_PROBE in (c.args.update as object));
|
||||
const queries = () => calls.filter((c) => c.method === 'CalendarEvent/query');
|
||||
const gets = () => calls.filter((c) => c.method === 'CalendarEvent/get');
|
||||
|
||||
it('probes once, then asks a supporting server to expand recurrences', async () => {
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
await client.queryCalendarEvents(RANGE);
|
||||
|
||||
expect(probes()).toHaveLength(1);
|
||||
// The probe is a side-effect-free update of an id that cannot exist.
|
||||
expect(probes()[0].args).toMatchObject({ accountId: 'acct-1', update: { [SYNTHETIC_ID_PROBE]: {} } });
|
||||
expect(queries()).toHaveLength(2);
|
||||
for (const query of queries()) {
|
||||
expect(query.args.expandRecurrences).toBe(true);
|
||||
expect(query.args.filter).toEqual(RANGE);
|
||||
}
|
||||
expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']);
|
||||
});
|
||||
|
||||
it('lets the probe ride along in the first Calendar/get instead of a request of its own', async () => {
|
||||
await client.getAllCalendars();
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
|
||||
// One request carried both Calendar/get and the probe; the range query
|
||||
// then already knew the verdict and asked for server-side expansion.
|
||||
expect(probes()).toHaveLength(1);
|
||||
const firstRequest = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body));
|
||||
expect(firstRequest.methodCalls.map((c: unknown[]) => c[0])).toEqual(['Calendar/get', 'CalendarEvent/set']);
|
||||
expect(queries()[0].args.expandRecurrences).toBe(true);
|
||||
expect(events.map((e) => e.id)).toEqual(['maaaaab', 'eaaaaad']);
|
||||
});
|
||||
|
||||
it('hydrates occurrences with the series context of their base event', async () => {
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
|
||||
// One extra get for the distinct base ids of recurring occurrences only
|
||||
// (the non-recurring one needs nothing), asking for recurrence data only.
|
||||
const baseGet = gets().find((g) => (g.args.ids as string[]).includes('b'));
|
||||
expect(baseGet).toBeDefined();
|
||||
expect(baseGet!.args.ids).toEqual(['b']);
|
||||
expect(baseGet!.args.properties).toEqual(expect.arrayContaining(['recurrenceRule', 'recurrenceOverrides', 'showWithoutTime']));
|
||||
expect(baseGet!.args.properties).not.toContain('title');
|
||||
|
||||
const occurrence = events.find((e) => e.id === 'maaaaab')!;
|
||||
expect(occurrence.baseEventId).toBe('b');
|
||||
expect(occurrence.recurrenceId).toBe('2026-09-08T10:00:00');
|
||||
expect(occurrence.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
|
||||
expect(occurrence.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
|
||||
expect(occurrence.start).toBe('2026-09-08T10:00:00');
|
||||
|
||||
const single = events.find((e) => e.id === 'eaaaaad')!;
|
||||
expect(single.baseEventId).toBe('d');
|
||||
expect(single.recurrenceRules ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps client-side expansion (no expandRecurrences) when the server rejects synthetic ids', async () => {
|
||||
serverSupportsSyntheticIds = false;
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
await client.queryCalendarEvents(RANGE);
|
||||
|
||||
expect(probes()).toHaveLength(1);
|
||||
for (const query of queries()) {
|
||||
expect(query.args.expandRecurrences).toBeUndefined();
|
||||
}
|
||||
// Raw base events with real ids come back for the browser to expand -
|
||||
// one full get per query, no base-event hydration get.
|
||||
expect(events.map((e) => e.id)).toEqual(['b', 'd']);
|
||||
expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
|
||||
expect(gets()).toHaveLength(2);
|
||||
for (const get of gets()) {
|
||||
expect(get.args.properties).toContain('title');
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a failed probe as unsupported', async () => {
|
||||
probeFails = true;
|
||||
const events = await client.queryCalendarEvents(RANGE);
|
||||
expect(queries()[0].args.expandRecurrences).toBeUndefined();
|
||||
expect(events.map((e) => e.id)).toEqual(['b', 'd']);
|
||||
});
|
||||
|
||||
it('never expands a query without both range bounds (the server requires them)', async () => {
|
||||
await client.queryCalendarEvents({ uid: 'series@example.com' });
|
||||
await client.queryCalendarEvents({ after: RANGE.after });
|
||||
|
||||
expect(probes()).toHaveLength(0);
|
||||
for (const query of queries()) {
|
||||
expect(query.args.expandRecurrences).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('strips baseEventId from an update patch', async () => {
|
||||
await client.updateCalendarEvent('maaaaab', { title: 'Renamed', baseEventId: 'b' });
|
||||
const update = calls.find((c) => c.method === 'CalendarEvent/set')!;
|
||||
expect(update.args.update).toEqual({ maaaaab: { title: 'Renamed' } });
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,9 @@ describe('JMAPClient first-touch ordering (#907)', () => {
|
||||
await expect(calendars).resolves.toEqual([]);
|
||||
await flush();
|
||||
|
||||
// The synthetic-id probe (lib/recurrence-instances.ts) rides along in
|
||||
// the Calendar/get request, so the query needs no request of its own first.
|
||||
expect(onWire[0].methods).toEqual(['Calendar/get', 'CalendarEvent/set']);
|
||||
expect(onWire.map((p) => p.methods[0])).toEqual(['Calendar/get', 'CalendarEvent/query']);
|
||||
onWire[1].resolve(emptyResponse('CalendarEvent/query'));
|
||||
await expect(events).resolves.toEqual([]);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import {
|
||||
SYNTHETIC_ID_PROBE,
|
||||
baseEventStoreId,
|
||||
buildFallbackExcludePatch,
|
||||
buildFallbackOverridePatch,
|
||||
buildOccurrencePatch,
|
||||
hydrateRecurrenceInstances,
|
||||
isServerRecurrenceInstance,
|
||||
isSyntheticIdMutationUnsupported,
|
||||
resolveOverrideKey,
|
||||
} from '../recurrence-instances';
|
||||
|
||||
// Shapes below mirror what Stalwart 0.16.19 returned for
|
||||
// CalendarEvent/query?expandRecurrences=true + CalendarEvent/get: the
|
||||
// occurrence carries baseEventId/recurrenceId but no recurrence rule or
|
||||
// overrides, all-day occurrences lose showWithoutTime and get the request
|
||||
// time zone, and a moved override reports its new start as recurrenceId.
|
||||
|
||||
function instance(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'maaaaab',
|
||||
baseEventId: 'b',
|
||||
uid: 'series@example.com',
|
||||
title: 'Daily standup',
|
||||
start: '2026-09-08T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-08T08:00:00Z',
|
||||
utcEnd: '2026-09-08T09:00:00Z',
|
||||
recurrenceId: '2026-09-08T10:00:00',
|
||||
recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
recurrenceRules: null,
|
||||
recurrenceOverrides: null,
|
||||
excludedRecurrenceRules: null,
|
||||
showWithoutTime: false,
|
||||
calendarIds: { f: true },
|
||||
...overrides,
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
const dailyRule = [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] as unknown as CalendarEvent['recurrenceRules'];
|
||||
|
||||
describe('isServerRecurrenceInstance', () => {
|
||||
it('is true for a synthetic occurrence id that differs from its base', () => {
|
||||
expect(isServerRecurrenceInstance({ id: 'maaaaab', baseEventId: 'b' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a base event, which reports itself as its own base', () => {
|
||||
expect(isServerRecurrenceInstance({ id: 'b', baseEventId: 'b' })).toBe(false);
|
||||
});
|
||||
|
||||
it('compares the raw id when the store namespaced it', () => {
|
||||
expect(isServerRecurrenceInstance({ id: 'acct:maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe(true);
|
||||
expect(isServerRecurrenceInstance({ id: 'acct:b', originalId: 'b', baseEventId: 'b' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is false without a baseEventId (older servers, demo data, client-side occurrences)', () => {
|
||||
expect(isServerRecurrenceInstance({ id: 'b:2026-09-08T10:00:00', originalId: 'b' })).toBe(false);
|
||||
expect(isServerRecurrenceInstance({ id: 'x', baseEventId: null })).toBe(false);
|
||||
expect(isServerRecurrenceInstance(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseEventStoreId', () => {
|
||||
it('keeps the namespace prefix and swaps in the base id', () => {
|
||||
expect(baseEventStoreId({ id: 'acct:maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe('acct:b');
|
||||
expect(baseEventStoreId({ id: 'A@h::maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe('A@h::b');
|
||||
expect(baseEventStoreId({ id: 'maaaaab', originalId: 'maaaaab', baseEventId: 'b' })).toBe('b');
|
||||
});
|
||||
|
||||
it('is null for anything that is not an expanded occurrence', () => {
|
||||
expect(baseEventStoreId({ id: 'b', baseEventId: 'b' })).toBeNull();
|
||||
expect(baseEventStoreId({ id: 'b:2026-09-08T10:00:00', originalId: 'b' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSyntheticIdMutationUnsupported', () => {
|
||||
it('recognises the pre-0.16.20 rejections for update and destroy', () => {
|
||||
expect(isSyntheticIdMutationUnsupported(new Error('Updating synthetic ids is not yet supported.'))).toBe(true);
|
||||
expect(isSyntheticIdMutationUnsupported(new Error('Deleting synthetic ids is not yet supported.'))).toBe(true);
|
||||
expect(isSyntheticIdMutationUnsupported('Updating synthetic ids is not yet supported.')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match other failures', () => {
|
||||
expect(isSyntheticIdMutationUnsupported(new Error('Failed to update calendar event'))).toBe(false);
|
||||
expect(isSyntheticIdMutationUnsupported(new Error('This property cannot be modified on a single occurrence.'))).toBe(false);
|
||||
expect(isSyntheticIdMutationUnsupported(undefined)).toBe(false);
|
||||
expect(isSyntheticIdMutationUnsupported({ message: 'synthetic ids not supported' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SYNTHETIC_ID_PROBE', () => {
|
||||
it('is Id::from_parts(1, u32::MAX) in Stalwart base32 (a-z then 792013)', () => {
|
||||
// 0x1_FFFF_FFFF in 7 groups of 5 bits: 00111 then six times 11111.
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
const value = 2 ** 32 + 0xffffffff; // fits in a double exactly
|
||||
let encoded = '';
|
||||
for (let shift = 30; shift >= 0; shift -= 5) {
|
||||
encoded += alphabet[Math.floor(value / 2 ** shift) % 32];
|
||||
}
|
||||
expect(SYNTHETIC_ID_PROBE).toBe(encoded);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOccurrencePatch', () => {
|
||||
it('drops identity, whole-series and per-event keys, keeps the rest', () => {
|
||||
const patch = buildOccurrencePatch({
|
||||
id: 'maaaaab',
|
||||
uid: 'series@example.com',
|
||||
'@type': 'Event',
|
||||
baseEventId: 'b',
|
||||
calendarIds: { f: true },
|
||||
recurrenceRules: dailyRule,
|
||||
recurrenceOverrides: {},
|
||||
excludedRecurrenceRules: null,
|
||||
recurrenceId: '2026-09-08T10:00:00',
|
||||
recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
isDraft: false,
|
||||
isOrigin: true,
|
||||
utcStart: '2026-09-08T08:00:00Z',
|
||||
utcEnd: '2026-09-08T09:00:00Z',
|
||||
useDefaultAlerts: true,
|
||||
mayInviteSelf: true,
|
||||
mayInviteOthers: false,
|
||||
hideAttendees: false,
|
||||
title: 'Renamed',
|
||||
start: '2026-09-08T11:00:00',
|
||||
duration: 'PT2H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
showWithoutTime: false,
|
||||
locations: { loc1: { '@type': 'Location', name: 'Room B' } },
|
||||
} as unknown as Partial<CalendarEvent>);
|
||||
expect(patch).toEqual({
|
||||
title: 'Renamed',
|
||||
start: '2026-09-08T11:00:00',
|
||||
duration: 'PT2H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
showWithoutTime: false,
|
||||
locations: { loc1: { '@type': 'Location', name: 'Room B' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the same rule to JSON-pointer patches by their first segment', () => {
|
||||
const patch = buildOccurrencePatch({
|
||||
'locations/loc1/name': 'Room B',
|
||||
'participants/p1/participationStatus': 'accepted',
|
||||
'calendarIds/f': true,
|
||||
'recurrenceOverrides/2026-09-09T10:00:00': { excluded: true },
|
||||
} as unknown as Partial<CalendarEvent>);
|
||||
expect(patch).toEqual({
|
||||
'locations/loc1/name': 'Room B',
|
||||
'participants/p1/participationStatus': 'accepted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOverrideKey', () => {
|
||||
it('uses recurrenceId when the base event has an override under it (or none at all)', () => {
|
||||
const occ = { start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00' };
|
||||
expect(resolveOverrideKey(occ, null)).toBe('2026-09-08T10:00:00');
|
||||
expect(resolveOverrideKey(occ, { '2026-09-08T10:00:00': { title: 'x' } })).toBe('2026-09-08T10:00:00');
|
||||
expect(resolveOverrideKey(occ, { '2026-09-07T10:00:00': { title: 'x' } })).toBe('2026-09-08T10:00:00');
|
||||
});
|
||||
|
||||
it('finds a moved override by its start when the server reported the new start as recurrenceId', () => {
|
||||
const moved = { start: '2026-09-09T14:00:00', recurrenceId: '2026-09-09T14:00:00' };
|
||||
const overrides = { '2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved' } };
|
||||
expect(resolveOverrideKey(moved, overrides)).toBe('2026-09-09T10:00:00');
|
||||
});
|
||||
|
||||
it('is null without a recurrenceId', () => {
|
||||
expect(resolveOverrideKey({ start: '2026-09-08T09:00:00', recurrenceId: null }, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydrateRecurrenceInstances', () => {
|
||||
const base: Partial<CalendarEvent> = {
|
||||
id: 'b',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRules: dailyRule,
|
||||
excludedRecurrenceRules: null,
|
||||
recurrenceOverrides: {
|
||||
'2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved' },
|
||||
'2026-09-10T10:00:00': { start: '2026-09-10T10:00:00', duration: 'PT3H', title: 'Long one' },
|
||||
},
|
||||
};
|
||||
const bases = new Map<string, Partial<CalendarEvent>>([[ 'b', base ]]);
|
||||
|
||||
it('copies the series context from the base event', () => {
|
||||
const [out] = hydrateRecurrenceInstances([instance()], bases);
|
||||
expect(out.recurrenceRules).toEqual(dailyRule);
|
||||
expect(out.recurrenceOverrides).toEqual(base.recurrenceOverrides);
|
||||
expect(out.excludedRecurrenceRules).toBeNull();
|
||||
// Own occurrence data is untouched.
|
||||
expect(out.id).toBe('maaaaab');
|
||||
expect(out.baseEventId).toBe('b');
|
||||
expect(out.start).toBe('2026-09-08T10:00:00');
|
||||
expect(out.recurrenceId).toBe('2026-09-08T10:00:00');
|
||||
expect(out.duration).toBe('PT1H');
|
||||
});
|
||||
|
||||
it('restores showWithoutTime and the floating time zone of an all-day series', () => {
|
||||
const allDayBase = new Map([['c', { id: 'c', duration: 'P1D', timeZone: null, showWithoutTime: true, recurrenceRules: dailyRule }]]);
|
||||
const [out] = hydrateRecurrenceInstances([instance({
|
||||
id: 'maaaaac', baseEventId: 'c', start: '2026-09-08T00:00:00', duration: 'P1D',
|
||||
timeZone: 'Europe/Berlin', recurrenceId: '2026-09-08T00:00:00',
|
||||
})], allDayBase);
|
||||
expect(out.showWithoutTime).toBe(true);
|
||||
expect(out.timeZone).toBeNull();
|
||||
});
|
||||
|
||||
it('repairs the recurrenceId of a moved override reported under its new start', () => {
|
||||
const [out] = hydrateRecurrenceInstances([instance({
|
||||
id: 'eaaaaab', start: '2026-09-09T14:00:00', recurrenceId: '2026-09-09T14:00:00', title: 'Moved',
|
||||
utcStart: '2026-09-09T12:00:00Z', utcEnd: '2026-09-09T21:59:59Z', duration: 'PT9H59M59S',
|
||||
})], bases);
|
||||
expect(out.recurrenceId).toBe('2026-09-09T10:00:00');
|
||||
// The override sets no duration: the occurrence inherits the base's.
|
||||
expect(out.duration).toBe('PT1H');
|
||||
expect(out.utcEnd).toBe('2026-09-09T13:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps a duration the override sets itself', () => {
|
||||
const [out] = hydrateRecurrenceInstances([instance({
|
||||
id: 'qaaaaab', start: '2026-09-10T10:00:00', recurrenceId: '2026-09-10T10:00:00', duration: 'PT3H',
|
||||
utcEnd: '2026-09-10T11:00:00Z',
|
||||
})], bases);
|
||||
expect(out.duration).toBe('PT3H');
|
||||
expect(out.utcEnd).toBe('2026-09-10T11:00:00Z');
|
||||
});
|
||||
|
||||
it('leaves non-recurring occurrences and occurrences of unknown bases alone', () => {
|
||||
const single = instance({ id: 'eaaaaad', baseEventId: 'd', recurrenceId: null });
|
||||
const orphan = instance({ id: 'maaaaaz', baseEventId: 'z' });
|
||||
const base = instance({ id: 'b', baseEventId: 'b', recurrenceId: null });
|
||||
expect(hydrateRecurrenceInstances([single, orphan, base], bases)).toEqual([single, orphan, base]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFallbackOverridePatch', () => {
|
||||
it('writes the whole override object, pinning start/duration and keeping existing override fields', () => {
|
||||
const occ = instance({
|
||||
start: '2026-09-09T14:00:00', recurrenceId: '2026-09-09T10:00:00', duration: 'PT1H',
|
||||
recurrenceOverrides: { '2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved', updated: '2026-08-01T00:00:00Z' } },
|
||||
});
|
||||
expect(buildFallbackOverridePatch(occ, { title: 'Moved, renamed', calendarIds: { g: true } } as Partial<CalendarEvent>)).toEqual({
|
||||
'recurrenceOverrides/2026-09-09T10:00:00': {
|
||||
start: '2026-09-09T14:00:00',
|
||||
duration: 'PT1H',
|
||||
title: 'Moved, renamed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the patch override the pinned start (a drag) and creates a fresh override otherwise', () => {
|
||||
expect(buildFallbackOverridePatch(instance(), { start: '2026-09-08T12:00:00' })).toEqual({
|
||||
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00', duration: 'PT1H' },
|
||||
});
|
||||
});
|
||||
|
||||
it('is null for something that is not an occurrence of a series', () => {
|
||||
expect(buildFallbackOverridePatch(instance({ recurrenceId: null }), { title: 'x' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFallbackExcludePatch', () => {
|
||||
it('excludes the occurrence under its override key', () => {
|
||||
expect(buildFallbackExcludePatch(instance())).toEqual({
|
||||
'recurrenceOverrides/2026-09-08T10:00:00': { excluded: true },
|
||||
});
|
||||
expect(buildFallbackExcludePatch(instance({ recurrenceId: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function isMidnightValue(value: string): boolean {
|
||||
return match[1] === '00' && match[2] === '00' && (match[3] ?? '00') === '00';
|
||||
}
|
||||
|
||||
function parseDurationSeconds(duration: string | undefined): number | null {
|
||||
export function parseDurationSeconds(duration: string | undefined): number | null {
|
||||
if (!duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+125
-12
@@ -8,6 +8,7 @@ import { keywordPointer } from "./patch-pointer";
|
||||
import { FirstTouchGate } from "./first-touch-gate";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||
import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
|
||||
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
|
||||
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
|
||||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
@@ -320,6 +321,7 @@ const CALENDAR_EVENT_PROPERTIES = [
|
||||
'relatedTo',
|
||||
'isDraft',
|
||||
'isOrigin',
|
||||
'baseEventId',
|
||||
] as const;
|
||||
|
||||
// Task-specific properties for CalendarEvent/get when fetching Task objects
|
||||
@@ -5206,12 +5208,27 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar/get for one account. The session's first one on the primary
|
||||
* calendar account also carries the synthetic-id probe (see
|
||||
* supportsSyntheticCalendarIds), so the range query that follows needs no
|
||||
* round trip of its own for it.
|
||||
*/
|
||||
private requestCalendars(accountId: string): Promise<JMAPResponse> {
|
||||
const methodCalls: JMAPMethodCall[] = [
|
||||
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"],
|
||||
];
|
||||
const carriesProbe = !this.syntheticCalendarIdSupport && accountId === this.getCalendarsAccountId();
|
||||
if (carriesProbe) methodCalls.push(this.syntheticIdProbeCall(accountId));
|
||||
const pending = this.request(methodCalls, this.calendarUsing());
|
||||
if (carriesProbe) this.rememberSyntheticIdProbe(pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async getCalendars(): Promise<Calendar[]> {
|
||||
try {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
|
||||
], this.calendarUsing());
|
||||
const response = await this.requestCalendars(accountId);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||
const calendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||||
@@ -5292,9 +5309,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const account = this.accounts[accountId];
|
||||
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"]
|
||||
], this.calendarUsing());
|
||||
const response = await this.requestCalendars(accountId);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||||
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||||
@@ -5585,6 +5600,57 @@ export class JMAPClient implements IJMAPClient {
|
||||
// for why the fan-out has to probe on suspicion).
|
||||
private calendarAccessDenied = new Set<string>();
|
||||
|
||||
// Result of the one-off synthetic-id capability probe, shared by every
|
||||
// range query on this client (see supportsSyntheticCalendarIds).
|
||||
private syntheticCalendarIdSupport: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* Whether CalendarEvent/set accepts the synthetic ids that
|
||||
* CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20).
|
||||
*
|
||||
* Probed once per client with a side-effect-free update of a synthetic id
|
||||
* whose event cannot exist (see SYNTHETIC_ID_PROBE): a server that still
|
||||
* rejects synthetic ids answers `invalidProperties` before looking the
|
||||
* event up, one that supports them answers `notFound`. Anything else
|
||||
* (transport error, non-Stalwart server) counts as unsupported so the
|
||||
* client-side expansion keeps being used. The session's first Calendar/get
|
||||
* carries the probe along (getCalendars), so the usual calendar start-up
|
||||
* pays no extra round trip; a range query that comes first probes itself.
|
||||
*/
|
||||
async supportsSyntheticCalendarIds(): Promise<boolean> {
|
||||
if (!this.syntheticCalendarIdSupport) {
|
||||
const accountId = this.getCalendarsAccountId();
|
||||
this.rememberSyntheticIdProbe(
|
||||
this.request([this.syntheticIdProbeCall(accountId)], this.calendarUsing()),
|
||||
);
|
||||
}
|
||||
return this.syntheticCalendarIdSupport!;
|
||||
}
|
||||
|
||||
private static readonly SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe';
|
||||
|
||||
private syntheticIdProbeCall(accountId: string): JMAPMethodCall {
|
||||
return ["CalendarEvent/set", { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID];
|
||||
}
|
||||
|
||||
/** Derive (and cache) the probe verdict from a response that carried the probe call. */
|
||||
private rememberSyntheticIdProbe(pending: Promise<JMAPResponse>): void {
|
||||
this.syntheticCalendarIdSupport = pending.then(
|
||||
(response) => {
|
||||
const entry = response.methodResponses?.find((r) => r[2] === JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID);
|
||||
if (!entry || entry[0] !== "CalendarEvent/set") return false;
|
||||
const failure = (entry[1] as { notUpdated?: Record<string, { type?: string }> } | undefined)?.notUpdated?.[SYNTHETIC_ID_PROBE];
|
||||
const supported = failure?.type === 'notFound';
|
||||
debug.log('calendar', 'Synthetic calendar id probe', { supported, failure: failure ?? null });
|
||||
return supported;
|
||||
},
|
||||
(error) => {
|
||||
debug.log('calendar', 'Synthetic calendar id probe failed; using client-side recurrence expansion', error);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async queryAllCalendarEvents(
|
||||
filter: CalendarEventFilter,
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
@@ -5647,9 +5713,16 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (timeZone) {
|
||||
queryArgs.timeZone = timeZone;
|
||||
}
|
||||
// NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic
|
||||
// IDs that cannot be used for CalendarEvent/set (update/destroy).
|
||||
// Recurrence expansion is done client-side instead.
|
||||
// Let the server expand recurring series into one synthetic id per
|
||||
// occurrence when it also accepts those ids in CalendarEvent/set
|
||||
// (Stalwart >= 0.16.20; the server needs both range bounds for it).
|
||||
// Older servers keep the client-side expansion in
|
||||
// lib/recurrence-expansion.ts, which runs on whatever comes back.
|
||||
const expandRecurrences = Boolean(filter.after && filter.before)
|
||||
&& await this.supportsSyntheticCalendarIds();
|
||||
if (expandRecurrences) {
|
||||
queryArgs.expandRecurrences = true;
|
||||
}
|
||||
if (sort) {
|
||||
queryArgs.sort = sort;
|
||||
}
|
||||
@@ -5693,9 +5766,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = allEvents
|
||||
const normalized = allEvents
|
||||
.filter((event) => !isTaskObject(event))
|
||||
.map((event) => normalizeCalendarEventLike(event));
|
||||
const filtered = expandRecurrences
|
||||
? await this.hydrateExpandedOccurrences(normalized, accountId, timeZone)
|
||||
: normalized;
|
||||
|
||||
const eventsWithParticipants = filtered.filter(e => e.participants && Object.keys(e.participants).length > 0);
|
||||
debug.log('calendar', 'queryCalendarEvents participant summary', {
|
||||
@@ -5729,6 +5805,43 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 client-side expansion
|
||||
* used to copy from its master.
|
||||
*/
|
||||
private async hydrateExpandedOccurrences(
|
||||
events: CalendarEvent[],
|
||||
accountId: string,
|
||||
timeZone?: string,
|
||||
): Promise<CalendarEvent[]> {
|
||||
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 bases = new Map<string, Partial<CalendarEvent>>();
|
||||
for (const batch of batched(baseIds, this.getMaxObjectsInGet())) {
|
||||
const response = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...RECURRENCE_BASE_PROPERTIES],
|
||||
ids: batch,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, "0"],
|
||||
], this.calendarUsing());
|
||||
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") continue;
|
||||
for (const base of (response.methodResponses[0][1].list || []) as Partial<CalendarEvent>[]) {
|
||||
if (base.id) bases.set(base.id, normalizeCalendarEventLike(base));
|
||||
}
|
||||
}
|
||||
return hydrateRecurrenceInstances(events, bases);
|
||||
}
|
||||
|
||||
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
|
||||
try {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
@@ -5757,7 +5870,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
|
||||
// Strip client-only shared fields before sending to JMAP
|
||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
||||
const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
||||
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
|
||||
|
||||
debug.group('CalendarEvent/create', 'calendar');
|
||||
@@ -5938,7 +6051,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
|
||||
// Strip client-only and server-immutable fields before sending to JMAP
|
||||
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
||||
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
||||
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
|
||||
@@ -593,6 +593,11 @@ export interface CalendarRights {
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
originalId?: string;
|
||||
// Set by the server on every CalendarEvent/get result. For an occurrence
|
||||
// handed out by CalendarEvent/query?expandRecurrences=true (a "synthetic"
|
||||
// id) this is the id of the stored base event; for a base event it equals
|
||||
// `id`. See lib/recurrence-instances.ts.
|
||||
baseEventId?: string | null;
|
||||
calendarIds: Record<string, boolean>;
|
||||
originalCalendarIds?: Record<string, boolean>;
|
||||
accountId?: string;
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
* recurrence rule interpretation algorithm with full byX filtering,
|
||||
* implicit byX property addition, and bySetPosition support.
|
||||
*
|
||||
* 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
|
||||
* in the browser.
|
||||
* 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): raw events (with real,
|
||||
* mutable ids) are fetched and recurring series are expanded into individual
|
||||
* occurrences in the browser. Occurrences a server already expanded (no
|
||||
* recurrence rule of their own) pass through unchanged.
|
||||
*/
|
||||
|
||||
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
|
||||
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
|
||||
import { isServerRecurrenceInstance } from '@/lib/recurrence-instances';
|
||||
|
||||
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
||||
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
||||
@@ -52,7 +55,10 @@ export function expandRecurringEvents(
|
||||
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.ts
|
||||
// hydrates it with that); never expand it again.
|
||||
if (!event.recurrenceRules?.length || isServerRecurrenceInstance(event)) {
|
||||
result.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Server-side recurrence instances ("synthetic ids").
|
||||
*
|
||||
* `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`: an
|
||||
* update on a synthetic id writes a `recurrenceOverrides` entry for that one
|
||||
* occurrence, a destroy excludes it. Older servers reject both with
|
||||
* `invalidProperties` ("… synthetic ids is not yet supported."), in which
|
||||
* case Bulwark keeps expanding recurrences in the browser
|
||||
* (lib/recurrence-expansion.ts) and patches the base event itself.
|
||||
*
|
||||
* What an expanded occurrence looks like (verified 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 visible range has to be refetched after mutating one.
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
||||
import { RECURRENCE_OVERRIDE_IMMUTABLE_KEYS } from '@/lib/recurrence-overrides';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Properties Stalwart refuses on a single occurrence ("This property cannot
|
||||
* be modified on a single occurrence."). They describe the stored event, not
|
||||
* one of its instances.
|
||||
*/
|
||||
export const OCCURRENCE_REJECTED_KEYS = [
|
||||
'baseEventId',
|
||||
'calendarIds',
|
||||
'isDraft',
|
||||
'isOrigin',
|
||||
'utcStart',
|
||||
'utcEnd',
|
||||
'useDefaultAlerts',
|
||||
'mayInviteSelf',
|
||||
'mayInviteOthers',
|
||||
'hideAttendees',
|
||||
] as const;
|
||||
|
||||
type EventIdentity = Pick<CalendarEvent, 'id'> & Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
|
||||
|
||||
/**
|
||||
* 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 (`<accountId>:<raw>`,
|
||||
* `<localAccountId>::<raw>` or just `<raw>`), 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `CalendarEvent/set` failure is a pre-0.16.20 server refusing a
|
||||
* synthetic id ("Updating/Deleting synthetic ids is not yet supported.").
|
||||
*/
|
||||
export function isSyntheticIdMutationUnsupported(error: unknown): boolean {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string' ? error : '';
|
||||
return /synthetic ids?\b.*\bnot (?:yet )?supported/i.test(message);
|
||||
}
|
||||
|
||||
function firstPointerSegment(key: string): string {
|
||||
const slash = key.indexOf('/');
|
||||
return slash === -1 ? key : key.slice(0, slash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an event patch to what may be written to a single occurrence
|
||||
* through its synthetic id: identity / whole-series keys and the per-event
|
||||
* keys the server rejects are dropped (also when addressed through a JSON
|
||||
* pointer such as `calendarIds/x`). Everything else - including pointer
|
||||
* patches like `locations/loc1/name` - passes through unchanged.
|
||||
*/
|
||||
export function buildOccurrencePatch(updates: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
const dropped = new Set<string>([
|
||||
...RECURRENCE_OVERRIDE_IMMUTABLE_KEYS,
|
||||
...OCCURRENCE_REJECTED_KEYS,
|
||||
'recurrenceId',
|
||||
'recurrenceIdTimeZone',
|
||||
]);
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (dropped.has(firstPointerSegment(key))) continue;
|
||||
patch[key] = value;
|
||||
}
|
||||
return patch as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
type OverrideMap = NonNullable<CalendarEvent['recurrenceOverrides']>;
|
||||
|
||||
/**
|
||||
* 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<CalendarEvent, 'start' | 'recurrenceId'>,
|
||||
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 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 client-side expansion used to
|
||||
* copy 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<string, Partial<CalendarEvent>>,
|
||||
): CalendarEvent[] {
|
||||
return instances.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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback for servers that reject synthetic ids: the same change
|
||||
* expressed as a whole-object `recurrenceOverrides/<recurrenceId>` patch on
|
||||
* the base event. The existing override (if any) is kept underneath so a
|
||||
* partial patch (a drag that only sets `start`) does not wipe fields the
|
||||
* occurrence already overrides, and `start`/`duration` are pinned the way
|
||||
* the server does it for synthetic-id updates.
|
||||
*/
|
||||
export function buildFallbackOverridePatch(
|
||||
instance: Pick<CalendarEvent, 'start' | 'duration' | 'recurrenceId' | 'recurrenceOverrides'>,
|
||||
patch: Partial<CalendarEvent>,
|
||||
): Partial<CalendarEvent> | null {
|
||||
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
|
||||
if (!key) return null;
|
||||
const existing = (instance.recurrenceOverrides?.[key] ?? {}) as Record<string, unknown>;
|
||||
const { updated: _updated, ...kept } = existing;
|
||||
const override: Record<string, unknown> = {
|
||||
...kept,
|
||||
start: instance.start,
|
||||
duration: instance.duration,
|
||||
...buildOccurrencePatch(patch),
|
||||
};
|
||||
return { [`recurrenceOverrides/${key}`]: override } as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
/** The fallback for destroying one occurrence: exclude it on the base event. */
|
||||
export function buildFallbackExcludePatch(
|
||||
instance: Pick<CalendarEvent, 'start' | 'recurrenceId' | 'recurrenceOverrides'>,
|
||||
): Partial<CalendarEvent> | null {
|
||||
const key = resolveOverrideKey(instance, instance.recurrenceOverrides);
|
||||
if (!key) return null;
|
||||
return { [`recurrenceOverrides/${key}`]: { excluded: true } } as Partial<CalendarEvent>;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useCalendarStore, resolveMutationTarget } from '../calendar-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Occurrences produced by server-side recurrence expansion carry synthetic
|
||||
// ids (lib/recurrence-instances.ts). The store writes "this occurrence only"
|
||||
// changes through that id, resolves whole-series operations (RSVP) and
|
||||
// non-recurring events to the base event, falls back to a recurrence
|
||||
// override on the base event when the server predates synthetic-id writes,
|
||||
// and reloads the visible range afterwards because the ids reshuffle.
|
||||
|
||||
const UNSUPPORTED = 'Updating synthetic ids is not yet supported.';
|
||||
const UNSUPPORTED_DESTROY = 'Deleting synthetic ids is not yet supported.';
|
||||
|
||||
function occurrence(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'maaaaab',
|
||||
originalId: 'maaaaab',
|
||||
baseEventId: 'b',
|
||||
uid: 'series@example.com',
|
||||
title: 'Daily standup',
|
||||
start: '2026-09-08T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
utcStart: '2026-09-08T08:00:00Z',
|
||||
utcEnd: '2026-09-08T09:00:00Z',
|
||||
showWithoutTime: false,
|
||||
recurrenceId: '2026-09-08T10:00:00',
|
||||
recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] as unknown as CalendarEvent['recurrenceRules'],
|
||||
recurrenceOverrides: { '2026-09-09T10:00:00': { start: '2026-09-09T14:00:00', title: 'Moved' } },
|
||||
excludedRecurrenceRules: null,
|
||||
calendarIds: { f: true },
|
||||
participants: {
|
||||
p1: { '@type': 'Participant', name: 'Me', email: 'me@example.com', participationStatus: 'needs-action' },
|
||||
} as unknown as CalendarEvent['participants'],
|
||||
...overrides,
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
function fakeClient(overrides: Partial<Record<keyof IJMAPClient, unknown>> = {}): IJMAPClient & {
|
||||
updateCalendarEvent: ReturnType<typeof vi.fn>;
|
||||
deleteCalendarEvent: ReturnType<typeof vi.fn>;
|
||||
queryAllCalendarEvents: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
updateCalendarEvent: vi.fn().mockResolvedValue(undefined),
|
||||
deleteCalendarEvent: vi.fn().mockResolvedValue(undefined),
|
||||
queryAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const RANGE = ['2026-09-01T00:00:00', '2026-09-30T00:00:00'] as const;
|
||||
|
||||
beforeEach(() => {
|
||||
useCalendarStore.getState().clearState();
|
||||
useCalendarStore.setState({ events: [], calendars: [], error: null });
|
||||
});
|
||||
|
||||
describe('resolveMutationTarget', () => {
|
||||
it('addresses an occurrence of a series by its synthetic id', () => {
|
||||
const target = resolveMutationTarget([occurrence()], 'maaaaab', 'occurrence');
|
||||
expect(target).toMatchObject({ realId: 'maaaaab', isOccurrence: true });
|
||||
});
|
||||
|
||||
it('addresses a whole-series operation and a non-recurring occurrence by the base id', () => {
|
||||
expect(resolveMutationTarget([occurrence()], 'maaaaab', 'series')).toMatchObject({ realId: 'b', isOccurrence: false });
|
||||
const single = occurrence({ id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceRules: null });
|
||||
expect(resolveMutationTarget([single], 'eaaaaad', 'occurrence')).toMatchObject({ realId: 'd', isOccurrence: false });
|
||||
});
|
||||
|
||||
it('routes a base event that is only in view through its occurrences with that account', () => {
|
||||
const shared = occurrence({ id: 'acct2:maaaaab', originalId: 'maaaaab', accountId: 'acct2', isShared: true });
|
||||
expect(resolveMutationTarget([shared], 'acct2:b', 'occurrence')).toEqual({
|
||||
realId: 'b', targetAccountId: 'acct2', localAccountId: undefined, isOccurrence: false,
|
||||
});
|
||||
const aggregated = occurrence({ id: 'A@h::maaaaab', originalId: 'maaaaab', localAccountId: 'A@h' });
|
||||
expect(resolveMutationTarget([aggregated], 'A@h::b', 'occurrence')).toMatchObject({ realId: 'b', localAccountId: 'A@h' });
|
||||
});
|
||||
|
||||
it('leaves events without synthetic ids exactly as before', () => {
|
||||
const legacy = occurrence({ id: 'x', originalId: 'x', baseEventId: 'x', recurrenceId: null });
|
||||
expect(resolveMutationTarget([legacy], 'x', 'occurrence')).toMatchObject({ realId: 'x', isOccurrence: false });
|
||||
const clientExpanded = occurrence({ id: 'b:2026-09-08T10:00:00', originalId: 'b', baseEventId: undefined });
|
||||
expect(resolveMutationTarget([clientExpanded], 'b:2026-09-08T10:00:00', 'occurrence')).toMatchObject({ realId: 'b', isOccurrence: false });
|
||||
expect(resolveMutationTarget([], 'unknown', 'occurrence')).toEqual({ realId: 'unknown', isOccurrence: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent on an expanded occurrence', () => {
|
||||
it('patches the occurrence through its synthetic id, minus per-event keys', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [occurrence()] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'maaaaab', {
|
||||
title: 'Renamed', start: '2026-09-08T11:00:00', calendarIds: { g: true },
|
||||
});
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
|
||||
'maaaaab', { title: 'Renamed', start: '2026-09-08T11:00:00' }, undefined, undefined,
|
||||
);
|
||||
const stored = useCalendarStore.getState().events[0];
|
||||
expect(stored.title).toBe('Renamed');
|
||||
expect(stored.utcStart).toBe('2026-09-08T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to a recurrence override on the base event when the server rejects synthetic ids', async () => {
|
||||
const client = fakeClient();
|
||||
client.updateCalendarEvent.mockRejectedValueOnce(new Error(UNSUPPORTED));
|
||||
useCalendarStore.setState({ events: [occurrence()] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' }, true);
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(2);
|
||||
expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(1, 'maaaaab', { title: 'Renamed' }, true, undefined);
|
||||
expect(client.updateCalendarEvent).toHaveBeenNthCalledWith(2, 'b', {
|
||||
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T10:00:00', duration: 'PT1H', title: 'Renamed' },
|
||||
}, true, undefined);
|
||||
expect(useCalendarStore.getState().events[0].title).toBe('Renamed');
|
||||
|
||||
// The rejection is remembered: the next change skips the doomed attempt.
|
||||
client.updateCalendarEvent.mockClear();
|
||||
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Again' });
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.updateCalendarEvent.mock.calls[0][0]).toBe('b');
|
||||
});
|
||||
|
||||
it('rethrows other failures without falling back', async () => {
|
||||
const client = fakeClient();
|
||||
client.updateCalendarEvent.mockRejectedValueOnce(new Error('This property cannot be modified on a single occurrence.'));
|
||||
useCalendarStore.setState({ events: [occurrence()] });
|
||||
|
||||
await expect(useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'x' })).rejects.toThrow('single occurrence');
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
expect(useCalendarStore.getState().error).toBe('Failed to update event');
|
||||
});
|
||||
|
||||
it('targets the base event for the single occurrence of a non-recurring event', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [occurrence({ id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceRules: null })] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'eaaaaad', { title: 'One off, renamed' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('d', { title: 'One off, renamed' }, undefined, undefined);
|
||||
});
|
||||
|
||||
it('routes a base event addressed through an occurrence to that occurrence’s account', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [occurrence({ id: 'acct2:maaaaab', originalId: 'maaaaab', accountId: 'acct2', isShared: true })] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'acct2:b', { title: 'Whole series' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { title: 'Whole series' }, undefined, 'acct2');
|
||||
});
|
||||
|
||||
it('reloads the visible range silently after the change, since synthetic ids reshuffle', async () => {
|
||||
const client = fakeClient();
|
||||
client.queryAllCalendarEvents.mockResolvedValue([occurrence()]);
|
||||
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1);
|
||||
const loadingStates: boolean[] = [];
|
||||
const unsubscribe = useCalendarStore.subscribe((s) => loadingStates.push(s.isLoadingEvents));
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'maaaaab', { title: 'Renamed' });
|
||||
unsubscribe();
|
||||
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2);
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenLastCalledWith({ after: RANGE[0], before: RANGE[1] });
|
||||
expect(loadingStates.every((v) => v === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('also reloads after a change to a base event whose occurrences are in view', 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' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('b', { title: 'Whole series' }, undefined, undefined);
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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 });
|
||||
client.queryAllCalendarEvents.mockResolvedValue([plain]);
|
||||
await useCalendarStore.getState().fetchEvents(client, RANGE[0], RANGE[1]);
|
||||
|
||||
await useCalendarStore.getState().updateEvent(client, 'x', { title: 'Renamed' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('x', { title: 'Renamed' }, undefined, undefined);
|
||||
expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvent on an expanded occurrence', () => {
|
||||
it('destroys the occurrence through its synthetic id and drops it from the store', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [occurrence()], selectedEventId: 'maaaaab' });
|
||||
|
||||
await useCalendarStore.getState().deleteEvent(client, 'maaaaab', true);
|
||||
|
||||
expect(client.deleteCalendarEvent).toHaveBeenCalledWith('maaaaab', true, undefined);
|
||||
expect(client.updateCalendarEvent).not.toHaveBeenCalled();
|
||||
expect(useCalendarStore.getState().events).toEqual([]);
|
||||
expect(useCalendarStore.getState().selectedEventId).toBeNull();
|
||||
});
|
||||
|
||||
it('excludes the occurrence on the base event when the server rejects synthetic ids', async () => {
|
||||
const client = fakeClient();
|
||||
client.deleteCalendarEvent.mockRejectedValueOnce(new Error(UNSUPPORTED_DESTROY));
|
||||
useCalendarStore.setState({ events: [occurrence()] });
|
||||
|
||||
await useCalendarStore.getState().deleteEvent(client, 'maaaaab');
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
|
||||
'b', { 'recurrenceOverrides/2026-09-08T10:00:00': { excluded: true } }, undefined, undefined,
|
||||
);
|
||||
expect(useCalendarStore.getState().events).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes the base event for the single occurrence of a non-recurring event', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [occurrence({ id: 'eaaaaad', originalId: 'eaaaaad', baseEventId: 'd', recurrenceId: null, recurrenceRules: null })] });
|
||||
|
||||
await useCalendarStore.getState().deleteEvent(client, 'eaaaaad');
|
||||
|
||||
expect(client.deleteCalendarEvent).toHaveBeenCalledWith('d', undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rsvpEvent on an expanded occurrence', () => {
|
||||
it('answers for the whole series on the base event', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [occurrence()] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'maaaaab', 'p1', 'accepted');
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith(
|
||||
'b', { 'participants/p1/participationStatus': 'accepted' }, true, undefined,
|
||||
);
|
||||
expect(useCalendarStore.getState().events[0].participants?.p1.participationStatus).toBe('accepted');
|
||||
});
|
||||
});
|
||||
+203
-22
@@ -8,6 +8,14 @@ import { displayNow } from '@/lib/timezone';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import {
|
||||
baseEventStoreId,
|
||||
buildFallbackExcludePatch,
|
||||
buildFallbackOverridePatch,
|
||||
buildOccurrencePatch,
|
||||
isServerRecurrenceInstance,
|
||||
isSyntheticIdMutationUnsupported,
|
||||
} from '@/lib/recurrence-instances';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
@@ -49,6 +57,154 @@ function rawIdentityOf(id: string): string {
|
||||
return idx >= 0 ? id.slice(idx + CROSS_ACCOUNT_ID_DELIMITER.length) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a mutation addressed at store id `id` has to go.
|
||||
*
|
||||
* Occurrences handed out by server-side recurrence expansion carry a
|
||||
* synthetic id (lib/recurrence-instances.ts). Addressed with scope
|
||||
* 'occurrence' they are written through that id - the server turns the patch
|
||||
* into a recurrence override - unless they are the single instance of a
|
||||
* non-recurring event, where the base event is the same thing and works on
|
||||
* every server version. Scope 'series' (an RSVP, a calendar move) always
|
||||
* targets the base event. A base event that is not in the store itself but
|
||||
* has an expanded occurrence in view (`baseEventStoreId`) borrows that
|
||||
* occurrence's account routing.
|
||||
*/
|
||||
interface MutationTarget {
|
||||
storeEvent?: CalendarEvent;
|
||||
realId: string;
|
||||
targetAccountId?: string;
|
||||
localAccountId?: string;
|
||||
/** True when `realId` is the synthetic id of one occurrence of a series. */
|
||||
isOccurrence: boolean;
|
||||
}
|
||||
|
||||
export function resolveMutationTarget(
|
||||
events: CalendarEvent[],
|
||||
id: string,
|
||||
scope: 'occurrence' | 'series',
|
||||
): MutationTarget {
|
||||
const storeEvent = events.find(e => e.id === id);
|
||||
if (storeEvent) {
|
||||
const rawId = storeEvent.originalId || stripLocalAccountPrefix(id, storeEvent.localAccountId);
|
||||
const context = {
|
||||
storeEvent,
|
||||
targetAccountId: storeEvent.accountId,
|
||||
localAccountId: storeEvent.localAccountId,
|
||||
};
|
||||
if (isServerRecurrenceInstance(storeEvent) && storeEvent.baseEventId) {
|
||||
if (scope === 'series' || !storeEvent.recurrenceId) {
|
||||
return { ...context, realId: storeEvent.baseEventId, isOccurrence: false };
|
||||
}
|
||||
return { ...context, realId: rawId, isOccurrence: true };
|
||||
}
|
||||
return { ...context, realId: rawId, isOccurrence: false };
|
||||
}
|
||||
const instance = events.find(e => baseEventStoreId(e) === id);
|
||||
if (instance?.baseEventId) {
|
||||
return {
|
||||
realId: instance.baseEventId,
|
||||
targetAccountId: instance.accountId,
|
||||
localAccountId: instance.localAccountId,
|
||||
isOccurrence: false,
|
||||
};
|
||||
}
|
||||
return { realId: id, isOccurrence: false };
|
||||
}
|
||||
|
||||
/** True when the store shows server-expanded occurrences of base event `baseId` on the given account. */
|
||||
function hasExpandedOccurrencesOf(events: CalendarEvent[], baseId: string, target: MutationTarget): boolean {
|
||||
return events.some(e =>
|
||||
isServerRecurrenceInstance(e)
|
||||
&& e.baseEventId === baseId
|
||||
&& e.accountId === target.targetAccountId
|
||||
&& e.localAccountId === target.localAccountId,
|
||||
);
|
||||
}
|
||||
|
||||
// Clients whose CalendarEvent/set rejected a synthetic id: skip the doomed
|
||||
// attempt and go straight to the base-event override for them.
|
||||
const syntheticIdRejected = new WeakSet<object>();
|
||||
|
||||
/**
|
||||
* Patch one expanded occurrence through its synthetic id. A server that
|
||||
* predates synthetic-id writes rejects it; the same change is then written
|
||||
* as a recurrence override on the base event instead (the way Bulwark did it
|
||||
* before), and that client is remembered as needing the fallback.
|
||||
*/
|
||||
async function updateOccurrence(
|
||||
client: IJMAPClient,
|
||||
instance: CalendarEvent,
|
||||
syntheticId: string,
|
||||
updates: Partial<CalendarEvent>,
|
||||
sendSchedulingMessages: boolean | undefined,
|
||||
targetAccountId: string | undefined,
|
||||
): Promise<void> {
|
||||
const patch = buildOccurrencePatch(updates);
|
||||
if (!syntheticIdRejected.has(client)) {
|
||||
try {
|
||||
await client.updateCalendarEvent(syntheticId, patch, sendSchedulingMessages, targetAccountId);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isSyntheticIdMutationUnsupported(error)) throw error;
|
||||
syntheticIdRejected.add(client);
|
||||
debug.log('calendar', 'Server rejects synthetic ids; writing a recurrence override on the base event instead');
|
||||
}
|
||||
}
|
||||
const fallback = buildFallbackOverridePatch(instance, patch);
|
||||
if (!fallback || !instance.baseEventId) {
|
||||
throw new Error('Cannot resolve the occurrence to override');
|
||||
}
|
||||
await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
|
||||
/** Destroy one expanded occurrence; falls back to excluding it on the base event like `updateOccurrence`. */
|
||||
async function destroyOccurrence(
|
||||
client: IJMAPClient,
|
||||
instance: CalendarEvent,
|
||||
syntheticId: string,
|
||||
sendSchedulingMessages: boolean | undefined,
|
||||
targetAccountId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!syntheticIdRejected.has(client)) {
|
||||
try {
|
||||
await client.deleteCalendarEvent(syntheticId, sendSchedulingMessages, targetAccountId);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isSyntheticIdMutationUnsupported(error)) throw error;
|
||||
syntheticIdRejected.add(client);
|
||||
debug.log('calendar', 'Server rejects synthetic ids; excluding the occurrence on the base event instead');
|
||||
}
|
||||
}
|
||||
const fallback = buildFallbackExcludePatch(instance);
|
||||
if (!fallback || !instance.baseEventId) {
|
||||
throw new Error('Cannot resolve the occurrence to exclude');
|
||||
}
|
||||
await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
|
||||
// Re-runs the most recent range fetch. Synthetic occurrence ids are
|
||||
// positional and reshuffle whenever a series' overrides change, so after
|
||||
// mutating an occurrence (or a base event with occurrences in view) the
|
||||
// visible range is reloaded to pick up the new ids; the optimistic store
|
||||
// update stays in place until then. Concurrent callers share one fetch.
|
||||
let refetchVisibleRange: (() => Promise<void>) | null = null;
|
||||
let refetchVisibleRangeInFlight: Promise<void> | null = null;
|
||||
|
||||
async function refetchAfterOccurrenceMutation(): Promise<void> {
|
||||
if (!refetchVisibleRange) return;
|
||||
if (!refetchVisibleRangeInFlight) {
|
||||
refetchVisibleRangeInFlight = refetchVisibleRange()
|
||||
.catch((error) => {
|
||||
debug.error('Failed to refresh events after an occurrence change:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
refetchVisibleRangeInFlight = null;
|
||||
});
|
||||
}
|
||||
await refetchVisibleRangeInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile persisted selectedCalendarIds against a freshly fetched calendars
|
||||
* list. A selected id can be stale in id-*form* (not existence): the raw->
|
||||
@@ -254,6 +410,11 @@ export interface CalendarAccountClient {
|
||||
client: IJMAPClient;
|
||||
}
|
||||
|
||||
export interface FetchEventsOptions {
|
||||
/** Reload without flipping `isLoadingEvents` (background refresh after a mutation). */
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
interface CalendarStore {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
@@ -269,9 +430,9 @@ interface CalendarStore {
|
||||
|
||||
setSupported: (supported: boolean) => void;
|
||||
fetchCalendars: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
|
||||
fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
|
||||
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[]) => Promise<void>;
|
||||
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string) => Promise<void>;
|
||||
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
|
||||
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
||||
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||
@@ -345,8 +506,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
fetchEvents: async (client, start, end) => {
|
||||
set({ isLoadingEvents: true, error: null });
|
||||
fetchEvents: async (client, start, end, options) => {
|
||||
refetchVisibleRange = () => get().fetchEvents(client, start, end, { silent: true });
|
||||
set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null });
|
||||
try {
|
||||
const rawEvents = await client.queryAllCalendarEvents({
|
||||
after: start,
|
||||
@@ -359,8 +521,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const droppedEvents = rawEvents.length - validEvents.length;
|
||||
// Expand recurring events client-side (Stalwart doesn't support
|
||||
// mutations on synthetic IDs from server-side expandRecurrences)
|
||||
// Expand recurring series that came back unexpanded - from a server
|
||||
// without synthetic-id support (lib/recurrence-instances.ts) or one
|
||||
// that ignores expandRecurrences. Occurrences the server already
|
||||
// expanded pass through untouched.
|
||||
const events = expandRecurringEvents(validEvents, start, end);
|
||||
debug.log('calendar', 'Calendar fetchEvents completed', {
|
||||
start,
|
||||
@@ -408,8 +572,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
fetchAllAccountsEvents: async (accounts, start, end) => {
|
||||
set({ isLoadingEvents: true, error: null });
|
||||
fetchAllAccountsEvents: async (accounts, start, end, options) => {
|
||||
refetchVisibleRange = () => get().fetchAllAccountsEvents(accounts, start, end, { silent: true });
|
||||
set(options?.silent ? { error: null } : { isLoadingEvents: true, error: null });
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
accounts.map(async ({ client, localAccountId }) => {
|
||||
@@ -526,13 +691,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
const target = resolveMutationTarget(get().events, id, 'occurrence');
|
||||
const { storeEvent, realId, targetAccountId } = target;
|
||||
client = resolveAccountClient(client, target.localAccountId);
|
||||
debug.log('calendar', 'Calendar updateEvent', {
|
||||
storeId: id,
|
||||
realId,
|
||||
isOccurrence: target.isOccurrence,
|
||||
uid: storeEvent?.uid,
|
||||
recurrenceId: storeEvent?.recurrenceId,
|
||||
targetAccountId,
|
||||
@@ -548,7 +713,11 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
cleanUpdates.calendarIds = remapped;
|
||||
}
|
||||
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
if (target.isOccurrence && storeEvent) {
|
||||
await updateOccurrence(client, storeEvent, realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
} else {
|
||||
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => {
|
||||
if (e.id !== id) return e;
|
||||
@@ -577,6 +746,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
return merged;
|
||||
}),
|
||||
}));
|
||||
if (target.isOccurrence || hasExpandedOccurrencesOf(get().events, realId, target)) {
|
||||
await refetchAfterOccurrenceMutation();
|
||||
}
|
||||
// Update emails (iTIP REQUEST/REPLY) are sent by the server via the
|
||||
// `sendSchedulingMessages` argument already passed above - a manual
|
||||
// iMIP send here produced duplicate emails.
|
||||
@@ -598,10 +770,11 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === eventId);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(eventId, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
// An RSVP answers for the whole series, so an expanded occurrence
|
||||
// is resolved to its base event here.
|
||||
const { storeEvent, realId, targetAccountId, localAccountId } =
|
||||
resolveMutationTarget(get().events, eventId, 'series');
|
||||
client = resolveAccountClient(client, localAccountId);
|
||||
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
||||
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patchKey = `participants/${escapedId}/participationStatus`;
|
||||
@@ -816,10 +989,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
const target = resolveMutationTarget(get().events, id, 'occurrence');
|
||||
const { storeEvent, realId, targetAccountId } = target;
|
||||
client = resolveAccountClient(client, target.localAccountId);
|
||||
// Cancellation emails (iTIP CANCEL) are sent by the server via the
|
||||
// `sendSchedulingMessages` argument on the destroy below - a manual
|
||||
// iMIP send here produced duplicate emails.
|
||||
@@ -830,11 +1002,19 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
recurrenceId: storeEvent?.recurrenceId,
|
||||
targetAccountId,
|
||||
});
|
||||
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||
if (target.isOccurrence && storeEvent) {
|
||||
await destroyOccurrence(client, storeEvent, realId, sendSchedulingMessages, targetAccountId);
|
||||
} else {
|
||||
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
const hadOccurrences = hasExpandedOccurrencesOf(get().events, realId, target);
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => e.id !== id),
|
||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||
}));
|
||||
if (target.isOccurrence || hadOccurrences) {
|
||||
await refetchAfterOccurrenceMutation();
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('Failed to delete event:', error);
|
||||
set({ error: 'Failed to delete event' });
|
||||
@@ -1305,6 +1485,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
},
|
||||
|
||||
clearState: () => {
|
||||
refetchVisibleRange = null;
|
||||
// Preserve iCal subscriptions across the account-switch teardown.
|
||||
// They're now scoped per-account via sub.accountId - wiping them
|
||||
// here would lose them from localStorage on every switch.
|
||||
|
||||
Reference in New Issue
Block a user