feat: implement client-side recurrence expansion for calendar events
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
# Check for AI attribution in commit message
|
||||
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
|
||||
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
|
||||
echo " This violates project policy in CLAUDE.md"
|
||||
echo " Remove 'Co-Authored-By: Claude' and similar references"
|
||||
exit 1
|
||||
fi
|
||||
@@ -356,8 +356,10 @@ export default function CalendarPage() {
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setDetailEvent(null);
|
||||
setDetailAnchorRect(null);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = useCallback(() => {
|
||||
|
||||
+36
-8
@@ -3145,10 +3145,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
filter,
|
||||
limit: limit || 1000,
|
||||
};
|
||||
// Expand recurring events into individual occurrences when a date range is provided
|
||||
if (filter.after || filter.before) {
|
||||
queryArgs.expandRecurrences = true;
|
||||
}
|
||||
// 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.
|
||||
if (sort) {
|
||||
queryArgs.sort = sort;
|
||||
}
|
||||
@@ -3206,6 +3205,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId,
|
||||
sendSchedulingMessages,
|
||||
event: getCalendarEventDebugSnapshot(cleanEvent),
|
||||
eventKeys: Object.keys(cleanEvent),
|
||||
});
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
@@ -3230,6 +3230,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (result.notCreated?.["new-event"]) {
|
||||
const error = result.notCreated["new-event"];
|
||||
debug.warn('CalendarEvent/create notCreated', error);
|
||||
debug.warn('CalendarEvent/create invalid properties', error.properties);
|
||||
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
|
||||
debug.groupEnd();
|
||||
throw new Error(error.description || "Failed to create calendar event");
|
||||
}
|
||||
@@ -3295,20 +3297,33 @@ export class JMAPClient implements IJMAPClient {
|
||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
|
||||
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", setArgs, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
const methodName = response.methodResponses?.[0]?.[0];
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
|
||||
if (methodName === "error") {
|
||||
const errorType = result?.type || 'unknown';
|
||||
const errorDesc = result?.description || '';
|
||||
debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc });
|
||||
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
|
||||
}
|
||||
|
||||
if (methodName === "CalendarEvent/set") {
|
||||
if (result.notUpdated?.[eventId]) {
|
||||
const error = result.notUpdated[eventId];
|
||||
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
||||
throw new Error(error.description || "Failed to update calendar event");
|
||||
}
|
||||
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
|
||||
return;
|
||||
}
|
||||
|
||||
debug.error('CalendarEvent/set update unexpected response', { methodName, result });
|
||||
throw new Error("Failed to update calendar event");
|
||||
}
|
||||
|
||||
@@ -3355,20 +3370,33 @@ export class JMAPClient implements IJMAPClient {
|
||||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
|
||||
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
|
||||
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", setArgs, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
const methodName = response.methodResponses?.[0]?.[0];
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
|
||||
if (methodName === "error") {
|
||||
const errorType = result?.type || 'unknown';
|
||||
const errorDesc = result?.description || '';
|
||||
debug.error('CalendarEvent/set destroy returned JMAP error', { type: errorType, description: errorDesc });
|
||||
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
|
||||
}
|
||||
|
||||
if (methodName === "CalendarEvent/set") {
|
||||
if (result.notDestroyed?.[eventId]) {
|
||||
const error = result.notDestroyed[eventId];
|
||||
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
|
||||
throw new Error(error.description || "Failed to delete calendar event");
|
||||
}
|
||||
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
|
||||
return;
|
||||
}
|
||||
|
||||
debug.error('CalendarEvent/set destroy unexpected response', { methodName, result });
|
||||
throw new Error("Failed to delete calendar event");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Client-side recurrence expansion for JSCalendar events.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
||||
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
|
||||
|
||||
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
||||
|
||||
/**
|
||||
* Given a master event and a date range, return an array of "virtual" occurrence
|
||||
* events. Each occurrence carries a synthetic `id` (for dedup in React) and a
|
||||
* `masterEventId` field that points back to the real server-side ID so the
|
||||
* store can use it for mutations.
|
||||
*
|
||||
* Non-recurring events are returned as-is. For recurring events the master is
|
||||
* **not** returned — only expanded instances within the range.
|
||||
*/
|
||||
export function expandRecurringEvents(
|
||||
events: CalendarEvent[],
|
||||
rangeStart: string,
|
||||
rangeEnd: string,
|
||||
): CalendarEvent[] {
|
||||
const start = parseISO(rangeStart);
|
||||
const end = parseISO(rangeEnd);
|
||||
const result: CalendarEvent[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.recurrenceRules?.length) {
|
||||
// Non-recurring event — pass through unchanged
|
||||
result.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand each recurrence rule
|
||||
const occurrences = expandEvent(event, start, end);
|
||||
result.push(...occurrences);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function expandEvent(
|
||||
master: CalendarEvent,
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
): CalendarEvent[] {
|
||||
const eventStart = parseISO(master.start);
|
||||
if (isNaN(eventStart.getTime())) return [];
|
||||
|
||||
const rules = master.recurrenceRules || [];
|
||||
const overrides = master.recurrenceOverrides || {};
|
||||
const occurrences: CalendarEvent[] = [];
|
||||
const seenDates = new Set<string>();
|
||||
|
||||
// Process each rule
|
||||
for (const rule of rules) {
|
||||
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
|
||||
for (const date of dates) {
|
||||
const dateKey = master.showWithoutTime
|
||||
? format(date, 'yyyy-MM-dd')
|
||||
: date.toISOString();
|
||||
|
||||
if (seenDates.has(dateKey)) continue;
|
||||
seenDates.add(dateKey);
|
||||
|
||||
const recurrenceId = master.showWithoutTime
|
||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
const override = overrides[recurrenceId] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
|
||||
if (override?.excluded) continue;
|
||||
|
||||
const occurrence = createOccurrence(master, date, recurrenceId, override);
|
||||
occurrences.push(occurrence);
|
||||
}
|
||||
}
|
||||
|
||||
// Also add any overrides that add new dates (not generated by rules)
|
||||
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
||||
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
||||
if (override.excluded) continue;
|
||||
const overrideDate = parseISO(recurrenceId);
|
||||
if (isNaN(overrideDate.getTime())) continue;
|
||||
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
|
||||
|
||||
const dateKey = master.showWithoutTime
|
||||
? format(overrideDate, 'yyyy-MM-dd')
|
||||
: overrideDate.toISOString();
|
||||
if (seenDates.has(dateKey)) continue;
|
||||
seenDates.add(dateKey);
|
||||
|
||||
const occurrence = createOccurrence(master, overrideDate, recurrenceId, override);
|
||||
occurrences.push(occurrence);
|
||||
}
|
||||
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
function createOccurrence(
|
||||
master: CalendarEvent,
|
||||
date: Date,
|
||||
recurrenceId: string,
|
||||
override?: Partial<CalendarEvent>,
|
||||
): CalendarEvent {
|
||||
const startStr = master.showWithoutTime
|
||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
||||
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
return {
|
||||
...master,
|
||||
...(override || {}),
|
||||
id: `${master.id}:${recurrenceId}`,
|
||||
originalId: master.originalId || master.id,
|
||||
uid: master.uid,
|
||||
calendarIds: master.calendarIds,
|
||||
start: (override?.start) || startStr,
|
||||
recurrenceId,
|
||||
// Remove recurrence rules from instances — only the master has them
|
||||
recurrenceRules: master.recurrenceRules,
|
||||
recurrenceOverrides: master.recurrenceOverrides,
|
||||
excludedRecurrenceRules: master.excludedRecurrenceRules,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate occurrence dates for a single recurrence rule within a range.
|
||||
* Capped at 500 occurrences to prevent runaway loops.
|
||||
*/
|
||||
function generateDates(
|
||||
eventStart: Date,
|
||||
rule: CalendarRecurrenceRule,
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const interval = rule.interval || 1;
|
||||
const limit = rule.count || 500;
|
||||
const until = rule.until ? parseISO(rule.until) : null;
|
||||
let count = 0;
|
||||
let current = new Date(eventStart);
|
||||
|
||||
// Safety: don't generate more than 500 occurrences
|
||||
const maxIterations = 2000;
|
||||
let iterations = 0;
|
||||
|
||||
while (iterations++ < maxIterations) {
|
||||
if (count >= limit) break;
|
||||
if (until && current > until) break;
|
||||
if (current >= rangeEnd) break;
|
||||
|
||||
// For weekly rules with byDay, expand into multiple days per week
|
||||
if (rule.frequency === 'weekly' && rule.byDay?.length) {
|
||||
const weekDates = expandByDay(current, rule.byDay, eventStart);
|
||||
for (const wd of weekDates) {
|
||||
if (until && wd > until) break;
|
||||
if (count >= limit) break;
|
||||
if (wd >= rangeEnd) break;
|
||||
count++;
|
||||
if (wd >= rangeStart) {
|
||||
dates.push(wd);
|
||||
}
|
||||
}
|
||||
} else if (rule.frequency === 'monthly' && rule.byDay?.length) {
|
||||
const monthDates = expandByDayMonthly(current, rule.byDay);
|
||||
for (const md of monthDates) {
|
||||
if (until && md > until) break;
|
||||
if (count >= limit) break;
|
||||
if (md >= rangeEnd) break;
|
||||
count++;
|
||||
if (md >= rangeStart) {
|
||||
dates.push(md);
|
||||
}
|
||||
}
|
||||
} else if (rule.frequency === 'monthly' && rule.byMonthDay?.length) {
|
||||
for (const day of rule.byMonthDay) {
|
||||
const d = new Date(current);
|
||||
d.setDate(day);
|
||||
if (d.getMonth() !== current.getMonth()) continue; // skip invalid days (e.g., Feb 30)
|
||||
if (until && d > until) break;
|
||||
if (count >= limit) break;
|
||||
count++;
|
||||
if (d >= rangeStart && d < rangeEnd) {
|
||||
dates.push(d);
|
||||
}
|
||||
}
|
||||
} else if (rule.frequency === 'yearly' && rule.byMonth?.length) {
|
||||
for (const monthStr of rule.byMonth) {
|
||||
const month = parseInt(monthStr, 10) - 1; // JSCalendar months are 1-indexed
|
||||
const d = new Date(current);
|
||||
d.setMonth(month);
|
||||
if (rule.byMonthDay?.length) {
|
||||
for (const day of rule.byMonthDay) {
|
||||
const dd = new Date(d);
|
||||
dd.setDate(day);
|
||||
if (dd.getMonth() !== month) continue;
|
||||
if (until && dd > until) break;
|
||||
if (count >= limit) break;
|
||||
count++;
|
||||
if (dd >= rangeStart && dd < rangeEnd) {
|
||||
dates.push(dd);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (until && d > until) break;
|
||||
if (count >= limit) break;
|
||||
count++;
|
||||
if (d >= rangeStart && d < rangeEnd) {
|
||||
dates.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
count++;
|
||||
if (current >= rangeStart && current < rangeEnd) {
|
||||
dates.push(new Date(current));
|
||||
}
|
||||
}
|
||||
|
||||
current = advanceDate(current, rule.frequency, interval);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function advanceDate(
|
||||
date: Date,
|
||||
frequency: CalendarRecurrenceRule['frequency'],
|
||||
interval: number,
|
||||
): Date {
|
||||
switch (frequency) {
|
||||
case 'daily': return addDays(date, interval);
|
||||
case 'weekly': return addWeeks(date, interval);
|
||||
case 'monthly': return addMonths(date, interval);
|
||||
case 'yearly': return addYears(date, interval);
|
||||
case 'hourly': return new Date(date.getTime() + interval * 3600000);
|
||||
case 'minutely': return new Date(date.getTime() + interval * 60000);
|
||||
case 'secondly': return new Date(date.getTime() + interval * 1000);
|
||||
default: return addDays(date, interval);
|
||||
}
|
||||
}
|
||||
|
||||
function expandByDay(
|
||||
weekStart: Date,
|
||||
byDay: { day: string; nthOfPeriod?: number }[],
|
||||
eventStart: Date,
|
||||
): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const baseDay = weekStart.getDay();
|
||||
|
||||
for (const { day } of byDay) {
|
||||
const targetDay = DAY_INDEX[day];
|
||||
if (targetDay === undefined) continue;
|
||||
let diff = targetDay - baseDay;
|
||||
if (diff < 0) diff += 7;
|
||||
const d = addDays(weekStart, diff);
|
||||
// Preserve time from the original event
|
||||
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds());
|
||||
dates.push(d);
|
||||
}
|
||||
|
||||
return dates.sort((a, b) => a.getTime() - b.getTime());
|
||||
}
|
||||
|
||||
function expandByDayMonthly(
|
||||
monthStart: Date,
|
||||
byDay: { day: string; nthOfPeriod?: number }[],
|
||||
): Date[] {
|
||||
const dates: Date[] = [];
|
||||
const year = monthStart.getFullYear();
|
||||
const month = monthStart.getMonth();
|
||||
|
||||
for (const { day, nthOfPeriod } of byDay) {
|
||||
const targetDay = DAY_INDEX[day];
|
||||
if (targetDay === undefined) continue;
|
||||
|
||||
if (nthOfPeriod) {
|
||||
// Find the nth occurrence of this weekday in the month
|
||||
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
|
||||
if (d) {
|
||||
d.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds());
|
||||
dates.push(d);
|
||||
}
|
||||
} else {
|
||||
// Every occurrence of this weekday in the month
|
||||
let d = new Date(year, month, 1);
|
||||
while (d.getDay() !== targetDay) {
|
||||
d = addDays(d, 1);
|
||||
}
|
||||
while (d.getMonth() === month) {
|
||||
const occ = new Date(d);
|
||||
occ.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds());
|
||||
dates.push(occ);
|
||||
d = addDays(d, 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates.sort((a, b) => a.getTime() - b.getTime());
|
||||
}
|
||||
|
||||
function nthWeekdayOfMonth(
|
||||
year: number,
|
||||
month: number,
|
||||
weekday: number,
|
||||
nth: number,
|
||||
): Date | null {
|
||||
if (nth > 0) {
|
||||
// Forward from start of month
|
||||
let d = new Date(year, month, 1);
|
||||
while (d.getDay() !== weekday) {
|
||||
d = addDays(d, 1);
|
||||
}
|
||||
d = addDays(d, (nth - 1) * 7);
|
||||
return d.getMonth() === month ? d : null;
|
||||
} else {
|
||||
// Backward from end of month
|
||||
let d = new Date(year, month + 1, 0); // last day of month
|
||||
while (d.getDay() !== weekday) {
|
||||
d = addDays(d, -1);
|
||||
}
|
||||
if (nth < -1) {
|
||||
d = addDays(d, (nth + 1) * 7);
|
||||
}
|
||||
return d.getMonth() === month ? d : null;
|
||||
}
|
||||
}
|
||||
+26
-136
@@ -5,6 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty
|
||||
import { debug } from '@/lib/debug';
|
||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||
|
||||
@@ -190,13 +191,17 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
before: end,
|
||||
});
|
||||
// Filter out malformed events missing required 'start' field
|
||||
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||
const droppedEvents = rawEvents.length - events.length;
|
||||
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||
const droppedEvents = rawEvents.length - validEvents.length;
|
||||
// Expand recurring events client-side (Stalwart doesn't support
|
||||
// mutations on synthetic IDs from server-side expandRecurrences)
|
||||
const events = expandRecurringEvents(validEvents, start, end);
|
||||
debug.log('Calendar fetchEvents completed', {
|
||||
start,
|
||||
end,
|
||||
rawCount: rawEvents.length,
|
||||
usableCount: events.length,
|
||||
validCount: validEvents.length,
|
||||
expandedCount: events.length,
|
||||
droppedEvents,
|
||||
});
|
||||
if (droppedEvents > 0) {
|
||||
@@ -291,10 +296,18 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
updateEvent: async (client, id, updates, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
// Resolve shared event IDs
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
debug.log('Calendar updateEvent', {
|
||||
storeId: id,
|
||||
realId,
|
||||
uid: storeEvent?.uid,
|
||||
recurrenceId: storeEvent?.recurrenceId,
|
||||
targetAccountId,
|
||||
updateKeys: Object.keys(updates),
|
||||
});
|
||||
// Remap namespaced calendarIds back to original IDs
|
||||
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
|
||||
if (cleanUpdates.calendarIds) {
|
||||
@@ -305,52 +318,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
cleanUpdates.calendarIds = remapped;
|
||||
}
|
||||
try {
|
||||
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
|
||||
} catch (updateError) {
|
||||
// Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
|
||||
// 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 : '';
|
||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||
debug.log('Event has synthetic ID, resolving real ID via UID query');
|
||||
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: patch the master event's recurrenceOverrides.
|
||||
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
|
||||
const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
// Build override object with all changed properties
|
||||
const overrideObj: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
|
||||
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||
overrideObj[key] = value;
|
||||
}
|
||||
const patchUpdates: Record<string, unknown> = {
|
||||
[`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj,
|
||||
};
|
||||
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, ...cleanUpdates } : e),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
|
||||
}));
|
||||
@@ -370,7 +338,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
throw new Error('Invalid participant ID');
|
||||
}
|
||||
try {
|
||||
// Resolve shared event IDs
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === eventId);
|
||||
const realId = storeEvent?.originalId || eventId;
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
@@ -383,65 +351,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
if (replyTo) {
|
||||
patch.replyTo = replyTo;
|
||||
}
|
||||
try {
|
||||
await client.updateCalendarEvent(
|
||||
realId,
|
||||
patch as unknown as Partial<CalendarEvent>,
|
||||
true,
|
||||
targetAccountId
|
||||
);
|
||||
} catch (updateError) {
|
||||
// Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query.
|
||||
const message = updateError instanceof Error ? updateError.message : '';
|
||||
if (message.toLowerCase().includes('synthetic') && storeEvent) {
|
||||
debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
|
||||
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: patch RSVP as recurrence override on master
|
||||
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
|
||||
const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const overrideObj: Record<string, unknown> = {
|
||||
[patchKey]: status,
|
||||
};
|
||||
if (replyTo) {
|
||||
overrideObj['replyTo'] = replyTo;
|
||||
}
|
||||
const overridePatch: Record<string, unknown> = {
|
||||
[`recurrenceOverrides/${escapedRecId}`]: overrideObj,
|
||||
};
|
||||
await client.updateCalendarEvent(
|
||||
resolvedId,
|
||||
overridePatch as unknown as Partial<CalendarEvent>,
|
||||
true,
|
||||
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;
|
||||
}
|
||||
set((state) => ({
|
||||
events: state.events.map(e => {
|
||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||
@@ -572,7 +487,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
// Resolve shared event IDs
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
const storeEvent = get().events.find(e => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
const targetAccountId = storeEvent?.accountId;
|
||||
@@ -586,39 +501,14 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
debug.error('Failed to send cancellation emails:', e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
debug.log('Calendar deleteEvent', {
|
||||
storeId: id,
|
||||
realId,
|
||||
uid: storeEvent?.uid,
|
||||
recurrenceId: storeEvent?.recurrenceId,
|
||||
targetAccountId,
|
||||
});
|
||||
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) => ({
|
||||
events: state.events.filter(e => e.id !== id),
|
||||
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
|
||||
|
||||
Reference in New Issue
Block a user