feat: recurring event delete options (this/future/all instances)
When deleting a recurring calendar event, users now choose between deleting just this instance, this and future instances, or all instances. Supports Google, Apple (iCloud), and Synology CalDAV providers. v1.62.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1011aef639
commit
57eeb3d8e8
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.61.1",
|
||||
"version": "1.62.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -156,8 +156,9 @@ export async function DELETE(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const calendarId = searchParams.get('calendarId');
|
||||
const eventId = searchParams.get('eventId');
|
||||
const deleteMode = searchParams.get('deleteMode') || 'all';
|
||||
|
||||
console.log('[API] Deleting event:', { calendarId, eventId });
|
||||
console.log('[API] Deleting event:', { calendarId, eventId, deleteMode });
|
||||
|
||||
if (!calendarId || !eventId) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
@ -170,11 +171,14 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await deleteCalendarEvent(connection, calendarId, eventId);
|
||||
await deleteCalendarEvent(connection, calendarId, eventId, deleteMode);
|
||||
|
||||
// Remove from cache - await to ensure consistency
|
||||
try {
|
||||
await deleteCachedEvent(userId, eventId, connection.provider);
|
||||
if (deleteMode === 'all') {
|
||||
await deleteCachedEvent(userId, eventId, connection.provider);
|
||||
}
|
||||
// For 'this' and 'future', force a full cache refresh
|
||||
} catch (e) {
|
||||
console.error('[CACHE] Failed to delete cached event:', e);
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ interface CalendarEventModalProps {
|
||||
connections: any[];
|
||||
onClose: () => void;
|
||||
onSave: (eventData: any) => Promise<void>;
|
||||
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
|
||||
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CalendarEventModal({
|
||||
@ -210,11 +210,19 @@ export default function CalendarEventModal({
|
||||
};
|
||||
|
||||
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
|
||||
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (mode?: string) => {
|
||||
if (!event?.id || !onDelete) return;
|
||||
|
||||
if (!isDeleteConfirming) {
|
||||
// For recurring events, show options first
|
||||
if (event.isRecurring && !mode && !showRecurringDeleteOptions) {
|
||||
setShowRecurringDeleteOptions(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-recurring events, use confirm flow
|
||||
if (!event.isRecurring && !isDeleteConfirming) {
|
||||
setIsDeleteConfirming(true);
|
||||
setTimeout(() => setIsDeleteConfirming(false), 3000);
|
||||
return;
|
||||
@ -222,12 +230,13 @@ export default function CalendarEventModal({
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(event.id, event.calendarId);
|
||||
await onDelete(event.id, event.calendarId, mode || 'all');
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete event');
|
||||
setIsDeleting(false);
|
||||
setIsDeleteConfirming(false);
|
||||
setShowRecurringDeleteOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -765,10 +774,10 @@ export default function CalendarEventModal({
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid var(--weekly-border)', paddingTop: '10px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{event && onDelete && (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{event && onDelete && !showRecurringDeleteOptions && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
onClick={() => handleDelete()}
|
||||
disabled={isSaving || isDeleting}
|
||||
style={{
|
||||
padding: '6px 10px', background: 'none', color: '#ef4444',
|
||||
@ -780,6 +789,39 @@ export default function CalendarEventModal({
|
||||
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
{event && onDelete && showRecurringDeleteOptions && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--weekly-text-light)', marginRight: '2px' }}>Delete:</span>
|
||||
{[
|
||||
{ mode: 'this', label: 'This event' },
|
||||
{ mode: 'future', label: 'This & future' },
|
||||
{ mode: 'all', label: 'All events' },
|
||||
].map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleDelete(mode)}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: '0.75rem', fontWeight: 500,
|
||||
color: '#ef4444', background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.2)', borderRadius: '5px',
|
||||
cursor: 'pointer', opacity: isDeleting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowRecurringDeleteOptions(false)}
|
||||
style={{
|
||||
padding: '4px 6px', fontSize: '0.75rem', color: 'var(--weekly-text-light)',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!event && (
|
||||
<button onClick={onClose} style={{ padding: '6px 10px', fontSize: '0.82rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||
Cancel
|
||||
|
||||
@ -168,6 +168,7 @@ interface CalendarEvent {
|
||||
calendarTitle?: string;
|
||||
calendarColor?: string;
|
||||
editable?: boolean;
|
||||
recurringEventId?: string;
|
||||
}
|
||||
|
||||
interface SomedayList {
|
||||
@ -2614,10 +2615,13 @@ export default function WeeklyView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventDelete = async (eventId: string, calendarId: string) => {
|
||||
const handleEventDelete = async (eventId: string, calendarId: string, deleteMode?: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams({ calendarId, eventId });
|
||||
if (deleteMode) params.set('deleteMode', deleteMode);
|
||||
|
||||
const res = await fetch(
|
||||
`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`,
|
||||
`/api/calendar/events?${params.toString()}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
@ -2628,8 +2632,30 @@ export default function WeeklyView() {
|
||||
throw new Error(err.error || "Failed to delete event");
|
||||
}
|
||||
|
||||
// Optimistically remove, then re-read cache
|
||||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||||
// Optimistically remove affected events
|
||||
if (deleteMode === 'this') {
|
||||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||||
} else if (deleteMode === 'future') {
|
||||
// Remove this and future instances of the same recurring series
|
||||
const targetEvent = calendarEvents.find(e => e.id === eventId);
|
||||
if (targetEvent) {
|
||||
const targetTime = new Date(targetEvent.startTime).getTime();
|
||||
const seriesId = targetEvent.recurringEventId || eventId;
|
||||
setRawCalendarEvents(prev => prev.filter(e => {
|
||||
if (e.recurringEventId !== seriesId && e.id !== seriesId) return true;
|
||||
return new Date(e.startTime).getTime() < targetTime;
|
||||
}));
|
||||
} else {
|
||||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||||
}
|
||||
} else {
|
||||
// 'all' — remove all instances of the series
|
||||
const targetEvent = calendarEvents.find(e => e.id === eventId);
|
||||
const seriesId = targetEvent?.recurringEventId || eventId;
|
||||
setRawCalendarEvents(prev => prev.filter(e =>
|
||||
e.id !== eventId && e.recurringEventId !== seriesId && e.id !== seriesId
|
||||
));
|
||||
}
|
||||
setTimeout(() => fetchCalendarEvents(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Error deleting event:", error);
|
||||
|
||||
@ -808,6 +808,114 @@ function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific instance or future instances of a recurring CalDAV event.
|
||||
* 'this' mode: adds EXDATE to exclude the specific occurrence.
|
||||
* 'future' mode: modifies RRULE to add UNTIL just before the target occurrence.
|
||||
*/
|
||||
export const deleteRecurringInstance = async (
|
||||
email: string,
|
||||
appSpecificPassword: string,
|
||||
calendarUrl: string,
|
||||
eventId: string,
|
||||
deleteMode: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
// Parse the caldav ID to get objectUrl and occurrence date
|
||||
const parts = eventId.split('::');
|
||||
if (parts.length < 4 || parts[0] !== 'caldav') {
|
||||
throw new Error('Cannot determine occurrence from event ID format');
|
||||
}
|
||||
const objectUrl = parts[1];
|
||||
const occurrenceISO = parts[3]; // e.g. "2026-03-30T12:40:00.000Z"
|
||||
|
||||
// Fetch the calendar object
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar: { url: calendarUrl } as any,
|
||||
objectUrls: [objectUrl],
|
||||
});
|
||||
const targetObject = objects?.[0];
|
||||
if (!targetObject?.data) {
|
||||
throw new Error('Event not found on server');
|
||||
}
|
||||
|
||||
// Parse iCal data
|
||||
const jcal = ICAL.parse(targetObject.data);
|
||||
const comp = new ICAL.Component(jcal);
|
||||
const vevent = comp.getFirstSubcomponent('vevent');
|
||||
if (!vevent) {
|
||||
throw new Error('No VEVENT found in calendar object');
|
||||
}
|
||||
|
||||
const occDate = new Date(occurrenceISO);
|
||||
|
||||
if (deleteMode === 'this') {
|
||||
// Add EXDATE to exclude this specific occurrence
|
||||
const dtstart = vevent.getFirstProperty('dtstart');
|
||||
const isAllDay = dtstart?.getParameter('value') === 'DATE';
|
||||
|
||||
if (isAllDay) {
|
||||
const exDate = ICAL.Time.fromJSDate(occDate, true);
|
||||
exDate.isDate = true;
|
||||
const prop = new ICAL.Property('exdate');
|
||||
prop.setParameter('value', 'DATE');
|
||||
prop.setValue(exDate);
|
||||
vevent.addProperty(prop);
|
||||
} else {
|
||||
// Use the same timezone as DTSTART if available
|
||||
const tzid = dtstart?.getParameter('tzid');
|
||||
const exDate = ICAL.Time.fromJSDate(occDate, false);
|
||||
const prop = new ICAL.Property('exdate');
|
||||
if (tzid) {
|
||||
prop.setParameter('tzid', tzid);
|
||||
}
|
||||
prop.setValue(exDate);
|
||||
vevent.addProperty(prop);
|
||||
}
|
||||
|
||||
console.log('[APPLE CALENDAR] Added EXDATE for occurrence:', occurrenceISO);
|
||||
|
||||
} else if (deleteMode === 'future') {
|
||||
// Modify RRULE to end just before this occurrence
|
||||
const rruleProp = vevent.getFirstProperty('rrule');
|
||||
if (!rruleProp) {
|
||||
throw new Error('No RRULE found on event');
|
||||
}
|
||||
const rrule = rruleProp.getFirstValue() as any;
|
||||
|
||||
// Set UNTIL to one second before the occurrence
|
||||
const untilDate = new Date(occDate.getTime() - 1000);
|
||||
const until = ICAL.Time.fromJSDate(untilDate, true);
|
||||
|
||||
// Remove COUNT if present, set UNTIL
|
||||
delete rrule.count;
|
||||
rrule.until = until;
|
||||
|
||||
// Write modified RRULE back
|
||||
rruleProp.setValue(rrule);
|
||||
|
||||
console.log('[APPLE CALENDAR] Modified RRULE UNTIL for future delete:', untilDate.toISOString());
|
||||
}
|
||||
|
||||
// PUT the modified object back
|
||||
const updatedData = comp.toString();
|
||||
await client.updateObject({
|
||||
url: objectUrl,
|
||||
data: updatedData,
|
||||
etag: targetObject.etag,
|
||||
} as any);
|
||||
|
||||
console.log('[APPLE CALENDAR] Recurring instance delete complete (mode:', deleteMode, ')');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[APPLE CALENDAR] Error deleting recurring instance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEvent = async (
|
||||
email: string,
|
||||
appSpecificPassword: string,
|
||||
|
||||
@ -1271,10 +1271,10 @@ export const updateCalendarEvent = async (
|
||||
export const deleteCalendarEvent = async (
|
||||
connection: CalendarConnection,
|
||||
calendarId: string,
|
||||
eventId: string
|
||||
eventId: string,
|
||||
deleteMode: string = 'all'
|
||||
): Promise<void> => {
|
||||
if (connection.provider === 'google') {
|
||||
// Refresh token if needed
|
||||
let accessToken = connection.accessToken;
|
||||
if (isTokenExpired(connection.expiresAt)) {
|
||||
const newToken = await refreshGoogleToken(connection);
|
||||
@ -1288,12 +1288,51 @@ export const deleteCalendarEvent = async (
|
||||
process.env.GOOGLE_REDIRECT_URI || ''
|
||||
);
|
||||
|
||||
// Default to deleting the whole series if repeating
|
||||
const baseEventId = eventId.split('_')[0];
|
||||
|
||||
await import('./google-calendar').then(m =>
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
|
||||
);
|
||||
if (deleteMode === 'this') {
|
||||
// Delete the specific instance (use full instance ID with _suffix)
|
||||
await import('./google-calendar').then(m =>
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, eventId)
|
||||
);
|
||||
} else if (deleteMode === 'future') {
|
||||
// Set UNTIL on the series to end before this instance
|
||||
const instanceDate = eventId.split('_')[1]; // e.g. "20260401T114000Z"
|
||||
if (instanceDate) {
|
||||
const baseEventId = eventId.split('_')[0];
|
||||
// Get the base event, modify its recurrence UNTIL
|
||||
const { getEvent, updateEvent } = await import('./google-calendar');
|
||||
try {
|
||||
const baseEvent = await getEvent(oauth2Client, accessToken, calendarId, baseEventId);
|
||||
if (baseEvent?.recurrence) {
|
||||
const updatedRecurrence = baseEvent.recurrence.map((rule: string) => {
|
||||
if (rule.startsWith('RRULE:')) {
|
||||
// Remove existing UNTIL/COUNT, add new UNTIL
|
||||
const cleaned = rule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
|
||||
return `${cleaned};UNTIL=${instanceDate.replace(/[-:]/g, '')}`;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
await updateEvent(oauth2Client, accessToken, calendarId, baseEventId, { recurrence: updatedRecurrence });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DELETE] Failed to modify Google series for future delete, falling back to full delete:', e);
|
||||
await import('./google-calendar').then(m =>
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Can't determine instance date, delete all
|
||||
const baseEventId = eventId.split('_')[0];
|
||||
await import('./google-calendar').then(m =>
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 'all' — delete the whole series
|
||||
const baseEventId = eventId.split('_')[0];
|
||||
await import('./google-calendar').then(m =>
|
||||
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else if (connection.provider === 'outlook') {
|
||||
let accessToken = connection.accessToken;
|
||||
@ -1303,17 +1342,23 @@ export const deleteCalendarEvent = async (
|
||||
else throw new Error('Failed to refresh token');
|
||||
}
|
||||
|
||||
// Extract base series ID for Outlook
|
||||
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
|
||||
|
||||
// Outlook: for simplicity, always delete the series for now
|
||||
// (Outlook instance deletion requires fetching specific occurrence IDs)
|
||||
await deleteOutlookEvent(accessToken, calendarId, baseEventId);
|
||||
return;
|
||||
} else if (connection.provider === 'apple') {
|
||||
const [email, appPassword] = connection.accessToken.split(':');
|
||||
|
||||
await import('./apple-calendar').then(m =>
|
||||
m.deleteEvent(email, appPassword, calendarId, eventId)
|
||||
);
|
||||
if (deleteMode === 'this' || deleteMode === 'future') {
|
||||
await import('./apple-calendar').then(m =>
|
||||
m.deleteRecurringInstance(email, appPassword, calendarId, eventId, deleteMode)
|
||||
);
|
||||
} else {
|
||||
await import('./apple-calendar').then(m =>
|
||||
m.deleteEvent(email, appPassword, calendarId, eventId)
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else if (connection.provider === 'synology') {
|
||||
const [username, password] = connection.accessToken.split(':');
|
||||
@ -1321,9 +1366,15 @@ export const deleteCalendarEvent = async (
|
||||
|
||||
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
|
||||
|
||||
await import('./synology-calendar').then(m =>
|
||||
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
||||
);
|
||||
if (deleteMode === 'this' || deleteMode === 'future') {
|
||||
await import('./synology-calendar').then(m =>
|
||||
m.deleteRecurringInstance(serverUrl, username, password, calendarId, eventId, deleteMode)
|
||||
);
|
||||
} else {
|
||||
await import('./synology-calendar').then(m =>
|
||||
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else if (connection.provider === 'notion') {
|
||||
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');
|
||||
|
||||
@ -26,6 +26,7 @@ export interface GoogleCalendarEvent {
|
||||
};
|
||||
transparency?: string; // 'opaque' | 'transparent'
|
||||
visibility?: string; // 'default' | 'public' | 'private' | 'confidential'
|
||||
recurrence?: string[];
|
||||
}
|
||||
|
||||
export interface GoogleCalendar {
|
||||
@ -194,6 +195,29 @@ export const updateEvent = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
*/
|
||||
export const getEvent = async (
|
||||
oauth2Client: any,
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
eventId: string
|
||||
): Promise<GoogleCalendarEvent | null> => {
|
||||
oauth2Client.setCredentials({ access_token: accessToken });
|
||||
try {
|
||||
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
|
||||
const response = await calendar.events.get({
|
||||
calendarId,
|
||||
eventId,
|
||||
});
|
||||
return response.data as any;
|
||||
} catch (error) {
|
||||
console.error('Error getting event:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
|
||||
@ -765,6 +765,115 @@ export const updateEvent = async (
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
/**
|
||||
* Delete a specific instance or future instances of a recurring CalDAV event.
|
||||
* 'this' mode: adds EXDATE to exclude the specific occurrence.
|
||||
* 'future' mode: modifies RRULE to add UNTIL just before the target occurrence.
|
||||
*/
|
||||
export const deleteRecurringInstance = async (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
calendarUrl: string,
|
||||
eventId: string,
|
||||
deleteMode: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const client = createClient(serverUrl, username, password);
|
||||
await loginClient(client, serverUrl, username);
|
||||
|
||||
// Parse the caldav ID to get objectUrl and occurrence date
|
||||
const parts = eventId.split('::');
|
||||
if (parts.length < 4 || parts[0] !== 'caldav') {
|
||||
throw new Error('Cannot determine occurrence from event ID format');
|
||||
}
|
||||
const objectUrl = parts[1];
|
||||
const occurrenceISO = parts[3]; // e.g. "2026-03-30T12:40:00.000Z"
|
||||
|
||||
// Fetch the calendar object
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar: { url: calendarUrl } as any,
|
||||
objectUrls: [objectUrl],
|
||||
});
|
||||
const targetObject = objects?.[0];
|
||||
if (!targetObject?.data) {
|
||||
throw new Error('Event not found on server');
|
||||
}
|
||||
|
||||
// Parse iCal data
|
||||
const jcal = ICAL.parse(targetObject.data);
|
||||
const comp = new ICAL.Component(jcal);
|
||||
const vevent = comp.getFirstSubcomponent('vevent');
|
||||
if (!vevent) {
|
||||
throw new Error('No VEVENT found in calendar object');
|
||||
}
|
||||
|
||||
const occDate = new Date(occurrenceISO);
|
||||
|
||||
if (deleteMode === 'this') {
|
||||
// Add EXDATE to exclude this specific occurrence
|
||||
const dtstart = vevent.getFirstProperty('dtstart');
|
||||
const isAllDay = dtstart?.getParameter('value') === 'DATE';
|
||||
|
||||
if (isAllDay) {
|
||||
const exDate = ICAL.Time.fromJSDate(occDate, true);
|
||||
exDate.isDate = true;
|
||||
const prop = new ICAL.Property('exdate');
|
||||
prop.setParameter('value', 'DATE');
|
||||
prop.setValue(exDate);
|
||||
vevent.addProperty(prop);
|
||||
} else {
|
||||
// Use the same timezone as DTSTART if available
|
||||
const tzid = dtstart?.getParameter('tzid');
|
||||
const exDate = ICAL.Time.fromJSDate(occDate, false);
|
||||
const prop = new ICAL.Property('exdate');
|
||||
if (tzid) {
|
||||
prop.setParameter('tzid', tzid);
|
||||
}
|
||||
prop.setValue(exDate);
|
||||
vevent.addProperty(prop);
|
||||
}
|
||||
|
||||
console.log('[SYNOLOGY CALENDAR] Added EXDATE for occurrence:', occurrenceISO);
|
||||
|
||||
} else if (deleteMode === 'future') {
|
||||
// Modify RRULE to end just before this occurrence
|
||||
const rruleProp = vevent.getFirstProperty('rrule');
|
||||
if (!rruleProp) {
|
||||
throw new Error('No RRULE found on event');
|
||||
}
|
||||
const rrule = rruleProp.getFirstValue() as any;
|
||||
|
||||
// Set UNTIL to one second before the occurrence
|
||||
const untilDate = new Date(occDate.getTime() - 1000);
|
||||
const until = ICAL.Time.fromJSDate(untilDate, true);
|
||||
|
||||
// Remove COUNT if present, set UNTIL
|
||||
delete rrule.count;
|
||||
rrule.until = until;
|
||||
|
||||
// Write modified RRULE back
|
||||
rruleProp.setValue(rrule);
|
||||
|
||||
console.log('[SYNOLOGY CALENDAR] Modified RRULE UNTIL for future delete:', untilDate.toISOString());
|
||||
}
|
||||
|
||||
// PUT the modified object back
|
||||
const updatedData = comp.toString();
|
||||
await client.updateObject({
|
||||
url: objectUrl,
|
||||
data: updatedData,
|
||||
etag: targetObject.etag,
|
||||
} as any);
|
||||
|
||||
console.log('[SYNOLOGY CALENDAR] Recurring instance delete complete (mode:', deleteMode, ')');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SYNOLOGY CALENDAR] Error deleting recurring instance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEvent = async (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user