fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13

This commit is contained in:
Linus Rath
2026-07-22 22:17:40 +02:00
parent 2427bdbd79
commit 802e1e1c71
10 changed files with 562 additions and 30 deletions
+93 -3
View File
@@ -87,7 +87,10 @@ describe('calendar operations', () => {
});
describe('getEvents', () => {
it('should fetch events by id with expected properties', async () => {
it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => {
// Stalwart (calcard) names the property `recurrenceRule` (singular,
// single object) — requesting the RFC 8984 plural form returns no
// recurrence data and repeating events vanish from the grid (#13).
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
@@ -99,7 +102,48 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].properties).toContain('title');
expect(call[1].properties).toContain('start');
expect(call[1].properties).toContain('recurrenceRules');
expect(call[1].properties).toContain('recurrenceRule');
expect(call[1].properties).toContain('excludedRecurrenceRule');
expect(call[1].properties).not.toContain('recurrenceRules');
expect(call[1].properties).not.toContain('excludedRecurrenceRules');
});
it('should normalize a singular recurrenceRule object to the plural array form', async () => {
const events = [{
id: 'ev1',
title: 'Every 5 weeks',
start: '2026-03-02T10:00:00',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
}];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const [event] = await getEvents(['ev1']);
expect(event.recurrenceRules).toEqual([
{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
]);
expect(event.excludedRecurrenceRules).toEqual([
{ frequency: 'weekly', interval: 10 },
]);
expect(event).not.toHaveProperty('recurrenceRule');
expect(event).not.toHaveProperty('excludedRecurrenceRule');
});
it('should keep an array-valued recurrenceRule as-is (JMAP-created events)', async () => {
const events = [{
id: 'ev1',
title: 'Weekly',
start: '2026-03-02T10:00:00',
recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
}];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const [event] = await getEvents(['ev1']);
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
});
});
@@ -115,10 +159,39 @@ describe('calendar operations', () => {
'cal-1',
);
expect(result).toEqual(created);
// The /set response echoes only server-set properties; the return value
// merges them over the submitted payload.
expect(result).toEqual({
id: 'ev-new',
title: 'Lunch',
start: '2026-03-30T12:00:00',
duration: 'PT1H',
calendarIds: { 'cal-1': true },
});
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true });
});
it('should send recurrence as a singular cleaned object', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { created: { 'new-event': { id: 'ev-new' } } }, '0']],
});
const result = await createEvent(
{
title: 'Series',
start: '2026-03-30T12:00:00',
recurrenceRules: [{ frequency: 'weekly', interval: 5, until: null as unknown as string }],
},
'cal-1',
);
const sent = mockRequest.mock.calls[0][0][0][1].create['new-event'];
expect(sent.recurrenceRule).toEqual({ frequency: 'weekly', interval: 5 });
expect(sent).not.toHaveProperty('recurrenceRules');
// The returned event exposes the internal plural form again.
expect(result.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 5 }]);
});
});
describe('updateEvent', () => {
@@ -132,6 +205,23 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } });
});
it('should convert recurrenceRules updates to the singular property', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { updated: {} }, '0']],
});
await updateEvent('ev1', { recurrenceRules: [{ frequency: 'monthly' }] });
expect(mockRequest.mock.calls[0][0][0][1].update).toEqual({
ev1: { recurrenceRule: { frequency: 'monthly' } },
});
// Empty array means "remove recurrence" — sent as an explicit null.
await updateEvent('ev1', { recurrenceRules: [] });
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({
ev1: { recurrenceRule: null },
});
});
});
describe('deleteEvents', () => {
+93 -10
View File
@@ -37,18 +37,94 @@ function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
};
}
// Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property
// names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single
// object, not RFC 8984's plural array forms. Requesting the plural names
// returns no recurrence data at all, so recurring events silently render as
// one-off events (#13). Request the singular names and normalize below.
const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime',
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
'recurrenceOverrides', 'excludedRecurrenceRules',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId',
'replyTo', 'organizerCalendarAddress', 'sequence',
'locations', 'virtualLocations',
'progress', 'due', 'priority', 'percentComplete',
'links', 'created', 'updated',
];
/**
* Normalize Stalwart's singular recurrence property names to the RFC 8984
* plural array forms the client uses internally. JSCalendar 2.0 defines
* recurrenceRule as a single object, but Stalwart may also return an array
* (for events created via JMAP), so both forms are handled. Mirrors webmail's
* normalizeStalwartPropertyNames.
*/
export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(event: T): T {
const raw = event as Record<string, unknown>;
let patched = false;
const updates: Partial<CalendarEvent> = {};
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
const val = raw.recurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
} else {
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
}
patched = true;
}
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
const val = raw.excludedRecurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
} else {
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
}
patched = true;
}
if (!patched) return event;
const result = { ...event, ...updates } as T;
delete (result as Record<string, unknown>).recurrenceRule;
delete (result as Record<string, unknown>).excludedRecurrenceRule;
return result;
}
/**
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
* properties Stalwart expects on write, dropping null rule fields. An empty
* array (or null) becomes an explicit null so "remove recurrence" round-trips.
* Mirrors webmail's cleanRecurrenceRules.
*/
function cleanRecurrenceRules(event: Record<string, unknown>): void {
const keyMap: Record<string, string> = {
recurrenceRules: 'recurrenceRule',
excludedRecurrenceRules: 'excludedRecurrenceRule',
};
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
const rules = event[pluralKey];
if (rules === undefined) continue;
delete event[pluralKey];
if (!Array.isArray(rules)) {
event[singularKey] = rules;
continue;
}
if (rules.length === 0) {
event[singularKey] = null;
continue;
}
const rule = rules[0] as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rule)) {
if (v !== null) cleaned[k] = v;
}
event[singularKey] = cleaned;
}
}
function methodResult<T = any>(res: any, index = 0): T {
const entry = res?.methodResponses?.[index];
if (!entry) throw new Error('JMAP: empty method response');
@@ -149,7 +225,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
USING,
);
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
all.push(...list);
all.push(...list.map(normalizeRecurrenceProperties));
}
return all;
}
@@ -176,11 +252,11 @@ export async function createEvent(
targetAccountId?: string,
): Promise<CalendarEvent> {
const accountId = targetAccountId || jmapClient.accountId;
const payload: Record<string, unknown> = { ...event, calendarIds: { [calendarId]: true } };
cleanRecurrenceRules(payload);
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, {
create: {
'new-event': { ...event, calendarIds: { [calendarId]: true } },
},
create: { 'new-event': payload },
}, sendSchedulingMessages), '0']],
USING,
);
@@ -193,7 +269,9 @@ export async function createEvent(
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
}
return created;
// The /set response echoes only server-set properties; merge them over the
// submitted payload so the store's optimistic insert keeps recurrence data.
return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent);
}
// Batch-create many events in one CalendarEvent/set. Returns the number created.
@@ -206,7 +284,9 @@ export async function batchCreateEvents(
const accountId = targetAccountId || jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
cleanRecurrenceRules(payload);
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
});
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, create }, '0']],
@@ -223,8 +303,10 @@ export async function updateEvent(
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const patch: Record<string, unknown> = { ...changes };
cleanRecurrenceRules(patch);
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
@@ -285,7 +367,8 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
const parsed = result.parsed?.[blobId];
if (!parsed) return [];
return Array.isArray(parsed) ? parsed : [parsed];
const list = Array.isArray(parsed) ? parsed : [parsed];
return list.map(normalizeRecurrenceProperties);
}
export async function createCalendar(