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",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -156,8 +156,9 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const calendarId = searchParams.get('calendarId');
|
const calendarId = searchParams.get('calendarId');
|
||||||
const eventId = searchParams.get('eventId');
|
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) {
|
if (!calendarId || !eventId) {
|
||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
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 });
|
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
|
// Remove from cache - await to ensure consistency
|
||||||
try {
|
try {
|
||||||
|
if (deleteMode === 'all') {
|
||||||
await deleteCachedEvent(userId, eventId, connection.provider);
|
await deleteCachedEvent(userId, eventId, connection.provider);
|
||||||
|
}
|
||||||
|
// For 'this' and 'future', force a full cache refresh
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[CACHE] Failed to delete cached event:', e);
|
console.error('[CACHE] Failed to delete cached event:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ interface CalendarEventModalProps {
|
|||||||
connections: any[];
|
connections: any[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (eventData: any) => Promise<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({
|
export default function CalendarEventModal({
|
||||||
@ -210,11 +210,19 @@ export default function CalendarEventModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
|
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
|
||||||
|
const [showRecurringDeleteOptions, setShowRecurringDeleteOptions] = useState(false);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (mode?: string) => {
|
||||||
if (!event?.id || !onDelete) return;
|
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);
|
setIsDeleteConfirming(true);
|
||||||
setTimeout(() => setIsDeleteConfirming(false), 3000);
|
setTimeout(() => setIsDeleteConfirming(false), 3000);
|
||||||
return;
|
return;
|
||||||
@ -222,12 +230,13 @@ export default function CalendarEventModal({
|
|||||||
|
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await onDelete(event.id, event.calendarId);
|
await onDelete(event.id, event.calendarId, mode || 'all');
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete event');
|
setError(err.message || 'Failed to delete event');
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setIsDeleteConfirming(false);
|
setIsDeleteConfirming(false);
|
||||||
|
setShowRecurringDeleteOptions(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -765,10 +774,10 @@ export default function CalendarEventModal({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid var(--weekly-border)', paddingTop: '10px' }}>
|
<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' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{event && onDelete && (
|
{event && onDelete && !showRecurringDeleteOptions && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={() => handleDelete()}
|
||||||
disabled={isSaving || isDeleting}
|
disabled={isSaving || isDeleting}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 10px', background: 'none', color: '#ef4444',
|
padding: '6px 10px', background: 'none', color: '#ef4444',
|
||||||
@ -780,6 +789,39 @@ export default function CalendarEventModal({
|
|||||||
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
|
{isDeleting ? 'Deleting...' : isDeleteConfirming ? 'Confirm Delete' : 'Delete'}
|
||||||
</button>
|
</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 && (
|
{!event && (
|
||||||
<button onClick={onClose} style={{ padding: '6px 10px', fontSize: '0.82rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
|
<button onClick={onClose} style={{ padding: '6px 10px', fontSize: '0.82rem', color: 'var(--weekly-text-light)', border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@ -168,6 +168,7 @@ interface CalendarEvent {
|
|||||||
calendarTitle?: string;
|
calendarTitle?: string;
|
||||||
calendarColor?: string;
|
calendarColor?: string;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
|
recurringEventId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SomedayList {
|
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 {
|
try {
|
||||||
|
const params = new URLSearchParams({ calendarId, eventId });
|
||||||
|
if (deleteMode) params.set('deleteMode', deleteMode);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`,
|
`/api/calendar/events?${params.toString()}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
},
|
||||||
@ -2628,8 +2632,30 @@ export default function WeeklyView() {
|
|||||||
throw new Error(err.error || "Failed to delete event");
|
throw new Error(err.error || "Failed to delete event");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistically remove, then re-read cache
|
// Optimistically remove affected events
|
||||||
|
if (deleteMode === 'this') {
|
||||||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
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);
|
setTimeout(() => fetchCalendarEvents(false), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting event:", error);
|
console.error("Error deleting event:", error);
|
||||||
|
|||||||
@ -808,6 +808,114 @@ function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | nu
|
|||||||
return null;
|
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 (
|
export const deleteEvent = async (
|
||||||
email: string,
|
email: string,
|
||||||
appSpecificPassword: string,
|
appSpecificPassword: string,
|
||||||
|
|||||||
@ -1271,10 +1271,10 @@ export const updateCalendarEvent = async (
|
|||||||
export const deleteCalendarEvent = async (
|
export const deleteCalendarEvent = async (
|
||||||
connection: CalendarConnection,
|
connection: CalendarConnection,
|
||||||
calendarId: string,
|
calendarId: string,
|
||||||
eventId: string
|
eventId: string,
|
||||||
|
deleteMode: string = 'all'
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (connection.provider === 'google') {
|
if (connection.provider === 'google') {
|
||||||
// Refresh token if needed
|
|
||||||
let accessToken = connection.accessToken;
|
let accessToken = connection.accessToken;
|
||||||
if (isTokenExpired(connection.expiresAt)) {
|
if (isTokenExpired(connection.expiresAt)) {
|
||||||
const newToken = await refreshGoogleToken(connection);
|
const newToken = await refreshGoogleToken(connection);
|
||||||
@ -1288,12 +1288,51 @@ export const deleteCalendarEvent = async (
|
|||||||
process.env.GOOGLE_REDIRECT_URI || ''
|
process.env.GOOGLE_REDIRECT_URI || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default to deleting the whole series if repeating
|
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];
|
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 =>
|
await import('./google-calendar').then(m =>
|
||||||
m.deleteEvent(oauth2Client, accessToken, calendarId, baseEventId)
|
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;
|
return;
|
||||||
} else if (connection.provider === 'outlook') {
|
} else if (connection.provider === 'outlook') {
|
||||||
let accessToken = connection.accessToken;
|
let accessToken = connection.accessToken;
|
||||||
@ -1303,17 +1342,23 @@ export const deleteCalendarEvent = async (
|
|||||||
else throw new Error('Failed to refresh token');
|
else throw new Error('Failed to refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract base series ID for Outlook
|
|
||||||
const baseEventId = eventId.includes('::') ? eventId.split('::')[0] : eventId;
|
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);
|
await deleteOutlookEvent(accessToken, calendarId, baseEventId);
|
||||||
return;
|
return;
|
||||||
} else if (connection.provider === 'apple') {
|
} else if (connection.provider === 'apple') {
|
||||||
const [email, appPassword] = connection.accessToken.split(':');
|
const [email, appPassword] = connection.accessToken.split(':');
|
||||||
|
|
||||||
|
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 =>
|
await import('./apple-calendar').then(m =>
|
||||||
m.deleteEvent(email, appPassword, calendarId, eventId)
|
m.deleteEvent(email, appPassword, calendarId, eventId)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} else if (connection.provider === 'synology') {
|
} else if (connection.provider === 'synology') {
|
||||||
const [username, password] = connection.accessToken.split(':');
|
const [username, password] = connection.accessToken.split(':');
|
||||||
@ -1321,9 +1366,15 @@ export const deleteCalendarEvent = async (
|
|||||||
|
|
||||||
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
|
if (!username || !password || !serverUrl) throw new Error('Invalid Synology credentials');
|
||||||
|
|
||||||
|
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 =>
|
await import('./synology-calendar').then(m =>
|
||||||
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} else if (connection.provider === 'notion') {
|
} else if (connection.provider === 'notion') {
|
||||||
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');
|
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export interface GoogleCalendarEvent {
|
|||||||
};
|
};
|
||||||
transparency?: string; // 'opaque' | 'transparent'
|
transparency?: string; // 'opaque' | 'transparent'
|
||||||
visibility?: string; // 'default' | 'public' | 'private' | 'confidential'
|
visibility?: string; // 'default' | 'public' | 'private' | 'confidential'
|
||||||
|
recurrence?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoogleCalendar {
|
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
|
* Delete an event
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -765,6 +765,115 @@ export const updateEvent = async (
|
|||||||
/**
|
/**
|
||||||
* Delete an event
|
* 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 (
|
export const deleteEvent = async (
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user