fix: JSCalendar 2.0 recurrenceRule single-object compatibility 116

This commit is contained in:
Linus Rath
2026-03-31 00:11:04 +02:00
parent aaa283357e
commit 1cce5c3c8a
6 changed files with 986 additions and 146 deletions
+41 -5
View File
@@ -145,9 +145,9 @@ const CALENDAR_EVENT_PROPERTIES = [
'hideAttendees',
'recurrenceId',
'recurrenceIdTimeZone',
'recurrenceRules',
'recurrenceRule',
'recurrenceOverrides',
'excludedRecurrenceRules',
'excludedRecurrenceRule',
'useDefaultAlerts',
'alerts',
'locations',
@@ -183,14 +183,49 @@ const CALENDAR_TASK_PROPERTIES = [
'color',
'keywords',
'categories',
'recurrenceRules',
'recurrenceRule',
'recurrenceOverrides',
'excludedRecurrenceRules',
'excludedRecurrenceRule',
'useDefaultAlerts',
'alerts',
'relatedTo',
] as const;
/**
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
* instead of the RFC 8984 plural forms ("recurrenceRules").
* JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
* not an array. This function converts our internal array form to a single
* object, cleans null values, and renames the properties.
*/
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)) {
// null means "remove recurrence" — pass through with the correct key
event[singularKey] = rules;
continue;
}
if (rules.length === 0) {
event[singularKey] = null;
continue;
}
// JSCalendar 2.0: recurrenceRule is a single object, use first rule
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 getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
if (!event) {
return null;
@@ -3324,12 +3359,12 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
debug.group('CalendarEvent/create');
debug.log('CalendarEvent/create outgoing payload', {
accountId,
sendSchedulingMessages,
event: getCalendarEventDebugSnapshot(cleanEvent),
eventKeys: Object.keys(cleanEvent),
});
@@ -3480,6 +3515,7 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, 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> = {
accountId,