fix: handle updates and deletions for synthetic JMAP IDs in calendar events
This commit is contained in:
@@ -470,9 +470,22 @@ export default function CalendarPage() {
|
|||||||
try {
|
try {
|
||||||
if (type === "edit" && updates) {
|
if (type === "edit" && updates) {
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case "this":
|
case "this": {
|
||||||
await updateEvent(client, event.id, updates, sendScheduling);
|
// Synthetic IDs (from expandRecurrences) can't be updated directly.
|
||||||
|
// Patch the master event's recurrenceOverrides instead.
|
||||||
|
const master = await findMasterEvent(event);
|
||||||
|
if (master && event.recurrenceId) {
|
||||||
|
const patchUpdates: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||||
|
patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value;
|
||||||
|
}
|
||||||
|
await updateEvent(client, master.id, patchUpdates as Partial<CalendarEvent>, sendScheduling);
|
||||||
|
} else {
|
||||||
|
await updateEvent(client, event.id, updates, sendScheduling);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "this_and_future": {
|
case "this_and_future": {
|
||||||
const result = await truncateRecurrenceAtEvent(event);
|
const result = await truncateRecurrenceAtEvent(event);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -530,9 +543,20 @@ export default function CalendarPage() {
|
|||||||
toast.success(t("notifications.event_updated"));
|
toast.success(t("notifications.event_updated"));
|
||||||
} else {
|
} else {
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case "this":
|
case "this": {
|
||||||
await deleteEvent(client, event.id, sendScheduling);
|
// Synthetic IDs (from expandRecurrences) can't be destroyed directly.
|
||||||
|
// Exclude the instance via recurrenceOverrides on the master event.
|
||||||
|
const delMaster = await findMasterEvent(event);
|
||||||
|
if (delMaster && event.recurrenceId) {
|
||||||
|
await updateEvent(
|
||||||
|
client, delMaster.id,
|
||||||
|
{ [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial<CalendarEvent>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await deleteEvent(client, event.id, sendScheduling);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "this_and_future": {
|
case "this_and_future": {
|
||||||
const result = await truncateRecurrenceAtEvent(event);
|
const result = await truncateRecurrenceAtEvent(event);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
+106
-38
@@ -172,24 +172,40 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
try {
|
try {
|
||||||
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
// Stalwart doesn't support updating events created via CalDAV (e.g. by Thunderbird).
|
// Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
|
||||||
// These events have "synthetic" JMAP IDs. Work around by destroying and recreating.
|
// or expanded recurring-event instances returned by expandRecurrences).
|
||||||
|
// Resolve the real event via a UID query and retry.
|
||||||
const message = updateError instanceof Error ? updateError.message : '';
|
const message = updateError instanceof Error ? updateError.message : '';
|
||||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||||
debug.log('Event has synthetic ID, falling back to destroy+recreate');
|
debug.log('Event has synthetic ID, resolving real ID via UID query');
|
||||||
// Merge existing event with updates, strip server-computed fields
|
const queryResults = await client.queryCalendarEvents(
|
||||||
const { id: _id, originalId: _oi, originalCalendarIds: _oc,
|
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
||||||
accountId: _ai, accountName: _an, isShared: _is,
|
);
|
||||||
utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io,
|
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
||||||
created: _cr, updated: _up, ...baseEvent } = storeEvent;
|
if (realEvent) {
|
||||||
const mergedEvent: Partial<CalendarEvent> = { ...baseEvent, ...cleanUpdates };
|
const resolvedId = realEvent.originalId || realEvent.id;
|
||||||
// Delete the old event without sending cancellations (we're recreating it)
|
if (storeEvent.recurrenceId) {
|
||||||
await client.deleteCalendarEvent(realId, false, targetAccountId);
|
// Recurring instance: patch the master event's recurrenceOverrides
|
||||||
const created = await client.createCalendarEvent(mergedEvent, sendSchedulingMessages, targetAccountId);
|
const patchUpdates: Record<string, unknown> = {};
|
||||||
set((state) => ({
|
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
|
||||||
events: state.events.map(e => e.id === id ? created : e),
|
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||||
}));
|
patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value;
|
||||||
return;
|
}
|
||||||
|
await client.updateCalendarEvent(
|
||||||
|
resolvedId,
|
||||||
|
patchUpdates as unknown as Partial<CalendarEvent>,
|
||||||
|
sendSchedulingMessages,
|
||||||
|
targetAccountId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Non-recurring event with synthetic ID: retry with the real ID
|
||||||
|
await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
@@ -233,29 +249,49 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
targetAccountId
|
targetAccountId
|
||||||
);
|
);
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
// Fallback for CalDAV-created events with synthetic IDs
|
// Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query.
|
||||||
const message = updateError instanceof Error ? updateError.message : '';
|
const message = updateError instanceof Error ? updateError.message : '';
|
||||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||||
debug.log('RSVP: Event has synthetic ID, falling back to destroy+recreate');
|
debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
|
||||||
const { id: _id, originalId: _oi, originalCalendarIds: _oc,
|
const queryResults = await client.queryCalendarEvents(
|
||||||
accountId: _ai, accountName: _an, isShared: _is,
|
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
||||||
utcStart: _us, utcEnd: _ue, isDraft: _dr, isOrigin: _io,
|
);
|
||||||
created: _cr, updated: _up, ...baseEvent } = storeEvent;
|
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
||||||
const updatedParticipants = storeEvent.participants ? {
|
if (realEvent) {
|
||||||
...storeEvent.participants,
|
const resolvedId = realEvent.originalId || realEvent.id;
|
||||||
[participantId]: { ...storeEvent.participants[participantId], participationStatus: status },
|
if (storeEvent.recurrenceId) {
|
||||||
} : storeEvent.participants;
|
// Recurring instance: patch RSVP as recurrence override on master
|
||||||
const mergedEvent: Partial<CalendarEvent> = {
|
const overridePatch: Record<string, unknown> = {
|
||||||
...baseEvent,
|
[`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status,
|
||||||
participants: updatedParticipants as Record<string, CalendarParticipant> | null,
|
};
|
||||||
...(replyTo ? { replyTo } : {}),
|
if (replyTo) {
|
||||||
};
|
overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo;
|
||||||
await client.deleteCalendarEvent(realId, false, targetAccountId);
|
}
|
||||||
const created = await client.createCalendarEvent(mergedEvent, true, targetAccountId);
|
await client.updateCalendarEvent(
|
||||||
set((state) => ({
|
resolvedId,
|
||||||
events: state.events.map(e => e.id === eventId ? created : e),
|
overridePatch as unknown as Partial<CalendarEvent>,
|
||||||
}));
|
true,
|
||||||
return;
|
targetAccountId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Non-recurring event: retry RSVP with real ID
|
||||||
|
await client.updateCalendarEvent(
|
||||||
|
resolvedId,
|
||||||
|
patch as unknown as Partial<CalendarEvent>,
|
||||||
|
true,
|
||||||
|
targetAccountId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
events: state.events.map(e => e.id === eventId ? { ...e, participants: {
|
||||||
|
...e.participants,
|
||||||
|
...(e.participants?.[participantId] ? {
|
||||||
|
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
|
||||||
|
} : {}),
|
||||||
|
}} : e),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
@@ -402,7 +438,39 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
debug.error('Failed to send cancellation emails:', e);
|
debug.error('Failed to send cancellation emails:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
try {
|
||||||
|
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
|
||||||
|
} catch (deleteError) {
|
||||||
|
// Stalwart rejects deletes on synthetic IDs (CalDAV-created events or
|
||||||
|
// expanded recurring instances). Resolve the real ID via UID query.
|
||||||
|
const message = deleteError instanceof Error ? deleteError.message : '';
|
||||||
|
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||||
|
debug.log('Event has synthetic ID, resolving real ID via UID query for delete');
|
||||||
|
const queryResults = await client.queryCalendarEvents(
|
||||||
|
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
|
||||||
|
);
|
||||||
|
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
|
||||||
|
if (realEvent) {
|
||||||
|
const resolvedId = realEvent.originalId || realEvent.id;
|
||||||
|
if (storeEvent.recurrenceId) {
|
||||||
|
// Recurring instance: exclude via recurrenceOverrides on master
|
||||||
|
await client.updateCalendarEvent(
|
||||||
|
resolvedId,
|
||||||
|
{ [`recurrenceOverrides/${storeEvent.recurrenceId}`]: { excluded: true } } as unknown as Partial<CalendarEvent>,
|
||||||
|
false,
|
||||||
|
targetAccountId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Non-recurring event: delete using the real ID
|
||||||
|
await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw deleteError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw deleteError;
|
||||||
|
}
|
||||||
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.filter(e => e.id !== id),
|
events: state.events.filter(e => e.id !== id),
|
||||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||||
|
|||||||
Reference in New Issue
Block a user