mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user